1use std::collections::{BTreeSet, HashMap};
29use std::path::{Path, PathBuf};
30use std::sync::Arc;
31
32use agent_core::config::{ContextManagementConfig, ContextManagementMode, SynapsConfig};
33use agent_core::core::context_policy::ContextThresholds;
34use serde_json::{json, Value};
35
36use super::continuation::{
37 message_tokens, neutral_note, tool_groups, SuccessorBudget, TokenCalibration,
38 DROPPED_INDEX_TOKENS, PINNED_SHARE_PERCENT,
39};
40use crate::SharedMessage;
41
42pub(crate) const MARKER_KEY: &str = "_synaps_worker_context";
45pub(crate) const SCHEMA: &str = "synaps-worker-context/1";
46
47const INDEX_PREVIEW_CHARS: usize = 100;
49const NOTE_FRAME_TOKENS: u64 = 600;
51const NOTE_LISTED_WINDOWS: usize = 12;
54const MIN_SHRINK_TOKENS: u64 = 1024;
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub struct WorkerContextSettings {
62 pub enabled: bool,
63 pub rollover_tokens: u64,
64}
65
66impl WorkerContextSettings {
67 pub fn from_config(config: &SynapsConfig) -> Self {
68 Self {
69 enabled: config.subagent_context_management == ContextManagementMode::Auto,
70 rollover_tokens: config.subagent_rollover_tokens,
71 }
72 }
73
74 pub fn policy(&self, window: u64) -> Option<WorkerPolicy> {
80 if !self.enabled {
81 return None;
82 }
83 let base = ContextManagementConfig {
84 mode: ContextManagementMode::Auto,
85 finish_rounds: 0,
86 ..Default::default()
87 };
88 let band = ContextThresholds::resolve(&base, window).ok()?.rollover_tokens;
89 let config = ContextManagementConfig {
90 rollover_tokens: Some(self.rollover_tokens.min(band).max(2)),
91 ..base
92 };
93 let thresholds = ContextThresholds::resolve(&config, window).ok()?;
94 Some(WorkerPolicy { config, thresholds })
95 }
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub struct WorkerPolicy {
101 pub config: ContextManagementConfig,
102 pub thresholds: ContextThresholds,
103}
104
105impl WorkerPolicy {
106 pub fn target_tokens(&self) -> u64 {
107 self.thresholds.target_tokens(&self.config)
108 }
109
110 pub fn successor_budget(
113 &self,
114 overhead_tokens: u64,
115 calibration: Option<TokenCalibration>,
116 ) -> SuccessorBudget {
117 super::continuation::successor_budget(
118 self.target_tokens(),
119 self.thresholds.pressure_tokens,
120 overhead_tokens,
121 calibration,
122 )
123 }
124
125 pub fn hard_capacity_has_room(&self, context_window: u64, required_next_round: u64) -> bool {
141 context_window.saturating_sub(required_next_round) > self.target_tokens()
142 }
143}
144
145pub fn hard_capacity_budget(
151 budget: SuccessorBudget,
152 context_window: u64,
153 required_next_round: u64,
154 overhead_tokens: u64,
155) -> SuccessorBudget {
156 let room = context_window
157 .saturating_sub(required_next_round)
158 .saturating_sub(overhead_tokens);
159 SuccessorBudget {
160 history_tokens: budget.history_tokens.min(room * 2 / 3),
161 }
162}
163
164pub type RolloverObserver = Arc<dyn Fn(&WorkerRolloverReport) + Send + Sync>;
167
168#[derive(Clone)]
171pub struct WorkerTranscriptSink {
172 pub dir: PathBuf,
173 pub stem: String,
174 pub observer: Option<RolloverObserver>,
175}
176
177impl std::fmt::Debug for WorkerTranscriptSink {
178 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179 f.debug_struct("WorkerTranscriptSink")
180 .field("dir", &self.dir)
181 .field("stem", &self.stem)
182 .field("observer", &self.observer.is_some())
183 .finish()
184 }
185}
186
187#[derive(Debug, Clone, Default)]
191pub struct WorkerContext {
192 pub settings: Option<WorkerContextSettings>,
193 pub sink: Option<WorkerTranscriptSink>,
194}
195
196impl WorkerContext {
197 pub fn resolve(&self, window: u64) -> Option<(WorkerPolicy, WorkerTranscriptSink)> {
199 let policy = self.settings?.policy(window)?;
200 Some((policy, self.sink.clone()?))
201 }
202}
203
204#[derive(Debug, Clone, PartialEq, Eq)]
206pub struct WorkerRolloverReport {
207 pub window: u64,
209 pub before_tokens: u64,
211 pub after_tokens: u64,
212 pub evicted_messages: usize,
213 pub transcript: PathBuf,
215 pub transcripts: Vec<PathBuf>,
217}
218
219impl WorkerRolloverReport {
220 pub fn notice(&self) -> String {
222 format!(
223 "Worker context rolled over to window {}: ~{}k → ~{}k tokens; {} earlier messages saved to {}.",
224 self.window,
225 self.before_tokens / 1000,
226 self.after_tokens / 1000,
227 self.evicted_messages,
228 self.transcript.display()
229 )
230 }
231
232 pub fn step(&self) -> String {
234 format!(
235 "↻ context window {}: ~{}k → ~{}k tokens",
236 self.window,
237 self.before_tokens / 1000,
238 self.after_tokens / 1000
239 )
240 }
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
245pub struct TranscriptRef {
246 pub window: u64,
247 pub path: String,
248 pub messages: usize,
249}
250
251pub fn window_state(messages: &[SharedMessage]) -> (u64, Vec<TranscriptRef>) {
255 let mut window = 1;
256 let mut seen = BTreeSet::new();
257 let mut transcripts = Vec::new();
258 for marker in messages
259 .iter()
260 .filter_map(|m| m.get(MARKER_KEY))
261 .filter(|m| m["schema"] == SCHEMA)
262 {
263 window = window.max(marker["window"].as_u64().unwrap_or(1));
264 for t in marker["transcripts"].as_array().into_iter().flatten() {
265 if let Ok(t) = serde_json::from_value::<TranscriptRef>(t.clone()) {
266 if seen.insert((t.window, t.path.clone())) {
267 transcripts.push(t);
268 }
269 }
270 }
271 }
272 transcripts.sort_by_key(|t| t.window);
273 (window, transcripts)
274}
275
276pub fn carried_marker(history: &[Value]) -> Option<Value> {
280 let shared: Vec<SharedMessage> = history
281 .iter()
282 .filter(|m| m.get(MARKER_KEY).is_some())
283 .map(|m| Arc::new(m.clone()))
284 .collect();
285 if shared.is_empty() {
286 return None;
287 }
288 let (window, transcripts) = window_state(&shared);
289 Some(json!({"schema": SCHEMA, "window": window, "transcripts": transcripts}))
290}
291
292fn is_note(message: &Value) -> bool {
293 message.get(MARKER_KEY).is_some()
294}
295
296fn has_tool_result(message: &Value) -> bool {
297 message["content"]
298 .as_array()
299 .is_some_and(|b| b.iter().any(|b| b["type"] == "tool_result"))
300}
301
302fn is_host_bookkeeping(message: &Value) -> bool {
304 is_note(message)
305 || message.get("_synaps_context").is_some()
306 || crate::engine::interrupt::is_tool_in_flight_note(message)
307 || message["content"]
308 .as_str()
309 .is_some_and(crate::engine::interrupt::is_interruption_marker)
310}
311
312fn carried_instruction(message: &SharedMessage) -> Option<SharedMessage> {
317 if message["role"] != "user" || is_host_bookkeeping(message) {
318 return None;
319 }
320 match &message["content"] {
321 Value::String(s) if !s.trim().is_empty() => Some(Arc::clone(message)),
322 Value::Array(blocks) if !has_tool_result(message) => (!blocks.is_empty())
323 .then(|| Arc::clone(message)),
324 Value::Array(blocks) => {
325 let text: Vec<Value> = blocks
326 .iter()
327 .filter(|b| b["type"] == "text")
328 .filter(|b| b["text"].as_str().is_some_and(|t| !t.trim().is_empty()))
329 .map(|b| json!({"type": "text", "text": b["text"]}))
330 .collect();
331 (!text.is_empty()).then(|| Arc::new(json!({"role": "user", "content": text})))
332 }
333 _ => None,
334 }
335}
336
337#[derive(Debug)]
340pub struct RolloverPlan {
341 pub successor: Vec<SharedMessage>,
342 pub transcript: String,
343 pub transcript_path: PathBuf,
344 pub window: u64,
346 pub evicted_messages: usize,
347 pub transcripts: Vec<TranscriptRef>,
348 evicted_indices: Vec<usize>,
350}
351
352pub fn plan_rollover(
364 messages: &[SharedMessage],
365 budget: SuccessorBudget,
366 before_tokens: u64,
367 transcript_path: impl Fn(u64) -> PathBuf,
368) -> Option<RolloverPlan> {
369 let n = messages.len();
370 if n < 3 || messages[0]["role"] != "user" {
371 return None;
372 }
373 let last_assistant = messages.iter().rposition(|m| m["role"] == "assistant")?;
374 let after_notes = messages
375 .iter()
376 .enumerate()
377 .skip(1)
378 .filter(|(_, m)| is_note(m))
379 .map(|(i, _)| i + 1)
380 .max()
381 .unwrap_or(1);
382
383 let groups = tool_groups(messages);
386 let mut extent: HashMap<usize, (usize, usize)> = HashMap::new();
387 for (i, g) in groups.iter().enumerate() {
388 let e = extent.entry(*g).or_insert((i, i));
389 e.0 = e.0.min(i);
390 e.1 = e.1.max(i);
391 }
392 let mut straddled = vec![false; n + 1];
393 for &(lo, hi) in extent.values() {
394 for flag in straddled.iter_mut().take(hi + 1).skip(lo + 1) {
395 *flag = true;
396 }
397 }
398 let candidates: Vec<usize> = (after_notes.max(1)..=last_assistant)
399 .filter(|&k| messages[k]["role"] == "assistant" && !straddled[k])
400 .collect();
401 let &must_keep = candidates.last()?;
402
403 let costs: Vec<u64> = messages.iter().map(message_tokens).collect();
404 let mut suffix = vec![0u64; n + 1];
405 for i in (0..n).rev() {
406 suffix[i] = suffix[i + 1] + costs[i];
407 }
408 let total = budget.history_tokens;
409 let share = total.min(u64::MAX / 100) * PINNED_SHARE_PERCENT / 100;
410 let instructions: Vec<(usize, SharedMessage, u64)> = (1..must_keep)
411 .filter_map(|i| carried_instruction(&messages[i]).map(|m| (i, m)))
412 .map(|(i, m)| {
413 let cost = message_tokens(&m);
414 (i, m, cost)
415 })
416 .collect();
417 let instruction_reserve = instructions.iter().map(|(_, _, c)| c).sum::<u64>().min(share);
418 let (_, prior) = window_state(messages);
419 let note_reserve =
420 DROPPED_INDEX_TOKENS + NOTE_FRAME_TOKENS + 60 * (prior.len() as u64 + 1);
421 let available =
422 total.saturating_sub(costs[0] + instruction_reserve + note_reserve);
423 let first = candidates
427 .iter()
428 .position(|&k| suffix[k] <= available)
429 .unwrap_or(candidates.len() - 1);
430 let (window, prior_transcripts) = window_state(messages);
431 let path = transcript_path(window);
432 let mut plan = None;
433 for &cut in &candidates[first..] {
434 let built = build_successor(
435 messages,
436 cut,
437 after_notes,
438 &instructions,
439 share,
440 window,
441 &prior_transcripts,
442 &path,
443 before_tokens,
444 &costs,
445 )?;
446 let size = super::context::estimate_history(&built.successor);
447 plan = Some(built);
448 if size <= total || cut == must_keep {
449 if size > total && total != u64::MAX {
450 tracing::warn!(
451 successor_tokens = size,
452 target_tokens = total,
453 "worker rollover must-keep tail exceeds the successor target; keeping it whole"
454 );
455 }
456 break;
457 }
458 }
459 let mut plan = plan?;
460 let evicted: Vec<usize> = std::mem::take(&mut plan.evicted_indices);
461 plan.transcript = render_transcript(messages, &evicted, window, before_tokens);
462 Some(plan)
463}
464
465#[allow(clippy::too_many_arguments)]
468fn build_successor(
469 messages: &[SharedMessage],
470 cut: usize,
471 after_notes: usize,
472 instructions: &[(usize, SharedMessage, u64)],
473 share: u64,
474 window: u64,
475 prior_transcripts: &[TranscriptRef],
476 path: &Path,
477 before_tokens: u64,
478 costs: &[u64],
479) -> Option<RolloverPlan> {
480 let mut pinned = Vec::new();
482 let mut unpinned = Vec::new();
483 let mut used = 0u64;
484 for (i, m, cost) in instructions.iter().rev().filter(|(i, _, _)| *i < cut) {
485 if used + cost <= share {
486 used += cost;
487 pinned.push((*i, Arc::clone(m)));
488 } else {
489 unpinned.push(*i);
490 }
491 }
492 pinned.reverse();
493 unpinned.reverse();
494
495 let evicted: Vec<usize> = (after_notes..cut)
499 .filter(|&i| !is_note(&messages[i]))
500 .collect();
501 if evicted.is_empty() || evicted.iter().all(|i| pinned.iter().any(|(p, _)| p == i)) {
502 return None;
503 }
504 let mut transcripts = prior_transcripts.to_vec();
505 transcripts.push(TranscriptRef {
506 window,
507 path: path.display().to_string(),
508 messages: evicted.len(),
509 });
510 let evicted_tokens: u64 = evicted.iter().map(|&i| costs[i]).sum();
511 let kept = messages.len() - cut;
512 let note = note_message(NoteFacts {
513 window: window + 1,
514 before_tokens,
515 evicted: evicted.len(),
516 evicted_tokens,
517 kept,
518 carried_instructions: !pinned.is_empty(),
519 transcripts: &transcripts,
520 tool_index: tool_index(messages, &evicted),
521 unpinned: &unpinned
522 .iter()
523 .map(|&i| (i, preview(&messages[i])))
524 .collect::<Vec<_>>(),
525 });
526
527 let mut successor = Vec::with_capacity(2 + pinned.len() + kept);
528 successor.push(Arc::clone(&messages[0]));
529 successor.extend(pinned.into_iter().map(|(_, m)| m));
530 successor.push(Arc::new(note));
531 successor.extend(messages[cut..].iter().cloned());
532 super::read_dedupe::rewrite_dangling_stubs(&mut successor);
534 Some(RolloverPlan {
535 successor,
536 transcript: String::new(),
537 transcript_path: path.to_path_buf(),
538 window: window + 1,
539 evicted_messages: evicted.len(),
540 transcripts,
541 evicted_indices: evicted,
542 })
543}
544
545pub fn plan_is_productive(messages: &[SharedMessage], plan: &RolloverPlan) -> bool {
548 let before = super::context::estimate_history(messages);
549 let after = super::context::estimate_history(&plan.successor);
550 after.saturating_add(MIN_SHRINK_TOKENS) < before
551 && unpaired_tool_ids(&plan.successor).is_subset(&unpaired_tool_ids(messages))
552}
553
554pub fn unpaired_tool_ids(messages: &[SharedMessage]) -> BTreeSet<String> {
557 let mut calls = BTreeSet::new();
558 let mut unpaired = BTreeSet::new();
559 for m in messages {
560 for b in m["content"].as_array().into_iter().flatten() {
561 match (b["type"].as_str(), m["role"].as_str()) {
562 (Some("tool_use"), Some("assistant")) => {
563 if let Some(id) = b["id"].as_str() {
564 calls.insert(id.to_owned());
565 unpaired.insert(id.to_owned());
566 }
567 }
568 (Some("tool_result"), Some("user")) => {
569 if let Some(id) = b["tool_use_id"].as_str() {
570 if !unpaired.remove(id) || !calls.contains(id) {
571 unpaired.insert(format!("result:{id}"));
572 }
573 }
574 }
575 _ => {}
576 }
577 }
578 }
579 unpaired
580}
581
582struct NoteFacts<'a> {
583 window: u64,
584 before_tokens: u64,
585 evicted: usize,
586 evicted_tokens: u64,
587 kept: usize,
588 carried_instructions: bool,
589 transcripts: &'a [TranscriptRef],
590 tool_index: Vec<String>,
591 unpinned: &'a [(usize, String)],
592}
593
594fn note_message(f: NoteFacts<'_>) -> Value {
595 let mut text = format!(
596 "[Worker context rollover — host note, not a new instruction]\n\
597 Your context reached about {} tokens, so the host started context window {}. \
598 {} earlier messages (about {} tokens) were moved out of your context unchanged; \
599 nothing was summarized. Your task (the first message){} and your most recent {} \
600 messages are kept verbatim.\n\
601 The moved messages are saved as plain text you can search with grep or read by line range:",
602 f.before_tokens,
603 f.window,
604 f.evicted,
605 f.evicted_tokens,
606 if f.carried_instructions {
607 ", later orchestrator instructions"
608 } else {
609 ""
610 },
611 f.kept,
612 );
613 let hidden = f.transcripts.len().saturating_sub(NOTE_LISTED_WINDOWS);
614 if let Some(oldest) = f.transcripts.first().filter(|_| hidden > 0) {
615 let dir = Path::new(&oldest.path)
616 .parent()
617 .map(|d| d.display().to_string())
618 .unwrap_or_default();
619 text.push_str(&format!(
620 "\n- windows {}–{}: earlier transcripts (*.window-N.md) in {}",
621 oldest.window,
622 f.transcripts[hidden - 1].window,
623 neutral_note(&dir)
624 ));
625 }
626 for t in &f.transcripts[hidden..] {
627 text.push_str(&format!(
628 "\n- window {}: {} ({} messages)",
629 t.window,
630 neutral_note(&t.path),
631 t.messages
632 ));
633 }
634 text.push_str(
635 "\nLook there only when you need a specific earlier detail (an error, a file path, a \
636 decision); do not re-read them wholesale.",
637 );
638 if !f.unpinned.is_empty() {
639 text.push_str("\nEarlier orchestrator messages not kept verbatim (they are in the saved text):");
640 for (i, p) in f.unpinned {
641 text.push_str(&format!("\n- #{i}: {p}"));
642 }
643 }
644 if !f.tool_index.is_empty() {
645 text.push_str("\nTool calls in the moved messages, most recent first:");
646 let mut listed = 0;
647 for line in &f.tool_index {
648 let line = format!("\n- {line}");
649 if super::context::conservative_token_estimate(&text)
650 + super::context::conservative_token_estimate(&line)
651 > DROPPED_INDEX_TOKENS + NOTE_FRAME_TOKENS / 2
652 {
653 break;
654 }
655 text.push_str(&line);
656 listed += 1;
657 }
658 if listed < f.tool_index.len() {
659 text.push_str(&format!(
660 "\n- … and {} older not listed",
661 f.tool_index.len() - listed
662 ));
663 }
664 }
665 text.push_str(
666 "\nContinue the task from where the kept messages leave off; do not redo completed \
667 steps.\n[End worker context rollover]",
668 );
669 let transcripts: Vec<Value> = f
670 .transcripts
671 .iter()
672 .map(|t| serde_json::to_value(t).unwrap_or(Value::Null))
673 .collect();
674 json!({
675 "role": "user",
676 MARKER_KEY: {"schema": SCHEMA, "window": f.window, "transcripts": transcripts},
677 "content": [{"type": "text", "text": text}],
678 })
679}
680
681fn tool_index(messages: &[SharedMessage], evicted: &[usize]) -> Vec<String> {
683 let mut lines = Vec::new();
684 for &i in evicted.iter().rev() {
685 let blocks: Vec<&Value> = messages[i]["content"]
686 .as_array()
687 .into_iter()
688 .flatten()
689 .filter(|b| b["type"] == "tool_use")
690 .collect();
691 for b in blocks.into_iter().rev() {
692 let name = neutral_note(b["name"].as_str().unwrap_or("?"));
693 let input = &b["input"];
694 let arg = ["command", "path", "file_path", "pattern", "query", "url", "handle_id"]
695 .iter()
696 .find_map(|k| input[*k].as_str().map(str::to_owned))
697 .unwrap_or_else(|| input.to_string());
698 lines.push(format!("{name}: {}", clip(&flat(&arg), INDEX_PREVIEW_CHARS)));
699 }
700 }
701 lines
702}
703
704fn flat(text: &str) -> String {
705 neutral_note(&text.split_whitespace().collect::<Vec<_>>().join(" "))
706}
707
708fn clip(text: &str, chars: usize) -> String {
709 let mut out: String = text.chars().take(chars).collect();
710 if text.chars().count() > chars {
711 out.push('…');
712 }
713 out
714}
715
716fn preview(message: &Value) -> String {
717 let text = match &message["content"] {
718 Value::String(s) => s.clone(),
719 Value::Array(blocks) => blocks
720 .iter()
721 .filter_map(|b| b["text"].as_str())
722 .collect::<Vec<_>>()
723 .join(" "),
724 _ => String::new(),
725 };
726 clip(&flat(&text), 80)
727}
728
729pub fn render_transcript(
733 messages: &[SharedMessage],
734 evicted: &[usize],
735 window: u64,
736 before_tokens: u64,
737) -> String {
738 let mut out = format!(
739 "# Worker context window {window}: {} messages moved out of context at a rollover\n\
740 # (context about {before_tokens} tokens). Thinking is omitted; images are named only.\n",
741 evicted.len()
742 );
743 for &i in evicted {
744 let m = &messages[i];
745 let role = m["role"].as_str().unwrap_or("user");
746 out.push_str(&format!("\n## [{i}] {role}\n"));
747 match &m["content"] {
748 Value::String(s) => {
749 out.push_str(s);
750 out.push('\n');
751 }
752 Value::Array(blocks) => {
753 for b in blocks {
754 render_block(&mut out, b);
755 }
756 }
757 _ => {}
758 }
759 }
760 out
761}
762
763fn render_block(out: &mut String, b: &Value) {
764 match b["type"].as_str() {
765 Some("text") => {
766 out.push_str(b["text"].as_str().unwrap_or_default());
767 out.push('\n');
768 }
769 Some("tool_use") => {
770 let input = serde_json::to_string_pretty(&b["input"]).unwrap_or_default();
771 out.push_str(&format!(
772 "→ tool_use {} ({})\n{input}\n",
773 b["name"].as_str().unwrap_or("?"),
774 b["id"].as_str().unwrap_or("?")
775 ));
776 }
777 Some("tool_result") => {
778 let error = if b["is_error"] == true { " error" } else { "" };
779 out.push_str(&format!(
780 "← tool_result{error} ({})\n",
781 b["tool_use_id"].as_str().unwrap_or("?")
782 ));
783 match &b["content"] {
784 Value::String(s) => {
785 out.push_str(s);
786 out.push('\n');
787 }
788 Value::Array(inner) => {
789 for x in inner {
790 render_block(out, x);
791 }
792 }
793 Value::Null => {}
794 other => {
795 out.push_str(&other.to_string());
796 out.push('\n');
797 }
798 }
799 }
800 Some("thinking") | Some("redacted_thinking") => {}
801 Some(other) => out.push_str(&format!("[{other}]\n")),
802 None => {}
803 }
804}
805
806pub fn roll_over(
817 messages: &[SharedMessage],
818 budget: SuccessorBudget,
819 before_tokens: u64,
820 overhead_tokens: u64,
821 policy: &WorkerPolicy,
822 sink: &WorkerTranscriptSink,
823 hard_capacity: Option<u64>,
824) -> std::io::Result<Option<(Vec<SharedMessage>, WorkerRolloverReport)>> {
825 let Some(plan) = plan_rollover(messages, budget, before_tokens, |window| {
826 transcript_path(&sink.dir, &sink.stem, window)
827 }) else {
828 return Ok(None);
829 };
830 if !plan_is_productive(messages, &plan) {
831 return Ok(None);
832 }
833 let after_tokens =
834 overhead_tokens.saturating_add(super::context::estimate_history(&plan.successor));
835 if after_tokens >= hard_capacity.unwrap_or(policy.thresholds.pressure_tokens) {
836 return Ok(None);
837 }
838 crate::tools::write_window_transcript(
839 &plan.transcript_path,
840 &plan.transcript,
841 )?;
842 let report = WorkerRolloverReport {
843 window: plan.window,
844 before_tokens,
845 after_tokens,
846 evicted_messages: plan.evicted_messages,
847 transcript: plan.transcript_path,
848 transcripts: plan
849 .transcripts
850 .iter()
851 .map(|t| PathBuf::from(&t.path))
852 .collect(),
853 };
854 Ok(Some((plan.successor, report)))
855}
856
857pub fn transcript_path(dir: &Path, stem: &str, window: u64) -> PathBuf {
859 let stem: String = stem
860 .chars()
861 .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '-' })
862 .collect();
863 dir.join(format!("{stem}.window-{window}.md"))
864}
865
866#[cfg(test)]
867mod tests {
868 use super::*;
869
870 fn user(text: &str) -> SharedMessage {
871 Arc::new(json!({"role": "user", "content": text}))
872 }
873
874 fn round(i: usize, pad: usize) -> [SharedMessage; 2] {
877 [
878 Arc::new(json!({"role": "assistant", "content": [
879 {"type": "thinking", "thinking": format!("step {i}"), "signature": format!("sig{i}")},
880 {"type": "tool_use", "id": format!("t{i}"), "name": "bash",
881 "input": {"command": format!("cargo test step{i}")}}
882 ]})),
883 Arc::new(json!({"role": "user", "content": [
884 {"type": "tool_result", "tool_use_id": format!("t{i}"),
885 "content": format!("output {i} {}", "x".repeat(pad))}
886 ]})),
887 ]
888 }
889
890 fn worker_history(rounds: usize, pad: usize) -> Vec<SharedMessage> {
891 let mut m = vec![user("BRIEF: fix the parser")];
892 for i in 0..rounds {
893 m.extend(round(i, pad));
894 }
895 m
896 }
897
898 fn path(w: u64) -> PathBuf {
899 PathBuf::from(format!("/tmp/x/sa_1.window-{w}.md"))
900 }
901
902 fn budget(tokens: u64) -> SuccessorBudget {
903 SuccessorBudget { history_tokens: tokens }
904 }
905
906 #[test]
907 fn policy_lowers_the_band_and_derives_pressure_and_target_like_the_foreground() {
908 let on = WorkerContextSettings { enabled: true, rollover_tokens: 200_000 };
909 let p = on.policy(1_000_000).unwrap();
910 assert_eq!(p.thresholds.rollover_tokens, 200_000);
911 assert_eq!(p.thresholds.pressure_tokens, 199_999);
912 assert_eq!(p.target_tokens(), 100_000);
913 assert_eq!(p.config.finish_rounds, 0);
914 let p = on.policy(200_000).unwrap();
916 assert_eq!(p.thresholds.rollover_tokens, 180_000);
917 assert_eq!(p.thresholds.pressure_tokens, 140_000);
918 assert_eq!(p.target_tokens(), 93_333);
919 assert!(p.target_tokens() < p.thresholds.pressure_tokens);
920 let off = WorkerContextSettings { enabled: false, ..on };
921 assert!(off.policy(1_000_000).is_none());
922 assert!(on.policy(1).is_none(), "a window that holds no policy disables it");
923 let ctx = WorkerContext { settings: Some(on), sink: None };
925 assert!(ctx.resolve(1_000_000).is_none());
926 }
927
928 #[test]
929 fn policy_engine_admits_then_rolls_at_the_worker_band() {
930 use agent_core::core::context_policy::{assess_context, ContextAction, ContextBudget, ContextState};
931 let p = WorkerContextSettings { enabled: true, rollover_tokens: 200_000 }
932 .policy(1_000_000)
933 .unwrap();
934 let at = |used: u64| ContextBudget {
935 context_window_tokens: 1_000_000,
936 used_tokens: used,
937 hard_remaining_tokens: 1_000_000 - used,
938 required_next_round_tokens: 150_000,
939 };
940 let state = ContextState::default();
941 assert_eq!(assess_context(&p.config, &state, at(150_000)).action, ContextAction::Continue);
942 assert_eq!(assess_context(&p.config, &state, at(200_000)).action, ContextAction::Rollover);
943 assert_eq!(assess_context(&p.config, &state, at(900_000)).action, ContextAction::HardStop);
945 }
946
947 #[test]
948 fn hard_capacity_aims_inside_the_room_the_window_has_left() {
949 let normal = budget(56_000);
950 assert_eq!(hard_capacity_budget(normal, 120_000, 72_000, 1_000).history_tokens, 31_333);
952 assert_eq!(hard_capacity_budget(normal, 1_000_000, 150_000, 1_000).history_tokens, 56_000);
954 assert_eq!(hard_capacity_budget(normal, 10, 72_000, 1_000).history_tokens, 0);
955 }
956
957 fn required(model: &str, window: u64) -> u64 {
960 super::super::stream::pre_request_context_budget(model, window, None, &[], &[], 0, 30_000, None)
961 .1
962 .required_next_round_tokens
963 }
964
965 #[test]
974 fn hard_capacity_has_room_only_when_the_target_fits_below_it() {
975 use agent_core::core::context_policy::{assess_context, ContextAction, ContextBudget, ContextState};
976 let on = WorkerContextSettings { enabled: true, rollover_tokens: 200_000 };
977 let p = on.policy(200_000).unwrap();
978 assert_eq!(p.target_tokens(), 93_333);
979 for model in ["claude-opus-5-5", "claude-sonnet-4-6"] {
980 let need = required(model, 200_000);
981 assert_eq!(need, 32_768 + 10_000 + 30_000 + 512, "{model}");
982 assert!(p.hard_capacity_has_room(200_000, need), "{model}");
983 let at = |used: u64| ContextBudget {
985 context_window_tokens: 200_000,
986 used_tokens: used,
987 hard_remaining_tokens: 200_000 - used,
988 required_next_round_tokens: need,
989 };
990 let state = ContextState::default();
991 assert_eq!(assess_context(&p.config, &state, at(33_000)).action, ContextAction::Continue);
992 assert_eq!(assess_context(&p.config, &state, at(126_720)).action, ContextAction::Continue);
994 assert_eq!(assess_context(&p.config, &state, at(126_721)).action, ContextAction::HardStop);
995 let need_1m = required(model, 1_000_000);
996 assert!(on.policy(1_000_000).unwrap().hard_capacity_has_room(1_000_000, need_1m));
997 }
998 assert!(!p.hard_capacity_has_room(200_000, 128_000 + 10_000 + 30_000 + 512));
1000 let small = on.policy(100_000).unwrap();
1002 assert_eq!(small.target_tokens(), 46_666);
1003 assert_eq!(100_000 - required("claude-opus-5-5", 100_000), 41_720);
1004 assert!(!small.hard_capacity_has_room(100_000, required("claude-opus-5-5", 100_000)));
1005 assert!(!p.hard_capacity_has_room(200_000, 200_000 - 93_333));
1007 assert!(p.hard_capacity_has_room(200_000, 200_000 - 93_334));
1008 assert!(!p.hard_capacity_has_room(100_000, 200_000), "reserves over the window");
1009 }
1010
1011 #[test]
1015 fn a_successor_at_the_line_that_triggered_it_is_refused() {
1016 let tmp = tempfile::tempdir().unwrap();
1017 let sink = WorkerTranscriptSink { dir: tmp.path().into(), stem: "sa_1".into(), observer: None };
1018 let policy = WorkerContextSettings { enabled: true, rollover_tokens: 200_000 }
1019 .policy(200_000)
1020 .unwrap();
1021 let mut m = worker_history(20, 3_000);
1022 m.extend(round(20, 60_000)); let before = super::super::context::estimate_history(&m);
1024 let roll = |line| roll_over(&m, budget(10_000), before, 1_000, &policy, &sink, line).unwrap();
1025 assert!(roll(Some(20_000)).is_none(), "would start at hard capacity again");
1026 let (successor, report) = roll(Some(100_000)).expect("room below the line");
1027 assert!(report.after_tokens < 100_000 && successor.len() < m.len());
1028 assert!(roll(None).is_some());
1030 }
1031
1032 #[test]
1033 fn successor_keeps_brief_and_recent_rounds_within_the_target() {
1034 let messages = worker_history(40, 4_000);
1035 let before = super::super::context::estimate_history(&messages);
1036 let plan = plan_rollover(&messages, budget(20_000), before, path).unwrap();
1037 let s = &plan.successor;
1038 assert!(Arc::ptr_eq(&s[0], &messages[0]), "the brief survives verbatim");
1039 assert!(s[1].get(MARKER_KEY).is_some(), "then the host note");
1040 assert_eq!(s[2]["role"], "assistant", "the suffix starts at an assistant turn");
1041 assert!(Arc::ptr_eq(s.last().unwrap(), messages.last().unwrap()));
1042 for m in &s[2..] {
1044 assert!(messages.iter().any(|o| Arc::ptr_eq(o, m)));
1045 }
1046 assert!(s[2]["content"][0]["type"] == "thinking" && s[2]["content"][0]["signature"].is_string());
1047 let after = super::super::context::estimate_history(s);
1048 assert!(after <= 20_000, "target respected: {after}");
1049 assert!(after < before / 3);
1050 assert!(unpaired_tool_ids(s).is_empty());
1051 assert!(plan_is_productive(&messages, &plan));
1052 assert_eq!(plan.window, 2);
1053 assert_eq!(plan.evicted_messages, messages.len() - 1 - (s.len() - 2));
1054 assert!(plan.transcript.contains("cargo test step0"));
1056 assert!(plan.transcript.contains("output 0 "));
1057 assert!(!plan.transcript.contains("sig0") && !plan.transcript.contains("step 0\n"));
1058 let note = s[1]["content"][0]["text"].as_str().unwrap();
1060 assert!(note.contains("/tmp/x/sa_1.window-1.md"), "{note}");
1061 assert!(note.contains("bash: cargo test step"), "{note}");
1062 assert_eq!(s[1][MARKER_KEY]["window"], 2);
1063 }
1064
1065 #[test]
1069 fn a_kept_dedupe_stub_whose_read_was_evicted_is_rewritten() {
1070 use crate::runtime::read_dedupe::{unchanged_stub, DROPPED_STUB};
1071 let read = |id: &str, content: String| -> [SharedMessage; 2] {
1072 [
1073 Arc::new(json!({"role":"assistant","content":[
1074 {"type":"tool_use","id":id,"name":"read","input":{"path":"/w/database.py"}}]})),
1075 Arc::new(json!({"role":"user","content":[
1076 {"type":"tool_result","tool_use_id":id,"content":content}]})),
1077 ]
1078 };
1079 let full = "1\tdb = connect()\n".repeat(50);
1080 let history = |original_late: bool| {
1081 let mut m = vec![user("BRIEF: fix the parser")];
1082 if !original_late {
1083 m.extend(read("toolu_r1", full.clone()));
1084 }
1085 for i in 0..20 {
1086 m.extend(round(i, 4_000));
1087 }
1088 if original_late {
1089 m.extend(read("toolu_r1", full.clone()));
1090 }
1091 m.extend(read("toolu_r2", unchanged_stub("toolu_r1")));
1092 m.extend(round(99, 10));
1093 m
1094 };
1095
1096 let m = history(false);
1097 let plan = plan_rollover(&m, budget(8_000), 1, path).unwrap();
1098 let s = &plan.successor;
1099 assert!(!s.iter().any(|x| x.to_string().contains("toolu_r1")), "r1 evicted");
1100 let at = s.iter().position(|x| x["content"][0]["tool_use_id"] == "toolu_r2").unwrap();
1101 assert_eq!(s[at]["content"][0]["content"], DROPPED_STUB);
1102 assert!(Arc::ptr_eq(&s[at - 1], &m[m.len() - 4]), "the call itself is never edited");
1103 assert!(unpaired_tool_ids(s).is_empty() && plan_is_productive(&m, &plan));
1104
1105 let m = history(true);
1107 let plan = plan_rollover(&m, budget(8_000), 1, path).unwrap();
1108 for x in &plan.successor[2..] {
1109 assert!(m.iter().any(|o| Arc::ptr_eq(o, x)), "nothing edited");
1110 }
1111 assert!(plan.successor.iter().any(|x| x["content"][0]["content"] == unchanged_stub("toolu_r1")));
1112 }
1113
1114 #[test]
1115 fn cut_never_splits_a_tool_group_and_keeps_the_pending_loop() {
1116 let mut m = worker_history(6, 2_000);
1119 m.push(Arc::new(json!({"role":"assistant","content":[
1120 {"type":"tool_use","id":"p1","name":"read","input":{"path":"a"}},
1121 {"type":"tool_use","id":"p2","name":"read","input":{"path":"b"}}]})));
1122 m.push(Arc::new(json!({"role":"user","content":[
1123 {"type":"tool_result","tool_use_id":"p1","content":"A"}]})));
1124 m.push(Arc::new(json!({"role":"assistant","content":[{"type":"text","text":"hm"}]})));
1125 m.push(Arc::new(json!({"role":"user","content":[
1126 {"type":"tool_result","tool_use_id":"p2","content":"B"}]})));
1127 m.extend(round(99, 10));
1128 for tokens in [0, 1_000, 3_000, 6_000, 50_000] {
1129 let Some(plan) = plan_rollover(&m, budget(tokens), 1, path) else { continue };
1130 assert!(unpaired_tool_ids(&plan.successor).is_empty(), "budget {tokens}");
1131 let note = plan.successor.iter().position(|x| x.get(MARKER_KEY).is_some()).unwrap();
1132 assert_eq!(plan.successor[note + 1]["role"], "assistant");
1133 assert!(Arc::ptr_eq(plan.successor.last().unwrap(), m.last().unwrap()));
1134 }
1135 let plan = plan_rollover(&m, budget(0), 1, path).unwrap();
1137 assert_eq!(plan.successor.len(), 4, "brief, note, last call, its result");
1138 }
1139
1140 #[test]
1141 fn orchestrator_instructions_survive_and_bookkeeping_does_not() {
1142 let mut m = worker_history(3, 3_000);
1143 m.push(user("STEER: also update the docs"));
1144 m.extend(round(3, 3_000));
1146 let mut carrier = (*m.pop().unwrap()).clone();
1147 carrier["content"].as_array_mut().unwrap().push(
1148 json!({"type":"text","text":"[Resumed by the orchestrator.] RESUME: finish the tests"}),
1149 );
1150 m.push(Arc::new(carrier));
1151 m.push(Arc::new(json!({"role":"user","content":
1152 crate::engine::interrupt::InterruptReason::ALL[0].marker()})));
1153 for i in 4..30 {
1154 m.extend(round(i, 3_000));
1155 }
1156 let plan = plan_rollover(&m, budget(15_000), 1, path).unwrap();
1157 let s = &plan.successor;
1158 let texts: Vec<String> = s
1159 .iter()
1160 .take_while(|x| x.get(MARKER_KEY).is_none())
1161 .map(|x| x["content"].to_string())
1162 .collect();
1163 assert_eq!(texts.len(), 3, "brief + two carried instructions: {texts:?}");
1164 assert!(texts[1].contains("STEER: also update the docs"));
1165 assert!(texts[2].contains("RESUME: finish the tests"));
1166 assert!(!texts[2].contains("tool_result"), "only the text travels");
1167 assert!(!s.iter().any(|x| x["content"].as_str().is_some_and(
1168 crate::engine::interrupt::is_interruption_marker)));
1169 assert!(unpaired_tool_ids(s).is_empty());
1170 }
1171
1172 #[test]
1173 fn second_rollover_continues_numbering_and_lists_every_window() {
1174 let messages = worker_history(30, 3_000);
1175 let first = plan_rollover(&messages, budget(15_000), 1, path).unwrap();
1176 let mut next = first.successor.clone();
1177 for i in 100..130 {
1178 next.extend(round(i, 3_000));
1179 }
1180 assert_eq!(window_state(&next).0, 2);
1181 let second = plan_rollover(&next, budget(15_000), 1, path).unwrap();
1182 assert_eq!(second.window, 3);
1183 let s = &second.successor;
1184 assert!(Arc::ptr_eq(&s[0], &messages[0]), "the brief survives every rollover");
1185 assert_eq!(s.iter().filter(|x| x.get(MARKER_KEY).is_some()).count(), 1, "one note");
1186 let note = s[1]["content"][0]["text"].as_str().unwrap();
1187 assert!(note.contains("window-1.md") && note.contains("window-2.md"), "{note}");
1188 assert!(!second.transcript.contains("Worker context rollover"), "old note not re-saved");
1189 let (window, transcripts) = window_state(s);
1190 assert_eq!(window, 3);
1191 assert_eq!(transcripts.iter().map(|t| t.window).collect::<Vec<_>>(), vec![1, 2]);
1192 let history: Vec<Value> = s.iter().map(|x| (**x).clone()).collect();
1194 let marker = carried_marker(&history).unwrap();
1195 assert_eq!(marker["window"], 3);
1196 assert_eq!(marker["transcripts"].as_array().unwrap().len(), 2);
1197 assert!(carried_marker(&[json!({"role":"user","content":"x"})]).is_none());
1198 }
1199
1200 #[test]
1201 fn the_note_stays_bounded_however_many_windows_a_worker_has_had() {
1202 let transcripts: Vec<TranscriptRef> = (1..=40)
1203 .map(|w| TranscriptRef {
1204 window: w,
1205 path: format!("/d/sa_1.window-{w}.md"),
1206 messages: 9,
1207 })
1208 .collect();
1209 let note = note_message(NoteFacts {
1210 window: 41,
1211 before_tokens: 200_000,
1212 evicted: 9,
1213 evicted_tokens: 100_000,
1214 kept: 30,
1215 carried_instructions: false,
1216 transcripts: &transcripts,
1217 tool_index: (0..5_000).map(|i| format!("bash: step {i}")).collect(),
1218 unpinned: &[],
1219 });
1220 let text = note["content"][0]["text"].as_str().unwrap();
1221 assert!(text.contains("windows 1–28: earlier transcripts (*.window-N.md) in /d"), "{text}");
1222 assert!(!text.contains("sa_1.window-28.md") && text.contains("sa_1.window-29.md"));
1223 assert!(text.contains("sa_1.window-40.md"));
1224 assert!(text.contains("older not listed"));
1225 let tokens = super::super::context::conservative_token_estimate(text);
1226 assert!(tokens <= DROPPED_INDEX_TOKENS + NOTE_FRAME_TOKENS, "{tokens}");
1227 }
1228
1229 #[test]
1230 fn nothing_to_evict_is_no_plan_and_small_wins_are_unproductive() {
1231 assert!(plan_rollover(&[user("brief")], budget(1), 1, path).is_none());
1232 let one = worker_history(1, 50_000);
1233 assert!(plan_rollover(&one, budget(1), 1, path).is_none(), "only the must-keep round");
1234 let two = worker_history(2, 100);
1235 let plan = plan_rollover(&two, budget(0), 1, path).unwrap();
1236 assert!(!plan_is_productive(&two, &plan), "a tiny round is not worth a rewrite");
1237 }
1238
1239 #[test]
1240 fn host_metadata_never_reaches_the_wire() {
1241 let plan = plan_rollover(&worker_history(10, 3_000), budget(5_000), 1, path).unwrap();
1242 let wire = serde_json::to_string(&super::super::continuation::WireMessages(&plan.successor)).unwrap();
1243 assert!(!wire.contains(MARKER_KEY));
1244 assert!(wire.contains("Worker context rollover"));
1245 }
1246
1247 #[test]
1248 fn transcript_path_is_confined_to_the_directory() {
1249 let p = transcript_path(Path::new("/d"), "../sa 1", 3);
1250 assert_eq!(p, PathBuf::from("/d/---sa-1.window-3.md"));
1251 }
1252}