handoff: pass full session history to summarizer (#1033)

Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
tlongwell-block
2026-06-13 15:49:05 -04:00
committed by GitHub
co-authored by npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf
parent 6e4c8680c8
commit fa1cade3ba
3 changed files with 195 additions and 33 deletions
-4
View File
@@ -18,12 +18,8 @@ pub const MAX_TOOL_CALLS_PER_TURN: usize = 64;
pub const HANDOFF_MAX_OUTPUT_TOKENS: u32 = 8192;
pub const HANDOFF_TAIL_ITEMS: usize = 5;
pub const HANDOFF_ORIGINAL_TASK_MAX_BYTES: usize = 16 * 1024;
pub const HANDOFF_PROMPT_MAX_BYTES: usize = 32 * 1024;
pub const HANDOFF_MAX_TOOL_NAMES: usize = 20;
const DEFAULT_SYSTEM_PROMPT: &str =
+69 -29
View File
@@ -1,7 +1,6 @@
use crate::agent::RunCtx;
use crate::config::{
HANDOFF_MAX_OUTPUT_TOKENS, HANDOFF_MAX_TOOL_NAMES, HANDOFF_ORIGINAL_TASK_MAX_BYTES,
HANDOFF_PROMPT_MAX_BYTES, HANDOFF_TAIL_ITEMS,
};
use crate::types::HistoryItem;
@@ -16,8 +15,6 @@ turn of an autonomous agent. Be concise but thorough. Cover: what the original t
you accomplished, key decisions made, what remains, and one concrete next step. Output plain \
text only no tool calls, no JSON. Stay under 8192 tokens.";
const HANDOFF_SNIPPET_BYTES: usize = 2048;
impl RunCtx<'_> {
pub(crate) async fn maybe_handoff(&mut self) -> HandoffOutcome {
if !self.should_handoff() {
@@ -160,32 +157,46 @@ impl RunCtx<'_> {
Produce a context handoff summary covering: (1) original task, \
(2) what was accomplished, (3) key decisions, (4) what remains, \
(5) one concrete next step. Be concise but thorough. Plain text.\n";
let history_header = "\n# Recent History (most recent last)\n";
let history_header = "\n# Session History (oldest first)\n";
let prompt_budget = handoff_prompt_budget_bytes(
self.cfg.max_context_tokens,
HANDOFF_MAX_OUTPUT_TOKENS,
head.len() + history_header.len() + tail.len(),
);
let start = self.history.len().saturating_sub(HANDOFF_TAIL_ITEMS);
let mut snippets: Vec<String> = self.history[start..]
.iter()
.map(|item| {
let mut s = String::new();
push_history_snippet(&mut s, item);
s
})
.collect();
let fixed = head.len() + history_header.len() + tail.len();
let mut snippets_bytes: usize = snippets.iter().map(String::len).sum();
let mut snippets: Vec<String> = Vec::new();
let mut snippets_bytes = 0usize;
let mut dropped = 0usize;
while fixed + snippets_bytes > HANDOFF_PROMPT_MAX_BYTES && !snippets.is_empty() {
let removed = snippets.remove(0);
snippets_bytes -= removed.len();
dropped += 1;
for item in self.history.iter().rev() {
let mut snippet = String::new();
push_history_snippet(&mut snippet, item);
let snippet_bytes = snippet.len();
if snippets_bytes.saturating_add(snippet_bytes) > prompt_budget {
if snippets.is_empty() {
snippets.push(clamp_bytes(&snippet, prompt_budget));
snippets_bytes = prompt_budget;
}
dropped += 1;
continue;
}
snippets_bytes += snippet_bytes;
snippets.push(snippet);
}
snippets.reverse();
if dropped > 0 {
tracing::info!("handoff prompt cap, dropped {dropped} oldest snippets");
tracing::info!(
"handoff prompt budget, dropped {dropped} oldest snippets; kept {} bytes",
snippets_bytes
);
}
let mut out =
String::with_capacity(fixed + snippets_bytes + if dropped > 0 { 32 } else { 0 });
let mut out = String::with_capacity(
head.len()
+ history_header.len()
+ tail.len()
+ snippets_bytes
+ if dropped > 0 { 32 } else { 0 },
);
out.push_str(&head);
out.push_str(history_header);
if dropped > 0 {
@@ -211,13 +222,13 @@ fn push_history_snippet(out: &mut String, item: &HistoryItem) {
match item {
HistoryItem::User(s) => {
out.push_str("[user] ");
out.push_str(&clamp_for_snippet(s));
out.push_str(s);
out.push('\n');
}
HistoryItem::Assistant { text, tool_calls } => {
out.push_str("[assistant] ");
if !text.is_empty() {
out.push_str(&clamp_for_snippet(text));
out.push_str(text);
}
for c in tool_calls {
out.push_str(&format!(" tool:{}", c.name));
@@ -226,14 +237,30 @@ fn push_history_snippet(out: &mut String, item: &HistoryItem) {
}
HistoryItem::ToolResult(r) => {
out.push_str(if r.is_error { "[tool_err] " } else { "[tool] " });
out.push_str(&clamp_for_snippet(&r.text()));
out.push_str(&r.text());
out.push('\n');
}
}
}
fn clamp_for_snippet(s: &str) -> String {
clamp_bytes(s, HANDOFF_SNIPPET_BYTES)
/// Byte budget for session-history text inside the handoff prompt. The
/// summarizer uses the same provider/model config as normal completion, so
/// derive the input budget from the model context window instead of applying a
/// separate fixed prompt cap. We keep the same 1 byte/token upper-bound
/// estimate used by the handoff gate, which is conservative: it may drop old
/// history early for unusually large sessions, but it should not build a prompt
/// that exceeds the configured context window.
fn handoff_prompt_budget_bytes(
max_context_tokens: u64,
max_output_tokens: u32,
fixed_prompt_bytes: usize,
) -> usize {
max_context_tokens
.saturating_sub(u64::from(max_output_tokens))
.saturating_mul(CONSERVATIVE_BYTES_PER_TOKEN)
.saturating_sub(u64::try_from(fixed_prompt_bytes).unwrap_or(u64::MAX))
.try_into()
.unwrap_or(usize::MAX)
}
pub(crate) fn clamp_bytes(s: &str, max_bytes: usize) -> String {
@@ -299,7 +326,20 @@ fn byte_fallback_threshold(
#[cfg(test)]
mod tests {
use super::{byte_fallback_threshold, estimate_tokens_from_bytes, token_threshold};
use super::{
byte_fallback_threshold, estimate_tokens_from_bytes, handoff_prompt_budget_bytes,
token_threshold,
};
#[test]
fn handoff_prompt_budget_reserves_summary_output_and_fixed_prompt() {
assert_eq!(handoff_prompt_budget_bytes(25_000, 8_192, 1_000), 15_808);
}
#[test]
fn handoff_prompt_budget_saturates_when_fixed_prompt_exceeds_window() {
assert_eq!(handoff_prompt_budget_bytes(1_000, 2_000, 10_000), 0);
}
#[test]
fn token_threshold_uses_fraction_when_output_is_small() {
+126
View File
@@ -1132,6 +1132,132 @@ async fn hook_post_compact_injects_after_handoff() {
h.shutdown().await;
}
/// The handoff summary prompt should include all session history when that
/// history fits the summarizer context budget. This protects against regressing
/// to the old fixed tail of five tiny snippets.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn handoff_summary_prompt_includes_full_history_within_context_budget() {
let llm = spawn_capturing_llm(vec![
openai_text_with_usage("ack-0", 9500),
openai_text("handoff summary text"),
openai_text_with_usage("done", 10),
])
.await;
let mut h = Harness::spawn_with_env(
&llm.url,
&[
("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "10000"),
("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "1000"),
("BUZZ_AGENT_MAX_HANDOFFS", "3"),
(
"BUZZ_AGENT_MAX_HISTORY_BYTES",
&(16 * 1024 * 1024).to_string(),
),
],
)
.await;
let sid = init_session(&mut h, json!([])).await;
let p0 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"early-history-marker"}]}),
)
.await;
let _ = h.recv_until(|v| v["id"] == json!(p0)).await;
let p1 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"late-history-marker"}]}),
)
.await;
let _ = h.recv_until(|v| v["id"] == json!(p1)).await;
let captured = llm.captured.lock().await;
assert_eq!(captured.len(), 3, "expected prompt, handoff, prompt");
let handoff_messages = captured[1]["messages"].as_array().unwrap();
let handoff_prompt = handoff_messages[1]["content"].as_str().unwrap();
assert!(
handoff_prompt.contains("# Session History (oldest first)"),
"handoff prompt should describe full session history: {handoff_prompt}"
);
assert!(
handoff_prompt.contains("early-history-marker"),
"oldest prompt was omitted despite fitting budget: {handoff_prompt}"
);
assert!(
handoff_prompt.contains("ack-0"),
"assistant response was omitted despite fitting budget: {handoff_prompt}"
);
assert!(
handoff_prompt.contains("late-history-marker"),
"latest prompt was omitted despite fitting budget: {handoff_prompt}"
);
assert!(
!handoff_prompt.contains("older items omitted"),
"handoff should not report truncation when full history fits: {handoff_prompt}"
);
h.shutdown().await;
}
/// If one item is larger than the derived summarizer budget, keep a truncated
/// form of the most recent item instead of sending an empty history block.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn handoff_summary_prompt_keeps_latest_item_when_one_item_exceeds_budget() {
let llm = spawn_capturing_llm(vec![
openai_text_with_usage("ack-0", 9500),
openai_text("handoff summary text"),
openai_text_with_usage("done", 10),
])
.await;
let mut h = Harness::spawn_with_env(
&llm.url,
&[
("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "10000"),
("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "1000"),
("BUZZ_AGENT_MAX_HANDOFFS", "3"),
(
"BUZZ_AGENT_MAX_HISTORY_BYTES",
&(16 * 1024 * 1024).to_string(),
),
],
)
.await;
let sid = init_session(&mut h, json!([])).await;
let huge = format!("oversize-latest-marker {}", "x".repeat(12000));
let p0 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"early-history-marker"}]}),
)
.await;
let _ = h.recv_until(|v| v["id"] == json!(p0)).await;
let p1 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text": huge}]}),
)
.await;
let _ = h.recv_until(|v| v["id"] == json!(p1)).await;
let captured = llm.captured.lock().await;
assert_eq!(captured.len(), 3, "expected prompt, handoff, prompt");
let handoff_messages = captured[1]["messages"].as_array().unwrap();
let handoff_prompt = handoff_messages[1]["content"].as_str().unwrap();
assert!(
handoff_prompt.contains("oversize-latest-marker"),
"latest oversized item should be kept in truncated form: {handoff_prompt}"
);
assert!(
handoff_prompt.contains("older items omitted"),
"handoff should report truncation when history exceeds budget: {handoff_prompt}"
);
h.shutdown().await;
}
/// Regression for the original bug: context fills, the provider 400s on the
/// next request, and the handoff never fires because the old gate measured
/// BYTES (16 MiB threshold) while the limit is in TOKENS. The fix gates on