Skip to content

Commit 5fb846e

Browse files
zenshanda-ai
andcommitted
fix: compact oversized subagent input batches
Compact pending follow-up and steer batches before attaching them so idle subagent sessions can summarize history before a large background-result batch overflows the context window. Co-Authored-By: Anda Bot <noreply@anda.bot>
1 parent c7369f8 commit 5fb846e

3 files changed

Lines changed: 263 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
All notable changes to the Anda project will be documented in this file.
44

5+
## [0.13.4] — 2026-06-17
6+
7+
### Fixed — anda_engine v0.13.4
8+
9+
- **Subagent compaction before oversized input batches** — Idle subagent sessions now compact before attaching large batched follow-up or steering inputs, preventing background-result bursts from overflowing the context window before summarization can run. Compaction also refreshes session activity so small idle timeouts do not immediately reclaim freshly compacted sessions.
10+
11+
512
## [0.13.3] — 2026-06-15
613

714
### Changed — anda_core v0.13.3

anda_engine/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name = "anda_engine"
33
description = "Agents engine for Anda -- an AI agent framework built with Rust, powered by ICP and TEEs."
44
repository = "https://github.com/ldclabs/anda/tree/main/anda_engine"
55
publish = true
6-
version = "0.13.3"
6+
version = "0.13.4"
77
edition.workspace = true
88
keywords.workspace = true
99
categories.workspace = true

anda_engine/src/subagent.rs

Lines changed: 255 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,22 @@ impl SubSessionRunner {
699699
self.runner = runner;
700700
self.runner.accumulate(&carried_usage);
701701
self.runner.accumulate_tools_usage(&carried_tools_usage);
702+
// Compaction is real work: refresh the activity clock so the idle-timeout check on the
703+
// turn that follows does not mistake the session for stale.
704+
self.session.active_at.store(unix_ms(), Ordering::SeqCst);
705+
Ok(())
706+
}
707+
708+
/// Compacts the idle context when the committed history — or the batch about to be attached —
709+
/// would exceed the window. `pending_tokens` is the estimated size of follow-up/steer content
710+
/// queued in this run() call but not yet attached: compaction drains queued follow-ups into its
711+
/// own request, so it must run *before* the content is attached or the compaction request would
712+
/// overflow too.
713+
async fn compact_if_needed(&mut self, pending_tokens: u64) -> Result<(), BoxError> {
714+
if self.runner.is_idle() && needs_compaction_with_pending(&self.runner, pending_tokens) {
715+
self.compact().await?;
716+
}
717+
702718
Ok(())
703719
}
704720

@@ -710,6 +726,15 @@ impl SubSessionRunner {
710726
self.session.active_at.store(unix_ms(), Ordering::SeqCst);
711727
}
712728

