From 363290fcea0f4a477992e545f871dff841209c66 Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Tue, 16 Jun 2026 06:56:39 +0530 Subject: [PATCH] feat(agent): LLM auto-compact summarization for subagents (#3694) Co-authored-by: Claude Co-authored-by: Steven Enamakel --- src/openhuman/agent/harness/engine/core.rs | 55 ++++ .../agent/harness/engine/core_tests.rs | 289 ++++++++++++++++++ .../agent/harness/session/turn/core.rs | 1 + .../harness/subagent_runner/ops/loop_.rs | 41 +++ src/openhuman/agent/harness/tool_loop.rs | 1 + src/openhuman/context/mod.rs | 5 +- src/openhuman/context/summarizer.rs | 266 ++++++++++++++-- src/openhuman/context/summarizer_tests.rs | 113 ++++++- 8 files changed, 747 insertions(+), 24 deletions(-) create mode 100644 src/openhuman/agent/harness/engine/core_tests.rs diff --git a/src/openhuman/agent/harness/engine/core.rs b/src/openhuman/agent/harness/engine/core.rs index 28def0277..95d9e15d8 100644 --- a/src/openhuman/agent/harness/engine/core.rs +++ b/src/openhuman/agent/harness/engine/core.rs @@ -25,6 +25,7 @@ use crate::openhuman::agent::cost::TurnCost; use crate::openhuman::agent::multimodal; use crate::openhuman::agent::stop_hooks::{current_stop_hooks, StopDecision, TurnState}; use crate::openhuman::context::guard::{ContextCheckResult, ContextGuard}; +use crate::openhuman::context::{summarize_chat_history, EngineAutocompact}; use crate::openhuman::inference::provider::{ ChatMessage, ChatRequest, Provider, ProviderCapabilityError, }; @@ -90,6 +91,12 @@ pub(crate) async fn run_turn_engine( on_delta: Option>, early_exit_tool_names: &[&str], run_queue: Option>, + // When `Some`, the engine summarizes `history` in place once the context + // guard reports the window is filling (the soft compaction threshold). + // The main `Agent` path leaves this `None` — it compacts through its typed + // `ContextManager` in `observer.before_dispatch` instead — so only the + // sub-agent loop (which has no `ContextManager`) opts in. + autocompact: Option<&EngineAutocompact>, ) -> Result { // Resolve the model's context window once per turn. Local providers (e.g. // LM Studio) report their *runtime-loaded* window here, which can be far @@ -171,6 +178,50 @@ pub(crate) async fn run_turn_engine( "[agent_loop] context guard: compaction needed (>{:.0}% full)", crate::openhuman::context::guard::COMPACTION_TRIGGER_THRESHOLD * 100.0 ); + // Engine-level LLM autocompaction (sub-agent path opts in via + // `autocompact`; the main `Agent` path is `None` and compacts in + // `before_dispatch` instead). Runs BEFORE the hard token-budget + // trim below so the summary captures content the trim would + // otherwise drop. Feeds the guard's circuit breaker so three + // consecutive failures disable it and the next `check()` returns + // `ContextExhausted` rather than looping. + if let Some(ac) = autocompact { + let summary_model = ac.summarizer_model.as_deref().unwrap_or(model); + match summarize_chat_history( + provider, + history, + summary_model, + ac.keep_recent, + ac.temperature, + ) + .await + { + Ok(stats) if stats.messages_removed > 0 => { + context_guard.record_compaction_success(); + tracing::info!( + iteration, + messages_removed = stats.messages_removed, + approx_tokens_freed = stats.approx_tokens_freed, + "[agent_loop] engine autocompaction freed context" + ); + } + Ok(_) => { + tracing::debug!( + iteration, + "[agent_loop] engine autocompaction: nothing to summarize \ + (history below keep_recent); relying on token-budget trim" + ); + } + Err(e) => { + context_guard.record_compaction_failure(); + tracing::warn!( + iteration, + error = %e, + "[agent_loop] engine autocompaction failed" + ); + } + } + } } ContextCheckResult::ContextExhausted { utilization_pct, @@ -781,3 +832,7 @@ pub(crate) async fn run_turn_engine( early_exit_tool: None, }) } + +#[cfg(test)] +#[path = "core_tests.rs"] +mod tests; diff --git a/src/openhuman/agent/harness/engine/core_tests.rs b/src/openhuman/agent/harness/engine/core_tests.rs new file mode 100644 index 000000000..d4a5b4116 --- /dev/null +++ b/src/openhuman/agent/harness/engine/core_tests.rs @@ -0,0 +1,289 @@ +//! Integration tests for the turn engine's autocompaction wiring. +//! +//! Layer 1 (`context::summarizer::tests`) proves `summarize_chat_history` +//! summarizes correctly when called directly. These tests prove the *glue*: +//! that `run_turn_engine` actually invokes it on its own when the context +//! guard reports the window is filling — and, just as importantly, that it +//! does NOT when a caller opts out (`autocompact = None`, the main-agent / +//! channel path). Without these, the feature could silently regress (e.g. a +//! refactor passing `None`, or the `CompactionNeeded` arm never reaching the +//! hook) while every unit test stayed green. +//! +//! The whole flow is driven deterministically with no network: +//! * a scripted provider returns canned responses and reports usage that +//! pushes the guard past its 0.90 trigger (95k / 100k tokens); +//! * the provider pins `effective_context_window` to `None`, so the +//! pre-dispatch token-budget trims stay disabled — autocompaction is the +//! only thing that can mutate `history`; +//! * the first response carries a tool call so the loop runs a second +//! iteration, where `guard.check()` finally sees the recorded high usage. + +use super::*; +use crate::openhuman::agent::harness::engine::progress::NullProgress; +use crate::openhuman::agent::harness::engine::{ + DefaultParser, ErrorCheckpoint, NullObserver, ToolRunResult, ToolSource, +}; +use crate::openhuman::agent::harness::parse::ParsedToolCall; +use crate::openhuman::config::{MultimodalConfig, MultimodalFileConfig}; +use crate::openhuman::context::EngineAutocompact; +use crate::openhuman::inference::provider::{ChatResponse, ToolCall, UsageInfo}; +use async_trait::async_trait; +use std::sync::Mutex; + +/// Provider that replays a queue of `chat()` responses and records every +/// `chat_with_history()` call — that method is ONLY reached via the +/// autocompaction summary, so its call count is a clean "compaction fired" +/// signal independent of inspecting `history`. +struct CompactionProvider { + responses: Mutex>, + summarize_calls: Mutex, +} + +impl CompactionProvider { + fn new(responses: Vec) -> Arc { + Arc::new(Self { + responses: Mutex::new(responses), + summarize_calls: Mutex::new(0), + }) + } + fn summarize_call_count(&self) -> usize { + *self.summarize_calls.lock().unwrap() + } +} + +#[async_trait] +impl Provider for CompactionProvider { + async fn chat_with_system( + &self, + _system: Option<&str>, + _message: &str, + _model: &str, + _temperature: f64, + ) -> anyhow::Result { + Ok("noop".into()) + } + + async fn chat_with_history( + &self, + _messages: &[ChatMessage], + _model: &str, + _temperature: f64, + ) -> anyhow::Result { + *self.summarize_calls.lock().unwrap() += 1; + Ok("COMPACTED-SUMMARY-BODY".into()) + } + + async fn chat( + &self, + _request: ChatRequest<'_>, + _model: &str, + _temperature: f64, + ) -> anyhow::Result { + let mut q = self.responses.lock().unwrap(); + Ok(if q.is_empty() { + ChatResponse { + text: Some("FINAL".into()), + tool_calls: vec![], + usage: None, + reasoning_content: None, + } + } else { + q.remove(0) + }) + } + + fn supports_native_tools(&self) -> bool { + true + } + + /// Pin the effective context window to `None` so the pre-dispatch + /// token-budget trims stay disabled deterministically — autocompaction is + /// then the only thing that can mutate `history`. Don't rely on the + /// unknown-model fallback (it would silently re-enable trimming if the + /// static table ever learned the test's model id). + async fn effective_context_window(&self, _model: &str) -> Option { + None + } +} + +/// Minimal tool source: advertises nothing and reports success for any call, +/// so the engine's tool-execution seam is satisfied without real tools. +struct NoopToolSource { + specs: Vec, +} + +#[async_trait] +impl ToolSource for NoopToolSource { + fn request_specs(&self) -> &[crate::openhuman::tools::ToolSpec] { + &self.specs + } + + async fn execute_call( + &mut self, + _call: &ParsedToolCall, + _iteration: usize, + _progress: &dyn super::ProgressReporter, + _progress_call_id: &str, + ) -> ToolRunResult { + ToolRunResult { + text: "ok".into(), + success: true, + } + } +} + +/// First response: a tool call (so the loop runs a 2nd iteration) plus usage at +/// 95% of a 100k window (so the guard trips on that 2nd iteration). Second +/// response: plain final text, no tools, ending the loop. +fn scripted_responses() -> Vec { + vec![ + ChatResponse { + text: Some(String::new()), + tool_calls: vec![ToolCall { + id: "call-1".into(), + name: "noop".into(), + arguments: "{}".into(), + extra_content: None, + }], + usage: Some(UsageInfo { + input_tokens: 95_000, + output_tokens: 0, + context_window: 100_000, + cached_input_tokens: 0, + charged_amount_usd: 0.0, + }), + reasoning_content: None, + }, + ChatResponse { + text: Some("FINAL".into()), + tool_calls: vec![], + usage: None, + reasoning_content: None, + }, + ] +} + +/// Seed history: a leading system prompt that must survive compaction, plus +/// distinctly-labelled middle messages that must be summarized away. +fn seed_history() -> Vec { + vec![ + ChatMessage::system("SYSTEM"), + ChatMessage::user("TASK"), + ChatMessage::assistant("MID-1"), + ChatMessage::user("MID-2"), + ChatMessage::assistant("MID-3"), + ChatMessage::user("MID-4"), + ChatMessage::user("TAIL-1"), + ChatMessage::assistant("TAIL-2"), + ] +} + +#[allow(clippy::too_many_arguments)] +async fn run( + provider: &dyn Provider, + history: &mut Vec, + autocompact: Option<&EngineAutocompact>, +) -> TurnEngineOutcome { + let mut tool_source = NoopToolSource { specs: Vec::new() }; + let progress = NullProgress; + let mut observer = NullObserver; + let checkpoint = ErrorCheckpoint; + let parser = DefaultParser; + let multimodal = MultimodalConfig::default(); + let multimodal_files = MultimodalFileConfig::default(); + + run_turn_engine( + provider, + history, + &mut tool_source, + &progress, + &mut observer, + &checkpoint, + &parser, + "test-provider", + // The provider pins `effective_context_window` to `None`, so the + // token-budget trims stay disabled, isolating autocompaction as the + // only mutator. The model id is otherwise irrelevant here. + "ctx-test-model-xyz", + 0.0, + true, + &multimodal, + &multimodal_files, + 8, + None, + &[], + None, + autocompact, + ) + .await + .expect("turn engine should complete") +} + +#[tokio::test] +async fn engine_autocompacts_history_when_guard_trips() { + let provider = CompactionProvider::new(scripted_responses()); + let mut history = seed_history(); + + let autocompact = EngineAutocompact { + keep_recent: 2, + temperature: 0.2, + summarizer_model: None, + }; + + let outcome = run(provider.as_ref(), &mut history, Some(&autocompact)).await; + assert_eq!(outcome.text, "FINAL"); + + // The summary round-trip fired exactly once (only reachable via autocompact). + assert_eq!( + provider.summarize_call_count(), + 1, + "guard should have triggered exactly one autocompaction summary call" + ); + + // Leading system prompt survived verbatim at the head. + assert_eq!(history[0].role, "system"); + assert_eq!(history[0].content, "SYSTEM"); + + // The reference-only summary (carrying the stub body) is now in history. + assert!( + history.iter().any(|m| { + m.role == "system" + && m.content.contains("COMPACTED-SUMMARY-BODY") + && m.content.contains("REFERENCE ONLY") + && m.content.contains("END OF CONTEXT SUMMARY") + }), + "expected a reference-only summary message in history: {history:?}" + ); + + // Middle messages were collapsed into the summary, not left verbatim. + assert!( + !history.iter().any(|m| m.content == "MID-1"), + "old middle messages should have been summarized away: {history:?}" + ); +} + +#[tokio::test] +async fn engine_does_not_autocompact_when_opted_out() { + // Same guard-tripping scenario, but `autocompact = None` (the main-agent / + // channel path). The engine must NOT summarize — proving the behavior is + // gated on the opt-in, not on the guard alone. + let provider = CompactionProvider::new(scripted_responses()); + let mut history = seed_history(); + + let outcome = run(provider.as_ref(), &mut history, None).await; + assert_eq!(outcome.text, "FINAL"); + + assert_eq!( + provider.summarize_call_count(), + 0, + "no autocompaction summary call should happen when opted out" + ); + // Original middle messages remain untouched. + assert!(history.iter().any(|m| m.content == "MID-1")); + assert!( + !history + .iter() + .any(|m| m.content.contains("END OF CONTEXT SUMMARY")), + "no summary message should be inserted when opted out" + ); +} diff --git a/src/openhuman/agent/harness/session/turn/core.rs b/src/openhuman/agent/harness/session/turn/core.rs index 877664e61..afca8bad8 100644 --- a/src/openhuman/agent/harness/session/turn/core.rs +++ b/src/openhuman/agent/harness/session/turn/core.rs @@ -539,6 +539,7 @@ impl Agent { None, // the web bridge streams via on_progress deltas, not on_delta &[], turn_run_queue, + None, // main agent compacts via its ContextManager in before_dispatch )), ) .await?; diff --git a/src/openhuman/agent/harness/subagent_runner/ops/loop_.rs b/src/openhuman/agent/harness/subagent_runner/ops/loop_.rs index f6945a609..07408bda3 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops/loop_.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops/loop_.rs @@ -170,6 +170,15 @@ pub(super) async fn run_inner_loop( }; let parser = super::super::super::engine::DefaultParser; + + // Sub-agents have no typed `ContextManager`, so opt the shared turn engine + // into LLM autocompaction: when the context guard reports the window is + // filling, the engine summarizes the flat `ChatMessage` history in place + // (protecting the leading system prompt + recent tail) instead of only + // hard-trimming the oldest messages. Gated on the same `context` config the + // main chat uses, so disabling autocompaction disables it everywhere. + let autocompact = subagent_autocompact_config().await; + // Heap-allocate the child `run_turn_engine` state machine. Sub-agents // run as nested polls inside the *parent* agent's `run_turn_engine` // (the orchestrator → tool exec → `dispatch_subagent` → `run_subagent` @@ -204,6 +213,7 @@ pub(super) async fn run_inner_loop( None, // sub-agents don't stream a draft &["ask_user_clarification"], run_queue, // steering channel for `steer_subagent` (None = non-steerable) + autocompact.as_ref(), )), ) .await?; @@ -215,3 +225,34 @@ pub(super) async fn run_inner_loop( outcome.early_exit_tool, )) } + +/// Build the sub-agent's engine-autocompaction config from the global +/// `context` settings, or `None` when context management / autocompaction is +/// disabled (in which case the engine falls back to hard token-budget trim). +/// +/// Reuses the same `enabled` + `autocompact_enabled` toggles and +/// `summarizer_model` override as the main chat, so the two paths stay in sync. +async fn subagent_autocompact_config() -> Option { + let config = match crate::openhuman::config::Config::load_or_init().await { + Ok(cfg) => cfg, + Err(err) => { + tracing::warn!( + error = %err, + "[subagent_runner] failed to load config; engine autocompaction disabled" + ); + return None; + } + }; + let ctx = &config.context; + if !ctx.enabled || !ctx.autocompact_enabled { + tracing::debug!( + enabled = ctx.enabled, + autocompact_enabled = ctx.autocompact_enabled, + "[subagent_runner] engine autocompaction disabled by context config" + ); + return None; + } + Some(crate::openhuman::context::EngineAutocompact::with_defaults( + ctx.summarizer_model.clone(), + )) +} diff --git a/src/openhuman/agent/harness/tool_loop.rs b/src/openhuman/agent/harness/tool_loop.rs index 5f518a241..f26962a4a 100644 --- a/src/openhuman/agent/harness/tool_loop.rs +++ b/src/openhuman/agent/harness/tool_loop.rs @@ -373,6 +373,7 @@ pub(crate) async fn run_tool_call_loop( on_delta, &[], None, + None, // channel/CLI/triage loop: context guard + token-budget trim only ) .await .map(|outcome| outcome.text) diff --git a/src/openhuman/context/mod.rs b/src/openhuman/context/mod.rs index 95f17be6c..1f41de03b 100644 --- a/src/openhuman/context/mod.rs +++ b/src/openhuman/context/mod.rs @@ -55,7 +55,10 @@ pub use session_memory::{ SessionMemoryConfig, SessionMemoryState, ARCHIVIST_EXTRACTION_PROMPT, DEFAULT_MIN_TOKEN_GROWTH, DEFAULT_MIN_TOOL_CALLS, DEFAULT_MIN_TURNS_BETWEEN, }; -pub use summarizer::{ProviderSummarizer, Summarizer, SummaryStats}; +pub use summarizer::{ + summarize_chat_history, EngineAutocompact, ProviderSummarizer, Summarizer, SummaryStats, + DEFAULT_KEEP_RECENT, DEFAULT_SUMMARIZER_TEMPERATURE, +}; pub use tool_result_budget::{ apply_tool_result_budget, BudgetOutcome, DEFAULT_TOOL_RESULT_BUDGET_BYTES, }; diff --git a/src/openhuman/context/summarizer.rs b/src/openhuman/context/summarizer.rs index 6c491eff7..286ea2d6b 100644 --- a/src/openhuman/context/summarizer.rs +++ b/src/openhuman/context/summarizer.rs @@ -43,17 +43,89 @@ pub const DEFAULT_KEEP_RECENT: usize = 10; /// history produces stable summaries across retries. pub const DEFAULT_SUMMARIZER_TEMPERATURE: f64 = 0.2; -/// The system prompt pinned to every summarization call. Intentionally -/// short so it burns as few tokens as possible on a call whose whole -/// purpose is to *free* tokens. -pub const SUMMARIZER_SYSTEM_PROMPT: &str = - "You are a conversation summarizer. Your job is to take \ -a chronological history of a conversation between a user and an AI assistant (including any tool \ -calls and their results) and produce a compact, information-dense summary that preserves: \ -(1) the user's goals and constraints, (2) decisions made so far, (3) important facts discovered \ -via tool calls, (4) open questions or pending work. Do NOT preserve verbatim quotes, greetings, \ -small talk, or redundant acknowledgements. Return ONLY the summary text — no preamble, no \ -closing remarks."; +/// The system prompt pinned to every summarization call. +/// +/// Structured, reference-only checkpoint template adapted from the Hermes +/// agent's context compactor (`agent/context_compressor.py`): it asks for a +/// fixed set of sections so the handoff is dense and parseable, and it frames +/// the whole note as **background reference** so a weaker model doesn't read a +/// summarized "pending ask" as a fresh instruction. Kept as tight as the +/// structure allows — this call's whole purpose is to *free* tokens. +pub const SUMMARIZER_SYSTEM_PROMPT: &str = "You are a summarization agent creating a context \ +checkpoint for an AI assistant whose conversation has grown too long to fit its context window. \ +You are given the earlier portion of a chronological conversation (user, assistant, and tool \ +messages). Compress it into a dense, structured handoff note that the assistant will read as \ +BACKGROUND REFERENCE — not as new instructions.\n\ +\n\ +Rules:\n\ +- Write ONLY the structured summary below. No greeting, no preamble, no closing remarks.\n\ +- This is reference material describing turns that ALREADY happened. Do NOT answer any question \ +or perform any task mentioned in it. The assistant acts only on the live messages that appear \ +AFTER this summary; if a later message contradicts or changes topic, the later message wins.\n\ +- Redact secrets: replace any API keys, tokens, passwords, or credentials with [REDACTED] (note \ +that a credential was present).\n\ +- Be specific and information-dense: prefer concrete facts (paths, names, values, decisions) over \ +narration. Drop greetings, small talk, and redundant acknowledgements.\n\ +\n\ +Produce exactly these sections (write \"None\" when a section is empty):\n\ +\n\ +## Goal\n\ +What the user is ultimately trying to accomplish.\n\ +\n\ +## Completed Actions\n\ +Numbered list of what has already been done, with key results/outputs.\n\ +\n\ +## Active State\n\ +The current state of the work right now: files touched, systems configured, what is true.\n\ +\n\ +## Key Decisions\n\ +Decisions made and the reasoning, so they are not relitigated.\n\ +\n\ +## Resolved Questions\n\ +Questions already answered — include the answer so it is not repeated.\n\ +\n\ +## Pending / Open (reference only)\n\ +Requests or work outstanding in the compacted turns. These are STALE — do NOT act on them unless \ +the latest live message explicitly asks.\n\ +\n\ +## Relevant Files\n\ +Files read, created, or modified, with a one-line note on each.\n\ +\n\ +## Critical Context\n\ +Anything else essential to continue correctly (constraints, environment facts, gotchas)."; + +/// Prefix prepended to the inserted summary message so the model treats the +/// block as a background handoff rather than live instructions. +const SUMMARY_PREFIX: &str = "[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted \ +into the summary below. Treat it as background reference, not active instructions — act only on \ +the messages that appear after it."; + +/// Appended to the inserted summary message so the model has an unambiguous +/// boundary. Without it, weaker models read a verbatim quote inside the summary +/// (e.g. an `## Active State` line) as fresh user input. Mirrors the Hermes +/// `_SUMMARY_END_MARKER`. +const SUMMARY_END_MARKER: &str = + "--- END OF CONTEXT SUMMARY — respond to the messages below, not the summary above ---"; + +/// Build the two-message request (`system` instruction + `user` transcript) +/// sent to the summarizer. Shared by the typed [`ProviderSummarizer`] and the +/// `ChatMessage`-level [`summarize_chat_history`] so both paths use the same +/// structured prompt. +fn build_summary_request(transcript: &str) -> Vec { + vec![ + ChatMessage::system(SUMMARIZER_SYSTEM_PROMPT), + ChatMessage::user(format!( + "Summarize the conversation history below for continuation, following the section \ + structure exactly.\n\n--- BEGIN HISTORY ---\n{transcript}\n--- END HISTORY ---" + )), + ] +} + +/// Wrap the model's raw summary in the reference-only prefix + end marker that +/// frame the inserted message. Shared by both summarizer paths. +fn build_summary_message_body(summary: &str) -> String { + format!("{SUMMARY_PREFIX}\n\n{summary}\n\n{SUMMARY_END_MARKER}") +} /// Outcome of a single summarization pass. /// @@ -178,15 +250,8 @@ impl Summarizer for ProviderSummarizer { let transcript = render_transcript(&history[..head_len]); let approx_input_bytes = transcript.len(); - // Summarization chat call — one turn, no tools, fixed system. - let messages = vec![ - ChatMessage::system(SUMMARIZER_SYSTEM_PROMPT), - ChatMessage::user(format!( - "Summarize this conversation history for continuation. Focus on goals, \ - decisions, facts, and pending work.\n\n--- BEGIN HISTORY ---\n{transcript}\n\ - --- END HISTORY ---" - )), - ]; + // Summarization chat call — one turn, no tools, shared structured prompt. + let messages = build_summary_request(&transcript); tracing::info!( model, @@ -210,8 +275,7 @@ impl Summarizer for ProviderSummarizer { anyhow::bail!("summarizer returned empty response"); } - let summary_body = - format!("[auto-compacted] Summary of {head_len} earlier messages:\n\n{summary}"); + let summary_body = build_summary_message_body(summary); let summary_chars = summary_body.len(); let approx_tokens_freed = (approx_input_bytes as u64) .saturating_sub(summary_chars as u64) @@ -366,6 +430,164 @@ fn render_transcript(msgs: &[ConversationMessage]) -> String { out } +/// Opt-in configuration for engine-level autocompaction. +/// +/// The main `Agent` path drives summarization through its typed +/// [`super::ContextManager`] (via the turn engine's `before_dispatch` hook), so +/// it leaves this `None`. Sub-agents have no `ContextManager` — they run the +/// shared turn engine directly over a flat `Vec` — so they pass +/// `Some(_)` to make the engine summarize in place when the context guard +/// reports the window is filling up. See [`summarize_chat_history`]. +#[derive(Debug, Clone)] +pub struct EngineAutocompact { + /// Most-recent messages preserved verbatim at the tail. + pub keep_recent: usize, + /// Temperature for the summarization call. + pub temperature: f64, + /// Optional cheaper model for the summary call; falls back to the agent's + /// own model when `None`. + pub summarizer_model: Option, +} + +impl EngineAutocompact { + /// Construct with the shared summarizer defaults ([`DEFAULT_KEEP_RECENT`], + /// [`DEFAULT_SUMMARIZER_TEMPERATURE`]) and no model override. + pub fn with_defaults(summarizer_model: Option) -> Self { + Self { + keep_recent: DEFAULT_KEEP_RECENT, + temperature: DEFAULT_SUMMARIZER_TEMPERATURE, + summarizer_model, + } + } +} + +/// Summarize a flat `ChatMessage` history in place, mirroring +/// [`ProviderSummarizer::summarize`] but for the sub-agent turn engine, which +/// has no typed [`ConversationMessage`] history. +/// +/// Differences from the typed path, both required by the `ChatMessage` shape: +/// +/// 1. **A leading `system` message is protected**, never summarized — for +/// sub-agents `history[0]` is the rendered system prompt (role contract, +/// tools, identity) and must survive compaction verbatim. +/// 2. **The tail is snapped off any orphan `role:tool` messages.** A tool +/// result whose originating assistant call lands in the summarized head +/// would be rejected by strict providers ("no tool call for result"), so we +/// push the boundary forward until the tail starts on a clean message. +/// +/// Follows the same no-partial-mutation contract: on any early return or error +/// `history` is left untouched, so [`super::ContextGuard`]'s circuit breaker can +/// treat failure as "nothing happened". +pub async fn summarize_chat_history( + provider: &dyn Provider, + history: &mut Vec, + model: &str, + keep_recent: usize, + temperature: f64, +) -> Result { + let total = history.len(); + + // Protect a leading system message (the agent's system prompt). + let head_start = usize::from(history.first().map(|m| m.role == "system").unwrap_or(false)); + + // Need the protected head + something to summarize + the preserved tail. + if total <= head_start + keep_recent { + tracing::debug!( + total, + head_start, + keep_recent, + "[context::chat_summarizer] nothing to summarize — history below keep_recent" + ); + return Ok(SummaryStats::default()); + } + + // Head end = everything before the preserved tail … + let mut head_end = total - keep_recent; + if head_end <= head_start { + return Ok(SummaryStats::default()); + } + // … snapped forward past any orphan tool results so the tail starts clean. + while head_end < total && history[head_end].role == "tool" { + head_end += 1; + } + if head_end >= total { + // Snapping consumed the whole tail — nothing safe to summarize. + return Ok(SummaryStats::default()); + } + + let transcript = render_chat_transcript(&history[head_start..head_end]); + let approx_input_bytes = transcript.len(); + let messages = build_summary_request(&transcript); + + tracing::info!( + model, + head_messages = head_end - head_start, + protected_head = head_start, + tail_preserved = total - head_end, + approx_input_bytes, + "[context::chat_summarizer] dispatching sub-agent autocompaction summary" + ); + + let response = provider + .chat_with_history(&messages, model, temperature) + .await + .map_err(|e| { + tracing::warn!(error = %e, "[context::chat_summarizer] provider call failed"); + e + })?; + + let summary = response.trim(); + if summary.is_empty() { + anyhow::bail!("summarizer returned empty response"); + } + + let summary_body = build_summary_message_body(summary); + let summary_chars = summary_body.len(); + let approx_tokens_freed = (approx_input_bytes as u64) + .saturating_sub(summary_chars as u64) + .div_ceil(4); + let messages_removed = head_end - head_start; + + // Replace [head_start, head_end) with one `system` summary message. The + // protected leading system prompt (if any) and the verbatim tail are kept. + history.splice( + head_start..head_end, + std::iter::once(ChatMessage::system(summary_body)), + ); + + tracing::info!( + messages_removed, + approx_tokens_freed, + summary_chars, + "[context::chat_summarizer] sub-agent autocompaction complete" + ); + + Ok(SummaryStats { + messages_removed, + approx_tokens_freed, + summary_chars, + }) +} + +/// Render a slice of `ChatMessage` as the plain-text transcript the summarizer +/// reads. Image markers are stripped (the summarizer is a text model and a raw +/// base64 data URI would blow its input budget — see [`redact_image_markers`]). +fn render_chat_transcript(msgs: &[ChatMessage]) -> String { + let mut out = String::new(); + for (i, m) in msgs.iter().enumerate() { + if i > 0 { + out.push('\n'); + } + let _ = writeln!( + &mut out, + "[{i}] {}: {}", + m.role, + redact_image_markers(&m.content) + ); + } + out +} + #[cfg(test)] #[path = "summarizer_tests.rs"] mod tests; diff --git a/src/openhuman/context/summarizer_tests.rs b/src/openhuman/context/summarizer_tests.rs index 33a0d77c8..8b3ddc625 100644 --- a/src/openhuman/context/summarizer_tests.rs +++ b/src/openhuman/context/summarizer_tests.rs @@ -139,7 +139,8 @@ async fn summarizes_long_history_and_replaces_head() { ConversationMessage::Chat(m) => { assert_eq!(m.role, "system"); assert!(m.content.contains("SUMMARY_BODY")); - assert!(m.content.contains("[auto-compacted]")); + assert!(m.content.contains("REFERENCE ONLY")); + assert!(m.content.contains("END OF CONTEXT SUMMARY")); } other => panic!("expected system summary, got {other:?}"), } @@ -268,3 +269,113 @@ fn render_transcript_strips_image_base64() { // The 50k-char base64 payload must not survive into the summarizer input. assert!(rendered.len() < 200); } + +// ── ChatMessage-level summarizer (sub-agent engine path) ──────────────────── + +#[tokio::test] +async fn chat_summary_noop_when_below_keep_recent() { + let provider = Arc::new(StubProvider::new("IRRELEVANT")); + let mut history = vec![ + ChatMessage::system("sys"), + ChatMessage::user("hi"), + ChatMessage::assistant("hello"), + ]; + + let stats = summarize_chat_history(provider.as_ref(), &mut history, "m", 10, 0.2) + .await + .unwrap(); + + assert_eq!(stats.messages_removed, 0); + assert_eq!(history.len(), 3); + assert_eq!(provider.call_count(), 0, "must not call provider on no-op"); +} + +#[tokio::test] +async fn chat_summary_protects_leading_system_and_replaces_middle() { + let provider = Arc::new(StubProvider::new("SUMMARY_BODY")); + let mut history = vec![ + ChatMessage::system("SYSTEM-PROMPT"), + ChatMessage::user("q1"), + ChatMessage::assistant("a1"), + ChatMessage::user("q2"), + ChatMessage::assistant("a2"), + ChatMessage::user("q3-tail"), + ChatMessage::assistant("a3-tail"), + ]; + + let stats = summarize_chat_history(provider.as_ref(), &mut history, "m", 2, 0.2) + .await + .unwrap(); + + // System prompt protected; middle 4 collapsed to 1 summary; 2-tail kept. + assert_eq!(stats.messages_removed, 4); + assert_eq!(history.len(), 4, "system + summary + 2 tail"); + assert_eq!(provider.call_count(), 1); + + // Leading system prompt preserved verbatim. + assert_eq!(history[0].role, "system"); + assert_eq!(history[0].content, "SYSTEM-PROMPT"); + + // Inserted summary sits right after it, framed as reference-only. + assert_eq!(history[1].role, "system"); + assert!(history[1].content.contains("SUMMARY_BODY")); + assert!(history[1].content.contains("REFERENCE ONLY")); + assert!(history[1].content.contains("END OF CONTEXT SUMMARY")); + + // Tail preserved verbatim. + assert_eq!(history[2].content, "q3-tail"); + assert_eq!(history[3].content, "a3-tail"); +} + +#[tokio::test] +async fn chat_summary_snaps_tail_off_orphan_tool_result() { + // With keep_recent=2 the proposed tail would begin at the `tool` result, + // orphaning it from the assistant message that requested it. The snap must + // pull the boundary forward so the kept tail starts on a clean message. + let provider = Arc::new(StubProvider::new("SUMMARY")); + let mut history = vec![ + ChatMessage::system("sys"), + ChatMessage::user("q"), + ChatMessage::assistant("calling tool"), + ChatMessage::tool("tool-result"), + ChatMessage::user("tail-q"), + ChatMessage::assistant("tail-a"), + ]; + + summarize_chat_history(provider.as_ref(), &mut history, "m", 2, 0.2) + .await + .unwrap(); + + // No kept message may be an orphan `tool` result, and the system prompt + // stays at the head. + assert_eq!(history[0].content, "sys"); + assert_eq!(history[1].role, "system", "summary message"); + assert!( + history.iter().all(|m| m.role != "tool"), + "orphan tool result must be folded into the summarized head" + ); + // Tail kept verbatim. + assert_eq!(history[history.len() - 2].content, "tail-q"); + assert_eq!(history[history.len() - 1].content, "tail-a"); +} + +#[tokio::test] +async fn chat_summary_empty_response_errors_and_leaves_history_untouched() { + let provider = Arc::new(StubProvider::new(" \n\t ")); + let mut history = vec![ + ChatMessage::system("sys"), + ChatMessage::user("q1"), + ChatMessage::assistant("a1"), + ChatMessage::user("q2-tail"), + ]; + let before: Vec = history.iter().map(|m| m.content.clone()).collect(); + + let err = summarize_chat_history(provider.as_ref(), &mut history, "m", 1, 0.2) + .await + .unwrap_err(); + assert!(err.to_string().contains("empty")); + + // No partial mutation on failure. + let after: Vec = history.iter().map(|m| m.content.clone()).collect(); + assert_eq!(before, after); +}