Release 0.2.0 #4

Merged
dguiducci merged 96 commits from main into release 2026-08-17 18:07:20 +01:00
16 changed files with 227 additions and 105 deletions
Showing only changes of commit baf68878e4 - Show all commits
+15 -1
View File
@@ -109,7 +109,7 @@ Two rules keep the boundary real, and both are enforced by the compiler:
| `crates/skald-core/src/cron/` | Scheduled job runner | | `crates/skald-core/src/cron/` | Scheduled job runner |
| `crates/skald-core/src/system_agents/` | The `SystemAgent` trait + `run_and_record` + the shared ephemeral-turn/run-context machinery, plus `registry()` (the one enumeration of the agents) and `memory_lint.rs` (the two lint agents). See the system-agents section | | `crates/skald-core/src/system_agents/` | The `SystemAgent` trait + `run_and_record` + the shared ephemeral-turn/run-context machinery, plus `registry()` (the one enumeration of the agents) and `memory_lint.rs` (the two lint agents). See the system-agents section |
| `crates/skald-core/src/event_triage/` | `EventTriageManager`: one pass of the event-triage system agent for **one** user. No timer of its own — the instance-wide scheduler is `skald::wiring::spawn_system_agents` | | `crates/skald-core/src/event_triage/` | `EventTriageManager`: one pass of the event-triage system agent for **one** user. No timer of its own — the instance-wide scheduler is `skald::wiring::spawn_system_agents` |
| `crates/skald-core/src/compactor.rs` | Context compaction **policy** — when to compact and with which model; the mechanics are `agent_loop::compaction`. Model for the summary call: the instance-wide Settings pick (`compaction_model`, a `PropertyType::LlmModel` config property declared by `compactor::config_set`) wins; else AUTO by `compaction.strength` (config.yml); a missing configured model degrades to the same AUTO path | | `crates/skald-core/src/compactor.rs` | Context compaction **policy** — when to compact and with which model; the mechanics are `agent_loop::compaction`. The compactor is **always constructed** (manual `/compact` must work with no config); `compaction.threshold_tokens` is `Option` and arms only the *automatic* pass, and is **unset by default** — see the context-size defaults section. Model for the summary call: the instance-wide Settings pick (`compaction_model`, a `PropertyType::LlmModel` config property declared by `compactor::config_set`) wins; else AUTO by `compaction.strength` (config.yml); a missing configured model degrades to the same AUTO path |
| `crates/skald-core/src/approval/` | Approval rules engine | | `crates/skald-core/src/approval/` | Approval rules engine |
| `crates/skald-core/src/clarification/` | `ClarificationManager`: background-session question/answer | | `crates/skald-core/src/clarification/` | `ClarificationManager`: background-session question/answer |
| `crates/skald-core/src/elicitation/` | `ElicitationManager` + bridge: MCP server-initiated input (`elicitation/create`), surfaced in the Inbox; secrets never logged/persisted | | `crates/skald-core/src/elicitation/` | `ElicitationManager` + bridge: MCP server-initiated input (`elicitation/create`), surfaced in the Inbox; secrets never logged/persisted |
@@ -350,6 +350,20 @@ A crash loses RAM (the approval oneshot, the cancellation token), never truth: e
`agent_loop::compaction` owns the mechanics: split point (never between an assistant turn and its tool results), transcript, prompt (`SUMMARY_PREFIX` / preamble / template live there now), the single no-tools model call, the saved summary row. `skald-core/src/compactor.rs` owns the **policy**: the token threshold, the ephemeral guard, which model summarises (`compaction_model` from Settings, else AUTO by `compaction.strength`), and publishing `CompactionEvent` on the chat bus. The DTL re-anchor is the `on_compacted` hook (`loop_adapters/hooks.rs::DtlReanchorHook`). The next turn needs nothing: the assembler reads the latest summary from the store. `agent_loop::compaction` owns the mechanics: split point (never between an assistant turn and its tool results), transcript, prompt (`SUMMARY_PREFIX` / preamble / template live there now), the single no-tools model call, the saved summary row. `skald-core/src/compactor.rs` owns the **policy**: the token threshold, the ephemeral guard, which model summarises (`compaction_model` from Settings, else AUTO by `compaction.strength`), and publishing `CompactionEvent` on the chat bus. The DTL re-anchor is the `on_compacted` hook (`loop_adapters/hooks.rs::DtlReanchorHook`). The next turn needs nothing: the assembler reads the latest summary from the store.
### Context size: both automatic guards are off by default
Nothing shrinks a conversation unless a human asks. `llm.max_history_messages` and `llm.compaction.threshold_tokens` are both `Option`, both **unset** in `default.config.yaml`, and the only remaining reducer is the user typing `/compact`. The reason is the **prompt cache**: every provider that caches (Anthropic breakpoints, OpenAI automatic prefix caching) keys on the longest common *prefix*, so anything that rewrites history mid-conversation costs a full miss on the next request.
The two guards are not equally bad at that, and the difference is why one is merely off and the other is close to a trap. `max_history_messages` is a **sliding tail window** (`agent_loop::projection::window``drain(..len - max)`): past the cap it drops from the head on *every* turn, so it is a cache miss *per request*, forever, and it drops messages with **no summary standing in for them** — silent amnesia. Compaction rewrites the prefix **once per compaction** and leaves a summary behind. So the previous default — window on, compaction off — was the worse of the two in both dimensions, and the window's own doc-comment already said the two were mutually exclusive.
Three consequences worth not re-deriving:
- **The compactor is built unconditionally**, in both `bundles.rs` and `user_context.rs`. It used to be `Option<Arc<ContextCompactor>>`, keyed on the config section existing — which meant that commenting out `compaction:` also silently disabled **manual** `/compact` (`force_compact` returned `Ok(false)` and the chat answered "compaction disabled"). Manual compaction is a command a user types; it must not depend on an admin having filled in a token threshold. `try_compact` early-returns on `threshold_tokens: None`; `force_compact` deliberately does not consult it — the human *is* the trigger.
- **The projection yields to the *automatic* pass, not to the compactor's existence**: `LoopConfig.auto_compaction_enabled` (`= ContextCompactor::auto_enabled()`), so a configured message cap is not silently voided by the mere availability of `/compact`. Expressed as `max_history_messages.filter(|_| !auto_compaction_enabled)` in `projection_cfg.rs`.
- **`CompactionConfig`'s `Default` is hand-written**, same trap as `RoleAttrs`: a derived one gives `keep_recent: 0`, which would compact away every recent message on any box omitting the section — now the shipped default.
The future automatic pass should trigger off the **resolved model's own context window**, not a hand-tuned `threshold_tokens` that has no idea which model is answering.
## Approval gate ## Approval gate
The rule engine `ApprovalManager::check` returns `Allow`/`Deny`/`Require` per tool call (default rules seeded on first boot; the catch-all `* require @999999` gates anything not explicitly allowed — e.g. `execute_cmd`, `execute_task`, writes outside whitelisted paths). It is wired to the loop as `loop_adapters/gate.rs::ApprovalGate` (`agent_loop::gate::Gate`). A `Require` registers a `oneshot` in the in-memory `pending` map keyed by `request_id` and emits an approval event over WS. The rule engine `ApprovalManager::check` returns `Allow`/`Deny`/`Require` per tool call (default rules seeded on first boot; the catch-all `* require @999999` gates anything not explicitly allowed — e.g. `execute_cmd`, `execute_task`, writes outside whitelisted paths). It is wired to the loop as `loop_adapters/gate.rs::ApprovalGate` (`agent_loop::gate::Gate`). A `Require` registers a `oneshot` in the in-memory `pending` map keyed by `request_id` and emits an approval event over WS.
+1 -1
View File
@@ -345,7 +345,7 @@ async fn handle_compact(bot: &Bot, chat_id: ChatId, hub: &Arc<dyn core_api::chat
bot.send_message(chat_id, "✅ Context compacted.").await.ok(); bot.send_message(chat_id, "✅ Context compacted.").await.ok();
} }
Ok(false) => { Ok(false) => {
bot.send_message(chat_id, "⏩ Compaction skipped (no messages to summarise or compaction disabled).").await.ok(); bot.send_message(chat_id, "⏩ Compaction skipped (nothing to summarise).").await.ok();
} }
Err(e) => { Err(e) => {
error!(error = %e, "telegram: manual compaction failed"); error!(error = %e, "telegram: manual compaction failed");
+15 -2
View File
@@ -102,6 +102,13 @@ impl ContextCompactor {
Self { config, llm_manager, event_bus, config_store } Self { config, llm_manager, event_bus, config_store }
} }
/// Whether the **automatic** trigger is armed. The compactor always exists
/// (manual `/compact` needs no config), so this — not its presence — is what
/// tells the projection that a summary bounds the context.
pub fn auto_enabled(&self) -> bool {
self.config.threshold_tokens.is_some()
}
/// Attempt to compact the conversation history for `stack_id`. /// Attempt to compact the conversation history for `stack_id`.
/// ///
/// * `last_input_tokens` — input tokens from the **previous** turn. /// * `last_input_tokens` — input tokens from the **previous** turn.
@@ -123,10 +130,16 @@ impl ContextCompactor {
if is_ephemeral { if is_ephemeral {
return Ok(false); return Ok(false);
} }
// No threshold configured ⇒ automatic compaction is off and history stays
// append-only. `force_compact` deliberately does not consult this: the
// human asking for `/compact` *is* the trigger.
let Some(threshold) = self.config.threshold_tokens else {
return Ok(false);
};
// A provider that reported no usage leaves only the character estimate. // A provider that reported no usage leaves only the character estimate.
let estimated = chat_history::estimate_tokens_for_stack(pool, stack_id).await?; let estimated = chat_history::estimate_tokens_for_stack(pool, stack_id).await?;
if !should_compact(Some(last_input_tokens), estimated, self.config.threshold_tokens) { if !should_compact(Some(last_input_tokens), estimated, threshold) {
return Ok(false); return Ok(false);
} }
let effective_tokens = if last_input_tokens > 0 { last_input_tokens } else { estimated }; let effective_tokens = if last_input_tokens > 0 { last_input_tokens } else { estimated };
@@ -134,7 +147,7 @@ impl ContextCompactor {
info!( info!(
stack_id, stack_id,
effective_tokens, effective_tokens,
threshold = self.config.threshold_tokens, threshold,
"compactor: threshold exceeded, starting compaction" "compactor: threshold exceeded, starting compaction"
); );
+39 -7
View File
@@ -7,7 +7,14 @@ pub use core_api::provider::LlmStrength;
/// LLM runtime settings (clients are managed via LlmManager / DB, not here). /// LLM runtime settings (clients are managed via LlmManager / DB, not here).
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct LlmConfig { pub struct LlmConfig {
pub max_history_messages: usize, /// Hard cap on the number of history messages projected into the context,
/// applied as a **sliding tail window**. Omit (the default) to disable it:
/// once history exceeds the cap, every turn shifts the window's start, which
/// changes the prompt prefix and costs a full prompt-cache miss on every
/// single request — while dropping the oldest messages with no summary to
/// stand in for them. Set it only when a hard message bound is worth both.
#[serde(default)]
pub max_history_messages: Option<usize>,
pub max_tool_rounds: Option<usize>, pub max_tool_rounds: Option<usize>,
/// Maximum number of synchronous sub-agents run concurrently when the LLM emits /// Maximum number of synchronous sub-agents run concurrently when the LLM emits
/// a homogeneous batch of sub-agent calls in one response. Omit to use the /// a homogeneous batch of sub-agent calls in one response. Omit to use the
@@ -21,8 +28,11 @@ pub struct LlmConfig {
pub max_tool_result_chars: Option<usize>, pub max_tool_result_chars: Option<usize>,
/// Request/response logging configuration. Omit or set `enabled: false` to disable. /// Request/response logging configuration. Omit or set `enabled: false` to disable.
pub requests_log: Option<LlmRequestsLogConfig>, pub requests_log: Option<LlmRequestsLogConfig>,
/// Context compaction settings. Omit to disable automatic compaction. /// Context compaction settings. Omitting the section leaves manual `/compact`
pub compaction: Option<CompactionConfig>, /// working on defaults — only the automatic trigger is opt-in, see
/// [`CompactionConfig::threshold_tokens`].
#[serde(default)]
pub compaction: CompactionConfig,
/// Controls how the current date/time is injected into each LLM request. /// Controls how the current date/time is injected into each LLM request.
#[serde(default)] #[serde(default)]
pub datetime: DatetimeConfig, pub datetime: DatetimeConfig,
@@ -48,12 +58,21 @@ impl Default for DatetimeConfig {
} }
} }
/// Context compaction: summarises conversation history when the LLM context /// Context compaction: summarises conversation history so the context stops
/// exceeds `threshold_tokens`. /// growing.
///
/// The compactor is **always built** — `/compact` is a manual command and must
/// work out of the box. This struct only tunes it, and `threshold_tokens` is
/// the one switch that arms the *automatic* trigger.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct CompactionConfig { pub struct CompactionConfig {
/// Trigger compaction when the previous turn consumed more than this many input tokens. /// Trigger compaction when the previous turn consumed more than this many
pub threshold_tokens: u32, /// input tokens. Omit (the default) to leave automatic compaction **off**:
/// history is then append-only, which is what keeps the prompt prefix — and
/// so the provider's prompt cache — stable across a whole conversation.
/// Manual `/compact` is unaffected either way.
#[serde(default)]
pub threshold_tokens: Option<u32>,
/// Number of recent messages to keep outside the summary. Defaults to 6. /// Number of recent messages to keep outside the summary. Defaults to 6.
#[serde(default = "default_keep_recent")] #[serde(default = "default_keep_recent")]
pub keep_recent: usize, pub keep_recent: usize,
@@ -61,6 +80,19 @@ pub struct CompactionConfig {
pub strength: Option<LlmStrength>, pub strength: Option<LlmStrength>,
} }
/// Hand-written rather than derived: a derived `Default` would give
/// `keep_recent: 0`, silently compacting away every recent message on any box
/// that omits the section — which is now the shipped default.
impl Default for CompactionConfig {
fn default() -> Self {
Self {
threshold_tokens: None,
keep_recent: default_keep_recent(),
strength: None,
}
}
}
/// Event-triage background processor settings. /// Event-triage background processor settings.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct EventTriageConfig { pub struct EventTriageConfig {
@@ -236,7 +236,7 @@ impl AgentCatalog for SkaldAgentCatalog {
)), )),
Some(self.fs.load()), Some(self.fs.load()),
self.config.max_history_messages, self.config.max_history_messages,
self.config.compaction_enabled, self.config.auto_compaction_enabled,
self.config.max_tool_result_chars, 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. /// The knobs Skald's model fleet needs.
/// ///
/// - `max_history_messages` applies **only without compaction**: with the /// - `max_history_messages` is **off by default** (`None`), leaving history
/// compactor on, the summary is what bounds the context, and a window on top /// append-only so the prompt prefix — and the provider's cache of it — stays
/// of it would silently drop messages the summary does not cover. /// 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 /// - tool results are shrunk for previous turns only, so the in-flight turn
/// always sees its own output in full. /// always sees its own output in full.
pub fn skald_projection( pub fn skald_projection(
max_history_messages: usize, max_history_messages: Option<usize>,
compaction_enabled: bool, auto_compaction_enabled: bool,
max_tool_result_chars: Option<usize>, max_tool_result_chars: Option<usize>,
) -> Projection { ) -> Projection {
Projection { Projection {
summary_prefix: SUMMARY_PREFIX.to_string(), summary_prefix: SUMMARY_PREFIX.to_string(),
summary_suffix: Some(SUMMARY_SUFFIX.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_tool_result: max_tool_result_chars.map(|max_chars| ResultLimit {
max_chars, max_chars,
previous_turns_only: true, previous_turns_only: true,
@@ -68,14 +72,14 @@ pub fn skald_projection(
pub fn skald_assembler( pub fn skald_assembler(
activation: Arc<dyn ActivationSource>, activation: Arc<dyn ActivationSource>,
fs: Option<Arc<UserFs>>, fs: Option<Arc<UserFs>>,
max_history_messages: usize, max_history_messages: Option<usize>,
compaction_enabled: bool, auto_compaction_enabled: bool,
max_tool_result_chars: Option<usize>, max_tool_result_chars: Option<usize>,
) -> LinearAssembler { ) -> LinearAssembler {
let mut assembler = LinearAssembler::new() let mut assembler = LinearAssembler::new()
.with_projection(skald_projection( .with_projection(skald_projection(
max_history_messages, max_history_messages,
compaction_enabled, auto_compaction_enabled,
max_tool_result_chars, max_tool_result_chars,
)) ))
.with_activation(activation) .with_activation(activation)
@@ -60,10 +60,12 @@ use crate::tools::tool_names as tn;
pub struct LoopConfig { pub struct LoopConfig {
pub max_rounds: usize, pub max_rounds: usize,
pub max_parallel_calls: 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>, pub max_tool_result_chars: Option<usize>,
/// Compaction bounds the context instead of a message window. /// Automatic compaction bounds the context instead of a message window.
pub compaction_enabled: bool, pub auto_compaction_enabled: bool,
pub datetime: DatetimeConfig, pub datetime: DatetimeConfig,
pub max_agent_depth: u32, pub max_agent_depth: u32,
} }
@@ -272,7 +274,7 @@ impl UserLoopRuntime {
)), )),
Some(self.fs.load()), Some(self.fs.load()),
self.config.max_history_messages, self.config.max_history_messages,
self.config.compaction_enabled, self.config.auto_compaction_enabled,
self.config.max_tool_result_chars, self.config.max_tool_result_chars,
)); ));
@@ -244,8 +244,8 @@ pub async fn project(db: &Db, agent: &AgentFixture, case: &Case) -> Vec<Value> {
None, None,
)), )),
case.fs.clone(), case.fs.clone(),
HISTORY_LIMIT, Some(HISTORY_LIMIT),
// `compaction_enabled: false` mirrors the builder's `compactor: None`. // The snapshots exercise the window, so automatic compaction stays off.
false, false,
Some(TOOL_RESULT_LIMIT), Some(TOOL_RESULT_LIMIT),
); );
+9 -12
View File
@@ -277,9 +277,10 @@ pub struct ChatSessionHandler {
/// (no live oneshot to unblock). The next resume's approval gate skips re-gating /// (no live oneshot to unblock). The next resume's approval gate skips re-gating
/// these so a post-restart approve dispatches the tool without a second prompt. /// these so a post-restart approve dispatches the tool without a second prompt.
pub(super) pre_approved: Arc<std::sync::Mutex<std::collections::HashSet<i64>>>, pub(super) pre_approved: Arc<std::sync::Mutex<std::collections::HashSet<i64>>>,
/// Context compactor, shared across all sessions. `None` when compaction /// Context compactor, shared across all sessions. Always present: `/compact`
/// is disabled (no `compaction` section in config). /// works with no configuration, and the automatic pass below is what
pub(super) compactor: Option<Arc<ContextCompactor>>, /// `CompactionConfig::threshold_tokens` gates.
pub(super) compactor: Arc<ContextCompactor>,
/// This user's loop stack (manager, store, gate, catalog, delegate), built /// This user's loop stack (manager, store, gate, catalog, delegate), built
/// once per `ChatSessionManager` and shared by every session of the owner. /// once per `ChatSessionManager` and shared by every session of the owner.
pub(super) loop_runtime: Arc<crate::loop_adapters::runtime::UserLoopRuntime>, pub(super) loop_runtime: Arc<crate::loop_adapters::runtime::UserLoopRuntime>,
@@ -315,7 +316,7 @@ impl ChatSessionHandler {
event_bus: Arc<ChatEventBus>, event_bus: Arc<ChatEventBus>,
memory_manager: Arc<MemoryManager>, memory_manager: Arc<MemoryManager>,
image_generator_manager: Arc<ImageGeneratorManager>, image_generator_manager: Arc<ImageGeneratorManager>,
compactor: Option<Arc<ContextCompactor>>, compactor: Arc<ContextCompactor>,
run_context: Option<RunContext>, run_context: Option<RunContext>,
loop_runtime: Arc<crate::loop_adapters::runtime::UserLoopRuntime>, loop_runtime: Arc<crate::loop_adapters::runtime::UserLoopRuntime>,
) -> Self { ) -> Self {
@@ -449,16 +450,11 @@ impl ChatSessionHandler {
Some(s) => s, Some(s) => s,
None => return Ok(false), None => return Ok(false),
}; };
match self.compactor { self.compactor.force_compact(
Some(ref compactor) => {
compactor.force_compact(
self.loop_runtime.manager(), pool, &self.user_id, self.loop_runtime.manager(), pool, &self.user_id,
self.session_id, stack.id, self.is_ephemeral, self.session_id, stack.id, self.is_ephemeral,
).await ).await
} }
None => Ok(false),
}
}
/// Processes a user message end-to-end: /// Processes a user message end-to-end:
/// saves it, runs the tool-calling loop, saves the final response, /// saves it, runs the tool-calling loop, saves the final response,
@@ -553,9 +549,10 @@ impl ChatSessionHandler {
// threshold. If so, summarise the old history before processing the // threshold. If so, summarise the old history before processing the
// new message. This keeps latency transparent to the user — the wait // new message. This keeps latency transparent to the user — the wait
// happens here, before the LLM loop, and is not a separate turn. // happens here, before the LLM loop, and is not a separate turn.
if let Some(ref compactor) = self.compactor { // A no-op unless `compaction.threshold_tokens` is configured.
{
let last_tokens = self.last_input_tokens.load(Ordering::Relaxed); let last_tokens = self.last_input_tokens.load(Ordering::Relaxed);
match compactor.try_compact( match self.compactor.try_compact(
self.loop_runtime.manager(), pool, &self.user_id, self.loop_runtime.manager(), pool, &self.user_id,
self.session_id, stack.id, last_tokens, self.is_ephemeral, self.session_id, stack.id, last_tokens, self.is_ephemeral,
).await { ).await {
+10 -5
View File
@@ -44,8 +44,10 @@ pub struct ChatSessionManager {
event_bus: Arc<ChatEventBus>, event_bus: Arc<ChatEventBus>,
memory_manager: Arc<MemoryManager>, memory_manager: Arc<MemoryManager>,
image_generator_manager: Arc<ImageGeneratorManager>, image_generator_manager: Arc<ImageGeneratorManager>,
/// Shared compactor instance, `None` when compaction is disabled. /// Shared compactor instance. Always present — manual `/compact` needs no
compactor: Option<Arc<ContextCompactor>>, /// configuration; `CompactionConfig::threshold_tokens` arms the automatic
/// trigger on top of it.
compactor: Arc<ContextCompactor>,
run_context_manager: Arc<RunContextManager>, run_context_manager: Arc<RunContextManager>,
/// This user's loop stack (blueprint D12): built once here and shared by /// This user's loop stack (blueprint D12): built once here and shared by
/// every session of the owner, so the manager keeps a global view of what /// every session of the owner, so the manager keeps a global view of what
@@ -61,7 +63,7 @@ impl ChatSessionManager {
user_id: String, user_id: String,
user_fs: SharedFs, user_fs: SharedFs,
llm_manager: Arc<LlmManager>, llm_manager: Arc<LlmManager>,
max_history_messages: usize, max_history_messages: Option<usize>,
max_tool_rounds: usize, max_tool_rounds: usize,
max_parallel_subagents: usize, max_parallel_subagents: usize,
max_tool_result_chars: Option<usize>, max_tool_result_chars: Option<usize>,
@@ -73,7 +75,7 @@ impl ChatSessionManager {
event_bus: Arc<ChatEventBus>, event_bus: Arc<ChatEventBus>,
memory_manager: Arc<MemoryManager>, memory_manager: Arc<MemoryManager>,
image_generator_manager: Arc<ImageGeneratorManager>, image_generator_manager: Arc<ImageGeneratorManager>,
compactor: Option<Arc<ContextCompactor>>, compactor: Arc<ContextCompactor>,
run_context_manager: Arc<RunContextManager>, run_context_manager: Arc<RunContextManager>,
tool_discovery: Arc<ToolDiscovery>, tool_discovery: Arc<ToolDiscovery>,
) -> anyhow::Result<Self> { ) -> anyhow::Result<Self> {
@@ -93,7 +95,10 @@ impl ChatSessionManager {
max_parallel_calls: max_parallel_subagents, max_parallel_calls: max_parallel_subagents,
max_history_messages, max_history_messages,
max_tool_result_chars, max_tool_result_chars,
compaction_enabled: compactor.is_some(), // The window yields to the *automatic* compactor, not to its mere
// existence: manual `/compact` alone must not silently disable a
// configured message cap.
auto_compaction_enabled: compactor.auto_enabled(),
datetime: datetime_config.clone(), datetime: datetime_config.clone(),
max_agent_depth: crate::session::handler::MAX_AGENT_DEPTH as u32, max_agent_depth: crate::session::handler::MAX_AGENT_DEPTH as u32,
}, },
+14 -9
View File
@@ -329,23 +329,28 @@ impl Conversation {
} }
info!("run_context manager ready"); info!("run_context manager ready");
let compactor = config.llm.compaction.as_ref().map(|cfg| { // Always built: `/compact` is a manual command and must work with no
info!( // configuration. Only the automatic trigger is opt-in (`threshold_tokens`).
threshold_tokens = cfg.threshold_tokens, let compactor = {
let cfg = &config.llm.compaction;
match cfg.threshold_tokens {
Some(threshold_tokens) => info!(
threshold_tokens,
keep_recent = cfg.keep_recent, keep_recent = cfg.keep_recent,
?cfg.strength, ?cfg.strength,
"context compactor enabled" "context compactor ready (automatic compaction enabled)"
); ),
None => info!(
"context compactor ready (automatic compaction off — /compact only)"
),
}
Arc::new(ContextCompactor::new( Arc::new(ContextCompactor::new(
cfg.clone(), cfg.clone(),
Arc::clone(&models.llm_manager), Arc::clone(&models.llm_manager),
Arc::clone(&rt.event_bus), Arc::clone(&rt.event_bus),
Arc::clone(&rt.config), Arc::clone(&rt.config),
)) ))
}); };
if compactor.is_none() {
info!("context compactor disabled (no compaction config)");
}
// The ownerless manager is inert (no loops, no consumers — see §19): it takes // The ownerless manager is inert (no loops, no consumers — see §19): it takes
// a placeholder UserFs purely to satisfy the type, never used to resolve a path. // a placeholder UserFs purely to satisfy the type, never used to resolve a path.
+6 -7
View File
@@ -139,12 +139,12 @@ pub(super) struct UserContextFactory {
supervisor: Arc<super::supervisor::TaskSupervisor>, supervisor: Arc<super::supervisor::TaskSupervisor>,
shutdown_token: CancellationToken, shutdown_token: CancellationToken,
config_store: Arc<GlobalConfigManager>, config_store: Arc<GlobalConfigManager>,
max_history_messages: usize, max_history_messages: Option<usize>,
max_tool_rounds: usize, max_tool_rounds: usize,
max_parallel_subagents: usize, max_parallel_subagents: usize,
max_tool_result_chars: Option<usize>, max_tool_result_chars: Option<usize>,
datetime_config: DatetimeConfig, datetime_config: DatetimeConfig,
compaction: Option<CompactionConfig>, compaction: CompactionConfig,
cron_tz: Option<Tz>, cron_tz: Option<Tz>,
} }
@@ -217,14 +217,13 @@ impl UserContextFactory {
Arc::clone(&self.tools), Arc::clone(&self.tools),
); );
let compactor = self.compaction.as_ref().map(|cfg| { // Always built (see `bundles.rs`): manual `/compact` needs no config.
Arc::new(ContextCompactor::new( let compactor = Arc::new(ContextCompactor::new(
cfg.clone(), self.compaction.clone(),
Arc::clone(&self.llm_manager), Arc::clone(&self.llm_manager),
Arc::clone(&event_bus), Arc::clone(&event_bus),
Arc::clone(&self.config_store), Arc::clone(&self.config_store),
)) ));
});
// Per-user MCP runtime (blueprint §7/§9): the connectors this user has // Per-user MCP runtime (blueprint §7/§9): the connectors this user has
// activated, run INSIDE their container. Started here on first login and // activated, run INSIDE their container. Started here on first login and
+54 -31
View File
@@ -34,12 +34,26 @@ marketplace:
# via the web app and stored in the database — not in this file. # via the web app and stored in the database — not in this file.
# ─────────────────────────────────────────────────────────────────────────────── # ───────────────────────────────────────────────────────────────────────────────
llm: llm:
# Maximum number of messages kept in the LLM context window. # ── History window (DISABLED by default) ────────────────────────────────────
# NOTE: this setting is ignored when `compaction` is enabled — in that case # Hard cap on the number of history messages sent to the LLM, applied as a
# the compactor manages the token budget and truncating by count would silently # sliding tail window: past the cap, the oldest messages are dropped.
# discard history that should be summarised instead. With compaction active #
# this field has no effect; without it, this is the only context-size guard. # Off by default, for two reasons:
max_history_messages: 30 # 1. Cache. Once history exceeds the cap, every turn shifts the window's
# start, so the prompt prefix changes on every single request and the
# provider's prompt cache (Anthropic breakpoints, OpenAI automatic prefix
# caching) misses every time. Append-only history keeps the prefix stable
# for the whole conversation.
# 2. Memory. The window drops messages with no summary standing in for them,
# so the assistant silently forgets. `/compact` replaces them with a
# summary instead.
#
# With this off and automatic compaction off (the shipped default), the context
# grows until the model's own limit — use `/compact` to summarise it.
# Ignored when automatic compaction is enabled (see `compaction` below).
#
# max_history_messages: 30
# ───────────────────────────────────────────────────────────────────────────
max_tool_rounds: 100 max_tool_rounds: 100
# Max synchronous sub-agents dispatched concurrently when the LLM emits a # Max synchronous sub-agents dispatched concurrently when the LLM emits a
# homogeneous batch (≥2) of sub-agent calls (execute_task mode=sync / # homogeneous batch (≥2) of sub-agent calls (execute_task mode=sync /
@@ -61,12 +75,21 @@ llm:
max_tool_result_chars: 10000 max_tool_result_chars: 10000
# ─────────────────────────────────────────────────────────────────────────── # ───────────────────────────────────────────────────────────────────────────
# ── Context compaction ────────────────────────────────────────────────────── # ── Context compaction (AUTOMATIC pass disabled by default) ─────────────────
# When enabled, the conversation history is automatically summarised when the # Compaction summarises old history into a single block, persisted to the DB
# previous turn consumed more than `threshold_tokens` input tokens. # and injected at the start of subsequent turns in place of the messages it
# The summary is persisted to the DB and injected at the start of subsequent # covers, keeping the last `keep_recent` raw messages for immediate context.
# turns, replacing the old messages while preserving the last `keep_recent` #
# raw messages for immediate context. # The `/compact` command works ALWAYS and needs nothing here — this whole
# section is optional and only tunes it.
#
# `threshold_tokens` is what arms the AUTOMATIC pass: set it, and history is
# compacted on its own once the previous turn exceeded that many input tokens.
# It is COMMENTED OUT by default: every compaction rewrites the prompt prefix
# and so costs a prompt-cache miss, and doing it unprompted trades away context
# the user may still need. Compact manually with `/compact` for now.
# (Future: an automatic pass triggered by the model's own context window rather
# than by a hand-tuned token count.)
# #
# `strength` controls which LLM is picked for summary generation via the AUTO # `strength` controls which LLM is picked for summary generation via the AUTO
# selector (same strength levels used for agent assignment). Compaction is a # selector (same strength levels used for agent assignment). Compaction is a
@@ -80,32 +103,17 @@ llm:
# setups), a rough estimate (total chars / 4) is used as a fallback. # setups), a rough estimate (total chars / 4) is used as a fallback.
# #
# compaction: # compaction:
# threshold_tokens: 30000 # trigger above this many input tokens # threshold_tokens: 30000 # arms the automatic pass, above this many input tokens
# keep_recent: 6 # raw messages kept outside the summary # keep_recent: 6 # raw messages kept outside the summary
# strength: low # LLM strength for summary generation # strength: low # LLM strength for summary generation
# ─────────────────────────────────────────────────────────────────────────── # ───────────────────────────────────────────────────────────────────────────
# ── Event triage (background event processor) ──────────────────────────────
# Event triage runs periodically to process pending MCP events (email, calendar,
# WhatsApp) and decide whether to surface a notification to the user.
#
# interval_secs — how often it runs (default: 900 = 15 minutes)
# batch_size — max events processed per pass (default: 50)
#
# event_triage:
# interval_secs: 900
# batch_size: 50
# ───────────────────────────────────────────────────────────────────────────
# ── Date/time injection ───────────────────────────────────────────────────── # ── Date/time injection ─────────────────────────────────────────────────────
# Controls how the current date/time is injected into each LLM request. # Configured above as `datetime`. Rounding keeps the injected timestamp stable
# By default the exact timestamp is used, which changes every second and # for up to N minutes, so the dynamic tail can be KV-cached across requests
# prevents the dynamic tail from being KV-cached across requests. # instead of changing every second.
#
# datetime:
# enabled: true # set to false to disable injection entirely # enabled: true # set to false to disable injection entirely
# round_minutes: 10 # round down to nearest N minutes (e.g. 10:56 → 10:50) # round_minutes: 10 # round down to nearest N minutes (e.g. 10:56 → 10:50)
# # keeps the string stable for up to N minutes
# ─────────────────────────────────────────────────────────────────────────── # ───────────────────────────────────────────────────────────────────────────
# ── LLM request/response log ──────────────────────────────────────────────── # ── LLM request/response log ────────────────────────────────────────────────
@@ -141,3 +149,18 @@ llm:
cleanup_rows_after: 90 cleanup_rows_after: 90
# ─────────────────────────────────────────────────────────────────────────── # ───────────────────────────────────────────────────────────────────────────
# ── Event triage (background event processor) ──────────────────────────────────
# Runs periodically to process pending MCP events (email, calendar, WhatsApp) and
# decide whether to surface a notification to the user.
#
# NOTE: top-level, NOT under `llm:` — nesting it there parses fine and is then
# silently ignored.
#
# interval_secs — how often it runs (default: 900 = 15 minutes)
# batch_size — max events processed per pass (default: 50)
#
# event_triage:
# interval_secs: 900
# batch_size: 50
# ───────────────────────────────────────────────────────────────────────────────
+5 -1
View File
@@ -16,7 +16,11 @@ They are still admin-only, and still instance-wide. They simply live where their
## Compaction ## Compaction
When a conversation grows too large, the oldest messages are automatically summarised so the context stays within limits. The summary replaces those messages in future turns while the most recent ones are kept verbatim. Compaction summarises the older part of a conversation so the context stays within limits: the summary replaces those messages in future turns, while the most recent ones are kept verbatim.
It runs **on request, not on its own**. Type `/compact` in the chat whenever a conversation has grown long and you want it condensed. Nothing is summarised until you ask, so a conversation keeps its full history — which is also what lets the model provider reuse its cache of the conversation instead of re-reading it from scratch every message.
(An admin can arm an automatic pass by setting `compaction.threshold_tokens` in `config.yml`; it is off in the shipped configuration.)
- **Compaction model** — the model used to write those summaries, for the whole instance. Summarising is a simple writing task, so a cheap, fast model is usually the right choice — there is no reason to spend premium-model tokens on it. Leave it empty for automatic selection (by the `compaction.strength` value in `config.yml`, or the instance's default priority order). If the chosen model is later deleted, compaction silently falls back to automatic selection. - **Compaction model** — the model used to write those summaries, for the whole instance. Summarising is a simple writing task, so a cheap, fast model is usually the right choice — there is no reason to spend premium-model tokens on it. Leave it empty for automatic selection (by the `compaction.strength` value in `config.yml`, or the instance's default priority order). If the chosen model is later deleted, compaction silently falls back to automatic selection.
+24
View File
@@ -99,3 +99,27 @@ impl Config {
pub fn resolved_log_dir() -> std::path::PathBuf { pub fn resolved_log_dir() -> std::path::PathBuf {
std::path::PathBuf::from("logs") std::path::PathBuf::from("logs")
} }
#[cfg(test)]
mod tests {
use super::*;
/// The shipped default is copied verbatim to `config.yml` on first run, so a
/// field it omits must be genuinely optional — a required one would fail the
/// boot of a brand-new install, where nobody has a config to compare against.
#[test]
fn shipped_default_config_parses() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(DEFAULT_CONFIG);
let content = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
let cfg: Config = serde_yaml::from_str(&content).expect("default.config.yaml does not parse");
// Both automatic context reducers ship off: only `/compact` shrinks a
// conversation, so the prompt prefix (and the provider's cache of it)
// stays stable. See the context-size section in CLAUDE.md.
assert_eq!(cfg.llm.max_history_messages, None, "the history window must ship disabled");
assert_eq!(cfg.llm.compaction.threshold_tokens, None, "automatic compaction must ship disabled");
// ...while manual compaction still has usable settings behind it.
assert_eq!(cfg.llm.compaction.keep_recent, 6);
}
}
+1 -1
View File
@@ -277,7 +277,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String,
let _ = socket.send(to_msg(&ServerEvent::Done { let _ = socket.send(to_msg(&ServerEvent::Done {
message_id: 0, message_id: 0,
stack_id: 0, stack_id: 0,
content: "⏩ Compaction skipped (no messages to summarize or compaction disabled).".to_string(), content: "⏩ Compaction skipped (nothing to summarize).".to_string(),
input_tokens: None, input_tokens: None,
output_tokens: None, output_tokens: None,
reasoning_content: None, reasoning_content: None,