729+
// Accumulate all follow-up/steer content for this batch instead of queueing it
730+
// input-by-input. Background results arrive as separate inputs and are drained into a
731+
// single run() call, so a batch can be far larger than any single input. Queueing each one
732+
// immediately defeated compaction: only the first input was size-checked, because attaching
733+
// it made the runner report not-idle and the rest bypassed the check. Sizing the whole
734+
// batch up front lets idle compaction run before the content is attached.
735+
let mut follow_up_batch: Vec<ContentPart> = Vec::new();
736+
let mut steer_batch: Vec<ContentPart> = Vec::new();
737+
713738
for mut input in inputs {
714739
// 累计来自于后台任务的工具使用情况
715740
self.runner.accumulate(&input.usage);
@@ -750,33 +775,30 @@ impl SubSessionRunner {
750775

751776
match input.command {
752777
PromptCommand::Ping => {
753-
let content =
754-
prompt_and_resources_into_content(String::new(), &mut input.resources);
755-
if !content.is_empty() {
756-
self.runner.follow_up_content(content);
757-
}
778+
follow_up_batch.extend(prompt_and_resources_into_content(
779+
String::new(),
780+
&mut input.resources,
781+
));
758782
continue;
759783
}
760784
PromptCommand::Plain { prompt } => {
761-
let content = prompt_and_resources_into_content(prompt, &mut input.resources);
762-
if !content.is_empty() {
763-
self.runner.follow_up_content(content);
764-
}
785+
follow_up_batch.extend(prompt_and_resources_into_content(
786+
prompt,
787+
&mut input.resources,
788+
));
765789
}
766790
PromptCommand::Command { command, prompt } => match command.as_str() {
767791
"steer" => {
768-
let content =
769-
prompt_and_resources_into_content(prompt, &mut input.resources);
770-
if !content.is_empty() {
771-
self.runner.steer_content(content);
772-
}
792+
steer_batch.extend(prompt_and_resources_into_content(
793+
prompt,
794+
&mut input.resources,
795+
));
773796
}
774797
_ => {
775-
let content =
776-
prompt_and_resources_into_content(prompt, &mut input.resources);
777-
if !content.is_empty() {
778-
self.runner.follow_up_content(content);
779-
}
798+
follow_up_batch.extend(prompt_and_resources_into_content(
799+
prompt,
800+
&mut input.resources,
801+
));
780802
}
781803
},
782804
}
@@ -792,6 +814,19 @@ impl SubSessionRunner {
792814
return Ok(true);
793815
}
794816

817+
// Compact (if needed) before attaching the batch, accounting for its estimated size, then
818+
// queue it. Running unconditionally also covers the case where the committed history grew
819+
// over the threshold without any new input this round.
820+
let pending_tokens = estimated_content_tokens(&follow_up_batch)
821+
.saturating_add(estimated_content_tokens(&steer_batch));
822+
self.compact_if_needed(pending_tokens).await?;
823+
if !follow_up_batch.is_empty() {
824+
self.runner.follow_up_content(follow_up_batch);
825+
}
826+
if !steer_batch.is_empty() {
827+
self.runner.steer_content(steer_batch);
828+
}
829+
795830
match self.runner.next().await {
796831
Ok(None) => {
797832
if self.closing || self.runner.is_done() {
@@ -1934,15 +1969,96 @@ impl SubAgentSet for SubAgentSetManager {
19341969

19351970
/// Returns true when a session runner should compact its conversation history.
19361971
pub fn needs_compaction(runner: &CompletionRunner) -> bool {
1937-
let current_usage = runner.current_usage();
1972+
needs_compaction_with_pending(runner, 0)
1973+
}
1974+
1975+
/// Like [`needs_compaction`], but also accounts for `pending_tokens` — the estimated size of
1976+
/// follow-up/steer content about to be attached. This lets compaction run before a large batch of
1977+
/// background results is queued, instead of after the next request has already overflowed.
1978+
fn needs_compaction_with_pending(runner: &CompletionRunner, pending_tokens: u64) -> bool {
1979+
let threshold = compaction_threshold(runner);
1980+
1981+
// Cheap signals first: the model-reported size of the last request and the turn count.
1982+
if runner.current_usage().input_tokens >= threshold || runner.turns() >= MAX_TURNS_TO_COMPACT {
1983+
return true;
1984+
}
1985+
1986+
if pending_tokens == 0 {
1987+
return false;
1988+
}
1989+
1990+
// Use the larger of the model-reported size of the current context (which already counts the
1991+
// system prompt and tool schemas) and a char-based history estimate (a fallback before the
1992+
// first turn reports usage).
1993+
let current = runner
1994+
.current_usage()
1995+
.input_tokens
1996+
.max(estimated_history_tokens(runner));
1997+
current.saturating_add(pending_tokens) >= threshold
1998+
}
1999+
2000+
fn compaction_threshold(runner: &CompletionRunner) -> u64 {
19382001
let context_window = runner.model().context_window as u64;
1939-
let threshold = if context_window == 0 {
2002+
if context_window == 0 {
19402003
100_000
19412004
} else {
19422005
context_window.saturating_mul(8).saturating_div(10).max(1)
1943-
};
2006+
}
2007+
}
2008+
2009+
/// Rough char-based token estimate of the committed history. Used only as a pre-queue safety check
2010+
/// for a batch about to be attached; `current_usage` is the authoritative size once the first turn
2011+
/// has reported it.
2012+
fn estimated_history_tokens(runner: &CompletionRunner) -> u64 {
2013+
runner
2014+
.chat_history()
2015+
.iter()
2016+
.map(estimated_message_tokens)
2017+
.sum()
2018+
}
19442019

1945-
current_usage.input_tokens >= threshold || runner.turns() >= MAX_TURNS_TO_COMPACT
2020+
fn estimated_message_tokens(message: &Message) -> u64 {
2021+
8u64.saturating_add(estimated_content_tokens(&message.content))
2022+
}
2023+
2024+
fn estimated_content_tokens(content: &[ContentPart]) -> u64 {
2025+
content.iter().map(estimated_content_part_tokens).sum()
2026+
}
2027+
2028+
fn estimated_content_part_tokens(content: &ContentPart) -> u64 {
2029+
match content {
2030+
ContentPart::Text { text } | ContentPart::Reasoning { text } => estimated_text_tokens(text),
2031+
ContentPart::FileData {
2032+
file_uri,
2033+
mime_type,
2034+
} => estimated_text_tokens(file_uri)
2035+
.saturating_add(mime_type.as_deref().map_or(0, estimated_text_tokens)),
2036+
ContentPart::InlineData { mime_type, data } => estimated_text_tokens(mime_type)
2037+
.saturating_add((data.len() as u64).saturating_add(3) / 4),
2038+
ContentPart::ToolCall {
2039+
name,
2040+
args,
2041+
call_id,
2042+
} => estimated_text_tokens(name)
2043+
.saturating_add(estimated_text_tokens(&args.to_string()))
2044+
.saturating_add(call_id.as_deref().map_or(0, estimated_text_tokens)),
2045+
ContentPart::ToolOutput {
2046+
name,
2047+
output,
2048+
call_id,
2049+
..
2050+
} => estimated_text_tokens(name)
2051+
.saturating_add(estimated_text_tokens(&output.to_string()))
2052+
.saturating_add(call_id.as_deref().map_or(0, estimated_text_tokens)),
2053+
ContentPart::Action { name, payload, .. } => {
2054+
estimated_text_tokens(name).saturating_add(estimated_text_tokens(&payload.to_string()))
2055+
}
2056+
ContentPart::Any(value) => estimated_text_tokens(&value.to_string()),
2057+
}
2058+
}
2059+
2060+
fn estimated_text_tokens(text: &str) -> u64 {
2061+
(text.chars().count() as u64).saturating_add(3) / 4
19462062
}
19472063

19482064
#[cfg(test)]
@@ -2052,6 +2168,48 @@ mod tests {
20522168
}
20532169
}
20542170

2171+
#[derive(Clone, Debug)]
2172+
struct RecordingLowUsageCompactionCompleter {
2173+
requests: Arc<Mutex<Vec<CompletionRequest>>>,
2174+
}
2175+
2176+
impl CompletionFeaturesDyn for RecordingLowUsageCompactionCompleter {
2177+
fn model_name(&self) -> String {
2178+
"recording-low-usage-compaction".to_string()
2179+
}
2180+
2181+
fn completion(&self, req: CompletionRequest) -> BoxPinFut<Result<AgentOutput, BoxError>> {
2182+
self.requests.lock().push(req.clone());
2183+
2184+
let prompt = request_text(&req);
2185+
let history = req
2186+
.chat_history
2187+
.iter()
2188+
.filter_map(Message::text)
2189+
.collect::<Vec<_>>()
2190+
.join(" | ");
2191+
2192+
let content = if prompt.trim() == COMPACTION_PROMPT.trim() {
2193+
"compacted handoff".to_string()
2194+
} else if history.is_empty() {
2195+
prompt.clone()
2196+
} else {
2197+
format!("history={history}; input={prompt}")
2198+
};
2199+
2200+
Box::pin(futures::future::ready(Ok(AgentOutput {
2201+
content,
2202+
usage: Usage {
2203+
input_tokens: 1,
2204+
output_tokens: 1,
2205+
cached_tokens: 0,
2206+
requests: 1,
2207+
},
2208+
..Default::default()
2209+
})))
2210+
}
2211+
}
2212+
20552213
#[derive(Clone, Debug)]
20562214
struct EmptyCompactionCompleter;
20572215

@@ -2934,6 +3092,79 @@ mod tests {
29343092
assert_eq!(request_text(&recorded[2]), "continue after compaction");
29353093
}
29363094

3095+
#[tokio::test(flavor = "current_thread")]
3096+
async fn subsession_runner_compacts_oversized_input_batch_before_queueing() {
3097+
let requests = Arc::new(Mutex::new(Vec::new()));
3098+
let mut model = Model::with_completer(Arc::new(RecordingLowUsageCompactionCompleter {
3099+
requests: requests.clone(),
3100+
}));
3101+
model.context_window = 1_000; // compaction threshold = 800 tokens
3102+
let ctx = EngineBuilder::new().with_model(model).mock_ctx();
3103+
3104+
let (sender, _rx) = tokio::sync::mpsc::channel(4);
3105+
let session = Arc::new(SubSession {
3106+
id: "session-batch".to_string(),
3107+
agent: "compactor".to_string(),
3108+
sender,
3109+
background_tasks: Arc::new(RwLock::new(HashMap::new())),
3110+
active_at: AtomicU64::new(unix_ms()),
3111+
idle_timeout_ms: 0,
3112+
});
3113+
3114+
let mut runner = SubSessionRunner {
3115+
session,
3116+
agent_hook: None,
3117+
runner: ctx
3118+
.completion_iter(CompletionRequest::default(), Vec::new())
3119+
.unbound(),
3120+
last_output: None,
3121+
carried_artifacts: Vec::new(),
3122+
closing: false,
3123+
};
3124+
3125+
// Each input carries no usage, so only the batch content estimate can trigger compaction.
3126+
// Each chunk is ~400 tokens (under the 800 threshold); the three batched together are
3127+
// ~1200 (over it). The per-input check missed this: queueing the first follow-up made the
3128+
// runner report not-idle, so the rest bypassed the size check.
3129+
let chunk = "y".repeat(1_600);
3130+
let plain = |prompt: String| SubAgentInput {
3131+
command: PromptCommand::Plain { prompt },
3132+
resources: Vec::new(),
3133+
usage: Usage::default(),
3134+
model: None,
3135+
effort: None,
3136+
};
3137+
3138+
assert!(
3139+
runner
3140+
.run(vec![
3141+
plain(chunk.clone()),
3142+
plain(chunk.clone()),
3143+
plain(chunk.clone()),
3144+
])
3145+
.await
3146+
.unwrap()
3147+
);
3148+
3149+
let recorded = requests.lock().clone();
3150+
// Compaction runs once up front, then the whole batch is queued on top of the compacted
3151+
// handoff in a single follow-up request.
3152+
assert_eq!(recorded.len(), 2);
3153+
assert_eq!(request_text(&recorded[0]).trim(), COMPACTION_PROMPT.trim());
3154+
assert_eq!(
3155+
recorded[1]
3156+
.chat_history
3157+
.iter()
3158+
.filter_map(Message::text)
3159+
.collect::<Vec<_>>(),
3160+
vec!["compacted handoff".to_string()]
3161+
);
3162+
assert_eq!(
3163+
request_text(&recorded[1]).matches(chunk.as_str()).count(),
3164+
3
3165+
);
3166+
}
3167+
29373168
#[tokio::test(flavor = "current_thread")]
29383169
async fn subsession_runner_fails_when_compaction_summary_is_empty() {
29393170
let model = Model::with_completer(Arc::new(EmptyCompactionCompleter));
@@ -2959,8 +3190,7 @@ mod tests {
29593190
session,
29603191
agent_hook: None,
29613192
runner: ctx.completion_iter(req, Vec::new()).unbound(),
2962-
last_output: None,
2963-
// A previously rescued artifact must survive even when compaction fails.
3193+
last_output: None, // A previously rescued artifact must survive even when compaction fails.
29643194
carried_artifacts: vec![resource(5, &["artifact"])],
29653195
closing: false,
29663196
};

0 commit comments

Comments
 (0)