feat(agent): LLM auto-compact summarization for subagents (#3694)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
sanil-23
2026-06-15 18:26:39 -07:00
committed by GitHub
co-authored by Claude Steven Enamakel
parent 93c7b3d6a3
commit 363290fcea
8 changed files with 747 additions and 24 deletions
@@ -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<tokio::sync::mpsc::Sender<String>>,
early_exit_tool_names: &[&str],
run_queue: Option<Arc<RunQueue>>,
// 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<TurnEngineOutcome> {
// 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;
@@ -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<Vec<ChatResponse>>,
summarize_calls: Mutex<usize>,
}
impl CompactionProvider {
fn new(responses: Vec<ChatResponse>) -> Arc<Self> {
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<String> {
Ok("noop".into())
}
async fn chat_with_history(
&self,
_messages: &[ChatMessage],
_model: &str,
_temperature: f64,
) -> anyhow::Result<String> {
*self.summarize_calls.lock().unwrap() += 1;
Ok("COMPACTED-SUMMARY-BODY".into())
}
async fn chat(
&self,
_request: ChatRequest<'_>,
_model: &str,
_temperature: f64,
) -> anyhow::Result<ChatResponse> {
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<u64> {
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<crate::openhuman::tools::ToolSpec>,
}
#[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<ChatResponse> {
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<ChatMessage> {
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<ChatMessage>,
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"
);
}
@@ -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?;
@@ -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<crate::openhuman::context::EngineAutocompact> {
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(),
))
}
+1
View File
@@ -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)
+4 -1
View File
@@ -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,
};
+244 -22
View File
@@ -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<ChatMessage> {
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<ChatMessage>` — 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<String>,
}
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<String>) -> 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<ChatMessage>,
model: &str,
keep_recent: usize,
temperature: f64,
) -> Result<SummaryStats> {
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;
+112 -1
View File
@@ -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<String> = 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<String> = history.iter().map(|m| m.content.clone()).collect();
assert_eq!(before, after);
}