1use std::sync::atomic::Ordering;
24use std::sync::{Arc, Mutex, RwLock};
25
26use crate::agent::FinishReason;
27use crate::checkpoint::{CheckpointId, ShadowRepo};
28use crate::checkpoint_log::CheckpointLogWriter;
29use crate::compact::Compactor;
30use crate::error::Result;
31use crate::event::{AgentEvent, EventSink};
32use crate::hooks::HookEvent;
33use crate::kernel::{AgentKernel, TurnContext, TurnOutcome};
34use crate::llm::{ChatProvider, TokenUsage};
35use crate::message::Message;
36use crate::tools::plan_mode::{ExitPlanModeTool, PlanApprovalGate, PlanModeRequestGate};
37use crate::tools::{TodoItem, TodoWriteTool, TouchedFiles};
38
39mod builder;
42pub use builder::AgentRuntimeBuilder;
43
44mod checkpoint;
45pub(crate) use checkpoint::CheckpointState;
46
47pub use crate::runtime_goal::{GoalEvaluator, GoalState, GoalStatus, GoalVerdict};
54
55#[derive(Debug, Clone)]
65pub struct RuntimeOutcome {
66 pub final_text: Option<String>,
68 pub finish_reason: FinishReason,
70 pub total_usage: TokenUsage,
72 pub steps: usize,
74 pub llm_latency_ms: u64,
76 pub checkpoint_id: Option<CheckpointId>,
79}
80
81impl From<TurnOutcome> for RuntimeOutcome {
82 fn from(t: TurnOutcome) -> Self {
83 Self {
84 final_text: t.final_text,
85 finish_reason: t.finish_reason,
86 total_usage: t.usage,
87 steps: t.steps,
88 llm_latency_ms: t.llm_latency_ms,
89 checkpoint_id: None,
90 }
91 }
92}
93
94struct SessionLifecycle {
109 closed: bool,
110}
111
112impl SessionLifecycle {
113 fn open() -> Self {
114 Self { closed: false }
115 }
116}
117
118pub struct AgentRuntime {
129 kernel: AgentKernel,
131 transcript: Arc<Vec<Message>>,
134 event_sink: Arc<dyn EventSink>,
136 permission_hook: Option<Arc<dyn crate::tools::PermissionHook>>,
140 streaming: bool,
142 compactor: Option<Compactor>,
144 microcompactor: Option<crate::compact::Microcompactor>,
147 consecutive_compact_failures: u32,
150 checkpoints: CheckpointState,
152 session: SessionLifecycle,
156 todo_list: Arc<RwLock<Vec<TodoItem>>>,
159 plan_approval_gate: Arc<PlanApprovalGate>,
162 plan_mode_request_gate: Arc<PlanModeRequestGate>,
165 goal_state: Arc<RwLock<Option<GoalState>>>,
168 message_queue: std::collections::VecDeque<String>,
174 deferred_turn_finished: Option<AgentEvent>,
177 goal_eval_transcript_tail: usize,
182 prompt_segments: Option<crate::system_prompt::PromptSegments>,
184 file_reinjector: Option<crate::compact::FileReinjector>,
186 skill_reinjector: Option<crate::compact::SkillReinjector>,
188 plan_todo_reinjector: Option<crate::compact::PlanTodoReinjector>,
190 last_compact_turn: Option<u32>,
194}
195
196impl std::fmt::Debug for AgentRuntime {
197 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198 f.debug_struct("AgentRuntime")
199 .field("kernel", &self.kernel)
200 .field("transcript", &self.transcript)
201 .field("event_sink", &"<EventSink>")
202 .field("streaming", &self.streaming)
203 .field(
204 "todo_list",
205 &self.todo_list.read().map(|l| l.len()).unwrap_or(0),
206 )
207 .field(
208 "goal_state",
209 &self
210 .goal_state
211 .read()
212 .ok()
213 .and_then(|g| g.as_ref().map(|s| s.condition.clone())),
214 )
215 .field(
216 "deferred_turn_finished",
217 &self.deferred_turn_finished.as_ref().map(|_| "<event>"),
218 )
219 .field("goal_eval_transcript_tail", &self.goal_eval_transcript_tail)
220 .field("file_reinjector", &self.file_reinjector.is_some())
221 .field("skill_reinjector", &self.skill_reinjector.is_some())
222 .field("plan_todo_reinjector", &self.plan_todo_reinjector.is_some())
223 .finish()
224 }
225}
226
227impl AgentRuntime {
228 pub fn builder() -> AgentRuntimeBuilder {
230 AgentRuntimeBuilder::new()
231 }
232
233 pub async fn run(&mut self, user_text: impl Into<String>) -> Result<RuntimeOutcome> {
242 let user_text = user_text.into();
243
244 let turn = self.checkpoints.turn_index.load(Ordering::Relaxed);
245 tracing::Span::current().record(
246 "session_id",
247 self.checkpoints.session_id.as_deref().unwrap_or(""),
248 );
249 tracing::debug!(
250 session_id = self.checkpoints.session_id.as_deref().unwrap_or(""),
251 turn,
252 "agent.turn: starting"
253 );
254
255 if turn == 0 {
257 self.kernel
258 .hooks()
259 .dispatch(HookEvent::SessionStart { goal: &user_text });
260 }
261
262 self.reset_touched_files();
263 self.kernel.hooks().dispatch(HookEvent::UserPromptSubmit {
264 content: &user_text,
265 });
266 self.append_user_message(&user_text).await;
267
268 let turn_outcome = match self.execute_kernel_turn().await {
269 Ok(outcome) => outcome,
270 Err(e) if is_context_window_exceeded(&e) => {
271 tracing::warn!(
277 target: "recursive::agent",
278 error = %e,
279 "context window exceeded; attempting emergency compaction before retry"
280 );
281 if self.compact_on_overflow().await? {
282 self.execute_kernel_turn().await?
283 } else {
284 return Err(e);
285 }
286 }
287 Err(e) => return Err(e),
288 };
289 self.emit_turn_messages(&turn_outcome).await;
290 self.maybe_compact_cross_turn(&turn_outcome.usage).await?;
306
307 let outcome: RuntimeOutcome = turn_outcome.into();
308
309 tracing::info!(
310 steps = outcome.steps,
311 finish_reason = ?outcome.finish_reason,
312 "agent.turn: finished"
313 );
314 self.checkpoints.turn_index.fetch_add(1, Ordering::Relaxed);
315
316 Ok(outcome)
317 }
318
319 pub async fn close(&mut self, last_outcome: Option<&RuntimeOutcome>) {
325 if self.session.closed {
326 return;
327 }
328 self.session.closed = true;
329 if let Some(outcome) = last_outcome {
330 if !matches!(outcome.finish_reason, FinishReason::Cancelled) {
331 self.kernel
332 .hooks()
333 .dispatch(HookEvent::SessionEnd { outcome });
334 }
335 }
336 }
337
338 fn reset_touched_files(&self) {
340 if let Some(slot) = &self.checkpoints.touched_files {
341 if let Ok(mut t) = slot.lock() {
342 *t = TouchedFiles::new();
343 }
344 }
345 }
346
347 async fn append_user_message(&mut self, user_text: &str) {
349 let user_msg = Message::user(user_text.to_string());
350 Arc::make_mut(&mut self.transcript).push(user_msg.clone());
351 self.event_sink
352 .emit(AgentEvent::MessageAppended {
353 message: user_msg,
354 usage: None,
355 })
356 .await;
357 }
358
359 pub async fn maybe_compact_cross_turn(&mut self, last_usage: &TokenUsage) -> Result<()> {
375 if let Some(m) = &self.microcompactor {
379 let turn = self.checkpoints.turn_index.load(Ordering::Relaxed);
380 let pruned = m.prune(&mut *Arc::make_mut(&mut self.transcript));
381 if pruned > 0 {
382 self.event_sink
383 .emit(AgentEvent::Microcompact { step: turn, pruned })
384 .await;
385 }
386 }
387
388 let Some(ref compactor) = self.compactor else {
389 return Ok(());
390 };
391
392 if self.consecutive_compact_failures >= crate::compact::MAX_CONSECUTIVE_COMPACT_FAILURES {
394 self.event_sink
395 .emit(AgentEvent::CompactionSkipped {
396 step: self.checkpoints.turn_index.load(Ordering::Relaxed),
397 reason: crate::event::CompactionSkipReason::CircuitBreaker,
398 })
399 .await;
400 return Ok(());
401 }
402
403 let bytes = Compactor::estimate_bytes(&self.transcript);
404 if !compactor.should_compact(bytes, last_usage.prompt_tokens) {
405 return Ok(());
406 }
407 if !compactor.would_compact(&self.transcript) {
412 return Ok(());
413 }
414 self.kernel.hooks().dispatch(HookEvent::PreCompact {
415 transcript_len: bytes,
416 });
417 let pre_compact: Vec<Message> = self.transcript.iter().cloned().collect();
419 let result = compactor
420 .apply_to_transcript(
421 self.kernel.llm().as_ref(),
422 Arc::make_mut(&mut self.transcript),
423 self.checkpoints
424 .turn_index
425 .load(std::sync::atomic::Ordering::Relaxed),
426 )
427 .await;
428 match result {
429 Ok(Some((removed, summary_chars))) => {
430 self.consecutive_compact_failures = 0;
432 self.kernel.hooks().dispatch(HookEvent::PostCompact {
433 removed,
434 summary_chars,
435 });
436 self.event_sink
437 .emit(AgentEvent::CompactionBoundary {
438 turn: self.checkpoints.turn_index.load(Ordering::Relaxed) as u32,
439 compacted_count: removed,
440 summary_uuid: None,
441 cache_hit_tokens: last_usage.cache_hit_tokens,
442 cache_miss_tokens: last_usage.cache_miss_tokens,
443 is_recompaction_in_chain: self.last_compact_turn.is_some(),
444 turns_since_previous_compact: match self.last_compact_turn {
445 Some(prev) => {
446 let current =
447 self.checkpoints.turn_index.load(Ordering::Relaxed) as u32;
448 current.saturating_sub(prev)
449 }
450 None => 0,
451 },
452 previous_compact_turn: self.last_compact_turn,
453 })
454 .await;
455 self.last_compact_turn =
456 Some(self.checkpoints.turn_index.load(Ordering::Relaxed) as u32);
457 if let Some(summary) = self.transcript.first().cloned() {
458 self.event_sink
459 .emit(AgentEvent::MessageAppended {
460 message: summary,
461 usage: None,
462 })
463 .await;
464 }
465 if let Some(r) = &self.file_reinjector {
468 let preserved: Vec<_> = self.transcript.iter().skip(1).cloned().collect();
472 let atts = r.reinject(&preserved);
473 for (offset, att) in atts.into_iter().enumerate() {
475 Arc::make_mut(&mut self.transcript).insert(1 + offset, att.clone());
476 self.event_sink
477 .emit(AgentEvent::MessageAppended {
478 message: att,
479 usage: None,
480 })
481 .await;
482 }
483 }
484 if let Some(sr) = &self.skill_reinjector {
486 let atts = sr.reinject(&pre_compact);
487 let insert_base: usize = 1 + self.file_reinjector.as_ref().map_or(0, |r| {
489 self.transcript
495 .iter()
496 .skip(1)
497 .take(r.max_files)
498 .filter(|m| m.content.starts_with("[post-compact file restore:"))
499 .count()
500 });
501 for (offset, att) in atts.into_iter().enumerate() {
502 Arc::make_mut(&mut self.transcript)
503 .insert(insert_base + offset, att.clone());
504 self.event_sink
505 .emit(AgentEvent::MessageAppended {
506 message: att,
507 usage: None,
508 })
509 .await;
510 }
511 }
512 if let Some(ptr) = &self.plan_todo_reinjector {
514 let atts = ptr.reinject();
515 if !atts.is_empty() {
516 let insert_base: usize = 1 + self
519 .transcript
520 .iter()
521 .skip(1)
522 .take_while(|m| {
523 m.content.starts_with("[post-compact file restore:")
524 || m.content.starts_with("[post-compact skill restore:")
525 })
526 .count();
527 for (offset, att) in atts.into_iter().enumerate() {
528 Arc::make_mut(&mut self.transcript)
529 .insert(insert_base + offset, att.clone());
530 self.event_sink
531 .emit(AgentEvent::MessageAppended {
532 message: att,
533 usage: None,
534 })
535 .await;
536 }
537 }
538 }
539 }
540 Ok(None) => {
541 }
543 Err(e) => {
544 tracing::warn!(error = %e, "cross-turn proactive compaction failed");
546 self.consecutive_compact_failures += 1;
547 self.event_sink
548 .emit(AgentEvent::CompactionSkipped {
549 step: self.checkpoints.turn_index.load(Ordering::Relaxed),
550 reason: crate::event::CompactionSkipReason::Error,
551 })
552 .await;
553 }
554 }
555 Ok(())
556 }
557
558 async fn compact_on_overflow(&mut self) -> Result<bool> {
569 let Some(ref compactor) = self.compactor else {
570 return Ok(false);
571 };
572 if !compactor.would_compact(&self.transcript) {
575 return Ok(false);
576 }
577 let bytes = Compactor::estimate_bytes(&self.transcript);
578 self.kernel.hooks().dispatch(HookEvent::PreCompact {
579 transcript_len: bytes,
580 });
581 let turn = self
582 .checkpoints
583 .turn_index
584 .load(std::sync::atomic::Ordering::Relaxed);
585 let Some((removed, summary_chars)) = compactor
586 .apply_to_transcript(
587 self.kernel.llm().as_ref(),
588 Arc::make_mut(&mut self.transcript),
589 turn,
590 )
591 .await?
592 else {
593 return Ok(false);
594 };
595 self.kernel.hooks().dispatch(HookEvent::PostCompact {
596 removed,
597 summary_chars,
598 });
599 self.event_sink
601 .emit(AgentEvent::CompactionBoundary {
602 turn: turn as u32,
603 compacted_count: removed,
604 summary_uuid: None,
605 cache_hit_tokens: 0,
606 cache_miss_tokens: 0,
607 is_recompaction_in_chain: self.last_compact_turn.is_some(),
608 turns_since_previous_compact: match self.last_compact_turn {
609 Some(prev) => (turn as u32).saturating_sub(prev),
610 None => 0,
611 },
612 previous_compact_turn: self.last_compact_turn,
613 })
614 .await;
615 self.last_compact_turn = Some(turn as u32);
616 if let Some(summary) = self.transcript.first().cloned() {
617 self.event_sink
618 .emit(AgentEvent::MessageAppended {
619 message: summary,
620 usage: None,
621 })
622 .await;
623 }
624 tracing::info!(
625 target: "recursive::agent",
626 removed,
627 summary_chars,
628 "emergency compaction complete; retrying turn"
629 );
630 Ok(true)
631 }
632
633 async fn execute_kernel_turn(&mut self) -> Result<crate::kernel::TurnOutcome> {
639 let (event_tx, mut event_rx) =
640 tokio::sync::mpsc::unbounded_channel::<crate::event::AgentEvent>();
641 let sink = self.event_sink.clone();
642 let forwarder = tokio::spawn(async move {
643 let mut deferred_finished: Option<crate::event::AgentEvent> = None;
644 while let Some(ev) = event_rx.recv().await {
645 if matches!(ev, AgentEvent::TurnFinished { .. }) {
646 deferred_finished = Some(ev);
647 continue;
648 }
649 sink.emit(ev).await;
650 }
651 deferred_finished
652 });
653
654 let ctx = TurnContext {
655 messages: Arc::clone(&self.transcript),
656 tool_specs: self.kernel.tools().specs(),
657 step_events_tx: Some(event_tx.clone()),
658 streaming: self.streaming,
659 permission_hook: self.permission_hook.clone(),
660 exploring_plan_mode: self.plan_approval_gate.exploring_plan_mode.clone(),
661 permission_mode: self.kernel.tools().permission_mode(),
662 mailbox: None,
663 turn: self.checkpoints.turn_index.load(Ordering::Relaxed) as u32,
664 prompt_segments: self.prompt_segments.clone(),
665 wall_timeout_secs: 0,
666 };
667
668 let turn_outcome = self.kernel.run(ctx).await?;
669 drop(event_tx);
670 self.deferred_turn_finished = match forwarder.await {
672 Ok(v) => v,
673 Err(e) => {
674 tracing::error!("forwarder task panicked, TurnFinished will be synthesized: {e}");
675 None
676 }
677 };
678 Ok(turn_outcome)
679 }
680
681 async fn emit_turn_messages(&mut self, outcome: &crate::kernel::TurnOutcome) {
685 let new_messages = &outcome.new_messages;
686 let turn_usage = crate::session::UsageMeta::from_token_usage(&outcome.usage);
687 let mut tool_audits = outcome.tool_audits.clone();
688 let last_assistant_idx = new_messages
692 .iter()
693 .rposition(|m| matches!(m.role, crate::message::Role::Assistant));
694 Arc::make_mut(&mut self.transcript).extend(new_messages.iter().cloned());
695 for (idx, msg) in new_messages.iter().enumerate() {
696 let event = if msg.role == crate::message::Role::Tool {
697 if let Some(tcid) = &msg.tool_call_id {
698 if let Some(audit) = tool_audits.remove(&(outcome.turn, tcid.clone())) {
699 AgentEvent::MessageAppendedWithAudit {
700 message: msg.clone(),
701 audit,
702 }
703 } else {
704 AgentEvent::MessageAppended {
705 message: msg.clone(),
706 usage: None,
707 }
708 }
709 } else {
710 AgentEvent::MessageAppended {
711 message: msg.clone(),
712 usage: None,
713 }
714 }
715 } else {
716 let usage = if matches!(msg.role, crate::message::Role::Assistant)
717 && Some(idx) == last_assistant_idx
718 {
719 Some(turn_usage.clone())
720 } else {
721 None
722 };
723 AgentEvent::MessageAppended {
724 message: msg.clone(),
725 usage,
726 }
727 };
728 self.event_sink.emit(event).await;
729 }
730 if let Some(ev) = self.deferred_turn_finished.take() {
732 self.event_sink.emit(ev).await;
733 }
734 }
735
736 pub async fn enqueue(&mut self, text: impl Into<String>) -> Result<Option<RuntimeOutcome>> {
758 self.message_queue.push_back(text.into());
759 self.drain_queue().await
760 }
761
762 async fn drain_queue(&mut self) -> Result<Option<RuntimeOutcome>> {
771 let mut last: Option<RuntimeOutcome> = None;
772 while let Some(msg) = self.message_queue.front().cloned() {
778 match self.run(msg).await {
779 Ok(outcome) => {
780 self.message_queue.pop_front();
781 last = Some(outcome);
782 }
783 Err(e) => {
784 return Err(e);
785 }
786 }
787 }
788 Ok(last)
789 }
790
791 pub fn queue_len(&self) -> usize {
795 self.message_queue.len()
796 }
797
798 pub fn transcript(&self) -> &[Message] {
802 &self.transcript
803 }
804
805 pub fn transcript_tail(&self, n: usize) -> &[Message] {
813 let t: &Vec<Message> = &self.transcript;
814 let len = t.len();
815 if n >= len {
816 t
817 } else {
818 &t[len - n..]
819 }
820 }
821
822 pub fn set_transcript(&mut self, transcript: Vec<Message>) {
824 self.transcript = Arc::new(transcript);
825 }
826
827 pub fn truncate_transcript(&mut self, len: usize) {
831 Arc::make_mut(&mut self.transcript).truncate(len);
832 }
833
834 pub fn kernel(&self) -> &AgentKernel {
836 &self.kernel
837 }
838
839 pub fn set_llm(&mut self, llm: Arc<dyn ChatProvider>) {
845 self.kernel.set_llm(llm);
846 }
847
848 pub fn event_sink(&self) -> &dyn EventSink {
850 self.event_sink.as_ref()
851 }
852
853 pub fn set_interrupt_token(&mut self, token: tokio_util::sync::CancellationToken) {
860 self.kernel.shutdown_token = Some(token);
861 }
862
863 pub fn set_session_id(&mut self, id: impl Into<String>) {
870 self.checkpoints.session_id = Some(id.into());
871 }
872
873 pub fn set_event_sink(&mut self, sink: Arc<dyn EventSink>) {
887 self.event_sink = sink.clone();
888 self.kernel
891 .tools_mut()
892 .register_mut(Arc::new(TodoWriteTool::new(
893 self.todo_list.clone(),
894 sink.clone(),
895 )));
896 self.kernel
899 .tools_mut()
900 .register_mut(Arc::new(ExitPlanModeTool::new(
901 self.plan_approval_gate.clone(),
902 sink,
903 )));
904 }
905
906 pub fn replace_event_sink(&mut self, sink: Arc<dyn EventSink>) {
918 self.event_sink = sink;
919 }
920
921 pub fn current_todos(&self) -> Vec<TodoItem> {
926 self.todo_list.read().map(|l| l.clone()).unwrap_or_default()
927 }
928
929 pub fn set_permission_hook(&mut self, hook: Arc<dyn crate::tools::PermissionHook>) {
933 self.permission_hook = Some(hook.clone());
934 self.kernel.tools_mut().set_permission_hook(hook);
935 }
936
937 pub fn set_sdk_hook_forwarder(
940 &mut self,
941 forwarder: Option<Arc<dyn crate::hooks::SdkHookForwarder>>,
942 ) {
943 self.kernel
944 .tools_mut()
945 .hook_runner
946 .set_sdk_forwarder(forwarder);
947 }
948
949 pub fn plan_approval_gate(&self) -> Arc<PlanApprovalGate> {
955 self.plan_approval_gate.clone()
956 }
957
958 pub fn plan_mode_request_gate(&self) -> Arc<PlanModeRequestGate> {
964 self.plan_mode_request_gate.clone()
965 }
966
967 pub fn confirm_plan(&mut self) {
971 self.plan_approval_gate.approve();
972 }
973
974 pub async fn compact_now(&mut self) -> Result<()> {
982 let Some(ref compactor) = self.compactor else {
983 return Ok(());
984 };
985 if compactor
986 .apply_to_transcript(
987 self.kernel.llm().as_ref(),
988 Arc::make_mut(&mut self.transcript),
989 self.checkpoints
990 .turn_index
991 .load(std::sync::atomic::Ordering::Relaxed),
992 )
993 .await?
994 .is_some()
995 {
996 self.last_compact_turn =
997 Some(self.checkpoints.turn_index.load(Ordering::Relaxed) as u32);
998 }
999 Ok(())
1000 }
1001
1002 pub async fn compact_partial_before(&mut self, pivot_index: usize) -> Result<()> {
1014 let Some(ref compactor) = self.compactor else {
1015 return Ok(());
1016 };
1017 let transcript = Arc::make_mut(&mut self.transcript);
1018 if pivot_index >= transcript.len() {
1019 return Ok(());
1020 }
1021 let scope = &transcript[..=pivot_index];
1023 let split = Compactor::safe_split_point(scope, compactor.keep_recent_n);
1024 if split == 0 {
1025 return Ok(());
1026 }
1027 let zero_keep = Compactor {
1031 threshold_chars: compactor.threshold_chars,
1032 threshold_prompt_tokens: compactor.threshold_prompt_tokens,
1033 keep_recent_n: 0,
1034 };
1035 let step = self.checkpoints.turn_index.load(Ordering::Relaxed);
1036 let summary_msg = zero_keep
1037 .compact(self.kernel.llm().as_ref(), &transcript[..split], step)
1038 .await?;
1039 transcript.drain(..split);
1040 transcript.insert(0, summary_msg);
1041 self.last_compact_turn = Some(self.checkpoints.turn_index.load(Ordering::Relaxed) as u32);
1042 Ok(())
1043 }
1044
1045 pub async fn compact_partial_after(&mut self, pivot_index: usize) -> Result<()> {
1056 let Some(ref compactor) = self.compactor else {
1057 return Ok(());
1058 };
1059 let transcript = Arc::make_mut(&mut self.transcript);
1060 if pivot_index >= transcript.len() {
1061 return Ok(());
1062 }
1063 let mut start = pivot_index;
1068 loop {
1069 if start == 0 {
1070 break;
1071 }
1072 let msg = &transcript[start];
1073 let should_back_up = msg.role == crate::message::Role::Tool
1074 || (msg.role == crate::message::Role::Assistant && !msg.tool_calls.is_empty());
1075 if should_back_up {
1076 start -= 1;
1077 } else {
1078 break;
1079 }
1080 }
1081 if start >= transcript.len() {
1082 return Ok(());
1083 }
1084 let suffix = transcript[start..].to_vec();
1085 if suffix.len() <= 1 {
1091 return Ok(());
1092 }
1093 let zero_keep = Compactor {
1095 threshold_chars: compactor.threshold_chars,
1096 threshold_prompt_tokens: compactor.threshold_prompt_tokens,
1097 keep_recent_n: 0,
1098 };
1099 let step = self.checkpoints.turn_index.load(Ordering::Relaxed);
1100 let summary_msg = zero_keep
1101 .compact(self.kernel.llm().as_ref(), &suffix, step)
1102 .await?;
1103 transcript.truncate(start);
1104 transcript.push(summary_msg);
1105 self.last_compact_turn = Some(self.checkpoints.turn_index.load(Ordering::Relaxed) as u32);
1106 Ok(())
1107 }
1108
1109 pub fn approve_plan_mode_request(&self) {
1114 self.plan_mode_request_gate.approve();
1115 }
1116
1117 pub fn reject_plan_mode_request(&self, reason: &str) {
1122 self.plan_mode_request_gate.reject(reason);
1123 }
1124
1125 pub fn reject_plan(&mut self, reason: &str) {
1130 let rejection_msg = Message::user(format!("Plan rejected: {}", reason));
1131 Arc::make_mut(&mut self.transcript).push(rejection_msg);
1132 self.plan_approval_gate.reject(reason);
1133 }
1134
1135 pub fn current_goal(&self) -> Option<GoalState> {
1139 self.goal_state.read().ok().and_then(|g| g.clone())
1140 }
1141
1142 pub async fn set_goal(&self, condition: String, max_turns: u32) {
1144 let state = GoalState {
1145 condition: condition.clone(),
1146 status: GoalStatus::Pursuing,
1147 turns: 0,
1148 max_turns,
1149 last_reason: None,
1150 };
1151 if let Ok(mut g) = self.goal_state.write() {
1152 *g = Some(state);
1153 }
1154 self.event_sink
1155 .emit(AgentEvent::GoalSet {
1156 condition,
1157 max_turns,
1158 })
1159 .await;
1160 }
1161
1162 pub async fn clear_goal(&self) {
1164 if let Ok(mut g) = self.goal_state.write() {
1165 *g = None;
1166 }
1167 self.event_sink.emit(AgentEvent::GoalCleared).await;
1168 }
1169
1170 pub async fn run_goal_loop(
1181 &mut self,
1182 initial_prompt: impl Into<String>,
1183 condition: impl Into<String>,
1184 max_turns: u32,
1185 ) -> Result<Vec<RuntimeOutcome>> {
1186 let condition = condition.into();
1187 self.set_goal(condition.clone(), max_turns).await;
1188
1189 let evaluator = GoalEvaluator::new(self.kernel.llm().clone());
1190 let mut outcomes = Vec::new();
1191 let mut next_prompt = initial_prompt.into();
1192
1193 loop {
1194 let active = self
1196 .goal_state
1197 .read()
1198 .ok()
1199 .and_then(|g| g.clone())
1200 .map(|g| g.status == GoalStatus::Pursuing)
1201 .unwrap_or(false);
1202 if !active {
1203 break;
1204 }
1205
1206 let outcome = self.run(&next_prompt).await?;
1207 outcomes.push(outcome);
1208
1209 enum TurnOutcomeKind {
1214 Continue(u32),
1215 BudgetExceeded(u32),
1216 ExternallyCleared,
1217 }
1218 let turn_outcome = {
1219 let mut guard = match self.goal_state.write().ok() {
1220 Some(g) => g,
1221 None => break,
1222 };
1223 match *guard {
1224 None => TurnOutcomeKind::ExternallyCleared,
1225 Some(ref mut gs) => {
1226 gs.turns += 1;
1227 let turns = gs.turns;
1228 if turns >= max_turns {
1229 *guard = None;
1230 TurnOutcomeKind::BudgetExceeded(turns)
1231 } else {
1232 TurnOutcomeKind::Continue(turns)
1233 }
1234 }
1235 }
1236 };
1237
1238 let turns = match turn_outcome {
1239 TurnOutcomeKind::ExternallyCleared => break,
1240 TurnOutcomeKind::BudgetExceeded(t) => {
1241 self.event_sink.emit(AgentEvent::GoalCleared).await;
1242 tracing::warn!(
1243 "goal loop: turn budget of {max_turns} exceeded without achieving condition"
1244 );
1245 let _ = t;
1246 break;
1247 }
1248 TurnOutcomeKind::Continue(t) => t,
1249 };
1250
1251 let tail = self.transcript_tail(self.goal_eval_transcript_tail);
1259 let verdict = evaluator.evaluate(&condition, tail).await?;
1260 if verdict.achieved {
1261 if let Ok(mut g) = self.goal_state.write() {
1262 if let Some(ref mut gs) = *g {
1263 gs.status = GoalStatus::Achieved;
1264 gs.last_reason = Some(verdict.reason.clone());
1265 }
1266 *g = None;
1267 }
1268 self.event_sink
1269 .emit(AgentEvent::GoalAchieved {
1270 condition: condition.clone(),
1271 turns,
1272 })
1273 .await;
1274 break;
1275 } else {
1276 if let Ok(mut g) = self.goal_state.write() {
1278 if let Some(ref mut gs) = *g {
1279 gs.last_reason = Some(verdict.reason.clone());
1280 }
1281 }
1282 self.event_sink
1283 .emit(AgentEvent::GoalContinuing {
1284 reason: verdict.reason.clone(),
1285 turns,
1286 })
1287 .await;
1288
1289 next_prompt = format!(
1290 "(Goal: {condition})\n\nPrevious attempt reason: {}\n\nContinue.",
1291 verdict.reason
1292 );
1293 }
1294 }
1295
1296 Ok(outcomes)
1297 }
1298
1299 pub async fn run_loop(
1307 &mut self,
1308 initial_goal: impl Into<String>,
1309 wakeup_slot: &crate::tools::WakeupSlot,
1310 ) -> Result<Vec<RuntimeOutcome>> {
1311 let mut outcomes = Vec::new();
1312 let mut next_goal = initial_goal.into();
1313
1314 loop {
1315 let outcome = self.run(&next_goal).await?;
1316 outcomes.push(outcome);
1317
1318 let wakeup = wakeup_slot.lock().ok().and_then(|mut slot| slot.take());
1320
1321 match wakeup {
1322 Some(req) => {
1323 tokio::time::sleep(req.delay).await;
1324 next_goal = req.prompt;
1325 }
1326 None => break,
1327 }
1328 }
1329 Ok(outcomes)
1330 }
1331
1332 pub async fn run_event_loop(
1342 &mut self,
1343 initial_goal: impl Into<String>,
1344 wakeup_slot: &crate::tools::WakeupSlot,
1345 bg_manager: Option<&tokio::sync::Mutex<crate::tools::BackgroundJobManager>>,
1346 ) -> Result<Vec<RuntimeOutcome>> {
1347 let mut outcomes = Vec::new();
1348 let mut next_goal = initial_goal.into();
1349
1350 let mut pending_jobs: std::collections::VecDeque<(String, String)> =
1355 std::collections::VecDeque::new();
1356
1357 loop {
1358 let outcome = self.run(&next_goal).await?;
1359 outcomes.push(outcome);
1360
1361 let wakeup = wakeup_slot.lock().ok().and_then(|mut slot| slot.take());
1363 if let Some(req) = wakeup {
1364 tokio::time::sleep(req.delay).await;
1365 next_goal = req.prompt;
1366 continue;
1367 }
1368
1369 if let Some(mgr) = bg_manager {
1378 let mut mgr = mgr.lock().await;
1379 while let Some((id, output)) = mgr.take_completed() {
1380 pending_jobs.push_back((id, output));
1381 }
1382 }
1383 if let Some((id, output)) = pending_jobs.pop_front() {
1384 next_goal = format!("Background job '{}' completed:\n{}", id, output);
1385 continue;
1386 }
1387
1388 break;
1390 }
1391 Ok(outcomes)
1392 }
1393
1394 pub fn enable_checkpoints(
1406 &mut self,
1407 shadow: Arc<ShadowRepo>,
1408 session_id: impl Into<String>,
1409 log_path: std::path::PathBuf,
1410 touched_slot: Option<Arc<Mutex<TouchedFiles>>>,
1411 ) -> Result<()> {
1412 let writer = Arc::new(Mutex::new(CheckpointLogWriter::open(&log_path)?));
1413 let session_id = session_id.into();
1414
1415 let tool_repo = Arc::new(Mutex::new(ShadowRepo::clone(&shadow)));
1420 let ctx = crate::tools::CheckpointToolCtx {
1421 repo: tool_repo.clone(),
1422 session_id: session_id.clone(),
1423 };
1424 let tools = self.kernel.tools_mut();
1425 tools.register_mut(Arc::new(crate::tools::CheckpointList::new(ctx.clone())));
1426 tools.register_mut(Arc::new(crate::tools::CheckpointDiff::new(ctx)));
1427
1428 let save_tool = crate::tools::checkpoint::build_checkpoint_save_tool(
1430 tool_repo,
1431 session_id.clone(),
1432 touched_slot.clone(),
1433 writer.clone(),
1434 self.checkpoints.turn_index.clone(),
1435 log_path.clone(),
1436 );
1437 tools.register_mut(Arc::new(save_tool));
1438
1439 self.checkpoints.shadow = Some(shadow);
1440 self.checkpoints.session_id = Some(session_id);
1441 self.checkpoints.writer = Some(writer);
1442 self.checkpoints.touched_files = touched_slot;
1443 self.checkpoints.log_path = Some(log_path);
1444 Ok(())
1445 }
1446
1447 pub fn checkpoints_enabled(&self) -> bool {
1449 self.checkpoints.enabled()
1450 }
1451
1452 pub fn turn_index(&self) -> usize {
1455 self.checkpoints.turn_index.load(Ordering::Relaxed)
1456 }
1457}
1458
1459use crate::error::is_context_window_exceeded;
1464
1465#[cfg(test)]
1470mod tests {
1471 use super::*;
1472 use crate::hooks::HookRegistry;
1473 use crate::llm::{Completion, MockProvider};
1474 use crate::tools::plan_mode::{ENTER_PLAN_MODE_TOOL_NAME, EXIT_PLAN_MODE_TOOL_NAME};
1475 use crate::tools::todo::TodoStatus;
1476 use crate::tools::Tool;
1477 use crate::tools::ToolRegistry;
1478 use async_trait::async_trait;
1479 use serde_json::{json, Value};
1480
1481 struct Adder;
1482
1483 #[async_trait]
1484 impl Tool for Adder {
1485 fn spec(&self) -> crate::llm::ToolSpec {
1486 crate::llm::ToolSpec {
1487 name: "add".into(),
1488 description: "add two numbers".into(),
1489 parameters: json!({"type":"object","properties":{"a":{"type":"integer"},"b":{"type":"integer"}}}),
1490 }
1491 }
1492 async fn execute(&self, args: Value) -> crate::error::Result<String> {
1493 let a = args["a"].as_i64().unwrap_or(0);
1494 let b = args["b"].as_i64().unwrap_or(0);
1495 Ok((a + b).to_string())
1496 }
1497 }
1498
1499 #[tokio::test]
1502 async fn single_turn_no_tools() {
1503 let llm = Arc::new(MockProvider::new(vec![Completion {
1504 content: "Hello!".into(),
1505 tool_calls: vec![],
1506 finish_reason: Some("stop".into()),
1507 usage: None,
1508 reasoning_content: None,
1509 }]));
1510 let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1511 let out = rt.run("hi").await.unwrap();
1512 assert_eq!(out.final_text.as_deref(), Some("Hello!"));
1513 assert_eq!(out.steps, 1);
1514 assert_eq!(rt.transcript().len(), 2); }
1516
1517 #[tokio::test]
1518 async fn turn_with_tool() {
1519 let llm = Arc::new(MockProvider::new(vec![
1520 Completion {
1521 content: "Let me check...".into(),
1522 tool_calls: vec![crate::llm::ToolCall {
1523 id: "c1".into(),
1524 name: "add".into(),
1525 arguments: json!({"a": 3, "b": 4}),
1526 }],
1527 finish_reason: Some("tool_calls".into()),
1528 usage: None,
1529 reasoning_content: None,
1530 },
1531 Completion {
1532 content: "7".into(),
1533 tool_calls: vec![],
1534 finish_reason: Some("stop".into()),
1535 usage: None,
1536 reasoning_content: None,
1537 },
1538 ]));
1539 let tools = ToolRegistry::local().register(Arc::new(Adder));
1540 let mut rt = AgentRuntime::builder()
1541 .llm(llm)
1542 .tools(tools)
1543 .build()
1544 .unwrap();
1545 let out = rt.run("3+4?").await.unwrap();
1546 assert_eq!(out.final_text.as_deref(), Some("7"));
1547 assert_eq!(out.steps, 2);
1548 assert_eq!(rt.transcript().len(), 4); }
1550
1551 #[tokio::test]
1554 async fn multi_turn_transcript_grows() {
1555 let llm = Arc::new(MockProvider::new(vec![
1556 Completion {
1557 content: "First reply".into(),
1558 tool_calls: vec![],
1559 finish_reason: Some("stop".into()),
1560 usage: None,
1561 reasoning_content: None,
1562 },
1563 Completion {
1564 content: "Second reply".into(),
1565 tool_calls: vec![],
1566 finish_reason: Some("stop".into()),
1567 usage: None,
1568 reasoning_content: None,
1569 },
1570 ]));
1571 let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1572
1573 let o1 = rt.run("turn 1").await.unwrap();
1574 assert_eq!(o1.final_text.as_deref(), Some("First reply"));
1575 assert_eq!(rt.transcript().len(), 2);
1576
1577 let o2 = rt.run("turn 2").await.unwrap();
1578 assert_eq!(o2.final_text.as_deref(), Some("Second reply"));
1579 assert_eq!(rt.transcript().len(), 4);
1580 }
1581
1582 #[tokio::test]
1585 async fn system_prompt_is_prepended() {
1586 let llm = Arc::new(MockProvider::new(vec![Completion {
1587 content: "ok".into(),
1588 tool_calls: vec![],
1589 finish_reason: Some("stop".into()),
1590 usage: None,
1591 reasoning_content: None,
1592 }]));
1593 let mut rt = AgentRuntime::builder()
1594 .llm(llm)
1595 .system_prompt("Be helpful.")
1596 .build()
1597 .unwrap();
1598 rt.run("hello").await.unwrap();
1599 assert_eq!(rt.transcript()[0].role, crate::message::Role::System);
1600 assert_eq!(rt.transcript()[0].content, "Be helpful.");
1601 }
1602
1603 #[tokio::test]
1604 async fn seed_transcript_is_included() {
1605 let seed = vec![
1606 Message::user("old Q".to_string()),
1607 Message::assistant("old A".to_string()),
1608 ];
1609 let llm = Arc::new(MockProvider::new(vec![Completion {
1610 content: "fresh".into(),
1611 tool_calls: vec![],
1612 finish_reason: Some("stop".into()),
1613 usage: None,
1614 reasoning_content: None,
1615 }]));
1616 let mut rt = AgentRuntime::builder()
1617 .llm(llm)
1618 .seed_transcript(seed)
1619 .build()
1620 .unwrap();
1621 rt.run("new Q").await.unwrap();
1622 assert_eq!(rt.transcript().len(), 4);
1624 assert_eq!(rt.transcript()[0].content, "old Q");
1625 assert_eq!(rt.transcript()[1].content, "old A");
1626 assert_eq!(rt.transcript()[2].content, "new Q");
1627 assert_eq!(rt.transcript()[3].content, "fresh");
1628 }
1629
1630 #[tokio::test]
1631 async fn system_and_seed_ordering() {
1632 let seed = vec![Message::user("seeded user".to_string())];
1633 let llm = Arc::new(MockProvider::new(vec![Completion {
1634 content: "r".into(),
1635 tool_calls: vec![],
1636 finish_reason: Some("stop".into()),
1637 usage: None,
1638 reasoning_content: None,
1639 }]));
1640 let mut rt = AgentRuntime::builder()
1641 .llm(llm)
1642 .system_prompt("sys prompt")
1643 .seed_transcript(seed)
1644 .build()
1645 .unwrap();
1646 rt.run("real").await.unwrap();
1647 assert_eq!(rt.transcript()[0].role, crate::message::Role::System);
1648 assert_eq!(rt.transcript()[0].content, "sys prompt");
1649 assert_eq!(rt.transcript()[1].content, "seeded user");
1650 assert_eq!(rt.transcript()[2].content, "real");
1651 }
1652
1653 #[tokio::test]
1656 async fn set_transcript_replaces() {
1657 let llm = Arc::new(MockProvider::new(vec![Completion {
1658 content: "ok".into(),
1659 tool_calls: vec![],
1660 finish_reason: Some("stop".into()),
1661 usage: None,
1662 reasoning_content: None,
1663 }]));
1664 let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1665 rt.set_transcript(vec![Message::user("custom".to_string())]);
1666 assert_eq!(rt.transcript().len(), 1);
1667 assert_eq!(rt.transcript()[0].content, "custom");
1668 }
1669
1670 #[tokio::test]
1671 async fn kernel_accessor_works() {
1672 let llm = Arc::new(MockProvider::new(vec![Completion {
1673 content: "ok".into(),
1674 tool_calls: vec![],
1675 finish_reason: Some("stop".into()),
1676 usage: None,
1677 reasoning_content: None,
1678 }]));
1679 let rt = AgentRuntime::builder().llm(llm).build().unwrap();
1680 let _kernel = rt.kernel(); }
1682
1683 #[tokio::test]
1686 async fn defaults_are_sensible() {
1687 let llm = Arc::new(MockProvider::new(vec![Completion {
1688 content: "done".into(),
1689 tool_calls: vec![],
1690 finish_reason: Some("stop".into()),
1691 usage: None,
1692 reasoning_content: None,
1693 }]));
1694 let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1695 let out = rt.run("test").await.unwrap();
1696 assert_eq!(out.finish_reason, FinishReason::NoMoreToolCalls);
1697 assert_eq!(rt.transcript().len(), 2);
1698 }
1699
1700 fn has_git() -> bool {
1703 std::process::Command::new("git")
1704 .arg("--version")
1705 .output()
1706 .is_ok()
1707 }
1708
1709 struct ShadowWs {
1713 workspace: tempfile::TempDir,
1714 shadow: tempfile::TempDir,
1715 }
1716
1717 impl ShadowWs {
1718 fn path(&self) -> &std::path::Path {
1719 self.workspace.path()
1720 }
1721 fn shadow_dir(&self) -> std::path::PathBuf {
1722 self.shadow.path().join("shadow-git")
1723 }
1724 }
1725
1726 fn shadow_ws() -> ShadowWs {
1727 ShadowWs {
1728 workspace: tempfile::tempdir().expect("workspace tempdir"),
1729 shadow: tempfile::tempdir().expect("shadow tempdir"),
1730 }
1731 }
1732
1733 #[tokio::test]
1738 async fn runtime_no_auto_snapshots_with_checkpoints_enabled() {
1739 if !has_git() {
1740 return;
1741 }
1742 let dir = shadow_ws();
1743 std::fs::write(dir.path().join("seed.txt"), "v0").unwrap();
1744
1745 let llm = Arc::new(MockProvider::new(vec![
1746 Completion {
1747 content: "ok".into(),
1748 tool_calls: vec![],
1749 finish_reason: Some("stop".into()),
1750 usage: None,
1751 reasoning_content: None,
1752 },
1753 Completion {
1754 content: "ok2".into(),
1755 tool_calls: vec![],
1756 finish_reason: Some("stop".into()),
1757 usage: None,
1758 reasoning_content: None,
1759 },
1760 ]));
1761 let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1762
1763 let shadow = Arc::new(crate::ShadowRepo::open_at(dir.path(), dir.shadow_dir()).unwrap());
1764 let log_path = dir.path().join("checkpoints.jsonl");
1765 rt.enable_checkpoints(shadow.clone(), "sess", log_path.clone(), None)
1766 .unwrap();
1767 assert!(rt.checkpoints_enabled());
1768
1769 let o1 = rt.run("turn 0").await.unwrap();
1770 assert!(o1.checkpoint_id.is_none(), "no auto-snapshot in Goal 284");
1771 let o2 = rt.run("turn 1").await.unwrap();
1772 assert!(o2.checkpoint_id.is_none(), "no auto-snapshot in Goal 284");
1773
1774 let recs = crate::read_checkpoint_log(&log_path).unwrap();
1776 assert_eq!(recs.len(), 0, "no auto log entries");
1777 }
1778
1779 #[tokio::test]
1782 async fn checkpoint_save_tool_is_registered() {
1783 if !has_git() {
1784 return;
1785 }
1786 let dir = shadow_ws();
1787 std::fs::write(dir.path().join("a.txt"), "hi").unwrap();
1788
1789 let llm = Arc::new(MockProvider::new(vec![Completion {
1790 content: "ok".into(),
1791 tool_calls: vec![],
1792 finish_reason: Some("stop".into()),
1793 usage: None,
1794 reasoning_content: None,
1795 }]));
1796 let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1797
1798 let shadow = Arc::new(crate::ShadowRepo::open_at(dir.path(), dir.shadow_dir()).unwrap());
1799 let log_path = dir.path().join("checkpoints.jsonl");
1800 rt.enable_checkpoints(shadow, "sess", log_path, None)
1801 .unwrap();
1802
1803 let tools = rt.kernel.tools();
1804 assert!(
1805 tools.get("checkpoint_save").is_some(),
1806 "checkpoint_save must be registered"
1807 );
1808 assert!(
1809 tools.get("checkpoint_list").is_some(),
1810 "checkpoint_list must be registered"
1811 );
1812 assert!(
1813 tools.get("checkpoint_diff").is_some(),
1814 "checkpoint_diff must be registered"
1815 );
1816 }
1817
1818 #[tokio::test]
1819 async fn runtime_works_when_checkpoints_disabled() {
1820 let llm = Arc::new(MockProvider::new(vec![Completion {
1823 content: "ok".into(),
1824 tool_calls: vec![],
1825 finish_reason: Some("stop".into()),
1826 usage: None,
1827 reasoning_content: None,
1828 }]));
1829 let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1830 let out = rt.run("hi").await.unwrap();
1831 assert!(out.checkpoint_id.is_none());
1832 assert!(!rt.checkpoints_enabled());
1833 }
1834
1835 #[tokio::test]
1838 async fn compact_now_invokes_compactor() {
1839 let llm = Arc::new(MockProvider::new(vec![
1842 Completion {
1843 content: "first reply".into(),
1844 tool_calls: vec![],
1845 finish_reason: Some("stop".into()),
1846 usage: None,
1847 reasoning_content: None,
1848 },
1849 Completion {
1850 content: "second reply".into(),
1851 tool_calls: vec![],
1852 finish_reason: Some("stop".into()),
1853 usage: None,
1854 reasoning_content: None,
1855 },
1856 Completion {
1857 content: "compacted summary".into(),
1858 tool_calls: vec![],
1859 finish_reason: Some("stop".into()),
1860 usage: None,
1861 reasoning_content: None,
1862 },
1863 ]));
1864 let compactor = crate::compact::Compactor::new(usize::MAX).keep_recent_n(1);
1868 let mut rt = AgentRuntime::builder()
1869 .llm(llm)
1870 .compactor(compactor)
1871 .build()
1872 .unwrap();
1873 rt.run("turn 1").await.unwrap();
1874 rt.run("turn 2").await.unwrap();
1875 let len_before = rt.transcript().len();
1876 assert!(len_before >= 3, "expected ≥3 messages, got {len_before}");
1877
1878 rt.compact_now().await.unwrap();
1879 assert_eq!(rt.transcript().len(), 2);
1882 assert_eq!(rt.transcript()[0].role, crate::message::Role::System);
1883 assert!(rt.transcript()[0].content.starts_with("[compacted:"));
1884 }
1885
1886 #[tokio::test]
1887 async fn compact_now_is_noop_without_compactor() {
1888 let llm = Arc::new(MockProvider::new(vec![Completion {
1889 content: "x".into(),
1890 tool_calls: vec![],
1891 finish_reason: Some("stop".into()),
1892 usage: None,
1893 reasoning_content: None,
1894 }]));
1895 let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1896 rt.run("hi").await.unwrap();
1897 let before = rt.transcript().len();
1898 rt.compact_now().await.unwrap();
1899 assert_eq!(rt.transcript().len(), before);
1900 }
1901
1902 #[tokio::test]
1905 async fn compact_now_uses_turn_index_in_header() {
1906 let llm = Arc::new(MockProvider::new(vec![
1907 Completion {
1908 content: "first reply".into(),
1909 tool_calls: vec![],
1910 finish_reason: Some("stop".into()),
1911 usage: None,
1912 reasoning_content: None,
1913 },
1914 Completion {
1915 content: "second reply".into(),
1916 tool_calls: vec![],
1917 finish_reason: Some("stop".into()),
1918 usage: None,
1919 reasoning_content: None,
1920 },
1921 Completion {
1922 content: "compacted summary text".into(),
1923 tool_calls: vec![],
1924 finish_reason: Some("stop".into()),
1925 usage: None,
1926 reasoning_content: None,
1927 },
1928 ]));
1929 let compactor = crate::compact::Compactor::new(usize::MAX).keep_recent_n(1);
1932 let mut rt = AgentRuntime::builder()
1933 .llm(llm)
1934 .compactor(compactor)
1935 .build()
1936 .unwrap();
1937
1938 rt.run("turn 1").await.unwrap();
1940 rt.run("turn 2").await.unwrap();
1941 assert_eq!(rt.turn_index(), 2, "turn_index should be 2 after 2 turns");
1942
1943 rt.compact_now().await.unwrap();
1944
1945 assert_eq!(rt.transcript().len(), 2);
1947 let summary = &rt.transcript()[0].content;
1948 assert!(
1949 summary.contains("at step 2"),
1950 "compaction header should contain 'at step 2', got: {summary}"
1951 );
1952 }
1953
1954 #[tokio::test]
1957 async fn compact_partial_before_summarizes_prefix_keeps_suffix() {
1958 let provider = Arc::new(MockProvider::new(vec![Completion {
1959 content: "partial summary before pivot".into(),
1960 tool_calls: vec![],
1961 finish_reason: Some("stop".into()),
1962 usage: None,
1963 reasoning_content: None,
1964 }]));
1965 let compactor = crate::compact::Compactor::new(usize::MAX).keep_recent_n(2);
1966 let mut rt = AgentRuntime::builder()
1967 .llm(provider)
1968 .compactor(compactor)
1969 .build()
1970 .unwrap();
1971 let msgs = vec![
1973 Message::system("sys"),
1974 Message::user("u0"),
1975 Message::assistant("a0"),
1976 Message::user("u1"),
1977 Message::assistant("a1"),
1978 Message::user("u2"),
1979 Message::assistant("a2"),
1980 ];
1981 *Arc::make_mut(&mut rt.transcript) = msgs;
1982
1983 let len_before = rt.transcript().len();
1988 rt.compact_partial_before(5).await.unwrap();
1989
1990 let after = rt.transcript();
1991 assert!(
1992 after.len() < len_before,
1993 "compact_partial_before must shrink the transcript: {} -> {}",
1994 len_before,
1995 after.len()
1996 );
1997 assert!(
1998 after[0].content.starts_with("[compacted:"),
1999 "summary must lead the transcript after compact_partial_before"
2000 );
2001 assert!(
2004 after.iter().any(|m| m.content == "u2"),
2005 "pivot message u2 must survive in the tail"
2006 );
2007 assert!(
2008 after.iter().any(|m| m.content == "a2"),
2009 "message after pivot (a2) must survive in the tail"
2010 );
2011 }
2012
2013 #[tokio::test]
2014 async fn compact_partial_after_summarizes_suffix_keeps_prefix() {
2015 let provider = Arc::new(MockProvider::new(vec![Completion {
2016 content: "partial summary after pivot".into(),
2017 tool_calls: vec![],
2018 finish_reason: Some("stop".into()),
2019 usage: None,
2020 reasoning_content: None,
2021 }]));
2022 let compactor = crate::compact::Compactor::new(usize::MAX).keep_recent_n(2);
2023 let mut rt = AgentRuntime::builder()
2024 .llm(provider)
2025 .compactor(compactor)
2026 .build()
2027 .unwrap();
2028 let msgs = vec![
2030 Message::system("sys"),
2031 Message::user("u0"),
2032 Message::assistant("a0"),
2033 Message::user("u1"),
2034 Message::assistant("a1"),
2035 Message::user("u2"),
2036 Message::assistant("a2"),
2037 ];
2038 *Arc::make_mut(&mut rt.transcript) = msgs;
2039
2040 let len_before = rt.transcript().len();
2044 rt.compact_partial_after(3).await.unwrap();
2045
2046 let after = rt.transcript();
2047 assert!(
2048 after.len() < len_before,
2049 "compact_partial_after must shrink the transcript: {} -> {}",
2050 len_before,
2051 after.len()
2052 );
2053 assert_eq!(after[0].content, "sys");
2055 assert_eq!(after[1].content, "u0");
2056 assert_eq!(after[2].content, "a0");
2057 assert!(
2059 after.last().unwrap().content.starts_with("[compacted:"),
2060 "summary must be the last message after compact_partial_after"
2061 );
2062 }
2063
2064 #[tokio::test]
2065 async fn compact_partial_too_short_is_noop() {
2066 let provider = Arc::new(MockProvider::new(vec![Completion {
2067 content: "summary".into(),
2068 tool_calls: vec![],
2069 finish_reason: Some("stop".into()),
2070 usage: None,
2071 reasoning_content: None,
2072 }]));
2073 let compactor = crate::compact::Compactor::new(usize::MAX).keep_recent_n(2);
2074 let mut rt = AgentRuntime::builder()
2075 .llm(provider)
2076 .compactor(compactor)
2077 .build()
2078 .unwrap();
2079 *Arc::make_mut(&mut rt.transcript) = vec![Message::system("sys")];
2081
2082 let before = rt.transcript().len();
2083 rt.compact_partial_before(0).await.unwrap();
2084 assert_eq!(rt.transcript().len(), before);
2085
2086 rt.compact_partial_after(0).await.unwrap();
2087 assert_eq!(rt.transcript().len(), before);
2088 }
2089
2090 #[tokio::test]
2091 async fn compact_partial_preserves_tool_call_pairing() {
2092 let provider = Arc::new(MockProvider::new(vec![Completion {
2093 content: "tool-pair-summary".into(),
2094 tool_calls: vec![],
2095 finish_reason: Some("stop".into()),
2096 usage: None,
2097 reasoning_content: None,
2098 }]));
2099 let compactor = crate::compact::Compactor::new(usize::MAX).keep_recent_n(1);
2100 let mut rt = AgentRuntime::builder()
2101 .llm(provider)
2102 .compactor(compactor)
2103 .build()
2104 .unwrap();
2105
2106 let tc = crate::llm::ToolCall {
2108 id: "c1".into(),
2109 name: "add".into(),
2110 arguments: serde_json::json!({}),
2111 };
2112 let msgs = vec![
2113 Message::user("u0"),
2114 Message::assistant_with_tool_calls("a0", vec![tc]),
2115 Message::tool_result("call_c1", "3"),
2116 Message::user("u1"),
2117 Message::assistant("a1"),
2118 ];
2119 *Arc::make_mut(&mut rt.transcript) = msgs;
2120
2121 rt.compact_partial_before(3).await.unwrap();
2124
2125 assert_eq!(rt.transcript().len(), 3, "summary + 2 kept");
2127 assert!(rt.transcript()[0].content.starts_with("[compacted:"));
2128 assert_eq!(rt.transcript()[1].content, "u1");
2129 assert_eq!(rt.transcript()[2].content, "a1");
2130 for msg in &rt.transcript()[1..] {
2132 if msg.role == crate::message::Role::Tool {
2133 panic!("orphan Tool message in kept region: {:?}", msg);
2134 }
2135 }
2136 }
2137
2138 #[tokio::test]
2139 async fn compact_partial_noop_without_compactor() {
2140 let provider = Arc::new(MockProvider::new(vec![Completion {
2141 content: "x".into(),
2142 tool_calls: vec![],
2143 finish_reason: Some("stop".into()),
2144 usage: None,
2145 reasoning_content: None,
2146 }]));
2147 let mut rt = AgentRuntime::builder().llm(provider).build().unwrap();
2148 rt.set_transcript(vec![Message::user("hi"), Message::assistant("ok")]);
2149 let before = rt.transcript().len();
2150 rt.compact_partial_before(1).await.unwrap();
2151 assert_eq!(rt.transcript().len(), before);
2152 rt.compact_partial_after(0).await.unwrap();
2153 assert_eq!(rt.transcript().len(), before);
2154 }
2155
2156 #[tokio::test]
2159 async fn set_goal_stores_state() {
2160 let llm = Arc::new(MockProvider::new(vec![]));
2161 let rt = AgentRuntime::builder().llm(llm).build().unwrap();
2162 assert!(rt.current_goal().is_none());
2163 rt.set_goal("task is done".to_string(), 10).await;
2164 let g = rt.current_goal().expect("goal should be set");
2165 assert_eq!(g.condition, "task is done");
2166 assert_eq!(g.max_turns, 10);
2167 assert_eq!(g.turns, 0);
2168 assert_eq!(g.status, GoalStatus::Pursuing);
2169 }
2170
2171 #[tokio::test]
2172 async fn clear_goal_removes_state() {
2173 let llm = Arc::new(MockProvider::new(vec![]));
2174 let rt = AgentRuntime::builder().llm(llm).build().unwrap();
2175 rt.set_goal("anything".to_string(), 5).await;
2176 assert!(rt.current_goal().is_some());
2177 rt.clear_goal().await;
2178 assert!(rt.current_goal().is_none());
2179 }
2180
2181 #[tokio::test]
2182 async fn goal_status_default_is_pursuing() {
2183 let g = GoalState {
2184 condition: "done".to_string(),
2185 status: GoalStatus::Pursuing,
2186 turns: 0,
2187 max_turns: 20,
2188 last_reason: None,
2189 };
2190 assert_eq!(g.status, GoalStatus::Pursuing);
2191 assert_eq!(g.turns, 0);
2192 assert!(g.last_reason.is_none());
2193 }
2194
2195 #[tokio::test]
2196 async fn goal_evaluator_returns_achieved_on_yes_response() {
2197 let llm = Arc::new(MockProvider::new(vec![Completion {
2199 content: "YES\nLooks complete.".into(),
2200 tool_calls: vec![],
2201 finish_reason: Some("stop".into()),
2202 usage: None,
2203 reasoning_content: None,
2204 }]));
2205 let evaluator = GoalEvaluator::new(llm);
2206 let msgs = vec![crate::message::Message::user("I completed the task.")];
2207 let verdict = evaluator
2208 .evaluate("task is done", &msgs)
2209 .await
2210 .expect("evaluate should succeed");
2211 assert!(verdict.achieved);
2212 assert!(!verdict.reason.is_empty());
2213 }
2214
2215 #[tokio::test]
2216 async fn goal_evaluator_returns_not_achieved_on_no_response() {
2217 let llm = Arc::new(MockProvider::new(vec![Completion {
2218 content: "NO\nStill in progress.".into(),
2219 tool_calls: vec![],
2220 finish_reason: Some("stop".into()),
2221 usage: None,
2222 reasoning_content: None,
2223 }]));
2224 let evaluator = GoalEvaluator::new(llm);
2225 let msgs = vec![crate::message::Message::user("I started the task.")];
2226 let verdict = evaluator
2227 .evaluate("task is done", &msgs)
2228 .await
2229 .expect("evaluate should succeed");
2230 assert!(!verdict.achieved);
2231 }
2232
2233 #[tokio::test]
2234 async fn goal_evaluator_tolerates_empty_transcript() {
2235 let llm = Arc::new(MockProvider::new(vec![Completion {
2236 content: "YES\nEmpty transcript but condition trivially met.".into(),
2237 tool_calls: vec![],
2238 finish_reason: Some("stop".into()),
2239 usage: None,
2240 reasoning_content: None,
2241 }]));
2242 let evaluator = GoalEvaluator::new(llm);
2243 let verdict = evaluator
2244 .evaluate("anything", &[])
2245 .await
2246 .expect("should not error on empty transcript");
2247 assert!(verdict.achieved);
2248 }
2249
2250 #[tokio::test]
2251 async fn run_goal_loop_stops_when_achieved() {
2252 let llm = Arc::new(MockProvider::new(vec![
2254 Completion {
2255 content: "I wrote the greeting.".into(),
2256 tool_calls: vec![],
2257 finish_reason: Some("stop".into()),
2258 usage: None,
2259 reasoning_content: None,
2260 },
2261 Completion {
2262 content: "YES\nGreeting was written.".into(),
2263 tool_calls: vec![],
2264 finish_reason: Some("stop".into()),
2265 usage: None,
2266 reasoning_content: None,
2267 },
2268 ]));
2269 let null_sink = Arc::new(crate::event::NullSink);
2270 let mut rt = AgentRuntime::builder()
2271 .llm(llm)
2272 .event_sink(null_sink)
2273 .build()
2274 .unwrap();
2275 let _ = rt
2276 .run_goal_loop("write a greeting", "write a greeting", 5)
2277 .await;
2278 assert!(rt.current_goal().is_none());
2280 }
2281
2282 #[tokio::test]
2283 async fn run_goal_loop_stops_at_max_turns() {
2284 let completions: Vec<Completion> = (0..20)
2286 .flat_map(|_| {
2287 vec![
2288 Completion {
2289 content: "still working".into(),
2290 tool_calls: vec![],
2291 finish_reason: Some("stop".into()),
2292 usage: None,
2293 reasoning_content: None,
2294 },
2295 Completion {
2296 content: "NO\nNot done yet.".into(),
2297 tool_calls: vec![],
2298 finish_reason: Some("stop".into()),
2299 usage: None,
2300 reasoning_content: None,
2301 },
2302 ]
2303 })
2304 .collect();
2305 let llm = Arc::new(MockProvider::new(completions));
2306 let null_sink = Arc::new(crate::event::NullSink);
2307 let mut rt = AgentRuntime::builder()
2308 .llm(llm)
2309 .event_sink(null_sink)
2310 .build()
2311 .unwrap();
2312 let _ = rt
2314 .run_goal_loop("start on impossible task", "impossible task", 2)
2315 .await;
2316 assert!(rt.current_goal().is_none());
2318 }
2319
2320 #[tokio::test]
2321 async fn goal_serde_round_trip() {
2322 let g = GoalState {
2323 condition: "file written".to_string(),
2324 status: GoalStatus::Achieved,
2325 turns: 3,
2326 max_turns: 10,
2327 last_reason: Some("File was created.".to_string()),
2328 };
2329 let json = serde_json::to_string(&g).expect("serialize");
2330 let g2: GoalState = serde_json::from_str(&json).expect("deserialize");
2331 assert_eq!(g2.condition, g.condition);
2332 assert_eq!(g2.status, GoalStatus::Achieved);
2333 assert_eq!(g2.turns, 3);
2334 assert_eq!(g2.last_reason, Some("File was created.".to_string()));
2335 }
2336
2337 #[tokio::test]
2338 async fn multiple_set_goal_calls_overwrite_state() {
2339 let llm = Arc::new(MockProvider::new(vec![]));
2340 let rt = AgentRuntime::builder().llm(llm).build().unwrap();
2341 rt.set_goal("first goal".to_string(), 5).await;
2342 rt.set_goal("second goal".to_string(), 15).await;
2343 let g = rt.current_goal().unwrap();
2344 assert_eq!(g.condition, "second goal");
2345 assert_eq!(g.max_turns, 15);
2346 }
2347
2348 #[test]
2351 fn transcript_tail_returns_full_when_n_exceeds_len() -> Result<(), Box<dyn std::error::Error>> {
2352 let llm = Arc::new(MockProvider::new(vec![]));
2353 let mut rt = AgentRuntime::builder().llm(llm).build()?;
2354 rt.set_transcript(vec![
2356 crate::message::Message::user("one"),
2357 crate::message::Message::assistant("two"),
2358 crate::message::Message::user("three"),
2359 ]);
2360 let tail = rt.transcript_tail(10);
2361 assert_eq!(tail.len(), 3, "n > len should return the full transcript");
2362 assert_eq!(tail[0].content, "one");
2363 assert_eq!(tail[2].content, "three");
2364 Ok(())
2365 }
2366
2367 #[test]
2368 fn transcript_tail_returns_last_n() -> Result<(), Box<dyn std::error::Error>> {
2369 let llm = Arc::new(MockProvider::new(vec![]));
2370 let mut rt = AgentRuntime::builder().llm(llm).build()?;
2371 rt.set_transcript(vec![
2372 crate::message::Message::user("m0"),
2373 crate::message::Message::assistant("m1"),
2374 crate::message::Message::user("m2"),
2375 crate::message::Message::assistant("m3"),
2376 crate::message::Message::user("m4"),
2377 ]);
2378 let tail = rt.transcript_tail(2);
2379 assert_eq!(tail.len(), 2, "should return exactly the last 2 messages");
2380 assert_eq!(tail[0].content, "m3");
2381 assert_eq!(tail[1].content, "m4");
2382 Ok(())
2383 }
2384
2385 #[test]
2386 fn transcript_tail_handles_zero() -> Result<(), Box<dyn std::error::Error>> {
2387 let llm = Arc::new(MockProvider::new(vec![]));
2388 let mut rt = AgentRuntime::builder().llm(llm).build()?;
2389 rt.set_transcript(vec![
2390 crate::message::Message::user("only"),
2391 crate::message::Message::assistant("reply"),
2392 ]);
2393 let tail = rt.transcript_tail(0);
2394 assert_eq!(tail.len(), 0, "n == 0 should return an empty slice");
2395 assert!(tail.is_empty());
2396 Ok(())
2397 }
2398
2399 #[test]
2409 fn goal_eval_transcript_tail_builder_default_is_twelve() {
2410 let llm = Arc::new(MockProvider::new(vec![]));
2411 let rt = AgentRuntime::builder().llm(llm).build().unwrap();
2412 assert_eq!(rt.goal_eval_transcript_tail, 12);
2414 }
2415
2416 #[test]
2417 fn goal_eval_transcript_tail_builder_override_propagates() {
2418 let llm = Arc::new(MockProvider::new(vec![]));
2419 let rt = AgentRuntime::builder()
2420 .llm(llm)
2421 .goal_eval_transcript_tail(3)
2422 .build()
2423 .unwrap();
2424 assert_eq!(rt.goal_eval_transcript_tail, 3);
2425 }
2426
2427 #[test]
2432 fn goal_eval_transcript_tail_honored_over_old_default() {
2433 let llm = Arc::new(MockProvider::new(vec![]));
2434 let mut rt = AgentRuntime::builder()
2435 .llm(llm)
2436 .goal_eval_transcript_tail(3)
2437 .build()
2438 .unwrap();
2439 rt.set_transcript(vec![
2441 crate::message::Message::user("m0"),
2442 crate::message::Message::assistant("m1"),
2443 crate::message::Message::user("m2"),
2444 crate::message::Message::assistant("m3"),
2445 crate::message::Message::user("m4"),
2446 crate::message::Message::assistant("m5"),
2447 ]);
2448 let judge_slice = rt.transcript_tail(rt.goal_eval_transcript_tail);
2451 assert_eq!(
2452 judge_slice.len(),
2453 3,
2454 "judge should see only 3 messages, got {}",
2455 judge_slice.len()
2456 );
2457 assert_eq!(judge_slice[0].content, "m3");
2458 assert_eq!(judge_slice[1].content, "m4");
2459 assert_eq!(judge_slice[2].content, "m5");
2460 }
2461
2462 #[tokio::test]
2467 async fn run_goal_loop_respects_tail_config() {
2468 use crate::event::ChannelSink;
2469
2470 let completions = vec![
2471 Completion {
2473 content: "still working".into(),
2474 tool_calls: vec![],
2475 finish_reason: Some("stop".into()),
2476 usage: None,
2477 reasoning_content: None,
2478 },
2479 Completion {
2481 content: "NO\nNot done yet.".into(),
2482 tool_calls: vec![],
2483 finish_reason: Some("stop".into()),
2484 usage: None,
2485 reasoning_content: None,
2486 },
2487 Completion {
2489 content: "trying again".into(),
2490 tool_calls: vec![],
2491 finish_reason: Some("stop".into()),
2492 usage: None,
2493 reasoning_content: None,
2494 },
2495 Completion {
2497 content: "YES\nAll good.".into(),
2498 tool_calls: vec![],
2499 finish_reason: Some("stop".into()),
2500 usage: None,
2501 reasoning_content: None,
2502 },
2503 ];
2504 let llm = Arc::new(MockProvider::new(completions));
2505 let (sink, _rx) = ChannelSink::new();
2506 let mut rt = AgentRuntime::builder()
2507 .llm(llm)
2508 .event_sink(Arc::new(sink))
2509 .goal_eval_transcript_tail(1)
2510 .build()
2511 .unwrap();
2512
2513 let _ = rt
2518 .run_goal_loop("achieve it", "achieve it", 5)
2519 .await
2520 .expect("goal loop should run without error");
2521 assert!(rt.current_goal().is_none());
2523 assert_eq!(rt.goal_eval_transcript_tail, 1);
2524 }
2525
2526 #[tokio::test]
2529 async fn enqueue_processes_single_message() {
2530 let llm = Arc::new(MockProvider::new(vec![Completion {
2531 content: "queued reply".into(),
2532 tool_calls: vec![],
2533 finish_reason: Some("stop".into()),
2534 usage: None,
2535 reasoning_content: None,
2536 }]));
2537 let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
2538 let out = rt.enqueue("hello from queue").await.unwrap();
2539 assert!(out.is_some());
2540 assert_eq!(out.unwrap().final_text.as_deref(), Some("queued reply"));
2541 assert_eq!(rt.transcript().len(), 2);
2542 }
2543
2544 #[tokio::test]
2545 async fn enqueue_drains_multiple_messages_in_order() {
2546 let llm = Arc::new(MockProvider::new(vec![
2547 Completion {
2548 content: "reply A".into(),
2549 tool_calls: vec![],
2550 finish_reason: Some("stop".into()),
2551 usage: None,
2552 reasoning_content: None,
2553 },
2554 Completion {
2555 content: "reply B".into(),
2556 tool_calls: vec![],
2557 finish_reason: Some("stop".into()),
2558 usage: None,
2559 reasoning_content: None,
2560 },
2561 ]));
2562 let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
2563 rt.message_queue.push_back("msg A".into());
2565 rt.message_queue.push_back("msg B".into());
2566 let last = rt.drain_queue().await.unwrap();
2567 assert_eq!(last.unwrap().final_text.as_deref(), Some("reply B"));
2568 assert_eq!(rt.transcript().len(), 4);
2570 }
2571
2572 #[test]
2573 fn queue_len_reflects_pending_messages() {
2574 let llm = Arc::new(MockProvider::new(vec![]));
2575 let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
2576 assert_eq!(rt.queue_len(), 0);
2577 rt.message_queue.push_back("pending".into());
2578 assert_eq!(rt.queue_len(), 1);
2579 rt.message_queue.push_back("also pending".into());
2580 assert_eq!(rt.queue_len(), 2);
2581 }
2582
2583 #[tokio::test]
2586 async fn drain_queue_returns_ok_for_all_messages() {
2587 let llm = Arc::new(MockProvider::new(vec![
2588 Completion {
2589 content: "reply A".into(),
2590 tool_calls: vec![],
2591 finish_reason: Some("stop".into()),
2592 usage: None,
2593 reasoning_content: None,
2594 },
2595 Completion {
2596 content: "reply B".into(),
2597 tool_calls: vec![],
2598 finish_reason: Some("stop".into()),
2599 usage: None,
2600 reasoning_content: None,
2601 },
2602 ]));
2603 let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
2604 rt.message_queue.push_back("msg A".into());
2605 rt.message_queue.push_back("msg B".into());
2606 let result = rt.drain_queue().await;
2607 assert!(result.is_ok());
2608 let last = result.unwrap();
2609 assert!(last.is_some());
2610 assert_eq!(last.unwrap().final_text.as_deref(), Some("reply B"));
2611 assert_eq!(rt.transcript().len(), 4);
2613 }
2614
2615 #[tokio::test]
2616 async fn drain_queue_stops_on_first_error() {
2617 let llm = Arc::new(MockProvider::new(vec![Completion {
2619 content: "reply A".into(),
2620 tool_calls: vec![],
2621 finish_reason: Some("stop".into()),
2622 usage: None,
2623 reasoning_content: None,
2624 }]));
2625 let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
2626 rt.message_queue.push_back("msg A".into());
2627 rt.message_queue.push_back("msg B".into());
2628 let result = rt.drain_queue().await;
2629 assert!(result.is_err(), "expected error, got {:?}", result);
2630 assert_eq!(
2633 rt.queue_len(),
2634 1,
2635 "second message should remain in queue for retry"
2636 );
2637 assert_eq!(
2639 rt.message_queue.front().map(String::as_str),
2640 Some("msg B"),
2641 "msg B should still be at the front of the queue"
2642 );
2643 assert_eq!(
2646 rt.transcript().len(),
2647 3,
2648 "transcript should hold msg A, reply A, and the in-flight msg B"
2649 );
2650 }
2651
2652 #[tokio::test]
2653 async fn drain_queue_preserves_remaining_messages_on_error() {
2654 let llm = Arc::new(MockProvider::new(vec![Completion {
2659 content: "reply A".into(),
2660 tool_calls: vec![],
2661 finish_reason: Some("stop".into()),
2662 usage: None,
2663 reasoning_content: None,
2664 }]));
2665 let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
2666 rt.message_queue.push_back("msg A".into());
2667 rt.message_queue.push_back("msg B".into());
2668 rt.message_queue.push_back("msg C".into());
2669 let result = rt.drain_queue().await;
2670 assert!(result.is_err(), "expected error, got {:?}", result);
2671 assert_eq!(
2673 rt.queue_len(),
2674 2,
2675 "B and C should remain in queue for retry"
2676 );
2677 assert_eq!(rt.message_queue.front().map(String::as_str), Some("msg B"));
2679 assert_eq!(rt.transcript().len(), 3);
2685 }
2686
2687 #[test]
2690 fn runtime_builder_skills_stores_skills_list() {
2691 use crate::skills::{Skill, SkillMode};
2694 let llm = Arc::new(MockProvider::new(vec![]));
2695 let skill = Skill {
2696 name: "my-skill".to_string(),
2697 description: "A test skill".to_string(),
2698 path: std::path::PathBuf::from("/tmp/my-skill/SKILL.md"),
2699 mode: SkillMode::Always,
2700 triggers: vec![],
2701 hint: String::new(),
2702 depends_on: vec![],
2703 refs: vec![],
2704 params: vec![],
2705 scripts: vec![],
2706 sections: vec![],
2707 globs: None,
2708 };
2709 let rt = AgentRuntime::builder()
2710 .llm(llm)
2711 .skills(vec![skill])
2712 .build()
2713 .unwrap();
2714 assert_eq!(
2716 rt.kernel.globs_skills.len(),
2717 1,
2718 "skills() must store the provided skills list; len was {}",
2719 rt.kernel.globs_skills.len()
2720 );
2721 assert_eq!(rt.kernel.globs_skills[0].name, "my-skill");
2722 }
2723
2724 #[test]
2725 fn reject_plan_appends_rejection_message_to_transcript() {
2726 let llm = Arc::new(MockProvider::new(vec![]));
2729 let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
2730 let before = rt.transcript_tail(100).len();
2731 rt.reject_plan("too risky");
2732 let after = rt.transcript_tail(100).len();
2733 assert!(
2734 after > before,
2735 "reject_plan must append a rejection message to the transcript"
2736 );
2737 let tail = rt.transcript_tail(100);
2739 let last = tail.last().expect("at least one message");
2740 assert!(
2741 last.content.contains("too risky"),
2742 "rejection message must contain the provided reason; got: {:?}",
2743 last.content
2744 );
2745 }
2746
2747 #[test]
2748 fn runtime_builder_has_plan_mode_tools() {
2749 let llm = Arc::new(MockProvider::new(vec![]));
2753 let rt = AgentRuntime::builder()
2754 .llm(llm)
2755 .with_plan_mode_tools(true)
2756 .build()
2757 .unwrap();
2758 let tools = rt.kernel.tools();
2759 assert!(
2760 tools.get(ENTER_PLAN_MODE_TOOL_NAME).is_some(),
2761 "enter_plan_mode must be registered by AgentRuntimeBuilder"
2762 );
2763 assert!(
2764 tools.get(EXIT_PLAN_MODE_TOOL_NAME).is_some(),
2765 "exit_plan_mode must be registered by AgentRuntimeBuilder"
2766 );
2767 }
2768
2769 #[tokio::test]
2775 async fn audit_survives_collision_across_turns() {
2776 let llm = Arc::new(MockProvider::new(vec![
2777 Completion {
2779 content: "calculating...".into(),
2780 tool_calls: vec![crate::llm::ToolCall {
2781 id: "c1".into(),
2782 name: "add".into(),
2783 arguments: json!({"a": 1, "b": 2}),
2784 }],
2785 finish_reason: Some("tool_calls".into()),
2786 usage: None,
2787 reasoning_content: None,
2788 },
2789 Completion {
2791 content: "3".into(),
2792 tool_calls: vec![],
2793 finish_reason: Some("stop".into()),
2794 usage: None,
2795 reasoning_content: None,
2796 },
2797 Completion {
2799 content: "calculating again...".into(),
2800 tool_calls: vec![crate::llm::ToolCall {
2801 id: "c1".into(),
2802 name: "add".into(),
2803 arguments: json!({"a": 5, "b": 7}),
2804 }],
2805 finish_reason: Some("tool_calls".into()),
2806 usage: None,
2807 reasoning_content: None,
2808 },
2809 Completion {
2811 content: "12".into(),
2812 tool_calls: vec![],
2813 finish_reason: Some("stop".into()),
2814 usage: None,
2815 reasoning_content: None,
2816 },
2817 ]));
2818 let tools = ToolRegistry::local().register(Arc::new(Adder));
2819 let (sink, mut rx) = crate::event::ChannelSink::new();
2820 let sink_arc = Arc::new(sink);
2821 let mut rt = AgentRuntime::builder()
2822 .llm(llm)
2823 .tools(tools)
2824 .event_sink(sink_arc)
2825 .build()
2826 .unwrap();
2827
2828 while let Ok(_ev) = rx.try_recv() {}
2830
2831 let _ = rt.run("turn 1").await.unwrap();
2832 let _ = rt.run("turn 2").await.unwrap();
2833
2834 let mut audit_count = 0usize;
2835 while let Ok(ev) = rx.try_recv() {
2836 if matches!(ev, AgentEvent::MessageAppendedWithAudit { .. }) {
2837 audit_count += 1;
2838 }
2839 }
2840
2841 assert_eq!(
2846 audit_count, 2,
2847 "expected both turns' tool results to have audit metadata"
2848 );
2849 }
2850
2851 #[tokio::test]
2857 async fn duplicate_tool_call_id_in_same_response_attaches_at_least_one() {
2858 let llm = Arc::new(MockProvider::new(vec![
2859 Completion {
2861 content: "doing two things...".into(),
2862 tool_calls: vec![
2863 crate::llm::ToolCall {
2864 id: "c1".into(),
2865 name: "add".into(),
2866 arguments: json!({"a": 1, "b": 2}),
2867 },
2868 crate::llm::ToolCall {
2869 id: "c1".into(), name: "add".into(),
2871 arguments: json!({"a": 3, "b": 4}),
2872 },
2873 ],
2874 finish_reason: Some("tool_calls".into()),
2875 usage: None,
2876 reasoning_content: None,
2877 },
2878 Completion {
2880 content: "done".into(),
2881 tool_calls: vec![],
2882 finish_reason: Some("stop".into()),
2883 usage: None,
2884 reasoning_content: None,
2885 },
2886 ]));
2887 let tools = ToolRegistry::local().register(Arc::new(Adder));
2888 let (sink, mut rx) = crate::event::ChannelSink::new();
2889 let sink_arc = Arc::new(sink);
2890 let mut rt = AgentRuntime::builder()
2891 .llm(llm)
2892 .tools(tools)
2893 .event_sink(sink_arc)
2894 .build()
2895 .unwrap();
2896
2897 while let Ok(_ev) = rx.try_recv() {}
2898
2899 let _ = rt.run("do it").await.unwrap();
2900
2901 let mut audit_count = 0usize;
2902 while let Ok(ev) = rx.try_recv() {
2903 if matches!(ev, AgentEvent::MessageAppendedWithAudit { .. }) {
2904 audit_count += 1;
2905 }
2906 }
2907
2908 assert!(
2910 audit_count >= 1,
2911 "expected at least one tool result to have audit metadata, got {audit_count}"
2912 );
2913 }
2914
2915 #[tokio::test]
2923 async fn denial_limit_sentinel_no_duplicate_pushes() {
2924 use crate::error::Error;
2925
2926 struct DenialTool;
2927
2928 #[async_trait]
2929 impl Tool for DenialTool {
2930 fn spec(&self) -> crate::llm::ToolSpec {
2931 crate::llm::ToolSpec {
2932 name: "denial_tool".into(),
2933 description: "always triggers permission denial limit".into(),
2934 parameters: json!({"type": "object", "properties": {}}),
2935 }
2936 }
2937 async fn execute(&self, _args: Value) -> crate::error::Result<String> {
2938 Err(Error::PermissionDeniedLimit {
2939 name: "denial_tool".into(),
2940 })
2941 }
2942 }
2943
2944 let llm = Arc::new(MockProvider::new(vec![Completion {
2945 content: "Let me try two things...".into(),
2946 tool_calls: vec![
2947 crate::llm::ToolCall {
2948 id: "c1".into(),
2949 name: "add".into(),
2950 arguments: json!({"a": 1, "b": 2}),
2951 },
2952 crate::llm::ToolCall {
2953 id: "c2".into(),
2954 name: "denial_tool".into(),
2955 arguments: json!({}),
2956 },
2957 ],
2958 finish_reason: Some("tool_calls".into()),
2959 usage: None,
2960 reasoning_content: None,
2961 }]));
2962
2963 let tools = ToolRegistry::local()
2964 .register(Arc::new(Adder))
2965 .register(Arc::new(DenialTool));
2966
2967 let mut rt = AgentRuntime::builder()
2968 .llm(llm)
2969 .tools(tools)
2970 .build()
2971 .unwrap();
2972
2973 let out = rt.run("test").await.unwrap();
2974
2975 assert_eq!(out.finish_reason, FinishReason::PermissionDenialLimit);
2977
2978 let tool_msgs: Vec<_> = rt
2981 .transcript()
2982 .iter()
2983 .filter(|m| m.role == crate::message::Role::Tool)
2984 .collect();
2985
2986 assert_eq!(
2987 tool_msgs.len(),
2988 2,
2989 "expected exactly 2 tool-result messages, got {} (double-push bug?)",
2990 tool_msgs.len()
2991 );
2992
2993 let add_count = tool_msgs
2995 .iter()
2996 .filter(|m| m.tool_call_id.as_deref() == Some("c1"))
2997 .count();
2998 assert_eq!(
2999 add_count, 1,
3000 "add result (c1) should appear exactly once, got {add_count}"
3001 );
3002
3003 let denial_count = tool_msgs
3005 .iter()
3006 .filter(|m| m.tool_call_id.as_deref() == Some("c2"))
3007 .count();
3008 assert_eq!(
3009 denial_count, 1,
3010 "denial result (c2) should appear exactly once, got {denial_count}"
3011 );
3012
3013 assert_eq!(
3015 rt.transcript().len(),
3016 4,
3017 "transcript should have 4 messages (user, assistant, 2× tool), got {}",
3018 rt.transcript().len()
3019 );
3020 }
3021
3022 #[tokio::test]
3031 async fn stuck_detection_keeps_tool_calls_paired() {
3032 use crate::error::Error;
3033
3034 struct AlwaysFails;
3035
3036 #[async_trait]
3037 impl Tool for AlwaysFails {
3038 fn spec(&self) -> crate::llm::ToolSpec {
3039 crate::llm::ToolSpec {
3040 name: "always_fails".into(),
3041 description: "always returns an error".into(),
3042 parameters: json!({"type": "object", "properties": {}}),
3043 }
3044 }
3045 async fn execute(&self, _args: Value) -> crate::error::Result<String> {
3046 Err(Error::Tool {
3047 name: "always_fails".into(),
3048 call_id: None,
3049 message: "boom".into(),
3050 })
3051 }
3052 }
3053
3054 let llm = Arc::new(MockProvider::new(vec![Completion {
3059 content: "Trying three things at once...".into(),
3060 tool_calls: vec![
3061 crate::llm::ToolCall {
3062 id: "c1".into(),
3063 name: "always_fails".into(),
3064 arguments: json!({}),
3065 },
3066 crate::llm::ToolCall {
3067 id: "c2".into(),
3068 name: "always_fails".into(),
3069 arguments: json!({}),
3070 },
3071 crate::llm::ToolCall {
3072 id: "c3".into(),
3073 name: "always_fails".into(),
3074 arguments: json!({}),
3075 },
3076 ],
3077 finish_reason: Some("tool_calls".into()),
3078 usage: None,
3079 reasoning_content: None,
3080 }]));
3081
3082 let tools = ToolRegistry::local().register(Arc::new(AlwaysFails));
3083
3084 let mut rt = AgentRuntime::builder()
3085 .llm(llm)
3086 .tools(tools)
3087 .stuck_window(2)
3088 .stuck_error_rate(1.0)
3089 .build()
3090 .unwrap();
3091
3092 let out = rt.run("go").await.unwrap();
3093
3094 assert!(
3096 matches!(out.finish_reason, FinishReason::Stuck { .. }),
3097 "expected Stuck finish, got {:?}",
3098 out.finish_reason
3099 );
3100
3101 let assistant = rt
3104 .transcript()
3105 .iter()
3106 .find(|m| m.role == crate::message::Role::Assistant && !m.tool_calls.is_empty())
3107 .expect("assistant-with-tool_calls must be in transcript");
3108 let tool_results: Vec<&str> = rt
3109 .transcript()
3110 .iter()
3111 .filter(|m| m.role == crate::message::Role::Tool)
3112 .filter_map(|m| m.tool_call_id.as_deref())
3113 .collect();
3114 for tc in &assistant.tool_calls {
3115 assert!(
3116 tool_results.contains(&tc.id.as_str()),
3117 "tool_call {} has no matching tool_result (orphaned tool_use); results={tool_results:?}",
3118 tc.id
3119 );
3120 }
3121 assert_eq!(
3122 tool_results.len(),
3123 3,
3124 "expected exactly 3 tool_result messages, got {}",
3125 tool_results.len()
3126 );
3127 }
3128
3129 #[tokio::test]
3134 async fn llm_retry_emits_event() {
3135 use crate::event::ChannelSink;
3136
3137 let (sink, _event_rx) = ChannelSink::new();
3138 let sink = Arc::new(sink);
3139
3140 let provider = Arc::new(
3143 MockProvider::new(vec![Completion {
3144 content: "Hello!".into(),
3145 tool_calls: vec![],
3146 finish_reason: Some("stop".into()),
3147 usage: None,
3148 reasoning_content: None,
3149 }])
3150 .with_errors(vec![crate::error::Error::RateLimited {
3151 provider: "mock".into(),
3152 retry_after_ms: 1,
3153 }]),
3154 );
3155
3156 let mut rt = AgentRuntime::builder()
3157 .llm(provider)
3158 .event_sink(sink)
3159 .build()
3160 .unwrap();
3161
3162 let result = rt.run("hi").await;
3165 assert!(
3166 result.is_err(),
3167 "expected error from RateLimited MockProvider without outer retry loop"
3168 );
3169 let err = result.unwrap_err();
3170 let err_str = format!("{err}");
3171 assert!(
3172 err_str.contains("rate limited"),
3173 "expected rate-limited error, got: {err_str}"
3174 );
3175 }
3176
3177 #[tokio::test]
3185 async fn replace_event_sink_does_not_reregister_tools() {
3186 let llm = Arc::new(MockProvider::new(vec![]));
3187 let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
3188
3189 let pre_todo = rt
3191 .kernel
3192 .tools()
3193 .get("TodoWrite")
3194 .expect("TodoWrite is in the default registry")
3195 .clone();
3196
3197 let (new_sink, _rx) = crate::event::ChannelSink::new();
3198 rt.replace_event_sink(Arc::new(new_sink));
3199
3200 let post_todo = rt
3202 .kernel
3203 .tools()
3204 .get("TodoWrite")
3205 .expect("TodoWrite still registered")
3206 .clone();
3207 assert!(
3208 Arc::ptr_eq(&pre_todo, &post_todo),
3209 "replace_event_sink must not re-register TodoWriteTool"
3210 );
3211 }
3212
3213 #[tokio::test]
3218 async fn set_event_sink_reregisters_todo_write_tool() {
3219 let llm = Arc::new(MockProvider::new(vec![]));
3220 let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
3221
3222 let pre_todo = rt
3223 .kernel
3224 .tools()
3225 .get("TodoWrite")
3226 .expect("TodoWrite registered")
3227 .clone();
3228
3229 let (new_sink, _rx) = crate::event::ChannelSink::new();
3230 rt.set_event_sink(Arc::new(new_sink));
3231
3232 let post_todo = rt
3233 .kernel
3234 .tools()
3235 .get("TodoWrite")
3236 .expect("TodoWrite still registered after set_event_sink")
3237 .clone();
3238 assert!(
3239 !Arc::ptr_eq(&pre_todo, &post_todo),
3240 "set_event_sink MUST re-register TodoWriteTool — this side effect is \
3241 load-bearing for the TUI/CLI/HTTP sink-swap flows; removing it would \
3242 silently drop TodoUpdated events on the new sink."
3243 );
3244 }
3245
3246 #[test]
3249 fn context_overflow_detector_matches_known_patterns() {
3250 let cases = [
3251 "HTTP 400: {\"error\":{\"code\":\"context_length_exceeded\",\"message\":\"too long\"}}",
3253 "HTTP 400: This model's maximum context length is 200000 tokens, however you requested 201234",
3255 "HTTP 400: prompt is too long for the model",
3257 "HTTP 400: tokens exceeds model limit",
3258 "HTTP 400: exceeds the model context window",
3259 ];
3260 for msg in &cases {
3261 let err = crate::error::Error::Llm {
3262 provider: "test".into(),
3263 message: msg.to_string(),
3264 };
3265 assert!(
3266 is_context_window_exceeded(&err),
3267 "should detect context overflow in: {msg}"
3268 );
3269 }
3270 }
3271
3272 #[test]
3273 fn context_overflow_detector_ignores_unrelated_errors() {
3274 let cases = [
3275 "HTTP 400: invalid request body",
3276 "HTTP 401: unauthorized",
3277 "HTTP 429: rate limit exceeded",
3278 "network error: connection refused",
3279 ];
3280 for msg in &cases {
3281 let err = crate::error::Error::Llm {
3282 provider: "test".into(),
3283 message: msg.to_string(),
3284 };
3285 assert!(
3286 !is_context_window_exceeded(&err),
3287 "should NOT detect context overflow in: {msg}"
3288 );
3289 }
3290 }
3291
3292 #[test]
3293 fn context_overflow_detector_ignores_non_llm_errors() {
3294 let err = crate::error::Error::Timeout {
3295 duration_ms: 30_000,
3296 };
3297 assert!(!is_context_window_exceeded(&err));
3298 let err = crate::error::Error::Config {
3299 message: "context_length_exceeded".into(),
3300 };
3301 assert!(
3302 !is_context_window_exceeded(&err),
3303 "Config errors must not be detected even if they contain the keyword"
3304 );
3305 }
3306
3307 #[tokio::test]
3310 async fn cross_turn_microcompact_prunes_before_summary_check() {
3311 let provider = Arc::new(MockProvider::new(vec![])); let (sink, mut rx) = crate::event::ChannelSink::new();
3318 let sink_arc = Arc::new(sink);
3319
3320 let mc = crate::compact::Microcompactor::new(2, 1); let compactor = crate::compact::Compactor::new(usize::MAX); let mut rt = AgentRuntime::builder()
3323 .llm(provider)
3324 .microcompactor(mc)
3325 .compactor(compactor)
3326 .event_sink(sink_arc)
3327 .build()
3328 .unwrap();
3329
3330 let mut msgs: Vec<Message> = Vec::new();
3332 for i in 0..5 {
3333 msgs.push(Message::user(format!("user {i}")));
3334 msgs.push(Message::assistant(format!("asst {i}")));
3335 msgs.push(Message::tool_result(format!("call_{i}"), "x".repeat(300)));
3336 }
3337 *Arc::make_mut(&mut rt.transcript) = msgs;
3338
3339 while rx.try_recv().is_ok() {}
3341
3342 rt.maybe_compact_cross_turn(&TokenUsage::default())
3344 .await
3345 .unwrap();
3346
3347 let mut microcompact_fired = false;
3349 while let Ok(ev) = rx.try_recv() {
3350 if matches!(ev, AgentEvent::Microcompact { .. }) {
3351 microcompact_fired = true;
3352 }
3353 }
3354
3355 assert!(
3356 microcompact_fired,
3357 "Microcompact event must be emitted when microcompactor prunes"
3358 );
3359 }
3360
3361 #[tokio::test]
3362 async fn cross_turn_microcompact_disabled_when_none() {
3363 let provider = Arc::new(MockProvider::new(vec![]));
3365 let (sink, mut rx) = crate::event::ChannelSink::new();
3366 let sink_arc = Arc::new(sink);
3367
3368 let compactor = crate::compact::Compactor::new(usize::MAX);
3371 let mut rt = AgentRuntime::builder()
3372 .llm(provider)
3373 .compactor(compactor)
3374 .event_sink(sink_arc)
3375 .build()
3376 .unwrap();
3377
3378 let mut msgs: Vec<Message> = Vec::new();
3380 for i in 0..5 {
3381 msgs.push(Message::user(format!("user {i}")));
3382 msgs.push(Message::assistant(format!("asst {i}")));
3383 msgs.push(Message::tool_result(format!("call_{i}"), "x".repeat(300)));
3384 }
3385 *Arc::make_mut(&mut rt.transcript) = msgs;
3386
3387 while rx.try_recv().is_ok() {}
3388
3389 rt.maybe_compact_cross_turn(&TokenUsage::default())
3392 .await
3393 .unwrap();
3394
3395 let mut microcompact_fired = false;
3396 while let Ok(ev) = rx.try_recv() {
3397 if matches!(ev, AgentEvent::Microcompact { .. }) {
3398 microcompact_fired = true;
3399 }
3400 }
3401
3402 assert!(
3403 !microcompact_fired,
3404 "Microcompact must NOT fire when no microcompactor is configured"
3405 );
3406 }
3407
3408 #[tokio::test]
3411 async fn compact_on_overflow_compacts_long_transcript() {
3412 let summary_resp = Completion {
3413 content: "Summary of prior conversation.".into(),
3414 tool_calls: vec![],
3415 finish_reason: Some("stop".into()),
3416 usage: None,
3417 reasoning_content: None,
3418 };
3419 let llm = Arc::new(MockProvider::new(vec![summary_resp]));
3420 let mut rt = AgentRuntime::builder()
3421 .llm(llm)
3422 .compactor(crate::Compactor::new(usize::MAX))
3423 .build()
3424 .unwrap();
3425
3426 let msgs: Vec<crate::message::Message> = (0..14)
3428 .map(|i| {
3429 if i % 2 == 0 {
3430 crate::message::Message::user(format!("msg {i}"))
3431 } else {
3432 crate::message::Message::assistant(format!("reply {i}"))
3433 }
3434 })
3435 .collect();
3436 *Arc::make_mut(&mut rt.transcript) = msgs;
3437 let before = rt.transcript.len();
3438
3439 let compacted = rt.compact_on_overflow().await.unwrap();
3440 assert!(compacted, "should return true when compaction ran");
3441 assert!(
3442 rt.transcript.len() < before,
3443 "transcript must shrink after compaction"
3444 );
3445 assert_eq!(
3446 rt.transcript[0].role,
3447 crate::message::Role::System,
3448 "first message after compaction is the summary"
3449 );
3450 }
3451
3452 #[tokio::test]
3453 async fn compact_on_overflow_returns_false_without_compactor() {
3454 let llm = Arc::new(MockProvider::new(vec![]));
3455 let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
3456 let ok = rt.compact_on_overflow().await.unwrap();
3457 assert!(!ok, "no compactor → must return false");
3458 }
3459
3460 #[tokio::test]
3461 async fn compact_on_overflow_rejects_degenerate_transcript_without_hook_events() {
3462 struct CompactionHookRecorder(Arc<std::sync::Mutex<Vec<&'static str>>>);
3463
3464 impl crate::hooks::Hook for CompactionHookRecorder {
3465 fn on_event(&self, event: HookEvent) -> crate::hooks::HookAction {
3466 match event {
3467 HookEvent::PreCompact { .. } => {
3468 self.0.lock().unwrap().push("PreCompact");
3469 }
3470 HookEvent::PostCompact { .. } => {
3471 self.0.lock().unwrap().push("PostCompact");
3472 }
3473 _ => {}
3474 }
3475 crate::hooks::HookAction::Continue
3476 }
3477 }
3478
3479 let llm = Arc::new(MockProvider::new(vec![]));
3480 let hook_events = Arc::new(std::sync::Mutex::new(Vec::new()));
3481 let mut hooks = HookRegistry::new();
3482 hooks.register(Arc::new(CompactionHookRecorder(hook_events.clone())));
3483 let mut rt = AgentRuntime::builder()
3484 .llm(llm.clone())
3485 .hooks(hooks)
3486 .compactor(Compactor::new(usize::MAX).keep_recent_n(8))
3487 .build()
3488 .unwrap();
3489
3490 *Arc::make_mut(&mut rt.transcript) = vec![
3493 Message::system("System prompt".to_string()),
3494 Message::user("Add a feature".to_string()),
3495 Message::assistant("Working on it".to_string()),
3496 Message::user("Status?".to_string()),
3497 Message::assistant("Almost done".to_string()),
3498 Message::user("Run tests".to_string()),
3499 Message::assistant("Tests pass".to_string()),
3500 Message::user("Commit".to_string()),
3501 Message::assistant("Done".to_string()),
3502 ];
3503
3504 assert!(
3505 !rt.compact_on_overflow().await.unwrap(),
3506 "a degenerate transcript must reject emergency compaction"
3507 );
3508 assert!(
3509 hook_events.lock().unwrap().is_empty(),
3510 "a rejected compaction must emit neither PreCompact nor PostCompact"
3511 );
3512 assert!(
3513 llm.calls().is_empty(),
3514 "a rejected compaction must not call the provider"
3515 );
3516 }
3517
3518 #[tokio::test]
3527 async fn context_overflow_triggers_compact_and_retry() {
3528 let overflow_err = crate::error::Error::Llm {
3529 provider: "test-model".into(),
3530 message: "HTTP 400: {\"error\":{\"code\":\"context_length_exceeded\",\
3531 \"message\":\"maximum context length is 200000 tokens\"}}"
3532 .into(),
3533 };
3534 let llm = Arc::new(
3535 MockProvider::new(vec![
3536 Completion {
3538 content: "Prior conversation summary for test.".into(),
3539 tool_calls: vec![],
3540 finish_reason: Some("stop".into()),
3541 usage: None,
3542 reasoning_content: None,
3543 },
3544 Completion {
3546 content: "Reply after emergency compaction.".into(),
3547 tool_calls: vec![],
3548 finish_reason: Some("stop".into()),
3549 usage: None,
3550 reasoning_content: None,
3551 },
3552 ])
3553 .with_errors(vec![overflow_err]),
3554 );
3555
3556 let mut rt = AgentRuntime::builder()
3557 .llm(llm)
3558 .compactor(crate::Compactor::new(usize::MAX))
3559 .build()
3560 .unwrap();
3561
3562 let msgs: Vec<Message> = (0..14)
3564 .map(|i| {
3565 if i % 2 == 0 {
3566 Message::user(format!("prior msg {i}"))
3567 } else {
3568 Message::assistant(format!("prior reply {i}"))
3569 }
3570 })
3571 .collect();
3572 *Arc::make_mut(&mut rt.transcript) = msgs;
3573
3574 let outcome = rt.run("test: overflow recovery").await.unwrap();
3575 assert_eq!(
3576 outcome.final_text.as_deref(),
3577 Some("Reply after emergency compaction."),
3578 "run() must return the retry's reply"
3579 );
3580
3581 assert_eq!(
3583 rt.transcript[0].role,
3584 crate::message::Role::System,
3585 "transcript must start with compaction summary after overflow recovery"
3586 );
3587 assert!(
3588 rt.transcript[0].is_compaction_summary,
3589 "summary message must be flagged as compaction_summary"
3590 );
3591 }
3592
3593 #[tokio::test]
3596 async fn cross_turn_compaction_reinjects_plan_and_todos() {
3597 let provider = Arc::new(MockProvider::new(vec![Completion {
3598 content: "Summary of prior conversation.".into(),
3599 tool_calls: vec![],
3600 finish_reason: Some("stop".into()),
3601 usage: None,
3602 reasoning_content: None,
3603 }]));
3604
3605 let mut rt = AgentRuntime::builder()
3606 .llm(provider)
3607 .compactor(crate::Compactor::new(0)) .build()
3609 .unwrap();
3610
3611 rt.plan_approval_gate
3613 .begin_approval("Step 1: explore\nStep 2: implement".to_string());
3614
3615 {
3617 let mut todos = rt.todo_list.write().unwrap();
3618 todos.push(TodoItem {
3619 content: "Read files".to_string(),
3620 status: TodoStatus::Completed,
3621 active_form: None,
3622 });
3623 todos.push(TodoItem {
3624 content: "Edit code".to_string(),
3625 status: TodoStatus::InProgress,
3626 active_form: Some("Editing code...".to_string()),
3627 });
3628 todos.push(TodoItem {
3629 content: "Run tests".to_string(),
3630 status: TodoStatus::Pending,
3631 active_form: None,
3632 });
3633 }
3634
3635 let msgs: Vec<Message> = (0..14)
3637 .map(|i| {
3638 if i % 2 == 0 {
3639 Message::user(format!("msg {i}"))
3640 } else {
3641 Message::assistant(format!("reply {i}"))
3642 }
3643 })
3644 .collect();
3645 *Arc::make_mut(&mut rt.transcript) = msgs;
3646
3647 rt.maybe_compact_cross_turn(&TokenUsage::default())
3648 .await
3649 .unwrap();
3650
3651 let transcript = rt.transcript();
3652
3653 assert!(
3655 transcript.len() >= 3,
3656 "at least summary + 2 atts + preserved"
3657 );
3658 assert_eq!(transcript[0].role, crate::message::Role::System);
3659 assert!(
3660 transcript[0].is_compaction_summary,
3661 "first message is the compaction summary"
3662 );
3663
3664 let plan_msg = transcript
3666 .iter()
3667 .find(|m| m.content.starts_with("[post-compact plan restore]"))
3668 .expect("plan restore message must be present");
3669
3670 assert!(plan_msg.content.contains("Step 1: explore"));
3671 assert!(plan_msg.content.contains("You are in plan mode"));
3672
3673 let todo_msg = transcript
3674 .iter()
3675 .find(|m| m.content.starts_with("[post-compact todo restore]"))
3676 .expect("todo restore message must be present");
3677
3678 assert!(todo_msg.content.contains("- [x] Read files"));
3679 assert!(todo_msg.content.contains("- [/] Edit code"));
3680 assert!(todo_msg.content.contains("(active: Editing code...)"));
3681 assert!(todo_msg.content.contains("- [ ] Run tests"));
3682
3683 let plan_idx = transcript
3685 .iter()
3686 .position(|m| m.content.starts_with("[post-compact plan restore]"))
3687 .unwrap();
3688 let todo_idx = transcript
3689 .iter()
3690 .position(|m| m.content.starts_with("[post-compact todo restore]"))
3691 .unwrap();
3692 assert!(
3693 plan_idx < todo_idx,
3694 "plan attachment must come before todo attachment"
3695 );
3696 }
3697}