fix: stop shrinking conversations behind the user's back — both automatic context guards ship off
Nightly Build / build (push) Successful in 7m35s

The shipped default combined a sliding history window with no compaction, which
is the worse of the two available trades in both directions it is measured on.

`max_history_messages: 30` is a sliding tail window (`projection::window` —
`drain(..len - max)`). Past 30 messages it drops from the head on *every* turn,
so the prompt prefix changes on every single request and every provider that
caches one (Anthropic breakpoints, OpenAI automatic prefix caching) misses every
time. It also drops those messages with no summary standing in for them: silent
amnesia, not just a cold cache. Compaction rewrites the prefix once per
compaction and leaves a summary behind — yet it was the half that was commented
out, while the window's own doc-comment already said the two were exclusive.

Both are now `Option` and both ship unset, so nothing shrinks a conversation
unless a human types `/compact`.

Which surfaced the real bug: `/compact` did not work either. The compactor was
`Option<Arc<ContextCompactor>>` keyed on the config section existing, so
commenting out `compaction:` disabled the manual command too — `force_compact`
returned `Ok(false)` and the chat answered "compaction disabled". Manual
compaction is a command a user types; it cannot depend on an admin having filled
in a token threshold. The compactor is now built unconditionally and
`threshold_tokens: Option<u32>` arms only the automatic pass; `try_compact`
early-returns without it, `force_compact` deliberately never consults it.

The projection accordingly yields to the *automatic* pass rather than to the
compactor's existence (`LoopConfig.auto_compaction_enabled`), so a configured
message cap is not silently voided by `/compact` merely being available.
`CompactionConfig::Default` is hand-written for the same reason `RoleAttrs`'s is:
a derived one gives `keep_recent: 0`, which would compact away every recent
message on any box omitting the section — now the default.

Also fixes two documentation bugs in the same file: `event_triage` was documented
nested under `llm:`, where it parses fine and is then silently ignored (it is a
top-level field), and `datetime` was documented twice with conflicting examples.

A new test asserts the shipped default actually deserializes and that both guards
are off — a field the default omits must be genuinely optional, or a brand-new
install fails to boot.

Automatic compaction returns later, triggered off the resolved model's own
context window instead of a hand-tuned token count that cannot know which model
is answering.
This commit is contained in:
2026-08-02 21:21:14 +01:00
parent d4b34e6130
commit baf68878e4
16 changed files with 227 additions and 105 deletions
@@ -236,7 +236,7 @@ impl AgentCatalog for SkaldAgentCatalog {
)),
Some(self.fs.load()),
self.config.max_history_messages,
self.config.compaction_enabled,
self.config.auto_compaction_enabled,
self.config.max_tool_result_chars,
),
);
@@ -26,20 +26,24 @@ const INTERRUPTED: &str = "Error: tool call was interrupted (connection lost bef
/// The knobs Skald's model fleet needs.
///
/// - `max_history_messages` applies **only without compaction**: with the
/// compactor on, the summary is what bounds the context, and a window on top
/// of it would silently drop messages the summary does not cover.
/// - `max_history_messages` is **off by default** (`None`), leaving history
/// append-only so the prompt prefix — and the provider's cache of it — stays
/// stable for the whole conversation. When set, it applies **only without
/// automatic compaction**: with the automatic pass on, the summary is what
/// bounds the context, and a window on top of it would silently drop messages
/// the summary does not cover. Manual `/compact` does not disarm it — a cap
/// the admin typed should not vanish because a user ran a command.
/// - tool results are shrunk for previous turns only, so the in-flight turn
/// always sees its own output in full.
pub fn skald_projection(
max_history_messages: usize,
compaction_enabled: bool,
max_tool_result_chars: Option<usize>,
max_history_messages: Option<usize>,
auto_compaction_enabled: bool,
max_tool_result_chars: Option<usize>,
) -> Projection {
Projection {
summary_prefix: SUMMARY_PREFIX.to_string(),
summary_suffix: Some(SUMMARY_SUFFIX.to_string()),
max_messages: (!compaction_enabled).then_some(max_history_messages),
max_messages: max_history_messages.filter(|_| !auto_compaction_enabled),
max_tool_result: max_tool_result_chars.map(|max_chars| ResultLimit {
max_chars,
previous_turns_only: true,
@@ -66,16 +70,16 @@ pub fn skald_projection(
/// never inlined (nothing can be authorized), which is the right default for a
/// context with no user workspace.
pub fn skald_assembler(
activation: Arc<dyn ActivationSource>,
fs: Option<Arc<UserFs>>,
max_history_messages: usize,
compaction_enabled: bool,
max_tool_result_chars: Option<usize>,
activation: Arc<dyn ActivationSource>,
fs: Option<Arc<UserFs>>,
max_history_messages: Option<usize>,
auto_compaction_enabled: bool,
max_tool_result_chars: Option<usize>,
) -> LinearAssembler {
let mut assembler = LinearAssembler::new()
.with_projection(skald_projection(
max_history_messages,
compaction_enabled,
auto_compaction_enabled,
max_tool_result_chars,
))
.with_activation(activation)
@@ -60,10 +60,12 @@ use crate::tools::tool_names as tn;
pub struct LoopConfig {
pub max_rounds: usize,
pub max_parallel_calls: usize,
pub max_history_messages: usize,
/// Sliding-window cap on projected history. `None` (the default) leaves
/// history append-only — see `LlmConfig::max_history_messages`.
pub max_history_messages: Option<usize>,
pub max_tool_result_chars: Option<usize>,
/// Compaction bounds the context instead of a message window.
pub compaction_enabled: bool,
/// Automatic compaction bounds the context instead of a message window.
pub auto_compaction_enabled: bool,
pub datetime: DatetimeConfig,
pub max_agent_depth: u32,
}
@@ -272,7 +274,7 @@ impl UserLoopRuntime {
)),
Some(self.fs.load()),
self.config.max_history_messages,
self.config.compaction_enabled,
self.config.auto_compaction_enabled,
self.config.max_tool_result_chars,
));
@@ -244,8 +244,8 @@ pub async fn project(db: &Db, agent: &AgentFixture, case: &Case) -> Vec<Value> {
None,
)),
case.fs.clone(),
HISTORY_LIMIT,
// `compaction_enabled: false` mirrors the builder's `compactor: None`.
Some(HISTORY_LIMIT),
// The snapshots exercise the window, so automatic compaction stays off.
false,
Some(TOOL_RESULT_LIMIT),
);