1use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::Arc;
10
11use tokio::sync::mpsc;
12use tokio_util::sync::CancellationToken;
13use tracing::{debug, warn};
14
15pub(crate) struct ToolCallOutcome {
17 pub id: String,
18 pub name: String,
19 pub result: String,
20 pub audit: Option<crate::tools::AuditMeta>,
21}
22
23pub(crate) const DENIAL_LIMIT_SENTINEL: &str = "ERROR_DENIAL_LIMIT:";
27
28use crate::compact::Compactor;
29use crate::error::Result;
30use crate::hooks::{HookAction, HookEvent, HookRegistry};
31use crate::llm::{
32 estimate_tokens, ChatProvider, Completion, ContextBreakdown, StreamChunk, StreamSender,
33 TokenUsage, ToolCall, ToolSpec,
34};
35use crate::message::Message;
36
37use crate::tools::plan_mode::{ENTER_PLAN_MODE_TOOL_NAME, EXIT_PLAN_MODE_TOOL_NAME};
38use crate::tools::ToolRegistry;
39
40use crate::agent::{FinishReason, PermissionDecision};
41use crate::event::AgentEvent;
42use crate::skills::Skill;
43use crate::skills_injector::SkillInjector;
44use crate::system_prompt::PromptSegments;
45use crate::tools::PermissionHook;
46
47pub(crate) const TRIM_PLACEHOLDER: &str = "[older tool output trimmed to fit budget]";
49
50const MIN_TRIM_LENGTH: usize = 200;
52
53fn estimate_tokens_by_bytes(bytes: usize) -> u32 {
63 (bytes as f64 / 4.0).ceil() as u32
68}
69
70#[cfg_attr(test, mutants::skip)]
79fn bump_stuck_count<'a>(counts: &mut std::collections::HashMap<&'a str, usize>, name: &'a str) {
80 *counts.entry(name).or_default() += 1;
81}
82
83fn finish_reason_str(reason: &FinishReason) -> String {
88 reason.to_string()
89}
90
91fn estimate_prompt_tokens(messages: &[Message]) -> u32 {
100 let total_chars: usize = messages.iter().map(|m| m.content.len()).sum();
101 (total_chars / 4).max(1) as u32
102}
103
104pub(crate) struct RunInnerOutcome {
106 pub(crate) messages: Arc<Vec<Message>>,
107 pub(crate) final_message: Option<String>,
108 pub(crate) finish_reason: FinishReason,
109 pub(crate) total_usage: TokenUsage,
110 pub(crate) total_llm_latency_ms: u64,
111 pub(crate) steps: usize,
112 pub(crate) tool_audits:
117 std::collections::HashMap<crate::tools::AuditKey, crate::tools::AuditMeta>,
118 pub(crate) last_prompt_tokens: u32,
125}
126
127pub(crate) struct RunCore<'a> {
133 pub(crate) messages: Arc<Vec<Message>>,
134 pub(crate) llm: Arc<dyn ChatProvider>,
135 pub(crate) tools: Arc<ToolRegistry>,
136 pub(crate) max_steps: usize,
137 pub(crate) max_transcript_chars: Option<usize>,
138 pub(crate) events: Option<mpsc::UnboundedSender<AgentEvent>>,
139 pub(crate) streaming: bool,
140 pub(crate) compactor: Option<Compactor>,
141 #[allow(dead_code)]
149 pub(crate) microcompactor: Option<crate::compact::Microcompactor>,
150 pub(crate) permission_hook: Option<Arc<dyn PermissionHook>>,
151 pub(crate) hooks: &'a HookRegistry,
152 pub(crate) total_llm_latency_ms: u64,
153 pub(crate) exploring_plan_mode: Arc<AtomicBool>,
156 pub(crate) shutdown_token: Option<CancellationToken>,
160
161 pub(crate) mailbox: Option<crate::tools::send_message::WorkerMailbox>,
166 pub(crate) stuck_window: usize,
168 pub(crate) stuck_error_rate: f64,
170 pub(crate) turn: u32,
173 pub(crate) globs_skills: Vec<Skill>,
176 #[allow(dead_code)]
188 pub(crate) prompt_segments: Option<PromptSegments>,
189 pub(crate) static_breakdown: StaticBreakdownCache,
194 pub(crate) last_prompt_tokens: u32,
198 pub(crate) consecutive_compact_failures: u32,
204 pub(crate) wall_timeout_secs: u64,
209 pub(crate) wall_start: Option<std::time::Instant>,
212}
213
214#[derive(Debug, Clone, Default, PartialEq, Eq)]
222pub(crate) struct StaticBreakdownCache {
223 pub system_prompt: u32,
224 pub rules: u32,
225 pub skills: u32,
226 pub subagents: u32,
227 pub tools: u32,
229 pub mcp_dynamic: u32,
231}
232
233impl StaticBreakdownCache {
234 pub(crate) fn build(
238 segments: &PromptSegments,
239 specs: &[ToolSpec],
240 registry: &ToolRegistry,
241 ) -> Self {
242 let mut tools = 0u32;
243 let mut mcp_dynamic = 0u32;
244 for spec in specs {
245 let text = serde_json::to_string(spec).unwrap_or_default();
251 let n = estimate_tokens(&text);
252 if registry.is_deferred_spec(spec) {
253 mcp_dynamic = mcp_dynamic.saturating_add(n);
254 } else {
255 tools = tools.saturating_add(n);
256 }
257 }
258 Self {
259 system_prompt: estimate_tokens(&segments.system_prompt),
260 rules: estimate_tokens(&segments.rules),
261 skills: estimate_tokens(&segments.skills),
262 subagents: estimate_tokens(&segments.subagents),
263 tools,
264 mcp_dynamic,
265 }
266 }
267}
268
269impl<'a> RunCore<'a> {
270 fn emit(&self, event: AgentEvent) {
271 if let Some(ref tx) = self.events {
272 let _ = tx.send(event);
273 }
274 }
275
276 fn push_message(&mut self, msg: Message) {
277 Arc::make_mut(&mut self.messages).push(msg);
278 }
279
280 fn attach_reasoning_content(&mut self, reasoning: Option<String>) {
285 if reasoning.is_some() {
286 if let Some(msg) = Arc::make_mut(&mut self.messages).last_mut() {
287 msg.reasoning_content = reasoning;
288 }
289 }
290 }
291
292 fn compute_breakdown(&self, provider_total: u32) -> ContextBreakdown {
298 let mut conversation_bytes: usize = 0;
303 for msg in self.messages.iter() {
304 conversation_bytes = conversation_bytes.saturating_add(msg.content.len());
305 if let Some(rc) = &msg.reasoning_content {
306 conversation_bytes = conversation_bytes.saturating_add(rc.len());
307 }
308 }
309 let conversation = estimate_tokens_by_bytes(conversation_bytes);
311
312 let local_sum = self
313 .static_breakdown
314 .system_prompt
315 .saturating_add(self.static_breakdown.rules)
316 .saturating_add(self.static_breakdown.skills)
317 .saturating_add(self.static_breakdown.subagents)
318 .saturating_add(self.static_breakdown.tools)
319 .saturating_add(self.static_breakdown.mcp_dynamic)
320 .saturating_add(conversation);
321 let overhead = provider_total.saturating_sub(local_sum);
322
323 ContextBreakdown {
324 system_prompt: self.static_breakdown.system_prompt,
325 rules: self.static_breakdown.rules,
326 skills: self.static_breakdown.skills,
327 subagents: self.static_breakdown.subagents,
328 tools: self.static_breakdown.tools,
329 mcp_dynamic: self.static_breakdown.mcp_dynamic,
330 conversation,
331 overhead,
332 }
333 }
334
335 fn emit_breakdown(&self, step: usize, usage: &TokenUsage) {
338 let cache_sum = usage
344 .cache_hit_tokens
345 .saturating_add(usage.cache_miss_tokens);
346 let provider_total = usage.prompt_tokens.max(cache_sum);
347 let breakdown = self.compute_breakdown(provider_total);
348 self.emit(AgentEvent::ContextBreakdown { breakdown, step });
349 }
350
351 async fn dispatch_llm_step(
363 &mut self,
364 specs: &[crate::llm::ToolSpec],
365 step: usize,
366 total_usage: &mut TokenUsage,
367 ) -> crate::error::Result<(Completion, Option<String>)> {
368 debug!(target: "recursive::agent", step, "calling llm");
369 let start = std::time::Instant::now();
370 let mut forward_handle = None;
373 let stream_tx: Option<StreamSender> = if self.streaming {
374 let (delta_tx, mut delta_rx) = mpsc::unbounded_channel::<StreamChunk>();
375 let events_tx = self.events.clone();
376 forward_handle = Some(tokio::spawn(async move {
377 while let Some(chunk) = delta_rx.recv().await {
378 if let Some(ref tx) = events_tx {
379 let event = match chunk {
380 StreamChunk::Text(text) => AgentEvent::PartialToken { text, step },
381 StreamChunk::Reasoning(text) => {
382 AgentEvent::PartialReasoning { text, step }
383 }
384 };
385 let _ = tx.send(event);
386 }
387 }
388 }));
389 Some(delta_tx)
390 } else {
391 None
392 };
393 let mut completion: Completion = self.call_llm(specs, stream_tx).await?;
394 if let Some(handle) = forward_handle.take() {
403 let _ = handle.await;
404 }
405 completion.extract_inline_reasoning();
412 let llm_ms = start.elapsed().as_millis() as u64;
413 self.total_llm_latency_ms = self.total_llm_latency_ms.saturating_add(llm_ms);
414 self.emit(AgentEvent::Latency { step, llm_ms });
415
416 if let Some(mut u) = completion.usage {
417 if u.prompt_tokens == 0 && u.completion_tokens > 0 {
422 u.prompt_tokens = estimate_prompt_tokens(&self.messages);
423 u.total_tokens = u.total_tokens.max(u.prompt_tokens + u.completion_tokens);
424 }
425 *total_usage = total_usage.accumulate(u);
426 self.last_prompt_tokens = u.prompt_tokens;
427 self.emit(AgentEvent::Usage {
428 input_tokens: u.prompt_tokens,
429 output_tokens: u.completion_tokens,
430 cache_hit_tokens: u.cache_hit_tokens,
431 cache_miss_tokens: u.cache_miss_tokens,
432 step,
433 });
434 self.emit_breakdown(step, &u);
441 } else {
442 let estimated_input = estimate_prompt_tokens(&self.messages);
445 let output_len = completion.content.len()
446 + completion.reasoning_content.as_ref().map_or(0, |s| s.len());
447 let estimated_output = (output_len / 4).max(1) as u32;
448 self.emit(AgentEvent::Usage {
449 input_tokens: estimated_input,
450 output_tokens: estimated_output,
451 cache_hit_tokens: 0,
452 cache_miss_tokens: 0,
453 step,
454 });
455 }
456
457 if let Some(reasoning) = &completion.reasoning_content {
466 if !reasoning.is_empty() {
467 self.emit(AgentEvent::Reasoning {
468 text: reasoning.clone(),
469 step,
470 });
471 }
472 }
473
474 let new_final_message = if !completion.content.is_empty() {
475 self.emit(AgentEvent::AssistantText {
476 text: completion.content.clone(),
477 step,
478 });
479 Some(completion.content.clone())
480 } else {
481 None
482 };
483
484 Ok((completion, new_final_message))
485 }
486
487 #[allow(clippy::too_many_arguments)]
507 fn process_tool_results(
508 &mut self,
509 results: &[ToolCallOutcome],
510 step: usize,
511 recent_errors: &mut std::collections::VecDeque<(bool, String)>,
512 tool_audits: &mut std::collections::HashMap<
513 crate::tools::AuditKey,
514 crate::tools::AuditMeta,
515 >,
516 skill_injector: &mut SkillInjector,
517 ) -> Option<FinishReason> {
518 if results.iter().any(|o| o.result == DENIAL_LIMIT_SENTINEL) {
523 for o in results {
524 if let Some(a) = &o.audit {
525 tool_audits.insert((self.turn, o.id.clone()), a.clone());
526 }
527 let is_error = o.result.starts_with("ERROR: ") || o.result == DENIAL_LIMIT_SENTINEL;
528 self.emit(AgentEvent::ToolResult {
529 id: o.id.clone(),
530 name: o.name.clone(),
531 output: o.result.clone(),
532 step,
533 is_error,
534 });
535 self.push_message(Message::tool_result(o.id.clone(), o.result.clone()));
536 }
537 let finish = FinishReason::PermissionDenialLimit;
538 self.emit(AgentEvent::TurnFinished {
539 reason: finish_reason_str(&finish),
540 steps: step,
541 });
542 return Some(finish);
543 }
544
545 let mut stuck_finish: Option<FinishReason> = None;
553 for ToolCallOutcome {
554 id,
555 name,
556 result,
557 audit,
558 } in results
559 {
560 let is_error = result.starts_with("ERROR: ");
561 self.emit(AgentEvent::ToolResult {
562 id: id.clone(),
563 name: name.clone(),
564 output: result.clone(),
565 step,
566 is_error,
567 });
568 if let Some(a) = audit {
569 tool_audits.insert((self.turn, id.clone()), a.clone());
570 }
571 self.push_message(Message::tool_result(id.clone(), result.clone()));
572
573 if recent_errors.len() == self.stuck_window {
578 recent_errors.pop_front();
579 }
580 recent_errors.push_back((is_error, name.clone()));
581
582 if stuck_finish.is_none() && recent_errors.len() == self.stuck_window {
583 let error_entries: Vec<&str> = recent_errors
584 .iter()
585 .filter(|(is_err, _)| *is_err)
586 .map(|(_, n)| n.as_str())
587 .collect();
588 let error_count = error_entries.len();
589 let rate = error_count as f64 / self.stuck_window as f64;
590 if rate >= self.stuck_error_rate {
591 let mut counts: std::collections::HashMap<&str, usize> =
592 std::collections::HashMap::new();
593 for n in &error_entries {
594 bump_stuck_count(&mut counts, n);
595 }
596 let top_tool = counts
597 .into_iter()
598 .max_by_key(|&(_, c)| c)
599 .map(|(n, _)| n)
600 .unwrap_or(name.as_str());
601 stuck_finish = Some(FinishReason::Stuck {
602 repeated_call: top_tool.to_string(),
603 repeats: error_count,
604 });
605 }
606 }
607 }
608
609 if let Some(finish) = stuck_finish {
613 self.emit(AgentEvent::TurnFinished {
614 reason: finish_reason_str(&finish),
615 steps: step,
616 });
617 return Some(finish);
618 }
619
620 let result_strings: Vec<String> = results.iter().map(|r| r.result.clone()).collect();
623 for (skill_name, skill_body) in skill_injector.check(&result_strings) {
624 self.push_message(Message::system(format!(
625 "<!-- skill:{skill_name} injected by globs match -->\n{skill_body}"
626 )));
627 }
628 None
629 }
630
631 fn handle_no_tool_calls(
639 &mut self,
640 completion: &Completion,
641 step: usize,
642 ) -> Option<FinishReason> {
643 if !completion.tool_calls.is_empty() {
644 return None;
645 }
646 self.push_message(Message::assistant(completion.content.clone()));
647 self.attach_reasoning_content(completion.reasoning_content.clone());
648 let finish = match completion.finish_reason.as_deref() {
649 Some(r) if r != "stop" && r != "end_turn" => FinishReason::ProviderStop(r.to_string()),
650 _ => FinishReason::NoMoreToolCalls,
651 };
652 self.emit(AgentEvent::TurnFinished {
653 reason: finish_reason_str(&finish),
654 steps: step,
655 });
656 Some(finish)
657 }
658
659 async fn drain_mailbox(&mut self) {
664 let Some(mailbox) = self.mailbox.as_ref() else {
665 return;
666 };
667 let pending = mailbox.drain_all().await;
668 for msg_text in pending {
669 self.push_message(Message {
670 role: crate::message::Role::User,
671 content: format!("[coordinator]: {msg_text}"),
672 tool_calls: vec![],
673 tool_call_id: None,
674 reasoning_content: None,
675 is_compaction_summary: false,
676 });
677 }
678 }
679
680 fn enforce_transcript_budget(
685 &mut self,
686 step: usize,
687 total_usage: &TokenUsage,
688 ) -> Option<(FinishReason, usize)> {
689 let limit = self.max_transcript_chars?;
690 self.maybe_trim_transcript(limit, step);
691 let chars: usize = self.messages.iter().map(|m| m.content.len()).sum();
692 if chars < limit {
693 return None;
694 }
695 let finish = FinishReason::TranscriptLimit { chars, limit };
696 self.emit(AgentEvent::TurnFinished {
697 reason: finish_reason_str(&finish),
698 steps: step,
699 });
700 tracing::info!(
701 target: "recursive::agent",
702 steps = step,
703 tokens_in = total_usage.prompt_tokens,
704 tokens_out = total_usage.completion_tokens,
705 finish = ?finish,
706 llm_latency_ms = self.total_llm_latency_ms,
707 "agent.run.complete"
708 );
709 Some((finish, step))
710 }
711
712 fn check_shutdown(
721 &self,
722 step: usize,
723 total_usage: &TokenUsage,
724 ) -> Option<(FinishReason, usize)> {
725 let token = self.shutdown_token.as_ref()?;
726 if !token.is_cancelled() {
727 return None;
728 }
729 let finished_steps = step.saturating_sub(1);
730 let finish = FinishReason::Cancelled;
731 self.emit(AgentEvent::TurnFinished {
732 reason: finish_reason_str(&finish),
733 steps: finished_steps,
734 });
735 tracing::info!(
736 target: "recursive::agent",
737 steps = finished_steps,
738 tokens_in = total_usage.prompt_tokens,
739 tokens_out = total_usage.completion_tokens,
740 finish = ?finish,
741 llm_latency_ms = self.total_llm_latency_ms,
742 "agent.run.complete"
743 );
744 Some((finish, finished_steps))
745 }
746
747 fn check_wall_deadline(
751 &self,
752 step: usize,
753 total_usage: &TokenUsage,
754 ) -> Option<(FinishReason, usize)> {
755 if self.wall_timeout_secs == 0 {
756 return None;
757 }
758 let elapsed = self.wall_start?.elapsed();
759 if elapsed.as_secs() < self.wall_timeout_secs {
760 return None;
761 }
762 let finished_steps = step.saturating_sub(1);
763 let finish = FinishReason::WallClockExceeded {
764 secs: self.wall_timeout_secs,
765 };
766 self.emit(AgentEvent::TurnFinished {
767 reason: finish_reason_str(&finish),
768 steps: finished_steps,
769 });
770 tracing::info!(
771 target: "recursive::agent",
772 steps = finished_steps,
773 tokens_in = total_usage.prompt_tokens,
774 tokens_out = total_usage.completion_tokens,
775 finish = ?finish,
776 llm_latency_ms = self.total_llm_latency_ms,
777 "agent.run.complete (wall clock)"
778 );
779 Some((finish, finished_steps))
780 }
781
782 fn make_outcome(
786 self,
787 finish: FinishReason,
788 steps: usize,
789 final_message: Option<String>,
790 total_usage: TokenUsage,
791 tool_audits: std::collections::HashMap<crate::tools::AuditKey, crate::tools::AuditMeta>,
792 ) -> RunInnerOutcome {
793 RunInnerOutcome {
794 messages: self.messages,
795 final_message,
796 finish_reason: finish,
797 total_usage,
798 total_llm_latency_ms: self.total_llm_latency_ms,
799 steps,
800 tool_audits,
801 last_prompt_tokens: self.last_prompt_tokens,
802 }
803 }
804
805 fn make_cancelled_outcome(
816 self,
817 step: usize,
818 final_message: Option<String>,
819 total_usage: TokenUsage,
820 tool_audits: std::collections::HashMap<crate::tools::AuditKey, crate::tools::AuditMeta>,
821 ) -> RunInnerOutcome {
822 let finish = FinishReason::Cancelled;
823 self.emit(AgentEvent::TurnFinished {
824 reason: finish_reason_str(&finish),
825 steps: step,
826 });
827 tracing::info!(
828 target: "recursive::agent",
829 steps = step,
830 finish = ?finish,
831 "agent.run.cancelled_mid_stream"
832 );
833 self.make_outcome(finish, step, final_message, total_usage, tool_audits)
834 }
835
836 fn finish_interrupted(
847 mut self,
848 completion: Completion,
849 step: usize,
850 total_usage: TokenUsage,
851 tool_audits: std::collections::HashMap<crate::tools::AuditKey, crate::tools::AuditMeta>,
852 ) -> RunInnerOutcome {
853 self.push_message(Message::assistant(completion.content.clone()));
854 self.attach_reasoning_content(completion.reasoning_content.clone());
855 self.make_cancelled_outcome(
856 step,
857 Some(completion.content), total_usage,
859 tool_audits,
860 )
861 }
862
863 fn inject_skill_reminder(
891 messages: &[crate::message::Message],
892 skills: &[crate::skills::Skill],
893 ) -> Vec<crate::message::Message> {
894 if skills.is_empty() {
895 return messages.to_vec();
896 }
897 let reminder = crate::skills::skill_reminder(skills);
898 let mut out = Vec::with_capacity(messages.len() + 1);
899 out.extend(messages.iter().cloned());
900 out.push(crate::message::Message::user(reminder));
901 out
902 }
903
904 async fn call_llm(
905 &self,
906 specs: &[crate::llm::ToolSpec],
907 stream_sender: Option<crate::llm::StreamSender>,
908 ) -> crate::error::Result<Completion> {
909 let eager_specs_owned: Vec<crate::llm::ToolSpec>;
913 let messages_with_deferred: Vec<crate::message::Message>;
914
915 let (call_specs, messages): (&[crate::llm::ToolSpec], &[crate::message::Message]) =
916 if self.llm.supports_deferred_tools() {
917 let (eager, deferred): (Vec<_>, Vec<_>) = specs
918 .iter()
919 .cloned()
920 .partition(|s| !self.tools.is_deferred_spec(s));
921
922 if deferred.is_empty() {
923 (specs, &self.messages)
924 } else {
925 let names: Vec<&str> = deferred.iter().map(|s| s.name.as_str()).collect();
926 let block = format!(
927 "<available-deferred-tools>\n{}\n</available-deferred-tools>",
928 names.join("\n")
929 );
930 messages_with_deferred = std::iter::once(crate::message::Message::user(block))
931 .chain(self.messages.iter().cloned())
932 .collect();
933 eager_specs_owned = eager;
934 (&eager_specs_owned, &messages_with_deferred)
935 }
936 } else {
937 (specs, &self.messages)
938 };
939
940 let injected: Vec<crate::message::Message> =
944 Self::inject_skill_reminder(messages, &self.globs_skills);
945
946 if let Some(ref tx) = stream_sender {
947 let cancel_token = self.shutdown_token.clone();
948 self.llm
949 .stream(&injected, call_specs, Some(tx.clone()), cancel_token)
950 .await
951 } else {
952 self.llm.complete(&injected, call_specs).await
953 }
954 }
955
956 fn maybe_trim_transcript(&mut self, limit: usize, step: usize) {
958 let mut chars: usize = self.messages.iter().map(|m| m.content.len()).sum();
959 if chars < limit {
960 return;
961 }
962
963 let mut trimmed_count: usize = 0;
964 let placeholder_len = TRIM_PLACEHOLDER.len();
965
966 for msg in Arc::make_mut(&mut self.messages).iter_mut().skip(1) {
967 if msg.role == crate::message::Role::Tool && msg.content.len() > MIN_TRIM_LENGTH {
968 let old_len = msg.content.len();
969 msg.content = TRIM_PLACEHOLDER.to_string();
970 trimmed_count += 1;
971 chars = chars
972 .saturating_sub(old_len)
973 .saturating_add(placeholder_len);
974 if chars < limit {
975 break;
976 }
977 }
978 }
979
980 if trimmed_count > 0 {
981 let note = format!(
982 "[trimmed {} old tool result{} to fit budget]",
983 trimmed_count,
984 if trimmed_count == 1 { "" } else { "s" }
985 );
986 self.emit(AgentEvent::AssistantText { text: note, step });
987 }
988 }
989
990 async fn maybe_compact(&mut self, step: usize) {
1000 let compactor = match &self.compactor {
1001 Some(c) => c,
1002 None => return,
1003 };
1004
1005 if self.consecutive_compact_failures >= crate::compact::MAX_CONSECUTIVE_COMPACT_FAILURES {
1007 self.emit(AgentEvent::CompactionSkipped {
1008 step,
1009 reason: crate::event::CompactionSkipReason::CircuitBreaker,
1010 });
1011 return;
1012 }
1013
1014 let bytes = Compactor::estimate_bytes(&self.messages);
1015 if !compactor.should_compact(bytes, self.last_prompt_tokens) {
1016 return;
1017 }
1018 if !compactor.would_compact(&self.messages) {
1024 return;
1025 }
1026
1027 self.hooks.dispatch(HookEvent::PreCompact {
1029 transcript_len: bytes,
1030 });
1031
1032 let kept_before = self.messages.len();
1033 match compactor
1034 .apply_to_transcript(self.llm.as_ref(), Arc::make_mut(&mut self.messages), step)
1035 .await
1036 {
1037 Ok(Some((removed, summary_chars))) => {
1038 self.consecutive_compact_failures = 0;
1040 let kept = kept_before - removed;
1041 self.hooks.dispatch(HookEvent::PostCompact {
1042 removed,
1043 summary_chars,
1044 });
1045 self.emit(AgentEvent::Compacted {
1046 removed,
1047 kept,
1048 summary_chars,
1049 step,
1050 });
1051 }
1052 Ok(None) => {
1053 }
1055 Err(e) => {
1056 tracing::warn!(error = %e, "proactive compaction failed");
1058 self.consecutive_compact_failures += 1;
1059 self.emit(AgentEvent::CompactionSkipped {
1060 step,
1061 reason: crate::event::CompactionSkipReason::Error,
1062 });
1063 }
1064 }
1065 }
1066
1067 async fn execute_tool_calls(&self, calls: &[ToolCall]) -> Vec<ToolCallOutcome> {
1071 let mut results: Vec<ToolCallOutcome> = Vec::new();
1072
1073 struct PendingCall {
1074 id: String,
1075 name: String,
1076 args: serde_json::Value,
1077 }
1078 let mut pending: Vec<PendingCall> = Vec::new();
1079
1080 for call in calls {
1081 if self.exploring_plan_mode.load(Ordering::Relaxed)
1084 && !self.tools.is_readonly(&call.name)
1085 && call.name != EXIT_PLAN_MODE_TOOL_NAME
1086 {
1087 results.push(ToolCallOutcome {
1088 id: call.id.clone(),
1089 name: call.name.clone(),
1090 result: format!(
1091 "ERROR: Cannot execute '{}' in plan mode. \
1092 You are in read-only planning mode. \
1093 Explore freely with read tools, then call \
1094 exit_plan_mode with your plan.",
1095 call.name
1096 ),
1097 audit: None,
1098 });
1099 continue;
1100 }
1101
1102 if !self.exploring_plan_mode.load(Ordering::Relaxed)
1105 && self.tools.is_plan_mode(&call.name)
1106 && call.name != ENTER_PLAN_MODE_TOOL_NAME
1107 && call.name != EXIT_PLAN_MODE_TOOL_NAME
1108 {
1109 results.push(ToolCallOutcome {
1110 id: call.id.clone(),
1111 name: call.name.clone(),
1112 result: format!(
1113 "ERROR: Tool '{}' requires plan mode. \
1114 Call enter_plan_mode first to explore and plan, \
1115 then call exit_plan_mode with your plan before using this tool.",
1116 call.name
1117 ),
1118 audit: None,
1119 });
1120 continue;
1121 }
1122
1123 let effective_args = if let Some(ref hook) = self.permission_hook {
1124 match hook.check(&call.name, &call.arguments).await {
1125 PermissionDecision::Allow => call.arguments.clone(),
1126 PermissionDecision::Deny(reason) => {
1127 let result = format!("ERROR: {reason}");
1128 results.push(ToolCallOutcome {
1129 id: call.id.clone(),
1130 name: call.name.clone(),
1131 result,
1132 audit: None,
1133 });
1134 continue;
1135 }
1136 PermissionDecision::Transform(new_args) => new_args,
1137 }
1138 } else {
1139 call.arguments.clone()
1140 };
1141
1142 let hook_action = self.hooks.dispatch(HookEvent::PreToolCall {
1143 name: &call.name,
1144 args: &effective_args,
1145 });
1146 match hook_action {
1147 HookAction::Skip => {
1148 let result = "ERROR: tool call skipped by hook".to_string();
1149 results.push(ToolCallOutcome {
1150 id: call.id.clone(),
1151 name: call.name.clone(),
1152 result,
1153 audit: None,
1154 });
1155 continue;
1156 }
1157 HookAction::Error(msg) => {
1158 let result = format!("ERROR: {msg}");
1159 results.push(ToolCallOutcome {
1160 id: call.id.clone(),
1161 name: call.name.clone(),
1162 result,
1163 audit: None,
1164 });
1165 continue;
1166 }
1167 HookAction::Continue => {}
1168 }
1169
1170 pending.push(PendingCall {
1171 id: call.id.clone(),
1172 name: call.name.clone(),
1173 args: effective_args,
1174 });
1175 }
1176
1177 let mut i = 0;
1178 while i < pending.len() {
1179 if self
1180 .tools
1181 .is_readonly_for_call(&pending[i].name, &pending[i].args)
1182 {
1183 let batch_start = i;
1184 while i < pending.len()
1185 && self
1186 .tools
1187 .is_readonly_for_call(&pending[i].name, &pending[i].args)
1188 {
1189 i += 1;
1190 }
1191 let batch: Vec<PendingCall> = pending.drain(batch_start..i).collect();
1192 i = batch_start;
1193
1194 let mut join_set = tokio::task::JoinSet::new();
1195 for pc in &batch {
1196 let name = pc.name.clone();
1197 let id = pc.id.clone();
1198 let args = pc.args.clone();
1199 let tools = Arc::clone(&self.tools);
1200 join_set.spawn(async move {
1201 let tool_start = std::time::Instant::now();
1202 let dispatch = tools.invoke_with_audit(&name, args).await;
1203 let result = match dispatch.result {
1204 Ok(output) => output,
1205 Err(crate::error::Error::PermissionDeniedLimit { .. }) => {
1206 DENIAL_LIMIT_SENTINEL.to_string()
1207 }
1208 Err(err) => format!("ERROR: {err}"),
1209 };
1210 let duration_ms = tool_start.elapsed().as_millis() as u64;
1211 (id, name, result, dispatch.audit, duration_ms)
1212 });
1213 }
1214
1215 type BatchRow = (String, String, String, crate::tools::AuditMeta, u64);
1216 let mut batch_map: std::collections::HashMap<String, BatchRow> =
1221 std::collections::HashMap::with_capacity(batch.len());
1222 while let Some(res) = join_set.join_next().await {
1223 match res {
1224 Ok(row) => {
1225 batch_map.insert(row.0.clone(), row);
1226 }
1227 Err(e) => {
1228 tracing::error!(target: "recursive::agent", "parallel tool task panicked: {e}");
1229 }
1230 }
1231 }
1232
1233 for pc in &batch {
1234 let Some((_, _, result, audit, duration_ms)) = batch_map.get(&pc.id) else {
1235 results.push(ToolCallOutcome {
1241 id: pc.id.clone(),
1242 name: pc.name.clone(),
1243 result: "ERROR: tool task panicked during parallel execution"
1244 .to_string(),
1245 audit: None,
1246 });
1247 continue;
1248 };
1249 results.push(ToolCallOutcome {
1250 id: pc.id.clone(),
1251 name: pc.name.clone(),
1252 result: result.clone(),
1253 audit: Some(audit.clone()),
1254 });
1255 self.hooks.dispatch(HookEvent::PostToolCall {
1256 name: &pc.name,
1257 args: &pc.args,
1258 result,
1259 duration_ms: *duration_ms,
1260 });
1261 }
1262 } else {
1263 let pc = pending.remove(i);
1264 let tool_start = std::time::Instant::now();
1265 let dispatch = self
1266 .tools
1267 .invoke_with_audit(&pc.name, pc.args.clone())
1268 .await;
1269 let result = match dispatch.result {
1270 Ok(output) => output,
1271 Err(crate::error::Error::PermissionDeniedLimit { .. }) => {
1272 DENIAL_LIMIT_SENTINEL.to_string()
1273 }
1274 Err(err) => format!("ERROR: {err}"),
1275 };
1276 let duration_ms = tool_start.elapsed().as_millis() as u64;
1277 results.push(ToolCallOutcome {
1278 id: pc.id.clone(),
1279 name: pc.name.clone(),
1280 result: result.clone(),
1281 audit: Some(dispatch.audit),
1282 });
1283 self.hooks.dispatch(HookEvent::PostToolCall {
1284 name: &pc.name,
1285 args: &pc.args,
1286 result: &result,
1287 duration_ms,
1288 });
1289 }
1290 }
1291
1292 results
1293 }
1294
1295 pub(crate) async fn run_inner(mut self) -> Result<RunInnerOutcome> {
1298 let specs = self.tools.specs();
1299
1300 let mut final_message: Option<String> = None;
1301 let mut recent_errors: std::collections::VecDeque<(bool, String)> =
1302 std::collections::VecDeque::with_capacity(self.stuck_window);
1303 let mut total_usage = TokenUsage::default();
1304 let mut skill_injector = SkillInjector::new(&self.globs_skills);
1306 let mut tool_audits: std::collections::HashMap<
1308 crate::tools::AuditKey,
1309 crate::tools::AuditMeta,
1310 > = std::collections::HashMap::new();
1311 self.total_llm_latency_ms = 0;
1312
1313 let step_cap = effective_step_limit(self.max_steps);
1314 for step in 1..=step_cap {
1315 let step_span = tracing::info_span!("agent.step", step);
1316 let _guard = step_span.enter();
1317
1318 if let Some((finish, finished_steps)) = self.check_shutdown(step, &total_usage) {
1325 return Ok(self.make_outcome(
1326 finish,
1327 finished_steps,
1328 final_message,
1329 total_usage,
1330 tool_audits,
1331 ));
1332 }
1333
1334 if let Some((finish, finished_steps)) = self.check_wall_deadline(step, &total_usage) {
1336 return Ok(self.make_outcome(
1337 finish,
1338 finished_steps,
1339 final_message,
1340 total_usage,
1341 tool_audits,
1342 ));
1343 }
1344
1345 self.drain_mailbox().await;
1347
1348 if let Some((finish, finish_step)) = self.enforce_transcript_budget(step, &total_usage)
1350 {
1351 return Ok(self.make_outcome(
1352 finish,
1353 finish_step,
1354 final_message,
1355 total_usage,
1356 tool_audits,
1357 ));
1358 }
1359
1360 self.maybe_compact(step).await;
1362
1363 let (completion, new_final_message) =
1365 match self.dispatch_llm_step(&specs, step, &mut total_usage).await {
1366 Ok(v) => v,
1367 Err(crate::error::Error::Cancelled) => {
1368 return Ok(self.make_cancelled_outcome(
1372 step,
1373 final_message,
1374 total_usage,
1375 tool_audits,
1376 ));
1377 }
1378 Err(e) => return Err(e),
1379 };
1380 if completion.finish_reason.as_deref() == Some("interrupted") {
1383 return Ok(self.finish_interrupted(completion, step, total_usage, tool_audits));
1384 }
1385 if let Some(text) = new_final_message {
1386 final_message = Some(text);
1387 }
1388
1389 if let Some(finish) = self.handle_no_tool_calls(&completion, step) {
1391 return Ok(self.make_outcome(
1392 finish,
1393 step,
1394 final_message,
1395 total_usage,
1396 tool_audits,
1397 ));
1398 }
1399
1400 self.push_message(Message::assistant_with_tool_calls(
1401 completion.content.clone(),
1402 completion.tool_calls.clone(),
1403 ));
1404 self.attach_reasoning_content(completion.reasoning_content.clone());
1405
1406 for call in &completion.tool_calls {
1407 self.emit(AgentEvent::ToolCall {
1408 name: call.name.clone(),
1409 id: call.id.clone(),
1410 arguments: call.arguments.to_string(),
1411 step,
1412 });
1413 }
1414
1415 let results = self.execute_tool_calls(&completion.tool_calls).await;
1417
1418 if let Some(finish) = self.process_tool_results(
1419 &results,
1420 step,
1421 &mut recent_errors,
1422 &mut tool_audits,
1423 &mut skill_injector,
1424 ) {
1425 return Ok(self.make_outcome(
1426 finish,
1427 step,
1428 final_message,
1429 total_usage,
1430 tool_audits,
1431 ));
1432 }
1433 }
1434
1435 warn!(target: "recursive::agent", "step budget exceeded");
1436 let finish = FinishReason::BudgetExceeded;
1437 let steps = self.max_steps;
1438 self.emit(AgentEvent::TurnFinished {
1439 reason: finish_reason_str(&finish),
1440 steps,
1441 });
1442 Ok(self.make_outcome(finish, steps, final_message, total_usage, tool_audits))
1443 }
1444}
1445
1446fn effective_step_limit(max_steps: usize) -> usize {
1455 let requested = if max_steps == 0 {
1456 usize::MAX
1457 } else {
1458 max_steps
1459 };
1460 match hard_step_cap_from_env() {
1461 Some(cap) if cap > 0 => requested.min(cap),
1462 _ => requested,
1463 }
1464}
1465
1466fn hard_step_cap_from_env() -> Option<usize> {
1470 std::env::var("RECURSIVE_HARD_STEP_CAP")
1471 .ok()
1472 .and_then(|s| s.parse::<usize>().ok())
1473}
1474
1475#[cfg(test)]
1476mod tests {
1477 use std::sync::Arc;
1478
1479 use super::{
1480 effective_step_limit, estimate_prompt_tokens, estimate_tokens_by_bytes, finish_reason_str,
1481 RunCore, StaticBreakdownCache, MIN_TRIM_LENGTH, TRIM_PLACEHOLDER,
1482 };
1483 use crate::message::Message;
1484
1485 static HARD_CAP_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1490
1491 #[test]
1492 fn effective_step_limit_zero_means_unbounded() {
1493 let _guard = HARD_CAP_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1494 let orig = std::env::var("RECURSIVE_HARD_STEP_CAP").ok();
1496 std::env::remove_var("RECURSIVE_HARD_STEP_CAP");
1497 assert_eq!(effective_step_limit(0), usize::MAX);
1498 assert_eq!(effective_step_limit(32), 32);
1499 if let Some(v) = orig {
1500 std::env::set_var("RECURSIVE_HARD_STEP_CAP", v);
1501 }
1502 }
1503
1504 #[test]
1505 fn effective_step_limit_respects_hard_cap_when_set() {
1506 let _guard = HARD_CAP_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1507 let orig = std::env::var("RECURSIVE_HARD_STEP_CAP").ok();
1508 std::env::set_var("RECURSIVE_HARD_STEP_CAP", "1000");
1509
1510 assert_eq!(effective_step_limit(0), 1000);
1512 assert_eq!(effective_step_limit(32), 32);
1514 assert_eq!(effective_step_limit(5000), 1000);
1516
1517 if let Some(v) = orig {
1518 std::env::set_var("RECURSIVE_HARD_STEP_CAP", v);
1519 } else {
1520 std::env::remove_var("RECURSIVE_HARD_STEP_CAP");
1521 }
1522 }
1523
1524 #[test]
1525 fn effective_step_limit_ignores_invalid_hard_cap() {
1526 let _guard = HARD_CAP_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1527 let orig = std::env::var("RECURSIVE_HARD_STEP_CAP").ok();
1528 std::env::set_var("RECURSIVE_HARD_STEP_CAP", "not-a-number");
1529 assert_eq!(effective_step_limit(0), usize::MAX);
1530 std::env::set_var("RECURSIVE_HARD_STEP_CAP", "0");
1531 assert_eq!(effective_step_limit(0), usize::MAX);
1533 if let Some(v) = orig {
1534 std::env::set_var("RECURSIVE_HARD_STEP_CAP", v);
1535 } else {
1536 std::env::remove_var("RECURSIVE_HARD_STEP_CAP");
1537 }
1538 }
1539
1540 #[test]
1543 fn stuck_detection_window_and_rate() {
1544 let stuck_window = 3usize;
1546 let stuck_error_rate = 1.0f64;
1547
1548 let mut recent_errors: std::collections::VecDeque<(bool, String)> =
1549 std::collections::VecDeque::with_capacity(stuck_window);
1550
1551 for i in 0..2 {
1553 if recent_errors.len() == stuck_window {
1554 recent_errors.pop_front();
1555 }
1556 let name = format!("tool_{i}");
1557 recent_errors.push_back((true, name));
1558 let rate = recent_errors.iter().filter(|(e, _)| *e).count() as f64
1559 / recent_errors.len() as f64;
1560 assert!(
1561 recent_errors.len() < stuck_window || rate < stuck_error_rate,
1562 "should not trigger before window is full"
1563 );
1564 }
1565
1566 if recent_errors.len() == stuck_window {
1568 recent_errors.pop_front();
1569 }
1570 recent_errors.push_back((true, "tool_2".to_string()));
1571 assert_eq!(recent_errors.len(), stuck_window);
1572 let error_count = recent_errors.iter().filter(|(e, _)| *e).count();
1573 let rate = error_count as f64 / stuck_window as f64;
1574 assert!(
1575 rate >= stuck_error_rate,
1576 "rate {rate} should trigger stuck at threshold {stuck_error_rate}"
1577 );
1578
1579 let error_entries: Vec<&str> = recent_errors
1581 .iter()
1582 .filter(|(e, _)| *e)
1583 .map(|(_, n)| n.as_str())
1584 .collect();
1585 let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
1586 for n in &error_entries {
1587 *counts.entry(n).or_default() += 1;
1588 }
1589 let top_tool = counts.into_iter().max_by_key(|&(_, c)| c).map(|(n, _)| n);
1590 assert!(top_tool.is_some());
1592 }
1593
1594 #[test]
1595 fn stuck_detection_partial_errors_below_threshold() {
1596 let stuck_window = 4usize;
1599 let stuck_error_rate = 0.8f64;
1600
1601 let mut recent_errors: std::collections::VecDeque<(bool, String)> =
1602 std::collections::VecDeque::with_capacity(stuck_window);
1603
1604 let pattern = [
1605 (true, "tool_a"),
1606 (true, "tool_b"),
1607 (true, "tool_a"),
1608 (false, "tool_c"),
1609 ]; for (is_error, name) in &pattern {
1611 if recent_errors.len() == stuck_window {
1612 recent_errors.pop_front();
1613 }
1614 recent_errors.push_back((*is_error, name.to_string()));
1615 }
1616
1617 assert_eq!(recent_errors.len(), stuck_window);
1618 let rate = recent_errors.iter().filter(|(e, _)| *e).count() as f64 / stuck_window as f64;
1619 assert!(
1620 rate < stuck_error_rate,
1621 "rate {rate} should be below threshold {stuck_error_rate}"
1622 );
1623 }
1624
1625 #[test]
1626 fn stuck_detection_reports_most_repeated_tool() {
1627 let stuck_window = 4usize;
1630 let stuck_error_rate = 0.75f64;
1631
1632 let mut recent_errors: std::collections::VecDeque<(bool, String)> =
1633 std::collections::VecDeque::with_capacity(stuck_window);
1634
1635 let pattern = [
1636 (true, "tool_a"),
1637 (true, "tool_b"),
1638 (true, "tool_a"),
1639 (false, "tool_b"),
1640 ];
1641 for (is_error, name) in &pattern {
1642 if recent_errors.len() == stuck_window {
1643 recent_errors.pop_front();
1644 }
1645 recent_errors.push_back((*is_error, name.to_string()));
1646 }
1647
1648 assert_eq!(recent_errors.len(), stuck_window);
1649
1650 let error_entries: Vec<&str> = recent_errors
1651 .iter()
1652 .filter(|(is_err, _)| *is_err)
1653 .map(|(_, n)| n.as_str())
1654 .collect();
1655 let error_count = error_entries.len();
1656 let rate = error_count as f64 / stuck_window as f64;
1657 assert!(
1658 rate >= stuck_error_rate,
1659 "rate {rate} should trigger stuck at threshold {stuck_error_rate}"
1660 );
1661
1662 let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
1664 for n in &error_entries {
1665 *counts.entry(n).or_default() += 1;
1666 }
1667 let top_tool = counts
1668 .into_iter()
1669 .max_by_key(|&(_, c)| c)
1670 .map(|(n, _)| n)
1671 .unwrap();
1672 assert_eq!(
1673 top_tool, "tool_a",
1674 "most repeated tool should be tool_a (2 errors), not tool_b (1 error)"
1675 );
1676 }
1677
1678 fn make_test_core<'a>(
1681 messages: Vec<Message>,
1682 hooks: &'a crate::hooks::HookRegistry,
1683 ) -> RunCore<'a> {
1684 use std::sync::atomic::AtomicBool;
1685 use std::sync::Arc;
1686 RunCore {
1687 messages: Arc::new(messages),
1688 llm: Arc::new(crate::llm::MockProvider::new(vec![])),
1689 tools: Arc::new(crate::tools::ToolRegistry::default()),
1690 max_steps: 1,
1691 max_transcript_chars: None,
1692 events: None,
1693 streaming: false,
1694 compactor: None,
1695 microcompactor: None,
1696 permission_hook: None,
1697 hooks,
1698 total_llm_latency_ms: 0,
1699 exploring_plan_mode: Arc::new(AtomicBool::new(false)),
1700 shutdown_token: None,
1701 mailbox: None,
1702 stuck_window: 3,
1703 stuck_error_rate: 1.0,
1704 turn: 0,
1705 globs_skills: vec![],
1706 prompt_segments: None,
1707 static_breakdown: StaticBreakdownCache::default(),
1708 last_prompt_tokens: 0,
1709 consecutive_compact_failures: 0,
1710 wall_timeout_secs: 0,
1711 wall_start: None,
1712 }
1713 }
1714
1715 #[test]
1716 fn attach_reasoning_content_sets_last_message_when_some() {
1717 let hooks = crate::hooks::HookRegistry::new();
1718 let mut core = make_test_core(vec![Message::assistant("response".to_string())], &hooks);
1719
1720 core.attach_reasoning_content(Some("I thought carefully about this.".to_string()));
1721
1722 assert_eq!(
1723 core.messages.last().unwrap().reasoning_content.as_deref(),
1724 Some("I thought carefully about this."),
1725 "reasoning_content should be set on the last message"
1726 );
1727 }
1728
1729 #[test]
1730 fn attach_reasoning_content_does_not_modify_when_none() {
1731 let hooks = crate::hooks::HookRegistry::new();
1732 let mut core = make_test_core(vec![Message::assistant("response".to_string())], &hooks);
1733
1734 core.attach_reasoning_content(None);
1735
1736 assert!(
1737 core.messages.last().unwrap().reasoning_content.is_none(),
1738 "reasoning_content should remain None when called with None"
1739 );
1740 }
1741
1742 #[test]
1743 fn attach_reasoning_content_preserves_existing_content_when_none() {
1744 let hooks = crate::hooks::HookRegistry::new();
1745 let mut msg = Message::assistant("response".to_string());
1746 msg.reasoning_content = Some("prior thinking".to_string());
1747 let mut core = make_test_core(vec![msg], &hooks);
1748
1749 core.attach_reasoning_content(None);
1750
1751 assert_eq!(
1752 core.messages.last().unwrap().reasoning_content.as_deref(),
1753 Some("prior thinking"),
1754 "calling with None should not overwrite existing reasoning_content"
1755 );
1756 }
1757
1758 #[test]
1768 fn finish_reason_str_is_nonempty_and_not_xyzzy() {
1769 use crate::agent::FinishReason;
1770 let reason = FinishReason::NoMoreToolCalls;
1771 let s = finish_reason_str(&reason);
1772 assert!(
1773 !s.is_empty(),
1774 "finish_reason_str must not return empty string"
1775 );
1776 assert_ne!(s, "xyzzy", "finish_reason_str must not return 'xyzzy'");
1777 }
1778
1779 #[test]
1780 fn finish_reason_str_matches_display() {
1781 use crate::agent::FinishReason;
1782 let reason = FinishReason::BudgetExceeded;
1783 let s = finish_reason_str(&reason);
1784 assert_eq!(
1785 s,
1786 reason.to_string(),
1787 "finish_reason_str must delegate to Display"
1788 );
1789 }
1790
1791 #[test]
1798 fn estimate_prompt_tokens_divides_total_chars_by_four() {
1799 let messages = vec![
1801 Message::user("abcd".to_string()), Message::assistant("abcdefgh".to_string()), Message::system("abcdefghijkl".to_string()), ];
1805 assert_eq!(
1806 estimate_prompt_tokens(&messages),
1807 6,
1808 "24 total chars / 4 = 6 tokens"
1809 );
1810 }
1811
1812 #[test]
1813 fn estimate_prompt_tokens_truncates_partial_tokens() {
1814 let messages = vec![Message::user("abcdefg".to_string())];
1816 assert_eq!(
1817 estimate_prompt_tokens(&messages),
1818 1,
1819 "integer division must truncate 7/4 to 1"
1820 );
1821 }
1822
1823 #[test]
1824 fn estimate_prompt_tokens_floors_to_one_for_empty_messages() {
1825 let messages: Vec<Message> = vec![];
1828 assert_eq!(
1829 estimate_prompt_tokens(&messages),
1830 1,
1831 "empty transcript must still report at least 1 token"
1832 );
1833 }
1834
1835 #[test]
1836 fn estimate_prompt_tokens_floors_to_one_for_sub_four_chars() {
1837 let messages = vec![Message::user("abc".to_string())];
1839 assert_eq!(
1840 estimate_prompt_tokens(&messages),
1841 1,
1842 "content shorter than 4 chars must floor to 1 token"
1843 );
1844 }
1845
1846 fn make_core_with_llm_and_events<'a>(
1849 messages: Vec<Message>,
1850 llm: Arc<dyn crate::llm::ChatProvider>,
1851 events: tokio::sync::mpsc::UnboundedSender<crate::event::AgentEvent>,
1852 hooks: &'a crate::hooks::HookRegistry,
1853 ) -> RunCore<'a> {
1854 use std::sync::atomic::AtomicBool;
1855 RunCore {
1856 messages: Arc::new(messages),
1857 llm,
1858 tools: Arc::new(crate::tools::ToolRegistry::default()),
1859 max_steps: 1,
1860 max_transcript_chars: None,
1861 events: Some(events),
1862 streaming: false,
1863 compactor: None,
1864 microcompactor: None,
1865 permission_hook: None,
1866 hooks,
1867 total_llm_latency_ms: 0,
1868 exploring_plan_mode: Arc::new(AtomicBool::new(false)),
1869 shutdown_token: None,
1870 mailbox: None,
1871 stuck_window: 3,
1872 stuck_error_rate: 1.0,
1873 turn: 0,
1874 globs_skills: vec![],
1875 prompt_segments: None,
1876 static_breakdown: StaticBreakdownCache::default(),
1877 last_prompt_tokens: 0,
1878 consecutive_compact_failures: 0,
1879 wall_timeout_secs: 0,
1880 wall_start: None,
1881 }
1882 }
1883
1884 #[tokio::test]
1890 async fn dispatch_llm_step_emits_estimated_usage_when_provider_reports_none() {
1891 use crate::event::AgentEvent;
1892 use crate::llm::{Completion, MockProvider, TokenUsage};
1893
1894 let hooks = crate::hooks::HookRegistry::new();
1895 let messages = vec![Message::user("x".repeat(40))];
1897
1898 let completion = Completion {
1901 content: "12345678".to_string(),
1902 tool_calls: vec![],
1903 finish_reason: Some("stop".to_string()),
1904 usage: None,
1905 reasoning_content: Some("abcd".to_string()),
1906 };
1907 let llm = Arc::new(MockProvider::new(vec![completion]));
1908
1909 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<AgentEvent>();
1910 let mut core = make_core_with_llm_and_events(messages, llm, tx, &hooks);
1911
1912 let specs: Vec<crate::llm::ToolSpec> = vec![];
1913 let mut total_usage = TokenUsage::default();
1914 core.dispatch_llm_step(&specs, 0, &mut total_usage)
1915 .await
1916 .expect("dispatch_llm_step should succeed");
1917
1918 assert_eq!(
1920 total_usage.prompt_tokens, 0,
1921 "estimate must not accumulate input into total_usage"
1922 );
1923 assert_eq!(
1924 total_usage.completion_tokens, 0,
1925 "estimate must not accumulate output into total_usage"
1926 );
1927
1928 let mut usage_event = None;
1930 while let Ok(ev) = rx.try_recv() {
1931 if matches!(ev, AgentEvent::Usage { .. }) {
1932 usage_event = Some(ev);
1933 break;
1934 }
1935 }
1936 match usage_event.expect("fallback branch must emit a Usage event") {
1937 AgentEvent::Usage {
1938 input_tokens,
1939 output_tokens,
1940 cache_hit_tokens,
1941 cache_miss_tokens,
1942 step,
1943 } => {
1944 assert_eq!(input_tokens, 10, "input estimate = 40 chars / 4");
1945 assert_eq!(output_tokens, 3, "output estimate = (8 + 4) chars / 4");
1946 assert_eq!(cache_hit_tokens, 0, "estimate reports no cache hits");
1947 assert_eq!(cache_miss_tokens, 0, "estimate reports no cache misses");
1948 assert_eq!(step, 0, "step must be forwarded unchanged");
1949 }
1950 other => panic!("expected Usage event, got {other:?}"),
1951 }
1952 }
1953
1954 #[test]
1959 fn maybe_trim_does_nothing_when_under_limit() {
1960 let hooks = crate::hooks::HookRegistry::new();
1961 let large_output = "x".repeat(MIN_TRIM_LENGTH + 10);
1965 let messages = vec![
1966 Message::system("sys".to_string()),
1967 Message::tool_result("c1", &large_output),
1968 ];
1969 let orig_content = messages[1].content.clone();
1970 let total_bytes = "sys".len() + large_output.len();
1971 let mut core = make_test_core(messages, &hooks);
1972
1973 let limit = total_bytes + 1;
1975 core.maybe_trim_transcript(limit, 0);
1976
1977 assert_eq!(
1978 core.messages[1].content, orig_content,
1979 "content must not be touched when total bytes < limit"
1980 );
1981 }
1982
1983 #[test]
1984 fn enforce_transcript_budget_returns_none_when_under_limit() {
1985 use crate::llm::TokenUsage;
1989 let hooks = crate::hooks::HookRegistry::new();
1990 let mut core = make_test_core(vec![Message::user("hi".to_string())], &hooks);
1992 core.max_transcript_chars = Some(1000);
1994 let usage = TokenUsage::default();
1995 let result = core.enforce_transcript_budget(0, &usage);
1996 assert!(
1997 result.is_none(),
1998 "chars (2) < limit (1000) must return None; got Some(_)"
1999 );
2000 }
2001
2002 #[test]
2003 fn enforce_transcript_budget_returns_finish_reason_when_over_limit() {
2004 use crate::agent::FinishReason;
2006 use crate::llm::TokenUsage;
2007 let hooks = crate::hooks::HookRegistry::new();
2008 let msg = Message::user("x".repeat(100));
2010 let mut core = make_test_core(vec![msg], &hooks);
2011 core.max_transcript_chars = Some(10);
2012 let usage = TokenUsage::default();
2013 let result = core.enforce_transcript_budget(0, &usage);
2014 assert!(
2015 result.is_some(),
2016 "chars (100) >= limit (10) must return Some finish reason"
2017 );
2018 let (finish, _step) = result.unwrap();
2019 assert!(
2020 matches!(finish, FinishReason::TranscriptLimit { .. }),
2021 "finish reason must be TranscriptLimit; got {finish:?}"
2022 );
2023 }
2024
2025 #[test]
2026 fn maybe_trim_replaces_large_tool_result_when_over_limit() {
2027 let hooks = crate::hooks::HookRegistry::new();
2028 let large_output = "x".repeat(MIN_TRIM_LENGTH + 100);
2030 let messages = vec![
2031 Message::system("sys".to_string()),
2032 Message::tool_result("c1", &large_output),
2033 ];
2034 let mut core = make_test_core(messages, &hooks);
2035
2036 core.maybe_trim_transcript(10, 0);
2038
2039 assert_eq!(
2040 core.messages[1].content, TRIM_PLACEHOLDER,
2041 "large tool result must be replaced with placeholder"
2042 );
2043 }
2044
2045 #[test]
2046 fn maybe_trim_does_not_trim_small_tool_results() {
2047 let hooks = crate::hooks::HookRegistry::new();
2048 let small_output = "small".to_string();
2050 let messages = vec![
2051 Message::system("sys".to_string()),
2052 Message::tool_result("c1", &small_output),
2053 ];
2054 let mut core = make_test_core(messages, &hooks);
2055
2056 core.maybe_trim_transcript(0, 0);
2058
2059 assert_eq!(
2060 core.messages[1].content, small_output,
2061 "short tool result must not be trimmed (< MIN_TRIM_LENGTH)"
2062 );
2063 }
2064
2065 #[test]
2066 fn maybe_trim_does_not_trim_exactly_min_trim_length() {
2067 let hooks = crate::hooks::HookRegistry::new();
2070 let exact_output = "z".repeat(MIN_TRIM_LENGTH);
2071 let messages = vec![
2072 Message::system("s".to_string()),
2073 Message::tool_result("c1", &exact_output),
2074 ];
2075 let mut core = make_test_core(messages, &hooks);
2076 core.maybe_trim_transcript(0, 0);
2078 assert_eq!(
2079 core.messages[1].content, exact_output,
2080 "content exactly at MIN_TRIM_LENGTH must NOT be trimmed (> is strict)"
2081 );
2082 }
2083
2084 #[test]
2085 fn maybe_trim_trims_one_more_than_min_trim_length() {
2086 let hooks = crate::hooks::HookRegistry::new();
2088 let just_over = "z".repeat(MIN_TRIM_LENGTH + 1);
2089 let messages = vec![
2090 Message::system("s".to_string()),
2091 Message::tool_result("c1", &just_over),
2092 ];
2093 let mut core = make_test_core(messages, &hooks);
2094 core.maybe_trim_transcript(0, 0);
2095 assert_eq!(
2096 core.messages[1].content, TRIM_PLACEHOLDER,
2097 "content one byte over MIN_TRIM_LENGTH must be trimmed"
2098 );
2099 }
2100
2101 #[test]
2102 fn maybe_trim_stops_after_fitting_under_limit() {
2103 let hooks = crate::hooks::HookRegistry::new();
2104 let large1 = "a".repeat(MIN_TRIM_LENGTH + 100);
2106 let large2 = "b".repeat(MIN_TRIM_LENGTH + 100);
2107 let messages = vec![
2108 Message::system("sys".to_string()),
2109 Message::tool_result("c1", &large1),
2110 Message::tool_result("c2", &large2),
2111 ];
2112 let mut core = make_test_core(messages, &hooks);
2113
2114 let placeholder_len = TRIM_PLACEHOLDER.len();
2116 let limit = 3 + placeholder_len + large2.len() + 50; core.maybe_trim_transcript(limit, 0);
2118
2119 assert_eq!(
2120 core.messages[1].content, TRIM_PLACEHOLDER,
2121 "first large result should be trimmed"
2122 );
2123 assert_eq!(
2124 core.messages[2].content, large2,
2125 "second result should not be touched once we're under limit"
2126 );
2127 }
2128
2129 #[test]
2130 fn maybe_trim_trims_when_chars_exactly_equals_limit() {
2131 let hooks = crate::hooks::HookRegistry::new();
2136 let tool_content = "a".repeat(MIN_TRIM_LENGTH + 50); let messages = vec![
2139 Message::system("s".to_string()), Message::tool_result("c1", &tool_content), ];
2142 let limit = 1 + tool_content.len();
2144 let mut core = make_test_core(messages, &hooks);
2145 core.maybe_trim_transcript(limit, 0);
2146
2147 assert_eq!(
2148 core.messages[1].content, TRIM_PLACEHOLDER,
2149 "tool result must be trimmed when chars == limit (not strictly less)"
2150 );
2151 }
2152
2153 #[test]
2154 fn maybe_trim_continues_when_chars_equals_limit_after_first_trim() {
2155 let hooks = crate::hooks::HookRegistry::new();
2161
2162 let tool_content = "a".repeat(MIN_TRIM_LENGTH + 50); let messages = vec![
2164 Message::system("s".to_string()), Message::tool_result("c1", &tool_content), Message::tool_result("c2", &tool_content), ];
2168 let after_first_trim = 1 + TRIM_PLACEHOLDER.len() + tool_content.len();
2175 let limit = after_first_trim; let mut core = make_test_core(messages, &hooks);
2178 core.maybe_trim_transcript(limit, 0);
2179
2180 assert_eq!(
2181 core.messages[1].content, TRIM_PLACEHOLDER,
2182 "first tool result must be trimmed"
2183 );
2184 assert_eq!(
2185 core.messages[2].content, TRIM_PLACEHOLDER,
2186 "second tool result must ALSO be trimmed when chars == limit after first trim"
2187 );
2188 }
2189
2190 #[test]
2191 fn maybe_trim_emits_event_when_trimmed() {
2192 use crate::event::AgentEvent;
2195 use tokio::sync::mpsc;
2196
2197 let hooks = crate::hooks::HookRegistry::new();
2198 let large_output = "x".repeat(MIN_TRIM_LENGTH + 50);
2199 let messages = vec![
2200 Message::system("sys".to_string()),
2201 Message::tool_result("c1", &large_output),
2202 ];
2203 let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
2204 let mut core = make_test_core(messages, &hooks);
2205 core.events = Some(tx);
2206
2207 core.maybe_trim_transcript(0, 0);
2209
2210 let event = rx
2212 .try_recv()
2213 .expect("an event must have been emitted after trimming");
2214 match event {
2215 AgentEvent::AssistantText { text, .. } => {
2216 assert!(
2217 text.contains("trimmed"),
2218 "trim event text must mention 'trimmed'; got: {text}"
2219 );
2220 }
2221 other => panic!("expected AssistantText but got: {other:?}"),
2222 }
2223 }
2224
2225 #[test]
2226 fn maybe_trim_no_event_when_nothing_trimmed() {
2227 use crate::event::AgentEvent;
2229 use tokio::sync::mpsc;
2230
2231 let hooks = crate::hooks::HookRegistry::new();
2232 let small_output = "tiny".to_string(); let messages = vec![
2234 Message::system("sys".to_string()),
2235 Message::tool_result("c1", &small_output),
2236 ];
2237 let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
2238 let mut core = make_test_core(messages, &hooks);
2239 core.events = Some(tx);
2240
2241 core.maybe_trim_transcript(0, 0);
2243
2244 assert!(
2245 rx.try_recv().is_err(),
2246 "no event must be emitted when nothing was trimmed"
2247 );
2248 }
2249
2250 #[tokio::test]
2255 async fn maybe_compact_noop_when_no_compactor() {
2256 let hooks = crate::hooks::HookRegistry::new();
2257 let messages = vec![
2258 Message::user("q".to_string()),
2259 Message::assistant("a".to_string()),
2260 ];
2261 let mut core = make_test_core(messages, &hooks);
2262 core.maybe_compact(0).await;
2264 assert_eq!(core.messages.len(), 2, "messages must not change");
2265 }
2266
2267 #[tokio::test]
2268 async fn maybe_compact_noop_when_under_threshold() {
2269 use crate::compact::Compactor;
2270 let hooks = crate::hooks::HookRegistry::new();
2271 let messages = vec![
2272 Message::user("hi".to_string()),
2273 Message::assistant("hello".to_string()),
2274 ];
2275 let mut core = make_test_core(messages, &hooks);
2276 core.compactor = Some(Compactor::new(usize::MAX));
2278
2279 core.maybe_compact(0).await;
2280 assert_eq!(core.messages.len(), 2, "no compaction under threshold");
2281 }
2282
2283 #[tokio::test]
2284 async fn maybe_compact_fires_when_over_threshold() {
2285 use crate::compact::Compactor;
2286 use crate::llm::{Completion, MockProvider};
2287 let hooks = crate::hooks::HookRegistry::new();
2288 let messages = vec![
2290 Message::system("sys".to_string()),
2291 Message::user("msg1".to_string()),
2292 Message::assistant("rep1".to_string()),
2293 Message::user("msg2".to_string()),
2294 Message::assistant("rep2".to_string()),
2295 Message::user("msg3".to_string()),
2296 ];
2297 let provider = Arc::new(MockProvider::new(vec![Completion {
2298 content: "compact summary".to_string(),
2299 tool_calls: vec![],
2300 finish_reason: Some("stop".to_string()),
2301 usage: None,
2302 reasoning_content: None,
2303 }]));
2304 let mut core = make_test_core(messages, &hooks);
2305 core.llm = provider;
2306 core.compactor = Some(Compactor::new(0).keep_recent_n(2));
2308
2309 core.maybe_compact(1).await;
2310 assert!(
2312 core.messages[0].is_compaction_summary,
2313 "first message must be a compaction summary after compaction"
2314 );
2315 }
2316
2317 #[tokio::test]
2318 async fn maybe_compact_fires_at_threshold_boundary() {
2319 use crate::compact::Compactor;
2322 use crate::llm::{Completion, MockProvider};
2323 let hooks = crate::hooks::HookRegistry::new();
2324 let messages = vec![
2325 Message::system("sys".to_string()),
2326 Message::user("msg1".to_string()),
2327 Message::assistant("rep1".to_string()),
2328 Message::user("msg2".to_string()),
2329 Message::assistant("rep2".to_string()),
2330 Message::user("last".to_string()),
2331 ];
2332 let bytes = crate::compact::Compactor::estimate_bytes(&messages);
2334 let provider = Arc::new(MockProvider::new(vec![Completion {
2335 content: "summary".to_string(),
2336 tool_calls: vec![],
2337 finish_reason: Some("stop".to_string()),
2338 usage: None,
2339 reasoning_content: None,
2340 }]));
2341 let mut core = make_test_core(messages, &hooks);
2342 core.llm = provider;
2343 core.compactor = Some(Compactor::new(bytes).keep_recent_n(2));
2346
2347 core.maybe_compact(1).await;
2348 assert!(
2349 core.messages[0].is_compaction_summary,
2350 "compaction must fire when bytes == threshold (bytes is NOT less than threshold)"
2351 );
2352 }
2353
2354 #[tokio::test]
2355 async fn maybe_compact_uses_token_threshold_intra_turn() {
2356 use crate::compact::Compactor;
2361 use crate::llm::{Completion, MockProvider};
2362 let hooks = crate::hooks::HookRegistry::new();
2363 let messages = vec![
2364 Message::system("sys".to_string()),
2365 Message::user("msg1".to_string()),
2366 Message::assistant("rep1".to_string()),
2367 Message::user("msg2".to_string()),
2368 Message::assistant("rep2".to_string()),
2369 Message::user("msg3".to_string()),
2370 ];
2371 let provider = Arc::new(MockProvider::new(vec![Completion {
2372 content: "token-threshold triggered summary".to_string(),
2373 tool_calls: vec![],
2374 finish_reason: Some("stop".to_string()),
2375 usage: None,
2376 reasoning_content: None,
2377 }]));
2378 let mut core = make_test_core(messages, &hooks);
2379 core.llm = provider;
2380 let bytes = crate::compact::Compactor::estimate_bytes(&core.messages);
2383 core.compactor = Some(
2384 Compactor::new(bytes + 1000) .threshold_prompt_tokens(500) .keep_recent_n(2),
2387 );
2388 core.last_prompt_tokens = 500;
2390
2391 core.maybe_compact(1).await;
2392 assert!(
2393 core.messages[0].is_compaction_summary,
2394 "compaction must fire via token threshold even though chars are below char threshold"
2395 );
2396 }
2397
2398 #[tokio::test]
2409 async fn execute_tool_calls_plan_mode_guard_when_not_exploring() {
2410 use crate::llm::ToolCall;
2411 use crate::permissions::{PermissionMode, PermissionsConfig};
2412 use std::sync::atomic::AtomicBool;
2413
2414 let hooks = crate::hooks::HookRegistry::new();
2415 let tools = Arc::new(crate::tools::ToolRegistry::default().with_permissions(
2416 PermissionsConfig {
2417 mode: PermissionMode::Plan {
2418 pre_plan_mode: Box::new(PermissionMode::Default),
2419 bypass_available: false,
2420 },
2421 layers: vec![],
2422 },
2423 ));
2424
2425 let mut core = make_test_core(vec![], &hooks);
2426 core.tools = tools;
2427 core.exploring_plan_mode = Arc::new(AtomicBool::new(false));
2429
2430 let call = ToolCall {
2431 id: "tc1".to_string(),
2432 name: "Write".to_string(),
2433 arguments: serde_json::json!({}),
2434 };
2435
2436 let results = core.execute_tool_calls(&[call]).await;
2437
2438 assert_eq!(results.len(), 1, "should produce exactly one outcome");
2439 assert!(
2440 results[0].result.contains("enter_plan_mode"),
2441 "must tell the agent to call enter_plan_mode first; got: {}",
2442 results[0].result,
2443 );
2444 }
2445
2446 #[tokio::test]
2447 async fn maybe_compact_kept_count_is_correct() {
2448 use crate::compact::Compactor;
2452 use crate::event::AgentEvent;
2453 use crate::llm::{Completion, MockProvider};
2454 use tokio::sync::mpsc;
2455
2456 let hooks = crate::hooks::HookRegistry::new();
2457 let messages = vec![
2458 Message::system("sys".to_string()),
2459 Message::user("msg1".to_string()),
2460 Message::assistant("rep1".to_string()),
2461 Message::user("msg2".to_string()),
2462 Message::assistant("rep2".to_string()),
2463 Message::user("last".to_string()),
2464 ];
2465 let kept_before = messages.len();
2466 let provider = Arc::new(MockProvider::new(vec![Completion {
2467 content: "summary".to_string(),
2468 tool_calls: vec![],
2469 finish_reason: Some("stop".to_string()),
2470 usage: None,
2471 reasoning_content: None,
2472 }]));
2473 let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
2474 let mut core = make_test_core(messages, &hooks);
2475 core.llm = provider;
2476 core.events = Some(tx);
2477 core.compactor = Some(Compactor::new(0).keep_recent_n(2));
2478
2479 core.maybe_compact(1).await;
2480
2481 let mut found_compacted = false;
2483 while let Ok(ev) = rx.try_recv() {
2484 if let AgentEvent::Compacted { removed, kept, .. } = ev {
2485 assert_eq!(
2486 kept + removed,
2487 kept_before,
2488 "kept + removed must equal the original message count"
2489 );
2490 assert_eq!(
2492 kept,
2493 kept_before - removed,
2494 "kept must be kept_before - removed, not kept_before + removed"
2495 );
2496 found_compacted = true;
2497 }
2498 }
2499 assert!(found_compacted, "a Compacted event must have been emitted");
2500 }
2501
2502 #[tokio::test]
2507 async fn maybe_compact_circuit_breaker_skips_when_failures_exceed_threshold() {
2508 use crate::compact::{Compactor, MAX_CONSECUTIVE_COMPACT_FAILURES};
2511 use crate::event::AgentEvent::{Compacted, CompactionSkipped};
2512 use crate::event::CompactionSkipReason;
2513 use tokio::sync::mpsc;
2514
2515 let hooks = crate::hooks::HookRegistry::new();
2516 let messages = vec![
2517 Message::system("sys".to_string()),
2518 Message::user("long enough to trigger compaction".to_string()),
2519 Message::assistant("response".to_string()),
2520 ];
2521 let (tx, mut rx) = mpsc::unbounded_channel();
2522 let mut core = make_test_core(messages, &hooks);
2523 core.events = Some(tx);
2524 core.compactor = Some(Compactor::new(0)); core.consecutive_compact_failures = MAX_CONSECUTIVE_COMPACT_FAILURES;
2526
2527 core.maybe_compact(0).await;
2528
2529 let mut found_breaker = false;
2531 while let Ok(ev) = rx.try_recv() {
2532 match ev {
2533 Compacted { .. } => panic!("circuit breaker should prevent compaction"),
2534 CompactionSkipped { reason, .. } => {
2535 assert_eq!(reason, CompactionSkipReason::CircuitBreaker);
2536 found_breaker = true;
2537 }
2538 _ => {}
2539 }
2540 }
2541 assert!(found_breaker, "must emit CircuitBreaker skip event");
2542 assert_eq!(
2543 core.messages.len(),
2544 3,
2545 "must not compact when breaker is tripped"
2546 );
2547 }
2548
2549 #[tokio::test]
2550 async fn maybe_compact_circuit_breaker_resets_on_success() {
2551 use crate::compact::Compactor;
2553 use crate::llm::{Completion, MockProvider};
2554
2555 let hooks = crate::hooks::HookRegistry::new();
2556 let messages = vec![
2557 Message::system("sys".to_string()),
2558 Message::user("msg1".to_string()),
2559 Message::assistant("rep1".to_string()),
2560 Message::user("msg2".to_string()),
2561 Message::assistant("rep2".to_string()),
2562 ];
2563 let provider = Arc::new(MockProvider::new(vec![Completion {
2564 content: "summary".to_string(),
2565 tool_calls: vec![],
2566 finish_reason: Some("stop".to_string()),
2567 usage: None,
2568 reasoning_content: None,
2569 }]));
2570 let mut core = make_test_core(messages, &hooks);
2571 core.llm = provider;
2572 core.compactor = Some(Compactor::new(0).keep_recent_n(2));
2573 core.consecutive_compact_failures = 2; core.maybe_compact(1).await;
2576
2577 assert_eq!(
2578 core.consecutive_compact_failures, 0,
2579 "breaker must reset on success"
2580 );
2581 }
2582
2583 #[tokio::test]
2584 async fn maybe_compact_circuit_breaker_increments_on_error() {
2585 use crate::compact::Compactor;
2587 use crate::error::Error;
2588 use crate::event::AgentEvent::CompactionSkipped;
2589 use crate::event::CompactionSkipReason;
2590 use crate::llm::MockProvider;
2591 use tokio::sync::mpsc;
2592
2593 let hooks = crate::hooks::HookRegistry::new();
2594 let messages = vec![
2596 Message::system("sys".to_string()),
2597 Message::user("msg1".to_string()),
2598 Message::assistant("rep1".to_string()),
2599 Message::user("msg2".to_string()),
2600 Message::assistant("rep2".to_string()),
2601 ];
2602 let provider = Arc::new(MockProvider::new(vec![]).with_errors(vec![Error::Llm {
2604 provider: "mock".into(),
2605 message: "simulated compaction failure".into(),
2606 }]));
2607 let (tx, mut rx) = mpsc::unbounded_channel();
2608 let mut core = make_test_core(messages, &hooks);
2609 core.llm = provider;
2610 core.events = Some(tx);
2611 core.compactor = Some(Compactor::new(0).keep_recent_n(2));
2612 core.consecutive_compact_failures = 0;
2613
2614 core.maybe_compact(1).await;
2615
2616 assert_eq!(
2617 core.consecutive_compact_failures, 1,
2618 "breaker must increment on error"
2619 );
2620 let mut found_error = false;
2622 while let Ok(ev) = rx.try_recv() {
2623 if let CompactionSkipped { reason, .. } = ev {
2624 assert_eq!(reason, CompactionSkipReason::Error);
2625 found_error = true;
2626 }
2627 }
2628 assert!(found_error, "must emit CompactionSkipped::Error event");
2629 }
2630
2631 #[tokio::test]
2632 async fn maybe_compact_circuit_breaker_accumulates_to_threshold() {
2633 use crate::compact::{Compactor, MAX_CONSECUTIVE_COMPACT_FAILURES};
2635 use crate::error::Error;
2636 use crate::event::AgentEvent::{Compacted, CompactionSkipped};
2637 use crate::event::CompactionSkipReason;
2638 use crate::llm::MockProvider;
2639 use tokio::sync::mpsc;
2640
2641 let hooks = crate::hooks::HookRegistry::new();
2642 let messages = vec![
2644 Message::system("sys".to_string()),
2645 Message::user("msg1".to_string()),
2646 Message::assistant("rep1".to_string()),
2647 Message::user("msg2".to_string()),
2648 Message::assistant("rep2".to_string()),
2649 ];
2650 let mk_err = || Error::Llm {
2652 provider: "mock".into(),
2653 message: "simulated compaction failure".into(),
2654 };
2655 let provider = Arc::new(MockProvider::new(vec![]).with_errors(vec![
2656 mk_err(),
2657 mk_err(),
2658 mk_err(),
2659 mk_err(),
2660 ]));
2661 let (tx, mut rx) = mpsc::unbounded_channel();
2662 let mut core = make_test_core(messages, &hooks);
2663 core.llm = provider.clone();
2664 core.events = Some(tx);
2665 core.compactor = Some(Compactor::new(0).keep_recent_n(2));
2666 core.consecutive_compact_failures = 0;
2667
2668 for step in 0..(MAX_CONSECUTIVE_COMPACT_FAILURES - 1) as usize {
2670 core.maybe_compact(step).await;
2671 }
2672 assert_eq!(
2673 core.consecutive_compact_failures,
2674 MAX_CONSECUTIVE_COMPACT_FAILURES - 1,
2675 "after {} failures, counter should be {}",
2676 MAX_CONSECUTIVE_COMPACT_FAILURES - 1,
2677 MAX_CONSECUTIVE_COMPACT_FAILURES - 1
2678 );
2679
2680 while rx.try_recv().is_ok() {}
2683 core.maybe_compact((MAX_CONSECUTIVE_COMPACT_FAILURES - 1) as usize)
2684 .await;
2685
2686 assert_eq!(
2688 core.consecutive_compact_failures, MAX_CONSECUTIVE_COMPACT_FAILURES,
2689 "counter must not exceed threshold"
2690 );
2691
2692 let (tx2, mut rx2) = mpsc::unbounded_channel();
2694 core.events = Some(tx2);
2695 core.maybe_compact(MAX_CONSECUTIVE_COMPACT_FAILURES as usize)
2696 .await;
2697
2698 assert_eq!(
2699 core.consecutive_compact_failures, MAX_CONSECUTIVE_COMPACT_FAILURES,
2700 "breaker tripped: counter should stay at max"
2701 );
2702
2703 let mut breaker_count = 0;
2705 while let Ok(ev) = rx2.try_recv() {
2706 if let CompactionSkipped { ref reason, .. } = ev {
2707 assert_eq!(*reason, CompactionSkipReason::CircuitBreaker);
2708 breaker_count += 1;
2709 }
2710 if let Compacted { .. } = ev {
2711 panic!("circuit breaker should prevent compaction once tripped");
2712 }
2713 }
2714 assert!(
2715 breaker_count >= 1,
2716 "must emit CircuitBreaker event when tripped"
2717 );
2718 }
2719
2720 fn make_run_core_for_inner<'a>(
2731 messages: Vec<Message>,
2732 hooks: &'a crate::hooks::HookRegistry,
2733 provider: Arc<crate::llm::MockProvider>,
2734 max_steps: usize,
2735 ) -> RunCore<'a> {
2736 use std::sync::atomic::AtomicBool;
2737 RunCore {
2738 messages: Arc::new(messages),
2739 llm: provider,
2740 tools: Arc::new(crate::tools::ToolRegistry::default()),
2741 max_steps,
2742 max_transcript_chars: None,
2743 events: None,
2744 streaming: false,
2745 compactor: None,
2746 microcompactor: None,
2747 permission_hook: None,
2748 hooks,
2749 total_llm_latency_ms: 0,
2750 exploring_plan_mode: Arc::new(AtomicBool::new(false)),
2751 shutdown_token: None,
2752 mailbox: None,
2753 stuck_window: 3,
2754 stuck_error_rate: 1.0,
2755 turn: 0,
2756 globs_skills: vec![],
2757 prompt_segments: None,
2758 static_breakdown: StaticBreakdownCache::default(),
2759 last_prompt_tokens: 0,
2760 consecutive_compact_failures: 0,
2761 wall_timeout_secs: 0,
2762 wall_start: None,
2763 }
2764 }
2765
2766 #[tokio::test]
2773 async fn run_inner_returns_transcript_limit_when_chars_at_or_above_limit() {
2774 use crate::agent::FinishReason;
2775
2776 let hooks = crate::hooks::HookRegistry::new();
2777 let messages = vec![Message::system("hello".to_string())];
2779 let provider = Arc::new(crate::llm::MockProvider::new(vec![]));
2780 let mut core = make_run_core_for_inner(messages, &hooks, provider, 1);
2781 core.max_transcript_chars = Some(3); let outcome = core.run_inner().await.expect("run_inner must not error");
2784 assert!(
2785 matches!(outcome.finish_reason, FinishReason::TranscriptLimit { .. }),
2786 "expected TranscriptLimit, got {:?}",
2787 outcome.finish_reason,
2788 );
2789 }
2790
2791 #[tokio::test]
2798 async fn run_inner_emits_reasoning_event_when_reasoning_nonempty() {
2799 use crate::event::AgentEvent;
2800 use crate::llm::Completion;
2801 use tokio::sync::mpsc;
2802
2803 let hooks = crate::hooks::HookRegistry::new();
2804 let provider = Arc::new(crate::llm::MockProvider::new(vec![Completion {
2805 content: "done".to_string(),
2806 tool_calls: vec![],
2807 finish_reason: Some("stop".to_string()),
2808 usage: None,
2809 reasoning_content: Some("I thought about it".to_string()),
2810 }]));
2811 let messages = vec![Message::user("hello".to_string())];
2812 let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
2813 let mut core = make_run_core_for_inner(messages, &hooks, provider, 1);
2814 core.events = Some(tx);
2815
2816 let outcome = core.run_inner().await.expect("run_inner must not error");
2817 drop(outcome); let events: Vec<AgentEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
2820 assert!(
2821 events.iter().any(|e| matches!(e, AgentEvent::Reasoning { .. })),
2822 "AgentEvent::Reasoning must be emitted when reasoning_content is non-empty; got: {events:?}",
2823 );
2824 }
2825
2826 #[tokio::test]
2834 async fn run_inner_provider_stop_for_nonstandard_finish_reason() {
2835 use crate::agent::FinishReason;
2836 use crate::llm::Completion;
2837
2838 let hooks = crate::hooks::HookRegistry::new();
2839 let provider = Arc::new(crate::llm::MockProvider::new(vec![Completion {
2840 content: "provider paused".to_string(),
2841 tool_calls: vec![],
2842 finish_reason: Some("rate_limited".to_string()),
2843 usage: None,
2844 reasoning_content: None,
2845 }]));
2846 let messages = vec![Message::user("hello".to_string())];
2847 let core = make_run_core_for_inner(messages, &hooks, provider, 1);
2848
2849 let outcome = core.run_inner().await.expect("run_inner must not error");
2850 assert!(
2851 matches!(
2852 &outcome.finish_reason,
2853 FinishReason::ProviderStop(r) if r == "rate_limited"
2854 ),
2855 "expected ProviderStop(\"rate_limited\"), got {:?}",
2856 outcome.finish_reason,
2857 );
2858 }
2859
2860 #[tokio::test]
2863 async fn run_inner_no_provider_stop_for_stop_finish_reason() {
2864 use crate::agent::FinishReason;
2865 use crate::llm::Completion;
2866
2867 let hooks = crate::hooks::HookRegistry::new();
2868 let provider = Arc::new(crate::llm::MockProvider::new(vec![Completion {
2869 content: "all done".to_string(),
2870 tool_calls: vec![],
2871 finish_reason: Some("stop".to_string()),
2872 usage: None,
2873 reasoning_content: None,
2874 }]));
2875 let messages = vec![Message::user("hello".to_string())];
2876 let core = make_run_core_for_inner(messages, &hooks, provider, 1);
2877
2878 let outcome = core.run_inner().await.expect("run_inner must not error");
2879 assert!(
2880 matches!(outcome.finish_reason, FinishReason::NoMoreToolCalls),
2881 "expected NoMoreToolCalls, got {:?}",
2882 outcome.finish_reason,
2883 );
2884 }
2885
2886 #[tokio::test]
2896 async fn cancelled_mid_stream_persists_transcript_and_finish_reason() {
2897 use crate::agent::FinishReason;
2898 use crate::event::AgentEvent;
2899 use tokio::sync::mpsc;
2900
2901 let hooks = crate::hooks::HookRegistry::new();
2902 let provider = Arc::new(
2907 crate::llm::MockProvider::new(vec![]).with_errors(vec![crate::error::Error::Cancelled]),
2908 );
2909 let messages = vec![Message::user("hello".to_string())];
2910 let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
2911 let mut core = make_run_core_for_inner(messages, &hooks, provider, 3);
2912 core.events = Some(tx);
2913 core.shutdown_token = Some(tokio_util::sync::CancellationToken::new());
2916
2917 let outcome = core.run_inner().await.expect("run_inner must not error");
2918 assert!(
2919 matches!(outcome.finish_reason, FinishReason::Cancelled),
2920 "expected FinishReason::Cancelled, got {:?}",
2921 outcome.finish_reason,
2922 );
2923 assert_eq!(
2928 outcome.steps, 1,
2929 "mid-stream cancel counts the in-flight step (not step - 1)",
2930 );
2931
2932 assert_eq!(outcome.messages.len(), 1, "transcript must be preserved");
2934 assert_eq!(
2935 outcome.messages[0].role,
2936 crate::message::Role::User,
2937 "seeded user message must be in the outcome transcript",
2938 );
2939
2940 let events: Vec<AgentEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
2941 assert!(
2942 events.iter().any(|e| matches!(
2943 e,
2944 AgentEvent::TurnFinished { reason, steps }
2945 if reason.as_str() == "cancelled" && *steps == 1
2946 )),
2947 "TurnFinished(cancelled) must be emitted with the in-flight step; got: {events:?}",
2948 );
2949 }
2950
2951 #[tokio::test]
2960 async fn interrupted_completion_persists_partial_content_as_cancelled() {
2961 use crate::agent::FinishReason;
2962 use crate::llm::Completion;
2963 use crate::message::Role;
2964
2965 let hooks = crate::hooks::HookRegistry::new();
2966 let provider = Arc::new(crate::llm::MockProvider::new(vec![Completion {
2967 content: "partial reply…".to_string(),
2968 tool_calls: vec![],
2969 finish_reason: Some("interrupted".to_string()),
2970 usage: None,
2971 reasoning_content: Some("half a thought".to_string()),
2972 }]));
2973 let messages = vec![Message::user("hello".to_string())];
2974 let core = make_run_core_for_inner(messages, &hooks, provider, 3);
2975
2976 let outcome = core.run_inner().await.expect("run_inner must not error");
2977 assert!(
2978 matches!(outcome.finish_reason, FinishReason::Cancelled),
2979 "interrupted completion must finish as Cancelled, got {:?}",
2980 outcome.finish_reason,
2981 );
2982
2983 assert_eq!(
2986 outcome.final_message.as_deref(),
2987 Some("partial reply…"),
2988 "the partial reply must become the final_message",
2989 );
2990
2991 let assistant_msgs: Vec<&Message> = outcome
2995 .messages
2996 .iter()
2997 .filter(|m| m.role == Role::Assistant)
2998 .collect();
2999 assert_eq!(
3000 assistant_msgs.len(),
3001 1,
3002 "exactly one partial assistant message must be pushed",
3003 );
3004 assert_eq!(assistant_msgs[0].content, "partial reply…");
3005 assert_eq!(
3006 assistant_msgs[0].reasoning_content.as_deref(),
3007 Some("half a thought"),
3008 "reasoning must be attached to the partial assistant message",
3009 );
3010 }
3011
3012 #[tokio::test]
3019 async fn cancelled_between_steps_uses_check_shutdown_path() {
3020 use crate::agent::FinishReason;
3021
3022 let hooks = crate::hooks::HookRegistry::new();
3023 let provider = Arc::new(crate::llm::MockProvider::new(vec![]));
3027 let messages = vec![Message::user("hello".to_string())];
3028 let mut core = make_run_core_for_inner(messages, &hooks, provider, 3);
3029 core.shutdown_token = Some(tokio_util::sync::CancellationToken::new());
3030 core.shutdown_token
3032 .as_ref()
3033 .expect("token just set")
3034 .cancel();
3035
3036 let outcome = core.run_inner().await.expect("run_inner must not error");
3037 assert!(
3038 matches!(outcome.finish_reason, FinishReason::Cancelled),
3039 "expected FinishReason::Cancelled, got {:?}",
3040 outcome.finish_reason,
3041 );
3042 assert_eq!(
3043 outcome.steps, 0,
3044 "between-steps cancel happens before any LLM call → 0 steps",
3045 );
3046 }
3047
3048 #[tokio::test]
3055 async fn non_cancelled_error_still_bubbles() {
3056 let hooks = crate::hooks::HookRegistry::new();
3057 let provider = Arc::new(crate::llm::MockProvider::new(vec![]).with_errors(vec![
3061 crate::error::Error::Llm {
3062 provider: "mock".to_string(),
3063 message: "injected non-cancelled error".to_string(),
3064 },
3065 ]));
3066 let messages = vec![Message::user("hello".to_string())];
3067 let core = make_run_core_for_inner(messages, &hooks, provider, 1);
3068
3069 let err = match core.run_inner().await {
3070 Ok(_) => panic!("run_inner must error for non-Cancelled provider errors"),
3071 Err(e) => e,
3072 };
3073 assert!(
3074 matches!(err, crate::error::Error::Llm { .. }),
3075 "expected Error::Llm, got {err:?}",
3076 );
3077 }
3078
3079 #[tokio::test]
3093 async fn run_inner_stuck_detection_fires_after_window_of_errors() {
3094 use crate::agent::FinishReason;
3095 use crate::llm::{Completion, ToolCall};
3096
3097 let hooks = crate::hooks::HookRegistry::new();
3098 let make_tc_completion = |id: &str| Completion {
3100 content: String::new(),
3101 tool_calls: vec![ToolCall {
3102 id: id.to_string(),
3103 name: "NonExistentTool".to_string(),
3104 arguments: serde_json::json!({}),
3105 }],
3106 finish_reason: None,
3107 usage: None,
3108 reasoning_content: None,
3109 };
3110 let provider = Arc::new(crate::llm::MockProvider::new(vec![
3111 make_tc_completion("tc1"),
3112 make_tc_completion("tc2"),
3113 make_tc_completion("tc3"),
3114 ]));
3115 let messages = vec![Message::user("keep trying".to_string())];
3116 let mut core = make_run_core_for_inner(messages, &hooks, provider, 5);
3117 core.stuck_window = 3;
3118 core.stuck_error_rate = 1.0;
3119
3120 let outcome = core.run_inner().await.expect("run_inner must not error");
3121 assert!(
3122 matches!(outcome.finish_reason, FinishReason::Stuck { .. }),
3123 "expected Stuck after 3 consecutive errors, got {:?}",
3124 outcome.finish_reason,
3125 );
3126 }
3127
3128 #[tokio::test]
3139 async fn run_inner_stuck_fires_only_after_window_full() {
3140 use crate::agent::FinishReason;
3141 use crate::llm::{Completion, ToolCall};
3142
3143 let hooks = crate::hooks::HookRegistry::new();
3144 let make_tc = |id: &str| Completion {
3145 content: String::new(),
3146 tool_calls: vec![ToolCall {
3147 id: id.to_string(),
3148 name: "NonExistentTool".to_string(),
3149 arguments: serde_json::json!({}),
3150 }],
3151 finish_reason: None,
3152 usage: None,
3153 reasoning_content: None,
3154 };
3155 let provider = Arc::new(crate::llm::MockProvider::new(vec![
3157 make_tc("tc1"),
3158 make_tc("tc2"),
3159 ]));
3160 let messages = vec![Message::user("go".to_string())];
3161 let mut core = make_run_core_for_inner(messages, &hooks, provider, 2);
3162 core.stuck_window = 3;
3163 core.stuck_error_rate = 0.5; let outcome = core.run_inner().await.expect("run_inner must not error");
3166 assert!(
3167 matches!(outcome.finish_reason, FinishReason::BudgetExceeded),
3168 "expected BudgetExceeded (window not yet full), got {:?}",
3169 outcome.finish_reason,
3170 );
3171 }
3172
3173 #[tokio::test]
3183 async fn run_inner_stuck_rate_uses_division_not_multiplication() {
3184 use crate::agent::FinishReason;
3185 use crate::llm::ToolSpec;
3186 use crate::llm::{Completion, ToolCall};
3187 use crate::tools::{Tool, ToolRegistry};
3188 use async_trait::async_trait;
3189
3190 struct SuccessTool;
3191
3192 #[async_trait]
3193 impl Tool for SuccessTool {
3194 fn spec(&self) -> ToolSpec {
3195 ToolSpec {
3196 name: "SuccessTool".to_string(),
3197 description: "Returns ok".to_string(),
3198 parameters: serde_json::json!({ "type": "object", "properties": {} }),
3199 }
3200 }
3201 async fn execute(&self, _args: serde_json::Value) -> crate::error::Result<String> {
3202 Ok("ok".to_string())
3203 }
3204 }
3205
3206 let hooks = crate::hooks::HookRegistry::new();
3207 let registry = ToolRegistry::default().register(Arc::new(SuccessTool));
3208
3209 let make_ok = |id: &str| Completion {
3210 content: String::new(),
3211 tool_calls: vec![ToolCall {
3212 id: id.to_string(),
3213 name: "SuccessTool".to_string(),
3214 arguments: serde_json::json!({}),
3215 }],
3216 finish_reason: None,
3217 usage: None,
3218 reasoning_content: None,
3219 };
3220 let make_err = |id: &str| Completion {
3221 content: String::new(),
3222 tool_calls: vec![ToolCall {
3223 id: id.to_string(),
3224 name: "NonExistentTool".to_string(),
3225 arguments: serde_json::json!({}),
3226 }],
3227 finish_reason: None,
3228 usage: None,
3229 reasoning_content: None,
3230 };
3231 let provider = Arc::new(crate::llm::MockProvider::new(vec![
3233 make_ok("tc1"),
3234 make_ok("tc2"),
3235 make_err("tc3"),
3236 ]));
3237 let messages = vec![Message::user("test".to_string())];
3238 let mut core = make_run_core_for_inner(messages, &hooks, provider, 3);
3239 core.tools = Arc::new(registry);
3240 core.stuck_window = 3;
3241 core.stuck_error_rate = 0.5; let outcome = core.run_inner().await.expect("run_inner must not error");
3244 assert!(
3245 matches!(outcome.finish_reason, FinishReason::BudgetExceeded),
3246 "expected BudgetExceeded (rate below threshold), got {:?}",
3247 outcome.finish_reason,
3248 );
3249 }
3250
3251 #[tokio::test]
3264 async fn run_inner_denial_sentinel_emits_is_error_true() {
3265 use crate::event::AgentEvent;
3266 use crate::llm::{Completion, ToolCall, ToolSpec};
3267 use crate::tools::{Tool, ToolRegistry};
3268 use async_trait::async_trait;
3269 use tokio::sync::mpsc;
3270
3271 struct DenialTool;
3272
3273 #[async_trait]
3274 impl Tool for DenialTool {
3275 fn spec(&self) -> ToolSpec {
3276 ToolSpec {
3277 name: "DenialTool".to_string(),
3278 description: "Returns PermissionDeniedLimit".to_string(),
3279 parameters: serde_json::json!({ "type": "object", "properties": {} }),
3280 }
3281 }
3282 async fn execute(&self, _args: serde_json::Value) -> crate::error::Result<String> {
3283 Err(crate::error::Error::PermissionDeniedLimit {
3284 name: "DenialTool".to_string(),
3285 })
3286 }
3287 }
3288
3289 let hooks = crate::hooks::HookRegistry::new();
3290 let registry = ToolRegistry::default().register(Arc::new(DenialTool));
3291 let provider = Arc::new(crate::llm::MockProvider::new(vec![Completion {
3292 content: String::new(),
3293 tool_calls: vec![ToolCall {
3294 id: "tc1".to_string(),
3295 name: "DenialTool".to_string(),
3296 arguments: serde_json::json!({}),
3297 }],
3298 finish_reason: None,
3299 usage: None,
3300 reasoning_content: None,
3301 }]));
3302
3303 let messages = vec![Message::user("trigger denial".to_string())];
3304 let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
3305 let mut core = make_run_core_for_inner(messages, &hooks, provider, 3);
3306 core.tools = Arc::new(registry);
3307 core.events = Some(tx);
3308
3309 let outcome = core.run_inner().await.expect("run_inner must not error");
3310 assert!(
3311 matches!(
3312 outcome.finish_reason,
3313 crate::agent::FinishReason::PermissionDenialLimit
3314 ),
3315 "expected PermissionDenialLimit, got {:?}",
3316 outcome.finish_reason,
3317 );
3318
3319 let mut events = vec![];
3320 while let Ok(e) = rx.try_recv() {
3321 events.push(e);
3322 }
3323 let denial_is_error = events.iter().find_map(|e| {
3324 if let AgentEvent::ToolResult { name, is_error, .. } = e {
3325 if name == "DenialTool" {
3326 return Some(*is_error);
3327 }
3328 }
3329 None
3330 });
3331 assert_eq!(
3332 denial_is_error,
3333 Some(true),
3334 "ToolResult for DENIAL_LIMIT_SENTINEL must have is_error = true; events: {events:?}"
3335 );
3336 }
3337
3338 #[test]
3347 fn compute_breakdown_overhead_is_provider_total_minus_local_sum() {
3348 use crate::llm::ContextBreakdown;
3349
3350 let hooks = crate::hooks::HookRegistry::new();
3351 let messages = vec![
3353 Message::system("s".to_string()),
3354 Message::user("hello world this is a turn".to_string()),
3355 Message::assistant("ok".to_string()),
3356 ];
3357 let mut core = make_test_core(messages, &hooks);
3358 core.static_breakdown = StaticBreakdownCache {
3360 system_prompt: 100,
3361 rules: 50,
3362 skills: 25,
3363 subagents: 10,
3364 tools: 15,
3365 mcp_dynamic: 0,
3366 };
3367 let breakdown = core.compute_breakdown(1000);
3368
3369 let local = breakdown.local_sum();
3371 let expected_overhead = 1000u32.saturating_sub(local);
3372 assert_eq!(
3373 breakdown.overhead, expected_overhead,
3374 "overhead must be provider_total - local_sum"
3375 );
3376 assert!(
3378 breakdown.conversation > 0,
3379 "conversation bucket must be non-zero for a seeded transcript"
3380 );
3381 assert_eq!(breakdown.system_prompt, 100);
3383 assert_eq!(breakdown.rules, 50);
3384 assert_eq!(breakdown.skills, 25);
3385 assert_eq!(breakdown.subagents, 10);
3386 assert_eq!(breakdown.tools, 15);
3387 assert_eq!(breakdown.mcp_dynamic, 0);
3388 assert_eq!(breakdown.local_sum(), local);
3390 assert_eq!(breakdown.total(), local + expected_overhead);
3391 let _ = ContextBreakdown::default();
3393 }
3394
3395 #[test]
3399 fn compute_breakdown_overhead_saturates_at_zero_when_local_exceeds_provider() {
3400 let hooks = crate::hooks::HookRegistry::new();
3401 let mut core = make_test_core(vec![Message::user("hi".to_string())], &hooks);
3402 core.static_breakdown = StaticBreakdownCache {
3403 system_prompt: 5000,
3404 rules: 1000,
3405 skills: 0,
3406 subagents: 0,
3407 tools: 0,
3408 mcp_dynamic: 0,
3409 };
3410 let breakdown = core.compute_breakdown(100); assert_eq!(
3412 breakdown.overhead, 0,
3413 "overhead must saturate to 0 when local sum > provider total; got {}",
3414 breakdown.overhead
3415 );
3416 }
3417
3418 #[tokio::test]
3424 async fn dispatch_llm_step_emits_context_breakdown_after_usage() {
3425 use crate::event::AgentEvent;
3426 use crate::llm::TokenUsage;
3427 use tokio::sync::mpsc;
3428
3429 let hooks = crate::hooks::HookRegistry::new();
3430 let mut core = make_test_core(vec![Message::user("hello world".to_string())], &hooks);
3431 core.static_breakdown = StaticBreakdownCache {
3433 system_prompt: 100,
3434 rules: 50,
3435 skills: 25,
3436 subagents: 10,
3437 tools: 15,
3438 mcp_dynamic: 0,
3439 };
3440 let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
3441 core.events = Some(tx);
3442
3443 let usage = TokenUsage {
3448 prompt_tokens: 1000,
3449 completion_tokens: 50,
3450 total_tokens: 1050,
3451 cache_hit_tokens: 0,
3452 cache_miss_tokens: 0,
3453 reasoning_tokens: 0,
3454 };
3455 core.emit_breakdown(7, &usage);
3456
3457 let event = rx
3459 .try_recv()
3460 .expect("ContextBreakdown event must be emitted");
3461 match event {
3462 AgentEvent::ContextBreakdown { breakdown, step } => {
3463 assert_eq!(step, 7, "step must be forwarded verbatim");
3464 let local = breakdown.local_sum();
3465 let expected_overhead = 1000u32.saturating_sub(local);
3466 assert_eq!(breakdown.overhead, expected_overhead);
3467 assert!(
3468 breakdown.total() >= 1000,
3469 "total must be >= provider_total; got {}",
3470 breakdown.total()
3471 );
3472 }
3473 other => panic!("expected ContextBreakdown event, got {other:?}"),
3474 }
3475 }
3476
3477 #[tokio::test]
3482 async fn run_inner_emits_context_breakdown_once_per_llm_step() {
3483 use crate::agent::FinishReason;
3484 use crate::event::AgentEvent;
3485 use crate::llm::{Completion, TokenUsage};
3486 use tokio::sync::mpsc;
3487
3488 let hooks = crate::hooks::HookRegistry::new();
3489 let provider = Arc::new(crate::llm::MockProvider::new(vec![Completion {
3490 content: "done".to_string(),
3491 tool_calls: vec![],
3492 finish_reason: Some("stop".to_string()),
3493 usage: Some(TokenUsage {
3494 prompt_tokens: 1000,
3495 completion_tokens: 50,
3496 total_tokens: 1050,
3497 cache_hit_tokens: 0,
3498 cache_miss_tokens: 0,
3499 reasoning_tokens: 0,
3500 }),
3501 reasoning_content: None,
3502 }]));
3503 let messages = vec![Message::user("hi".to_string())];
3504 let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
3505 let mut core = make_run_core_for_inner(messages, &hooks, provider, 1);
3506 core.events = Some(tx);
3507
3508 let outcome = core.run_inner().await.expect("run_inner must not error");
3509 assert_eq!(outcome.finish_reason, FinishReason::NoMoreToolCalls);
3510
3511 let mut events = Vec::new();
3514 while let Ok(e) = rx.try_recv() {
3515 events.push(e);
3516 }
3517 let usage_idx = events
3518 .iter()
3519 .position(|e| matches!(e, AgentEvent::Usage { .. }))
3520 .expect("a Usage event must have been emitted");
3521 let breakdown_idx = events
3522 .iter()
3523 .position(|e| matches!(e, AgentEvent::ContextBreakdown { .. }))
3524 .expect("a ContextBreakdown event must have been emitted");
3525 assert!(
3526 breakdown_idx > usage_idx,
3527 "ContextBreakdown (idx={breakdown_idx}) must come AFTER Usage (idx={usage_idx})"
3528 );
3529 }
3530
3531 #[tokio::test]
3534 async fn run_inner_skips_context_breakdown_on_no_llm_step() {
3535 use crate::agent::FinishReason;
3536 use crate::event::AgentEvent;
3537 use tokio::sync::mpsc;
3538
3539 let hooks = crate::hooks::HookRegistry::new();
3540 let provider = Arc::new(crate::llm::MockProvider::new(vec![]));
3541 let messages = vec![Message::system("hello".to_string())];
3542 let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
3543 let mut core = make_run_core_for_inner(messages, &hooks, provider, 1);
3544 core.events = Some(tx);
3545 core.max_transcript_chars = Some(3);
3548
3549 let outcome = core.run_inner().await.expect("run_inner must not error");
3550 assert!(
3551 matches!(outcome.finish_reason, FinishReason::TranscriptLimit { .. }),
3552 "expected TranscriptLimit finish; got {:?}",
3553 outcome.finish_reason
3554 );
3555
3556 let mut events = Vec::new();
3557 while let Ok(e) = rx.try_recv() {
3558 events.push(e);
3559 }
3560 assert!(
3561 !events
3562 .iter()
3563 .any(|e| matches!(e, AgentEvent::ContextBreakdown { .. })),
3564 "no ContextBreakdown must be emitted on a no-LLM step; got: {events:?}"
3565 );
3566 assert!(
3567 !events.iter().any(|e| matches!(e, AgentEvent::Usage { .. })),
3568 "no Usage must be emitted on a no-LLM step; got: {events:?}"
3569 );
3570 }
3571
3572 #[tokio::test]
3580 async fn static_buckets_dont_change_across_steps_conversation_grows() {
3581 use crate::event::AgentEvent;
3582 use crate::llm::{Completion, TokenUsage, ToolCall, ToolSpec};
3583 use crate::tools::{Tool, ToolRegistry};
3584 use async_trait::async_trait;
3585 use serde_json::{json, Value};
3586 use tokio::sync::mpsc;
3587
3588 struct Adder;
3591 #[async_trait]
3592 impl Tool for Adder {
3593 fn spec(&self) -> ToolSpec {
3594 ToolSpec {
3595 name: "add".to_string(),
3596 description: "add two numbers".to_string(),
3597 parameters: json!({"type":"object","properties":{"a":{"type":"integer"},"b":{"type":"integer"}}}),
3598 }
3599 }
3600 async fn execute(&self, _args: Value) -> crate::error::Result<String> {
3601 Ok("7".to_string())
3602 }
3603 }
3604
3605 let hooks = crate::hooks::HookRegistry::new();
3606 let registry = ToolRegistry::default().register(Arc::new(Adder));
3607 let provider = Arc::new(crate::llm::MockProvider::new(vec![
3608 Completion {
3610 content: "calculating…".to_string(),
3611 tool_calls: vec![ToolCall {
3612 id: "tc1".to_string(),
3613 name: "add".to_string(),
3614 arguments: json!({"a": 3, "b": 4}),
3615 }],
3616 finish_reason: Some("tool_calls".to_string()),
3617 usage: Some(TokenUsage {
3618 prompt_tokens: 1000,
3619 completion_tokens: 30,
3620 total_tokens: 1030,
3621 cache_hit_tokens: 0,
3622 cache_miss_tokens: 0,
3623 reasoning_tokens: 0,
3624 }),
3625 reasoning_content: None,
3626 },
3627 Completion {
3629 content: "the answer is 7".to_string(),
3630 tool_calls: vec![],
3631 finish_reason: Some("stop".to_string()),
3632 usage: Some(TokenUsage {
3633 prompt_tokens: 1200, completion_tokens: 50,
3635 total_tokens: 1250,
3636 cache_hit_tokens: 0,
3637 cache_miss_tokens: 0,
3638 reasoning_tokens: 0,
3639 }),
3640 reasoning_content: None,
3641 },
3642 ]));
3643 let messages = vec![Message::user("hi".to_string())];
3644 let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
3645 let mut core = make_run_core_for_inner(messages, &hooks, provider, 5);
3646 core.tools = Arc::new(registry);
3647 core.events = Some(tx);
3648 core.static_breakdown = StaticBreakdownCache {
3651 system_prompt: 100,
3652 rules: 50,
3653 skills: 25,
3654 subagents: 10,
3655 tools: 15,
3656 mcp_dynamic: 0,
3657 };
3658 core.prompt_segments = Some(crate::system_prompt::PromptSegments {
3659 rules: "RR".into(),
3660 system_prompt: "SS".into(),
3661 skills: String::new(),
3662 subagents: String::new(),
3663 });
3664
3665 let outcome = core.run_inner().await.expect("run_inner must not error");
3666 drop(outcome); let mut breakdowns = Vec::new();
3670 while let Ok(e) = rx.try_recv() {
3671 if let AgentEvent::ContextBreakdown { breakdown, .. } = e {
3672 breakdowns.push(breakdown);
3673 }
3674 }
3675 assert_eq!(
3676 breakdowns.len(),
3677 2,
3678 "two ContextBreakdown events must have been emitted; got {breakdowns:?}"
3679 );
3680
3681 assert_eq!(breakdowns[0].system_prompt, breakdowns[1].system_prompt);
3683 assert_eq!(breakdowns[0].rules, breakdowns[1].rules);
3684 assert_eq!(breakdowns[0].skills, breakdowns[1].skills);
3685 assert_eq!(breakdowns[0].subagents, breakdowns[1].subagents);
3686 assert_eq!(breakdowns[0].tools, breakdowns[1].tools);
3687 assert_eq!(breakdowns[0].mcp_dynamic, breakdowns[1].mcp_dynamic);
3688 assert_eq!(
3689 breakdowns[0].system_prompt, 100,
3690 "static_breakdown cache must be honoured"
3691 );
3692
3693 assert!(
3696 breakdowns[1].conversation > breakdowns[0].conversation,
3697 "conversation must grow between steps: step1={} step2={}",
3698 breakdowns[0].conversation,
3699 breakdowns[1].conversation
3700 );
3701
3702 assert!(breakdowns[0].overhead > 0);
3706 assert!(breakdowns[1].overhead > 0);
3707 }
3708
3709 #[test]
3715 fn static_breakdown_cache_build_zero_specs_yields_zero_tokens() {
3716 let segments = crate::system_prompt::PromptSegments::default();
3717 let registry = crate::tools::ToolRegistry::default();
3718 let cache = StaticBreakdownCache::build(&segments, &[], ®istry);
3719 assert_eq!(cache.system_prompt, 0);
3720 assert_eq!(cache.rules, 0);
3721 assert_eq!(cache.skills, 0);
3722 assert_eq!(cache.subagents, 0);
3723 assert_eq!(cache.tools, 0);
3724 assert_eq!(cache.mcp_dynamic, 0);
3725 }
3726
3727 #[test]
3730 fn static_breakdown_cache_build_tokenises_segments() {
3731 let segments = crate::system_prompt::PromptSegments {
3733 rules: "012345678".into(), system_prompt: "1234567890".into(), skills: "abcde".into(), subagents: "abcdefgh".into(), };
3738 let registry = crate::tools::ToolRegistry::default();
3739 let cache = StaticBreakdownCache::build(&segments, &[], ®istry);
3740 assert_eq!(cache.rules, 3, "9 chars / 4 = 2.25 → ceil = 3");
3741 assert_eq!(cache.system_prompt, 3, "10 chars / 4 = 2.5 → ceil = 3");
3742 assert_eq!(cache.skills, 2, "5 chars / 4 = 1.25 → ceil = 2");
3743 assert_eq!(cache.subagents, 2, "8 chars / 4 = 2.0 → ceil = 2");
3744 }
3745
3746 #[test]
3747 fn estimate_tokens_by_bytes_rounds_up_by_four_bytes() {
3748 assert_eq!(estimate_tokens_by_bytes(0), 0);
3752 assert_eq!(estimate_tokens_by_bytes(1), 1);
3753 assert_eq!(estimate_tokens_by_bytes(4), 1);
3754 assert_eq!(estimate_tokens_by_bytes(5), 2);
3755 assert_eq!(estimate_tokens_by_bytes(8), 2);
3756 }
3757
3758 #[test]
3759 fn enforce_transcript_budget_fires_when_chars_exactly_equal_limit() {
3760 use crate::agent::FinishReason;
3764 use crate::llm::TokenUsage;
3765 let hooks = crate::hooks::HookRegistry::new();
3766 let mut core = make_test_core(vec![Message::user("hello".to_string())], &hooks);
3768 core.max_transcript_chars = Some(5);
3769 let usage = TokenUsage::default();
3770 let result = core.enforce_transcript_budget(0, &usage);
3771 assert!(
3772 result.is_some(),
3773 "chars (5) == limit (5) must return Some finish reason"
3774 );
3775 let (finish, _step) = result.unwrap();
3776 assert!(
3777 matches!(finish, FinishReason::TranscriptLimit { .. }),
3778 "finish reason must be TranscriptLimit; got {finish:?}"
3779 );
3780 }
3781
3782 #[test]
3783 fn check_wall_deadline_disabled_when_timeout_zero() {
3784 use crate::llm::TokenUsage;
3788 let hooks = crate::hooks::HookRegistry::new();
3789 let mut core = make_test_core(vec![Message::user("hi".to_string())], &hooks);
3790 core.wall_timeout_secs = 0;
3791 core.wall_start = Some(std::time::Instant::now() - std::time::Duration::from_secs(5));
3792 let usage = TokenUsage::default();
3793 assert!(
3794 core.check_wall_deadline(0, &usage).is_none(),
3795 "timeout 0 must disable the wall-clock deadline"
3796 );
3797 }
3798
3799 #[test]
3800 fn check_wall_deadline_fires_after_timeout_elapsed() {
3801 use crate::agent::FinishReason;
3804 use crate::llm::TokenUsage;
3805 let hooks = crate::hooks::HookRegistry::new();
3806 let mut core = make_test_core(vec![Message::user("hi".to_string())], &hooks);
3807 core.wall_timeout_secs = 3;
3808 core.wall_start = Some(std::time::Instant::now() - std::time::Duration::from_secs(5));
3809 let usage = TokenUsage::default();
3810 let result = core.check_wall_deadline(0, &usage);
3811 assert!(
3812 result.is_some(),
3813 "elapsed 5s > timeout 3s must fire the deadline"
3814 );
3815 let (finish, _step) = result.unwrap();
3816 assert!(
3817 matches!(finish, FinishReason::WallClockExceeded { .. }),
3818 "finish reason must be WallClockExceeded; got {finish:?}"
3819 );
3820 }
3821
3822 #[test]
3823 fn check_wall_deadline_fires_when_elapsed_equals_timeout() {
3824 use crate::llm::TokenUsage;
3831 let hooks = crate::hooks::HookRegistry::new();
3832 let mut core = make_test_core(vec![Message::user("hi".to_string())], &hooks);
3833 core.wall_timeout_secs = 3;
3834 core.wall_start = Some(std::time::Instant::now() - std::time::Duration::from_secs(3));
3835 let usage = TokenUsage::default();
3836 let result = core.check_wall_deadline(0, &usage);
3837 assert!(
3838 result.is_some(),
3839 "elapsed == timeout must fire the deadline"
3840 );
3841 }
3842}