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(u) = completion.usage {
417 *total_usage = total_usage.accumulate(u);
418 self.last_prompt_tokens = u.prompt_tokens;
419 self.emit(AgentEvent::Usage {
420 input_tokens: u.prompt_tokens,
421 output_tokens: u.completion_tokens,
422 cache_hit_tokens: u.cache_hit_tokens,
423 cache_miss_tokens: u.cache_miss_tokens,
424 step,
425 });
426 self.emit_breakdown(step, &u);
433 } else {
434 let estimated_input = estimate_prompt_tokens(&self.messages);
437 let output_len = completion.content.len()
438 + completion.reasoning_content.as_ref().map_or(0, |s| s.len());
439 let estimated_output = (output_len / 4).max(1) as u32;
440 self.emit(AgentEvent::Usage {
441 input_tokens: estimated_input,
442 output_tokens: estimated_output,
443 cache_hit_tokens: 0,
444 cache_miss_tokens: 0,
445 step,
446 });
447 }
448
449 if let Some(reasoning) = &completion.reasoning_content {
458 if !reasoning.is_empty() {
459 self.emit(AgentEvent::Reasoning {
460 text: reasoning.clone(),
461 step,
462 });
463 }
464 }
465
466 let new_final_message = if !completion.content.is_empty() {
467 self.emit(AgentEvent::AssistantText {
468 text: completion.content.clone(),
469 step,
470 });
471 Some(completion.content.clone())
472 } else {
473 None
474 };
475
476 Ok((completion, new_final_message))
477 }
478
479 #[allow(clippy::too_many_arguments)]
499 fn process_tool_results(
500 &mut self,
501 results: &[ToolCallOutcome],
502 step: usize,
503 recent_errors: &mut std::collections::VecDeque<(bool, String)>,
504 tool_audits: &mut std::collections::HashMap<
505 crate::tools::AuditKey,
506 crate::tools::AuditMeta,
507 >,
508 skill_injector: &mut SkillInjector,
509 ) -> Option<FinishReason> {
510 if results.iter().any(|o| o.result == DENIAL_LIMIT_SENTINEL) {
515 for o in results {
516 if let Some(a) = &o.audit {
517 tool_audits.insert((self.turn, o.id.clone()), a.clone());
518 }
519 let is_error = o.result.starts_with("ERROR: ") || o.result == DENIAL_LIMIT_SENTINEL;
520 self.emit(AgentEvent::ToolResult {
521 id: o.id.clone(),
522 name: o.name.clone(),
523 output: o.result.clone(),
524 step,
525 is_error,
526 });
527 self.push_message(Message::tool_result(o.id.clone(), o.result.clone()));
528 }
529 let finish = FinishReason::PermissionDenialLimit;
530 self.emit(AgentEvent::TurnFinished {
531 reason: finish_reason_str(&finish),
532 steps: step,
533 });
534 return Some(finish);
535 }
536
537 let mut stuck_finish: Option<FinishReason> = None;
545 for ToolCallOutcome {
546 id,
547 name,
548 result,
549 audit,
550 } in results
551 {
552 let is_error = result.starts_with("ERROR: ");
553 self.emit(AgentEvent::ToolResult {
554 id: id.clone(),
555 name: name.clone(),
556 output: result.clone(),
557 step,
558 is_error,
559 });
560 if let Some(a) = audit {
561 tool_audits.insert((self.turn, id.clone()), a.clone());
562 }
563 self.push_message(Message::tool_result(id.clone(), result.clone()));
564
565 if recent_errors.len() == self.stuck_window {
570 recent_errors.pop_front();
571 }
572 recent_errors.push_back((is_error, name.clone()));
573
574 if stuck_finish.is_none() && recent_errors.len() == self.stuck_window {
575 let error_entries: Vec<&str> = recent_errors
576 .iter()
577 .filter(|(is_err, _)| *is_err)
578 .map(|(_, n)| n.as_str())
579 .collect();
580 let error_count = error_entries.len();
581 let rate = error_count as f64 / self.stuck_window as f64;
582 if rate >= self.stuck_error_rate {
583 let mut counts: std::collections::HashMap<&str, usize> =
584 std::collections::HashMap::new();
585 for n in &error_entries {
586 bump_stuck_count(&mut counts, n);
587 }
588 let top_tool = counts
589 .into_iter()
590 .max_by_key(|&(_, c)| c)
591 .map(|(n, _)| n)
592 .unwrap_or(name.as_str());
593 stuck_finish = Some(FinishReason::Stuck {
594 repeated_call: top_tool.to_string(),
595 repeats: error_count,
596 });
597 }
598 }
599 }
600
601 if let Some(finish) = stuck_finish {
605 self.emit(AgentEvent::TurnFinished {
606 reason: finish_reason_str(&finish),
607 steps: step,
608 });
609 return Some(finish);
610 }
611
612 let result_strings: Vec<String> = results.iter().map(|r| r.result.clone()).collect();
615 for (skill_name, skill_body) in skill_injector.check(&result_strings) {
616 self.push_message(Message::system(format!(
617 "<!-- skill:{skill_name} injected by globs match -->\n{skill_body}"
618 )));
619 }
620 None
621 }
622
623 fn handle_no_tool_calls(
631 &mut self,
632 completion: &Completion,
633 step: usize,
634 ) -> Option<FinishReason> {
635 if !completion.tool_calls.is_empty() {
636 return None;
637 }
638 self.push_message(Message::assistant(completion.content.clone()));
639 self.attach_reasoning_content(completion.reasoning_content.clone());
640 let finish = match completion.finish_reason.as_deref() {
641 Some(r) if r != "stop" && r != "end_turn" => FinishReason::ProviderStop(r.to_string()),
642 _ => FinishReason::NoMoreToolCalls,
643 };
644 self.emit(AgentEvent::TurnFinished {
645 reason: finish_reason_str(&finish),
646 steps: step,
647 });
648 Some(finish)
649 }
650
651 async fn drain_mailbox(&mut self) {
656 let Some(mailbox) = self.mailbox.as_ref() else {
657 return;
658 };
659 let pending = mailbox.drain_all().await;
660 for msg_text in pending {
661 self.push_message(Message {
662 role: crate::message::Role::User,
663 content: format!("[coordinator]: {msg_text}"),
664 tool_calls: vec![],
665 tool_call_id: None,
666 reasoning_content: None,
667 is_compaction_summary: false,
668 });
669 }
670 }
671
672 fn enforce_transcript_budget(
677 &mut self,
678 step: usize,
679 total_usage: &TokenUsage,
680 ) -> Option<(FinishReason, usize)> {
681 let limit = self.max_transcript_chars?;
682 self.maybe_trim_transcript(limit, step);
683 let chars: usize = self.messages.iter().map(|m| m.content.len()).sum();
684 if chars < limit {
685 return None;
686 }
687 let finish = FinishReason::TranscriptLimit { chars, limit };
688 self.emit(AgentEvent::TurnFinished {
689 reason: finish_reason_str(&finish),
690 steps: step,
691 });
692 tracing::info!(
693 target: "recursive::agent",
694 steps = step,
695 tokens_in = total_usage.prompt_tokens,
696 tokens_out = total_usage.completion_tokens,
697 finish = ?finish,
698 llm_latency_ms = self.total_llm_latency_ms,
699 "agent.run.complete"
700 );
701 Some((finish, step))
702 }
703
704 fn check_shutdown(
713 &self,
714 step: usize,
715 total_usage: &TokenUsage,
716 ) -> Option<(FinishReason, usize)> {
717 let token = self.shutdown_token.as_ref()?;
718 if !token.is_cancelled() {
719 return None;
720 }
721 let finished_steps = step.saturating_sub(1);
722 let finish = FinishReason::Cancelled;
723 self.emit(AgentEvent::TurnFinished {
724 reason: finish_reason_str(&finish),
725 steps: finished_steps,
726 });
727 tracing::info!(
728 target: "recursive::agent",
729 steps = finished_steps,
730 tokens_in = total_usage.prompt_tokens,
731 tokens_out = total_usage.completion_tokens,
732 finish = ?finish,
733 llm_latency_ms = self.total_llm_latency_ms,
734 "agent.run.complete"
735 );
736 Some((finish, finished_steps))
737 }
738
739 fn check_wall_deadline(
743 &self,
744 step: usize,
745 total_usage: &TokenUsage,
746 ) -> Option<(FinishReason, usize)> {
747 if self.wall_timeout_secs == 0 {
748 return None;
749 }
750 let elapsed = self.wall_start?.elapsed();
751 if elapsed.as_secs() < self.wall_timeout_secs {
752 return None;
753 }
754 let finished_steps = step.saturating_sub(1);
755 let finish = FinishReason::WallClockExceeded {
756 secs: self.wall_timeout_secs,
757 };
758 self.emit(AgentEvent::TurnFinished {
759 reason: finish_reason_str(&finish),
760 steps: finished_steps,
761 });
762 tracing::info!(
763 target: "recursive::agent",
764 steps = finished_steps,
765 tokens_in = total_usage.prompt_tokens,
766 tokens_out = total_usage.completion_tokens,
767 finish = ?finish,
768 llm_latency_ms = self.total_llm_latency_ms,
769 "agent.run.complete (wall clock)"
770 );
771 Some((finish, finished_steps))
772 }
773
774 fn make_outcome(
778 self,
779 finish: FinishReason,
780 steps: usize,
781 final_message: Option<String>,
782 total_usage: TokenUsage,
783 tool_audits: std::collections::HashMap<crate::tools::AuditKey, crate::tools::AuditMeta>,
784 ) -> RunInnerOutcome {
785 RunInnerOutcome {
786 messages: self.messages,
787 final_message,
788 finish_reason: finish,
789 total_usage,
790 total_llm_latency_ms: self.total_llm_latency_ms,
791 steps,
792 tool_audits,
793 last_prompt_tokens: self.last_prompt_tokens,
794 }
795 }
796
797 fn make_cancelled_outcome(
808 self,
809 step: usize,
810 final_message: Option<String>,
811 total_usage: TokenUsage,
812 tool_audits: std::collections::HashMap<crate::tools::AuditKey, crate::tools::AuditMeta>,
813 ) -> RunInnerOutcome {
814 let finish = FinishReason::Cancelled;
815 self.emit(AgentEvent::TurnFinished {
816 reason: finish_reason_str(&finish),
817 steps: step,
818 });
819 tracing::info!(
820 target: "recursive::agent",
821 steps = step,
822 finish = ?finish,
823 "agent.run.cancelled_mid_stream"
824 );
825 self.make_outcome(finish, step, final_message, total_usage, tool_audits)
826 }
827
828 fn finish_interrupted(
839 mut self,
840 completion: Completion,
841 step: usize,
842 total_usage: TokenUsage,
843 tool_audits: std::collections::HashMap<crate::tools::AuditKey, crate::tools::AuditMeta>,
844 ) -> RunInnerOutcome {
845 self.push_message(Message::assistant(completion.content.clone()));
846 self.attach_reasoning_content(completion.reasoning_content.clone());
847 self.make_cancelled_outcome(
848 step,
849 Some(completion.content), total_usage,
851 tool_audits,
852 )
853 }
854
855 fn inject_skill_reminder(
883 messages: &[crate::message::Message],
884 skills: &[crate::skills::Skill],
885 ) -> Vec<crate::message::Message> {
886 if skills.is_empty() {
887 return messages.to_vec();
888 }
889 let reminder = crate::skills::skill_reminder(skills);
890 let mut out = Vec::with_capacity(messages.len() + 1);
891 out.extend(messages.iter().cloned());
892 out.push(crate::message::Message::user(reminder));
893 out
894 }
895
896 async fn call_llm(
897 &self,
898 specs: &[crate::llm::ToolSpec],
899 stream_sender: Option<crate::llm::StreamSender>,
900 ) -> crate::error::Result<Completion> {
901 let eager_specs_owned: Vec<crate::llm::ToolSpec>;
905 let messages_with_deferred: Vec<crate::message::Message>;
906
907 let (call_specs, messages): (&[crate::llm::ToolSpec], &[crate::message::Message]) =
908 if self.llm.supports_deferred_tools() {
909 let (eager, deferred): (Vec<_>, Vec<_>) = specs
910 .iter()
911 .cloned()
912 .partition(|s| !self.tools.is_deferred_spec(s));
913
914 if deferred.is_empty() {
915 (specs, &self.messages)
916 } else {
917 let names: Vec<&str> = deferred.iter().map(|s| s.name.as_str()).collect();
918 let block = format!(
919 "<available-deferred-tools>\n{}\n</available-deferred-tools>",
920 names.join("\n")
921 );
922 messages_with_deferred = std::iter::once(crate::message::Message::user(block))
923 .chain(self.messages.iter().cloned())
924 .collect();
925 eager_specs_owned = eager;
926 (&eager_specs_owned, &messages_with_deferred)
927 }
928 } else {
929 (specs, &self.messages)
930 };
931
932 let injected: Vec<crate::message::Message> =
936 Self::inject_skill_reminder(messages, &self.globs_skills);
937
938 if let Some(ref tx) = stream_sender {
939 let cancel_token = self.shutdown_token.clone();
940 self.llm
941 .stream(&injected, call_specs, Some(tx.clone()), cancel_token)
942 .await
943 } else {
944 self.llm.complete(&injected, call_specs).await
945 }
946 }
947
948 fn maybe_trim_transcript(&mut self, limit: usize, step: usize) {
950 let mut chars: usize = self.messages.iter().map(|m| m.content.len()).sum();
951 if chars < limit {
952 return;
953 }
954
955 let mut trimmed_count: usize = 0;
956 let placeholder_len = TRIM_PLACEHOLDER.len();
957
958 for msg in Arc::make_mut(&mut self.messages).iter_mut().skip(1) {
959 if msg.role == crate::message::Role::Tool && msg.content.len() > MIN_TRIM_LENGTH {
960 let old_len = msg.content.len();
961 msg.content = TRIM_PLACEHOLDER.to_string();
962 trimmed_count += 1;
963 chars = chars
964 .saturating_sub(old_len)
965 .saturating_add(placeholder_len);
966 if chars < limit {
967 break;
968 }
969 }
970 }
971
972 if trimmed_count > 0 {
973 let note = format!(
974 "[trimmed {} old tool result{} to fit budget]",
975 trimmed_count,
976 if trimmed_count == 1 { "" } else { "s" }
977 );
978 self.emit(AgentEvent::AssistantText { text: note, step });
979 }
980 }
981
982 async fn maybe_compact(&mut self, step: usize) {
992 let compactor = match &self.compactor {
993 Some(c) => c,
994 None => return,
995 };
996
997 if self.consecutive_compact_failures >= crate::compact::MAX_CONSECUTIVE_COMPACT_FAILURES {
999 self.emit(AgentEvent::CompactionSkipped {
1000 step,
1001 reason: crate::event::CompactionSkipReason::CircuitBreaker,
1002 });
1003 return;
1004 }
1005
1006 let bytes = Compactor::estimate_bytes(&self.messages);
1007 if !compactor.should_compact(bytes, self.last_prompt_tokens) {
1008 return;
1009 }
1010 if !compactor.would_compact(&self.messages) {
1016 return;
1017 }
1018
1019 self.hooks.dispatch(HookEvent::PreCompact {
1021 transcript_len: bytes,
1022 });
1023
1024 let kept_before = self.messages.len();
1025 match compactor
1026 .apply_to_transcript(self.llm.as_ref(), Arc::make_mut(&mut self.messages), step)
1027 .await
1028 {
1029 Ok(Some((removed, summary_chars))) => {
1030 self.consecutive_compact_failures = 0;
1032 let kept = kept_before - removed;
1033 self.hooks.dispatch(HookEvent::PostCompact {
1034 removed,
1035 summary_chars,
1036 });
1037 self.emit(AgentEvent::Compacted {
1038 removed,
1039 kept,
1040 summary_chars,
1041 step,
1042 });
1043 }
1044 Ok(None) => {
1045 }
1047 Err(e) => {
1048 tracing::warn!(error = %e, "proactive compaction failed");
1050 self.consecutive_compact_failures += 1;
1051 self.emit(AgentEvent::CompactionSkipped {
1052 step,
1053 reason: crate::event::CompactionSkipReason::Error,
1054 });
1055 }
1056 }
1057 }
1058
1059 async fn execute_tool_calls(&self, calls: &[ToolCall]) -> Vec<ToolCallOutcome> {
1063 let mut results: Vec<ToolCallOutcome> = Vec::new();
1064
1065 struct PendingCall {
1066 id: String,
1067 name: String,
1068 args: serde_json::Value,
1069 }
1070 let mut pending: Vec<PendingCall> = Vec::new();
1071
1072 for call in calls {
1073 if self.exploring_plan_mode.load(Ordering::Relaxed)
1076 && !self.tools.is_readonly(&call.name)
1077 && call.name != EXIT_PLAN_MODE_TOOL_NAME
1078 {
1079 results.push(ToolCallOutcome {
1080 id: call.id.clone(),
1081 name: call.name.clone(),
1082 result: format!(
1083 "ERROR: Cannot execute '{}' in plan mode. \
1084 You are in read-only planning mode. \
1085 Explore freely with read tools, then call \
1086 exit_plan_mode with your plan.",
1087 call.name
1088 ),
1089 audit: None,
1090 });
1091 continue;
1092 }
1093
1094 if !self.exploring_plan_mode.load(Ordering::Relaxed)
1097 && self.tools.is_plan_mode(&call.name)
1098 && call.name != ENTER_PLAN_MODE_TOOL_NAME
1099 && call.name != EXIT_PLAN_MODE_TOOL_NAME
1100 {
1101 results.push(ToolCallOutcome {
1102 id: call.id.clone(),
1103 name: call.name.clone(),
1104 result: format!(
1105 "ERROR: Tool '{}' requires plan mode. \
1106 Call enter_plan_mode first to explore and plan, \
1107 then call exit_plan_mode with your plan before using this tool.",
1108 call.name
1109 ),
1110 audit: None,
1111 });
1112 continue;
1113 }
1114
1115 let effective_args = if let Some(ref hook) = self.permission_hook {
1116 match hook.check(&call.name, &call.arguments).await {
1117 PermissionDecision::Allow => call.arguments.clone(),
1118 PermissionDecision::Deny(reason) => {
1119 let result = format!("ERROR: {reason}");
1120 results.push(ToolCallOutcome {
1121 id: call.id.clone(),
1122 name: call.name.clone(),
1123 result,
1124 audit: None,
1125 });
1126 continue;
1127 }
1128 PermissionDecision::Transform(new_args) => new_args,
1129 }
1130 } else {
1131 call.arguments.clone()
1132 };
1133
1134 let hook_action = self.hooks.dispatch(HookEvent::PreToolCall {
1135 name: &call.name,
1136 args: &effective_args,
1137 });
1138 match hook_action {
1139 HookAction::Skip => {
1140 let result = "ERROR: tool call skipped by hook".to_string();
1141 results.push(ToolCallOutcome {
1142 id: call.id.clone(),
1143 name: call.name.clone(),
1144 result,
1145 audit: None,
1146 });
1147 continue;
1148 }
1149 HookAction::Error(msg) => {
1150 let result = format!("ERROR: {msg}");
1151 results.push(ToolCallOutcome {
1152 id: call.id.clone(),
1153 name: call.name.clone(),
1154 result,
1155 audit: None,
1156 });
1157 continue;
1158 }
1159 HookAction::Continue => {}
1160 }
1161
1162 pending.push(PendingCall {
1163 id: call.id.clone(),
1164 name: call.name.clone(),
1165 args: effective_args,
1166 });
1167 }
1168
1169 let mut i = 0;
1170 while i < pending.len() {
1171 if self
1172 .tools
1173 .is_readonly_for_call(&pending[i].name, &pending[i].args)
1174 {
1175 let batch_start = i;
1176 while i < pending.len()
1177 && self
1178 .tools
1179 .is_readonly_for_call(&pending[i].name, &pending[i].args)
1180 {
1181 i += 1;
1182 }
1183 let batch: Vec<PendingCall> = pending.drain(batch_start..i).collect();
1184 i = batch_start;
1185
1186 let mut join_set = tokio::task::JoinSet::new();
1187 for pc in &batch {
1188 let name = pc.name.clone();
1189 let id = pc.id.clone();
1190 let args = pc.args.clone();
1191 let tools = Arc::clone(&self.tools);
1192 join_set.spawn(async move {
1193 let tool_start = std::time::Instant::now();
1194 let dispatch = tools.invoke_with_audit(&name, args).await;
1195 let result = match dispatch.result {
1196 Ok(output) => output,
1197 Err(crate::error::Error::PermissionDeniedLimit { .. }) => {
1198 DENIAL_LIMIT_SENTINEL.to_string()
1199 }
1200 Err(err) => format!("ERROR: {err}"),
1201 };
1202 let duration_ms = tool_start.elapsed().as_millis() as u64;
1203 (id, name, result, dispatch.audit, duration_ms)
1204 });
1205 }
1206
1207 type BatchRow = (String, String, String, crate::tools::AuditMeta, u64);
1208 let mut batch_map: std::collections::HashMap<String, BatchRow> =
1213 std::collections::HashMap::with_capacity(batch.len());
1214 while let Some(res) = join_set.join_next().await {
1215 match res {
1216 Ok(row) => {
1217 batch_map.insert(row.0.clone(), row);
1218 }
1219 Err(e) => {
1220 tracing::error!(target: "recursive::agent", "parallel tool task panicked: {e}");
1221 }
1222 }
1223 }
1224
1225 for pc in &batch {
1226 let Some((_, _, result, audit, duration_ms)) = batch_map.get(&pc.id) else {
1227 results.push(ToolCallOutcome {
1233 id: pc.id.clone(),
1234 name: pc.name.clone(),
1235 result: "ERROR: tool task panicked during parallel execution"
1236 .to_string(),
1237 audit: None,
1238 });
1239 continue;
1240 };
1241 results.push(ToolCallOutcome {
1242 id: pc.id.clone(),
1243 name: pc.name.clone(),
1244 result: result.clone(),
1245 audit: Some(audit.clone()),
1246 });
1247 self.hooks.dispatch(HookEvent::PostToolCall {
1248 name: &pc.name,
1249 args: &pc.args,
1250 result,
1251 duration_ms: *duration_ms,
1252 });
1253 }
1254 } else {
1255 let pc = pending.remove(i);
1256 let tool_start = std::time::Instant::now();
1257 let dispatch = self
1258 .tools
1259 .invoke_with_audit(&pc.name, pc.args.clone())
1260 .await;
1261 let result = match dispatch.result {
1262 Ok(output) => output,
1263 Err(crate::error::Error::PermissionDeniedLimit { .. }) => {
1264 DENIAL_LIMIT_SENTINEL.to_string()
1265 }
1266 Err(err) => format!("ERROR: {err}"),
1267 };
1268 let duration_ms = tool_start.elapsed().as_millis() as u64;
1269 results.push(ToolCallOutcome {
1270 id: pc.id.clone(),
1271 name: pc.name.clone(),
1272 result: result.clone(),
1273 audit: Some(dispatch.audit),
1274 });
1275 self.hooks.dispatch(HookEvent::PostToolCall {
1276 name: &pc.name,
1277 args: &pc.args,
1278 result: &result,
1279 duration_ms,
1280 });
1281 }
1282 }
1283
1284 results
1285 }
1286
1287 pub(crate) async fn run_inner(mut self) -> Result<RunInnerOutcome> {
1290 let specs = self.tools.specs();
1291
1292 let mut final_message: Option<String> = None;
1293 let mut recent_errors: std::collections::VecDeque<(bool, String)> =
1294 std::collections::VecDeque::with_capacity(self.stuck_window);
1295 let mut total_usage = TokenUsage::default();
1296 let mut skill_injector = SkillInjector::new(&self.globs_skills);
1298 let mut tool_audits: std::collections::HashMap<
1300 crate::tools::AuditKey,
1301 crate::tools::AuditMeta,
1302 > = std::collections::HashMap::new();
1303 self.total_llm_latency_ms = 0;
1304
1305 let step_cap = effective_step_limit(self.max_steps);
1306 for step in 1..=step_cap {
1307 let step_span = tracing::info_span!("agent.step", step);
1308 let _guard = step_span.enter();
1309
1310 if let Some((finish, finished_steps)) = self.check_shutdown(step, &total_usage) {
1317 return Ok(self.make_outcome(
1318 finish,
1319 finished_steps,
1320 final_message,
1321 total_usage,
1322 tool_audits,
1323 ));
1324 }
1325
1326 if let Some((finish, finished_steps)) = self.check_wall_deadline(step, &total_usage) {
1328 return Ok(self.make_outcome(
1329 finish,
1330 finished_steps,
1331 final_message,
1332 total_usage,
1333 tool_audits,
1334 ));
1335 }
1336
1337 self.drain_mailbox().await;
1339
1340 if let Some((finish, finish_step)) = self.enforce_transcript_budget(step, &total_usage)
1342 {
1343 return Ok(self.make_outcome(
1344 finish,
1345 finish_step,
1346 final_message,
1347 total_usage,
1348 tool_audits,
1349 ));
1350 }
1351
1352 self.maybe_compact(step).await;
1354
1355 let (completion, new_final_message) =
1357 match self.dispatch_llm_step(&specs, step, &mut total_usage).await {
1358 Ok(v) => v,
1359 Err(crate::error::Error::Cancelled) => {
1360 return Ok(self.make_cancelled_outcome(
1364 step,
1365 final_message,
1366 total_usage,
1367 tool_audits,
1368 ));
1369 }
1370 Err(e) => return Err(e),
1371 };
1372 if completion.finish_reason.as_deref() == Some("interrupted") {
1375 return Ok(self.finish_interrupted(completion, step, total_usage, tool_audits));
1376 }
1377 if let Some(text) = new_final_message {
1378 final_message = Some(text);
1379 }
1380
1381 if let Some(finish) = self.handle_no_tool_calls(&completion, step) {
1383 return Ok(self.make_outcome(
1384 finish,
1385 step,
1386 final_message,
1387 total_usage,
1388 tool_audits,
1389 ));
1390 }
1391
1392 self.push_message(Message::assistant_with_tool_calls(
1393 completion.content.clone(),
1394 completion.tool_calls.clone(),
1395 ));
1396 self.attach_reasoning_content(completion.reasoning_content.clone());
1397
1398 for call in &completion.tool_calls {
1399 self.emit(AgentEvent::ToolCall {
1400 name: call.name.clone(),
1401 id: call.id.clone(),
1402 arguments: call.arguments.to_string(),
1403 step,
1404 });
1405 }
1406
1407 let results = self.execute_tool_calls(&completion.tool_calls).await;
1409
1410 if let Some(finish) = self.process_tool_results(
1411 &results,
1412 step,
1413 &mut recent_errors,
1414 &mut tool_audits,
1415 &mut skill_injector,
1416 ) {
1417 return Ok(self.make_outcome(
1418 finish,
1419 step,
1420 final_message,
1421 total_usage,
1422 tool_audits,
1423 ));
1424 }
1425 }
1426
1427 warn!(target: "recursive::agent", "step budget exceeded");
1428 let finish = FinishReason::BudgetExceeded;
1429 let steps = self.max_steps;
1430 self.emit(AgentEvent::TurnFinished {
1431 reason: finish_reason_str(&finish),
1432 steps,
1433 });
1434 Ok(self.make_outcome(finish, steps, final_message, total_usage, tool_audits))
1435 }
1436}
1437
1438fn effective_step_limit(max_steps: usize) -> usize {
1447 let requested = if max_steps == 0 {
1448 usize::MAX
1449 } else {
1450 max_steps
1451 };
1452 match hard_step_cap_from_env() {
1453 Some(cap) if cap > 0 => requested.min(cap),
1454 _ => requested,
1455 }
1456}
1457
1458fn hard_step_cap_from_env() -> Option<usize> {
1462 std::env::var("RECURSIVE_HARD_STEP_CAP")
1463 .ok()
1464 .and_then(|s| s.parse::<usize>().ok())
1465}
1466
1467#[cfg(test)]
1468mod tests {
1469 use std::sync::Arc;
1470
1471 use super::{
1472 effective_step_limit, estimate_prompt_tokens, estimate_tokens_by_bytes, finish_reason_str,
1473 RunCore, StaticBreakdownCache, MIN_TRIM_LENGTH, TRIM_PLACEHOLDER,
1474 };
1475 use crate::message::Message;
1476
1477 static HARD_CAP_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1482
1483 #[test]
1484 fn effective_step_limit_zero_means_unbounded() {
1485 let _guard = HARD_CAP_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1486 let orig = std::env::var("RECURSIVE_HARD_STEP_CAP").ok();
1488 std::env::remove_var("RECURSIVE_HARD_STEP_CAP");
1489 assert_eq!(effective_step_limit(0), usize::MAX);
1490 assert_eq!(effective_step_limit(32), 32);
1491 if let Some(v) = orig {
1492 std::env::set_var("RECURSIVE_HARD_STEP_CAP", v);
1493 }
1494 }
1495
1496 #[test]
1497 fn effective_step_limit_respects_hard_cap_when_set() {
1498 let _guard = HARD_CAP_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1499 let orig = std::env::var("RECURSIVE_HARD_STEP_CAP").ok();
1500 std::env::set_var("RECURSIVE_HARD_STEP_CAP", "1000");
1501
1502 assert_eq!(effective_step_limit(0), 1000);
1504 assert_eq!(effective_step_limit(32), 32);
1506 assert_eq!(effective_step_limit(5000), 1000);
1508
1509 if let Some(v) = orig {
1510 std::env::set_var("RECURSIVE_HARD_STEP_CAP", v);
1511 } else {
1512 std::env::remove_var("RECURSIVE_HARD_STEP_CAP");
1513 }
1514 }
1515
1516 #[test]
1517 fn effective_step_limit_ignores_invalid_hard_cap() {
1518 let _guard = HARD_CAP_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1519 let orig = std::env::var("RECURSIVE_HARD_STEP_CAP").ok();
1520 std::env::set_var("RECURSIVE_HARD_STEP_CAP", "not-a-number");
1521 assert_eq!(effective_step_limit(0), usize::MAX);
1522 std::env::set_var("RECURSIVE_HARD_STEP_CAP", "0");
1523 assert_eq!(effective_step_limit(0), usize::MAX);
1525 if let Some(v) = orig {
1526 std::env::set_var("RECURSIVE_HARD_STEP_CAP", v);
1527 } else {
1528 std::env::remove_var("RECURSIVE_HARD_STEP_CAP");
1529 }
1530 }
1531
1532 #[test]
1535 fn stuck_detection_window_and_rate() {
1536 let stuck_window = 3usize;
1538 let stuck_error_rate = 1.0f64;
1539
1540 let mut recent_errors: std::collections::VecDeque<(bool, String)> =
1541 std::collections::VecDeque::with_capacity(stuck_window);
1542
1543 for i in 0..2 {
1545 if recent_errors.len() == stuck_window {
1546 recent_errors.pop_front();
1547 }
1548 let name = format!("tool_{i}");
1549 recent_errors.push_back((true, name));
1550 let rate = recent_errors.iter().filter(|(e, _)| *e).count() as f64
1551 / recent_errors.len() as f64;
1552 assert!(
1553 recent_errors.len() < stuck_window || rate < stuck_error_rate,
1554 "should not trigger before window is full"
1555 );
1556 }
1557
1558 if recent_errors.len() == stuck_window {
1560 recent_errors.pop_front();
1561 }
1562 recent_errors.push_back((true, "tool_2".to_string()));
1563 assert_eq!(recent_errors.len(), stuck_window);
1564 let error_count = recent_errors.iter().filter(|(e, _)| *e).count();
1565 let rate = error_count as f64 / stuck_window as f64;
1566 assert!(
1567 rate >= stuck_error_rate,
1568 "rate {rate} should trigger stuck at threshold {stuck_error_rate}"
1569 );
1570
1571 let error_entries: Vec<&str> = recent_errors
1573 .iter()
1574 .filter(|(e, _)| *e)
1575 .map(|(_, n)| n.as_str())
1576 .collect();
1577 let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
1578 for n in &error_entries {
1579 *counts.entry(n).or_default() += 1;
1580 }
1581 let top_tool = counts.into_iter().max_by_key(|&(_, c)| c).map(|(n, _)| n);
1582 assert!(top_tool.is_some());
1584 }
1585
1586 #[test]
1587 fn stuck_detection_partial_errors_below_threshold() {
1588 let stuck_window = 4usize;
1591 let stuck_error_rate = 0.8f64;
1592
1593 let mut recent_errors: std::collections::VecDeque<(bool, String)> =
1594 std::collections::VecDeque::with_capacity(stuck_window);
1595
1596 let pattern = [
1597 (true, "tool_a"),
1598 (true, "tool_b"),
1599 (true, "tool_a"),
1600 (false, "tool_c"),
1601 ]; for (is_error, name) in &pattern {
1603 if recent_errors.len() == stuck_window {
1604 recent_errors.pop_front();
1605 }
1606 recent_errors.push_back((*is_error, name.to_string()));
1607 }
1608
1609 assert_eq!(recent_errors.len(), stuck_window);
1610 let rate = recent_errors.iter().filter(|(e, _)| *e).count() as f64 / stuck_window as f64;
1611 assert!(
1612 rate < stuck_error_rate,
1613 "rate {rate} should be below threshold {stuck_error_rate}"
1614 );
1615 }
1616
1617 #[test]
1618 fn stuck_detection_reports_most_repeated_tool() {
1619 let stuck_window = 4usize;
1622 let stuck_error_rate = 0.75f64;
1623
1624 let mut recent_errors: std::collections::VecDeque<(bool, String)> =
1625 std::collections::VecDeque::with_capacity(stuck_window);
1626
1627 let pattern = [
1628 (true, "tool_a"),
1629 (true, "tool_b"),
1630 (true, "tool_a"),
1631 (false, "tool_b"),
1632 ];
1633 for (is_error, name) in &pattern {
1634 if recent_errors.len() == stuck_window {
1635 recent_errors.pop_front();
1636 }
1637 recent_errors.push_back((*is_error, name.to_string()));
1638 }
1639
1640 assert_eq!(recent_errors.len(), stuck_window);
1641
1642 let error_entries: Vec<&str> = recent_errors
1643 .iter()
1644 .filter(|(is_err, _)| *is_err)
1645 .map(|(_, n)| n.as_str())
1646 .collect();
1647 let error_count = error_entries.len();
1648 let rate = error_count as f64 / stuck_window as f64;
1649 assert!(
1650 rate >= stuck_error_rate,
1651 "rate {rate} should trigger stuck at threshold {stuck_error_rate}"
1652 );
1653
1654 let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
1656 for n in &error_entries {
1657 *counts.entry(n).or_default() += 1;
1658 }
1659 let top_tool = counts
1660 .into_iter()
1661 .max_by_key(|&(_, c)| c)
1662 .map(|(n, _)| n)
1663 .unwrap();
1664 assert_eq!(
1665 top_tool, "tool_a",
1666 "most repeated tool should be tool_a (2 errors), not tool_b (1 error)"
1667 );
1668 }
1669
1670 fn make_test_core<'a>(
1673 messages: Vec<Message>,
1674 hooks: &'a crate::hooks::HookRegistry,
1675 ) -> RunCore<'a> {
1676 use std::sync::atomic::AtomicBool;
1677 use std::sync::Arc;
1678 RunCore {
1679 messages: Arc::new(messages),
1680 llm: Arc::new(crate::llm::MockProvider::new(vec![])),
1681 tools: Arc::new(crate::tools::ToolRegistry::default()),
1682 max_steps: 1,
1683 max_transcript_chars: None,
1684 events: None,
1685 streaming: false,
1686 compactor: None,
1687 microcompactor: None,
1688 permission_hook: None,
1689 hooks,
1690 total_llm_latency_ms: 0,
1691 exploring_plan_mode: Arc::new(AtomicBool::new(false)),
1692 shutdown_token: None,
1693 mailbox: None,
1694 stuck_window: 3,
1695 stuck_error_rate: 1.0,
1696 turn: 0,
1697 globs_skills: vec![],
1698 prompt_segments: None,
1699 static_breakdown: StaticBreakdownCache::default(),
1700 last_prompt_tokens: 0,
1701 consecutive_compact_failures: 0,
1702 wall_timeout_secs: 0,
1703 wall_start: None,
1704 }
1705 }
1706
1707 #[test]
1708 fn attach_reasoning_content_sets_last_message_when_some() {
1709 let hooks = crate::hooks::HookRegistry::new();
1710 let mut core = make_test_core(vec![Message::assistant("response".to_string())], &hooks);
1711
1712 core.attach_reasoning_content(Some("I thought carefully about this.".to_string()));
1713
1714 assert_eq!(
1715 core.messages.last().unwrap().reasoning_content.as_deref(),
1716 Some("I thought carefully about this."),
1717 "reasoning_content should be set on the last message"
1718 );
1719 }
1720
1721 #[test]
1722 fn attach_reasoning_content_does_not_modify_when_none() {
1723 let hooks = crate::hooks::HookRegistry::new();
1724 let mut core = make_test_core(vec![Message::assistant("response".to_string())], &hooks);
1725
1726 core.attach_reasoning_content(None);
1727
1728 assert!(
1729 core.messages.last().unwrap().reasoning_content.is_none(),
1730 "reasoning_content should remain None when called with None"
1731 );
1732 }
1733
1734 #[test]
1735 fn attach_reasoning_content_preserves_existing_content_when_none() {
1736 let hooks = crate::hooks::HookRegistry::new();
1737 let mut msg = Message::assistant("response".to_string());
1738 msg.reasoning_content = Some("prior thinking".to_string());
1739 let mut core = make_test_core(vec![msg], &hooks);
1740
1741 core.attach_reasoning_content(None);
1742
1743 assert_eq!(
1744 core.messages.last().unwrap().reasoning_content.as_deref(),
1745 Some("prior thinking"),
1746 "calling with None should not overwrite existing reasoning_content"
1747 );
1748 }
1749
1750 #[test]
1760 fn finish_reason_str_is_nonempty_and_not_xyzzy() {
1761 use crate::agent::FinishReason;
1762 let reason = FinishReason::NoMoreToolCalls;
1763 let s = finish_reason_str(&reason);
1764 assert!(
1765 !s.is_empty(),
1766 "finish_reason_str must not return empty string"
1767 );
1768 assert_ne!(s, "xyzzy", "finish_reason_str must not return 'xyzzy'");
1769 }
1770
1771 #[test]
1772 fn finish_reason_str_matches_display() {
1773 use crate::agent::FinishReason;
1774 let reason = FinishReason::BudgetExceeded;
1775 let s = finish_reason_str(&reason);
1776 assert_eq!(
1777 s,
1778 reason.to_string(),
1779 "finish_reason_str must delegate to Display"
1780 );
1781 }
1782
1783 #[test]
1790 fn estimate_prompt_tokens_divides_total_chars_by_four() {
1791 let messages = vec![
1793 Message::user("abcd".to_string()), Message::assistant("abcdefgh".to_string()), Message::system("abcdefghijkl".to_string()), ];
1797 assert_eq!(
1798 estimate_prompt_tokens(&messages),
1799 6,
1800 "24 total chars / 4 = 6 tokens"
1801 );
1802 }
1803
1804 #[test]
1805 fn estimate_prompt_tokens_truncates_partial_tokens() {
1806 let messages = vec![Message::user("abcdefg".to_string())];
1808 assert_eq!(
1809 estimate_prompt_tokens(&messages),
1810 1,
1811 "integer division must truncate 7/4 to 1"
1812 );
1813 }
1814
1815 #[test]
1816 fn estimate_prompt_tokens_floors_to_one_for_empty_messages() {
1817 let messages: Vec<Message> = vec![];
1820 assert_eq!(
1821 estimate_prompt_tokens(&messages),
1822 1,
1823 "empty transcript must still report at least 1 token"
1824 );
1825 }
1826
1827 #[test]
1828 fn estimate_prompt_tokens_floors_to_one_for_sub_four_chars() {
1829 let messages = vec![Message::user("abc".to_string())];
1831 assert_eq!(
1832 estimate_prompt_tokens(&messages),
1833 1,
1834 "content shorter than 4 chars must floor to 1 token"
1835 );
1836 }
1837
1838 fn make_core_with_llm_and_events<'a>(
1841 messages: Vec<Message>,
1842 llm: Arc<dyn crate::llm::ChatProvider>,
1843 events: tokio::sync::mpsc::UnboundedSender<crate::event::AgentEvent>,
1844 hooks: &'a crate::hooks::HookRegistry,
1845 ) -> RunCore<'a> {
1846 use std::sync::atomic::AtomicBool;
1847 RunCore {
1848 messages: Arc::new(messages),
1849 llm,
1850 tools: Arc::new(crate::tools::ToolRegistry::default()),
1851 max_steps: 1,
1852 max_transcript_chars: None,
1853 events: Some(events),
1854 streaming: false,
1855 compactor: None,
1856 microcompactor: None,
1857 permission_hook: None,
1858 hooks,
1859 total_llm_latency_ms: 0,
1860 exploring_plan_mode: Arc::new(AtomicBool::new(false)),
1861 shutdown_token: None,
1862 mailbox: None,
1863 stuck_window: 3,
1864 stuck_error_rate: 1.0,
1865 turn: 0,
1866 globs_skills: vec![],
1867 prompt_segments: None,
1868 static_breakdown: StaticBreakdownCache::default(),
1869 last_prompt_tokens: 0,
1870 consecutive_compact_failures: 0,
1871 wall_timeout_secs: 0,
1872 wall_start: None,
1873 }
1874 }
1875
1876 #[tokio::test]
1882 async fn dispatch_llm_step_emits_estimated_usage_when_provider_reports_none() {
1883 use crate::event::AgentEvent;
1884 use crate::llm::{Completion, MockProvider, TokenUsage};
1885
1886 let hooks = crate::hooks::HookRegistry::new();
1887 let messages = vec![Message::user("x".repeat(40))];
1889
1890 let completion = Completion {
1893 content: "12345678".to_string(),
1894 tool_calls: vec![],
1895 finish_reason: Some("stop".to_string()),
1896 usage: None,
1897 reasoning_content: Some("abcd".to_string()),
1898 };
1899 let llm = Arc::new(MockProvider::new(vec![completion]));
1900
1901 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<AgentEvent>();
1902 let mut core = make_core_with_llm_and_events(messages, llm, tx, &hooks);
1903
1904 let specs: Vec<crate::llm::ToolSpec> = vec![];
1905 let mut total_usage = TokenUsage::default();
1906 core.dispatch_llm_step(&specs, 0, &mut total_usage)
1907 .await
1908 .expect("dispatch_llm_step should succeed");
1909
1910 assert_eq!(
1912 total_usage.prompt_tokens, 0,
1913 "estimate must not accumulate input into total_usage"
1914 );
1915 assert_eq!(
1916 total_usage.completion_tokens, 0,
1917 "estimate must not accumulate output into total_usage"
1918 );
1919
1920 let mut usage_event = None;
1922 while let Ok(ev) = rx.try_recv() {
1923 if matches!(ev, AgentEvent::Usage { .. }) {
1924 usage_event = Some(ev);
1925 break;
1926 }
1927 }
1928 match usage_event.expect("fallback branch must emit a Usage event") {
1929 AgentEvent::Usage {
1930 input_tokens,
1931 output_tokens,
1932 cache_hit_tokens,
1933 cache_miss_tokens,
1934 step,
1935 } => {
1936 assert_eq!(input_tokens, 10, "input estimate = 40 chars / 4");
1937 assert_eq!(output_tokens, 3, "output estimate = (8 + 4) chars / 4");
1938 assert_eq!(cache_hit_tokens, 0, "estimate reports no cache hits");
1939 assert_eq!(cache_miss_tokens, 0, "estimate reports no cache misses");
1940 assert_eq!(step, 0, "step must be forwarded unchanged");
1941 }
1942 other => panic!("expected Usage event, got {other:?}"),
1943 }
1944 }
1945
1946 #[test]
1951 fn maybe_trim_does_nothing_when_under_limit() {
1952 let hooks = crate::hooks::HookRegistry::new();
1953 let large_output = "x".repeat(MIN_TRIM_LENGTH + 10);
1957 let messages = vec![
1958 Message::system("sys".to_string()),
1959 Message::tool_result("c1", &large_output),
1960 ];
1961 let orig_content = messages[1].content.clone();
1962 let total_bytes = "sys".len() + large_output.len();
1963 let mut core = make_test_core(messages, &hooks);
1964
1965 let limit = total_bytes + 1;
1967 core.maybe_trim_transcript(limit, 0);
1968
1969 assert_eq!(
1970 core.messages[1].content, orig_content,
1971 "content must not be touched when total bytes < limit"
1972 );
1973 }
1974
1975 #[test]
1976 fn enforce_transcript_budget_returns_none_when_under_limit() {
1977 use crate::llm::TokenUsage;
1981 let hooks = crate::hooks::HookRegistry::new();
1982 let mut core = make_test_core(vec![Message::user("hi".to_string())], &hooks);
1984 core.max_transcript_chars = Some(1000);
1986 let usage = TokenUsage::default();
1987 let result = core.enforce_transcript_budget(0, &usage);
1988 assert!(
1989 result.is_none(),
1990 "chars (2) < limit (1000) must return None; got Some(_)"
1991 );
1992 }
1993
1994 #[test]
1995 fn enforce_transcript_budget_returns_finish_reason_when_over_limit() {
1996 use crate::agent::FinishReason;
1998 use crate::llm::TokenUsage;
1999 let hooks = crate::hooks::HookRegistry::new();
2000 let msg = Message::user("x".repeat(100));
2002 let mut core = make_test_core(vec![msg], &hooks);
2003 core.max_transcript_chars = Some(10);
2004 let usage = TokenUsage::default();
2005 let result = core.enforce_transcript_budget(0, &usage);
2006 assert!(
2007 result.is_some(),
2008 "chars (100) >= limit (10) must return Some finish reason"
2009 );
2010 let (finish, _step) = result.unwrap();
2011 assert!(
2012 matches!(finish, FinishReason::TranscriptLimit { .. }),
2013 "finish reason must be TranscriptLimit; got {finish:?}"
2014 );
2015 }
2016
2017 #[test]
2018 fn maybe_trim_replaces_large_tool_result_when_over_limit() {
2019 let hooks = crate::hooks::HookRegistry::new();
2020 let large_output = "x".repeat(MIN_TRIM_LENGTH + 100);
2022 let messages = vec![
2023 Message::system("sys".to_string()),
2024 Message::tool_result("c1", &large_output),
2025 ];
2026 let mut core = make_test_core(messages, &hooks);
2027
2028 core.maybe_trim_transcript(10, 0);
2030
2031 assert_eq!(
2032 core.messages[1].content, TRIM_PLACEHOLDER,
2033 "large tool result must be replaced with placeholder"
2034 );
2035 }
2036
2037 #[test]
2038 fn maybe_trim_does_not_trim_small_tool_results() {
2039 let hooks = crate::hooks::HookRegistry::new();
2040 let small_output = "small".to_string();
2042 let messages = vec![
2043 Message::system("sys".to_string()),
2044 Message::tool_result("c1", &small_output),
2045 ];
2046 let mut core = make_test_core(messages, &hooks);
2047
2048 core.maybe_trim_transcript(0, 0);
2050
2051 assert_eq!(
2052 core.messages[1].content, small_output,
2053 "short tool result must not be trimmed (< MIN_TRIM_LENGTH)"
2054 );
2055 }
2056
2057 #[test]
2058 fn maybe_trim_does_not_trim_exactly_min_trim_length() {
2059 let hooks = crate::hooks::HookRegistry::new();
2062 let exact_output = "z".repeat(MIN_TRIM_LENGTH);
2063 let messages = vec![
2064 Message::system("s".to_string()),
2065 Message::tool_result("c1", &exact_output),
2066 ];
2067 let mut core = make_test_core(messages, &hooks);
2068 core.maybe_trim_transcript(0, 0);
2070 assert_eq!(
2071 core.messages[1].content, exact_output,
2072 "content exactly at MIN_TRIM_LENGTH must NOT be trimmed (> is strict)"
2073 );
2074 }
2075
2076 #[test]
2077 fn maybe_trim_trims_one_more_than_min_trim_length() {
2078 let hooks = crate::hooks::HookRegistry::new();
2080 let just_over = "z".repeat(MIN_TRIM_LENGTH + 1);
2081 let messages = vec![
2082 Message::system("s".to_string()),
2083 Message::tool_result("c1", &just_over),
2084 ];
2085 let mut core = make_test_core(messages, &hooks);
2086 core.maybe_trim_transcript(0, 0);
2087 assert_eq!(
2088 core.messages[1].content, TRIM_PLACEHOLDER,
2089 "content one byte over MIN_TRIM_LENGTH must be trimmed"
2090 );
2091 }
2092
2093 #[test]
2094 fn maybe_trim_stops_after_fitting_under_limit() {
2095 let hooks = crate::hooks::HookRegistry::new();
2096 let large1 = "a".repeat(MIN_TRIM_LENGTH + 100);
2098 let large2 = "b".repeat(MIN_TRIM_LENGTH + 100);
2099 let messages = vec![
2100 Message::system("sys".to_string()),
2101 Message::tool_result("c1", &large1),
2102 Message::tool_result("c2", &large2),
2103 ];
2104 let mut core = make_test_core(messages, &hooks);
2105
2106 let placeholder_len = TRIM_PLACEHOLDER.len();
2108 let limit = 3 + placeholder_len + large2.len() + 50; core.maybe_trim_transcript(limit, 0);
2110
2111 assert_eq!(
2112 core.messages[1].content, TRIM_PLACEHOLDER,
2113 "first large result should be trimmed"
2114 );
2115 assert_eq!(
2116 core.messages[2].content, large2,
2117 "second result should not be touched once we're under limit"
2118 );
2119 }
2120
2121 #[test]
2122 fn maybe_trim_trims_when_chars_exactly_equals_limit() {
2123 let hooks = crate::hooks::HookRegistry::new();
2128 let tool_content = "a".repeat(MIN_TRIM_LENGTH + 50); let messages = vec![
2131 Message::system("s".to_string()), Message::tool_result("c1", &tool_content), ];
2134 let limit = 1 + tool_content.len();
2136 let mut core = make_test_core(messages, &hooks);
2137 core.maybe_trim_transcript(limit, 0);
2138
2139 assert_eq!(
2140 core.messages[1].content, TRIM_PLACEHOLDER,
2141 "tool result must be trimmed when chars == limit (not strictly less)"
2142 );
2143 }
2144
2145 #[test]
2146 fn maybe_trim_continues_when_chars_equals_limit_after_first_trim() {
2147 let hooks = crate::hooks::HookRegistry::new();
2153
2154 let tool_content = "a".repeat(MIN_TRIM_LENGTH + 50); let messages = vec![
2156 Message::system("s".to_string()), Message::tool_result("c1", &tool_content), Message::tool_result("c2", &tool_content), ];
2160 let after_first_trim = 1 + TRIM_PLACEHOLDER.len() + tool_content.len();
2167 let limit = after_first_trim; let mut core = make_test_core(messages, &hooks);
2170 core.maybe_trim_transcript(limit, 0);
2171
2172 assert_eq!(
2173 core.messages[1].content, TRIM_PLACEHOLDER,
2174 "first tool result must be trimmed"
2175 );
2176 assert_eq!(
2177 core.messages[2].content, TRIM_PLACEHOLDER,
2178 "second tool result must ALSO be trimmed when chars == limit after first trim"
2179 );
2180 }
2181
2182 #[test]
2183 fn maybe_trim_emits_event_when_trimmed() {
2184 use crate::event::AgentEvent;
2187 use tokio::sync::mpsc;
2188
2189 let hooks = crate::hooks::HookRegistry::new();
2190 let large_output = "x".repeat(MIN_TRIM_LENGTH + 50);
2191 let messages = vec![
2192 Message::system("sys".to_string()),
2193 Message::tool_result("c1", &large_output),
2194 ];
2195 let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
2196 let mut core = make_test_core(messages, &hooks);
2197 core.events = Some(tx);
2198
2199 core.maybe_trim_transcript(0, 0);
2201
2202 let event = rx
2204 .try_recv()
2205 .expect("an event must have been emitted after trimming");
2206 match event {
2207 AgentEvent::AssistantText { text, .. } => {
2208 assert!(
2209 text.contains("trimmed"),
2210 "trim event text must mention 'trimmed'; got: {text}"
2211 );
2212 }
2213 other => panic!("expected AssistantText but got: {other:?}"),
2214 }
2215 }
2216
2217 #[test]
2218 fn maybe_trim_no_event_when_nothing_trimmed() {
2219 use crate::event::AgentEvent;
2221 use tokio::sync::mpsc;
2222
2223 let hooks = crate::hooks::HookRegistry::new();
2224 let small_output = "tiny".to_string(); let messages = vec![
2226 Message::system("sys".to_string()),
2227 Message::tool_result("c1", &small_output),
2228 ];
2229 let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
2230 let mut core = make_test_core(messages, &hooks);
2231 core.events = Some(tx);
2232
2233 core.maybe_trim_transcript(0, 0);
2235
2236 assert!(
2237 rx.try_recv().is_err(),
2238 "no event must be emitted when nothing was trimmed"
2239 );
2240 }
2241
2242 #[tokio::test]
2247 async fn maybe_compact_noop_when_no_compactor() {
2248 let hooks = crate::hooks::HookRegistry::new();
2249 let messages = vec![
2250 Message::user("q".to_string()),
2251 Message::assistant("a".to_string()),
2252 ];
2253 let mut core = make_test_core(messages, &hooks);
2254 core.maybe_compact(0).await;
2256 assert_eq!(core.messages.len(), 2, "messages must not change");
2257 }
2258
2259 #[tokio::test]
2260 async fn maybe_compact_noop_when_under_threshold() {
2261 use crate::compact::Compactor;
2262 let hooks = crate::hooks::HookRegistry::new();
2263 let messages = vec![
2264 Message::user("hi".to_string()),
2265 Message::assistant("hello".to_string()),
2266 ];
2267 let mut core = make_test_core(messages, &hooks);
2268 core.compactor = Some(Compactor::new(usize::MAX));
2270
2271 core.maybe_compact(0).await;
2272 assert_eq!(core.messages.len(), 2, "no compaction under threshold");
2273 }
2274
2275 #[tokio::test]
2276 async fn maybe_compact_fires_when_over_threshold() {
2277 use crate::compact::Compactor;
2278 use crate::llm::{Completion, MockProvider};
2279 let hooks = crate::hooks::HookRegistry::new();
2280 let messages = vec![
2282 Message::system("sys".to_string()),
2283 Message::user("msg1".to_string()),
2284 Message::assistant("rep1".to_string()),
2285 Message::user("msg2".to_string()),
2286 Message::assistant("rep2".to_string()),
2287 Message::user("msg3".to_string()),
2288 ];
2289 let provider = Arc::new(MockProvider::new(vec![Completion {
2290 content: "compact summary".to_string(),
2291 tool_calls: vec![],
2292 finish_reason: Some("stop".to_string()),
2293 usage: None,
2294 reasoning_content: None,
2295 }]));
2296 let mut core = make_test_core(messages, &hooks);
2297 core.llm = provider;
2298 core.compactor = Some(Compactor::new(0).keep_recent_n(2));
2300
2301 core.maybe_compact(1).await;
2302 assert!(
2304 core.messages[0].is_compaction_summary,
2305 "first message must be a compaction summary after compaction"
2306 );
2307 }
2308
2309 #[tokio::test]
2310 async fn maybe_compact_fires_at_threshold_boundary() {
2311 use crate::compact::Compactor;
2314 use crate::llm::{Completion, MockProvider};
2315 let hooks = crate::hooks::HookRegistry::new();
2316 let messages = vec![
2317 Message::system("sys".to_string()),
2318 Message::user("msg1".to_string()),
2319 Message::assistant("rep1".to_string()),
2320 Message::user("msg2".to_string()),
2321 Message::assistant("rep2".to_string()),
2322 Message::user("last".to_string()),
2323 ];
2324 let bytes = crate::compact::Compactor::estimate_bytes(&messages);
2326 let provider = Arc::new(MockProvider::new(vec![Completion {
2327 content: "summary".to_string(),
2328 tool_calls: vec![],
2329 finish_reason: Some("stop".to_string()),
2330 usage: None,
2331 reasoning_content: None,
2332 }]));
2333 let mut core = make_test_core(messages, &hooks);
2334 core.llm = provider;
2335 core.compactor = Some(Compactor::new(bytes).keep_recent_n(2));
2338
2339 core.maybe_compact(1).await;
2340 assert!(
2341 core.messages[0].is_compaction_summary,
2342 "compaction must fire when bytes == threshold (bytes is NOT less than threshold)"
2343 );
2344 }
2345
2346 #[tokio::test]
2347 async fn maybe_compact_uses_token_threshold_intra_turn() {
2348 use crate::compact::Compactor;
2353 use crate::llm::{Completion, MockProvider};
2354 let hooks = crate::hooks::HookRegistry::new();
2355 let messages = vec![
2356 Message::system("sys".to_string()),
2357 Message::user("msg1".to_string()),
2358 Message::assistant("rep1".to_string()),
2359 Message::user("msg2".to_string()),
2360 Message::assistant("rep2".to_string()),
2361 Message::user("msg3".to_string()),
2362 ];
2363 let provider = Arc::new(MockProvider::new(vec![Completion {
2364 content: "token-threshold triggered summary".to_string(),
2365 tool_calls: vec![],
2366 finish_reason: Some("stop".to_string()),
2367 usage: None,
2368 reasoning_content: None,
2369 }]));
2370 let mut core = make_test_core(messages, &hooks);
2371 core.llm = provider;
2372 let bytes = crate::compact::Compactor::estimate_bytes(&core.messages);
2375 core.compactor = Some(
2376 Compactor::new(bytes + 1000) .threshold_prompt_tokens(500) .keep_recent_n(2),
2379 );
2380 core.last_prompt_tokens = 500;
2382
2383 core.maybe_compact(1).await;
2384 assert!(
2385 core.messages[0].is_compaction_summary,
2386 "compaction must fire via token threshold even though chars are below char threshold"
2387 );
2388 }
2389
2390 #[tokio::test]
2401 async fn execute_tool_calls_plan_mode_guard_when_not_exploring() {
2402 use crate::llm::ToolCall;
2403 use crate::permissions::{PermissionMode, PermissionsConfig};
2404 use std::sync::atomic::AtomicBool;
2405
2406 let hooks = crate::hooks::HookRegistry::new();
2407 let tools = Arc::new(crate::tools::ToolRegistry::default().with_permissions(
2408 PermissionsConfig {
2409 mode: PermissionMode::Plan {
2410 pre_plan_mode: Box::new(PermissionMode::Default),
2411 bypass_available: false,
2412 },
2413 layers: vec![],
2414 },
2415 ));
2416
2417 let mut core = make_test_core(vec![], &hooks);
2418 core.tools = tools;
2419 core.exploring_plan_mode = Arc::new(AtomicBool::new(false));
2421
2422 let call = ToolCall {
2423 id: "tc1".to_string(),
2424 name: "Write".to_string(),
2425 arguments: serde_json::json!({}),
2426 };
2427
2428 let results = core.execute_tool_calls(&[call]).await;
2429
2430 assert_eq!(results.len(), 1, "should produce exactly one outcome");
2431 assert!(
2432 results[0].result.contains("enter_plan_mode"),
2433 "must tell the agent to call enter_plan_mode first; got: {}",
2434 results[0].result,
2435 );
2436 }
2437
2438 #[tokio::test]
2439 async fn maybe_compact_kept_count_is_correct() {
2440 use crate::compact::Compactor;
2444 use crate::event::AgentEvent;
2445 use crate::llm::{Completion, MockProvider};
2446 use tokio::sync::mpsc;
2447
2448 let hooks = crate::hooks::HookRegistry::new();
2449 let messages = vec![
2450 Message::system("sys".to_string()),
2451 Message::user("msg1".to_string()),
2452 Message::assistant("rep1".to_string()),
2453 Message::user("msg2".to_string()),
2454 Message::assistant("rep2".to_string()),
2455 Message::user("last".to_string()),
2456 ];
2457 let kept_before = messages.len();
2458 let provider = Arc::new(MockProvider::new(vec![Completion {
2459 content: "summary".to_string(),
2460 tool_calls: vec![],
2461 finish_reason: Some("stop".to_string()),
2462 usage: None,
2463 reasoning_content: None,
2464 }]));
2465 let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
2466 let mut core = make_test_core(messages, &hooks);
2467 core.llm = provider;
2468 core.events = Some(tx);
2469 core.compactor = Some(Compactor::new(0).keep_recent_n(2));
2470
2471 core.maybe_compact(1).await;
2472
2473 let mut found_compacted = false;
2475 while let Ok(ev) = rx.try_recv() {
2476 if let AgentEvent::Compacted { removed, kept, .. } = ev {
2477 assert_eq!(
2478 kept + removed,
2479 kept_before,
2480 "kept + removed must equal the original message count"
2481 );
2482 assert_eq!(
2484 kept,
2485 kept_before - removed,
2486 "kept must be kept_before - removed, not kept_before + removed"
2487 );
2488 found_compacted = true;
2489 }
2490 }
2491 assert!(found_compacted, "a Compacted event must have been emitted");
2492 }
2493
2494 #[tokio::test]
2499 async fn maybe_compact_circuit_breaker_skips_when_failures_exceed_threshold() {
2500 use crate::compact::{Compactor, MAX_CONSECUTIVE_COMPACT_FAILURES};
2503 use crate::event::AgentEvent::{Compacted, CompactionSkipped};
2504 use crate::event::CompactionSkipReason;
2505 use tokio::sync::mpsc;
2506
2507 let hooks = crate::hooks::HookRegistry::new();
2508 let messages = vec![
2509 Message::system("sys".to_string()),
2510 Message::user("long enough to trigger compaction".to_string()),
2511 Message::assistant("response".to_string()),
2512 ];
2513 let (tx, mut rx) = mpsc::unbounded_channel();
2514 let mut core = make_test_core(messages, &hooks);
2515 core.events = Some(tx);
2516 core.compactor = Some(Compactor::new(0)); core.consecutive_compact_failures = MAX_CONSECUTIVE_COMPACT_FAILURES;
2518
2519 core.maybe_compact(0).await;
2520
2521 let mut found_breaker = false;
2523 while let Ok(ev) = rx.try_recv() {
2524 match ev {
2525 Compacted { .. } => panic!("circuit breaker should prevent compaction"),
2526 CompactionSkipped { reason, .. } => {
2527 assert_eq!(reason, CompactionSkipReason::CircuitBreaker);
2528 found_breaker = true;
2529 }
2530 _ => {}
2531 }
2532 }
2533 assert!(found_breaker, "must emit CircuitBreaker skip event");
2534 assert_eq!(
2535 core.messages.len(),
2536 3,
2537 "must not compact when breaker is tripped"
2538 );
2539 }
2540
2541 #[tokio::test]
2542 async fn maybe_compact_circuit_breaker_resets_on_success() {
2543 use crate::compact::Compactor;
2545 use crate::llm::{Completion, MockProvider};
2546
2547 let hooks = crate::hooks::HookRegistry::new();
2548 let messages = vec![
2549 Message::system("sys".to_string()),
2550 Message::user("msg1".to_string()),
2551 Message::assistant("rep1".to_string()),
2552 Message::user("msg2".to_string()),
2553 Message::assistant("rep2".to_string()),
2554 ];
2555 let provider = Arc::new(MockProvider::new(vec![Completion {
2556 content: "summary".to_string(),
2557 tool_calls: vec![],
2558 finish_reason: Some("stop".to_string()),
2559 usage: None,
2560 reasoning_content: None,
2561 }]));
2562 let mut core = make_test_core(messages, &hooks);
2563 core.llm = provider;
2564 core.compactor = Some(Compactor::new(0).keep_recent_n(2));
2565 core.consecutive_compact_failures = 2; core.maybe_compact(1).await;
2568
2569 assert_eq!(
2570 core.consecutive_compact_failures, 0,
2571 "breaker must reset on success"
2572 );
2573 }
2574
2575 #[tokio::test]
2576 async fn maybe_compact_circuit_breaker_increments_on_error() {
2577 use crate::compact::Compactor;
2579 use crate::error::Error;
2580 use crate::event::AgentEvent::CompactionSkipped;
2581 use crate::event::CompactionSkipReason;
2582 use crate::llm::MockProvider;
2583 use tokio::sync::mpsc;
2584
2585 let hooks = crate::hooks::HookRegistry::new();
2586 let messages = vec![
2588 Message::system("sys".to_string()),
2589 Message::user("msg1".to_string()),
2590 Message::assistant("rep1".to_string()),
2591 Message::user("msg2".to_string()),
2592 Message::assistant("rep2".to_string()),
2593 ];
2594 let provider = Arc::new(MockProvider::new(vec![]).with_errors(vec![Error::Llm {
2596 provider: "mock".into(),
2597 message: "simulated compaction failure".into(),
2598 }]));
2599 let (tx, mut rx) = mpsc::unbounded_channel();
2600 let mut core = make_test_core(messages, &hooks);
2601 core.llm = provider;
2602 core.events = Some(tx);
2603 core.compactor = Some(Compactor::new(0).keep_recent_n(2));
2604 core.consecutive_compact_failures = 0;
2605
2606 core.maybe_compact(1).await;
2607
2608 assert_eq!(
2609 core.consecutive_compact_failures, 1,
2610 "breaker must increment on error"
2611 );
2612 let mut found_error = false;
2614 while let Ok(ev) = rx.try_recv() {
2615 if let CompactionSkipped { reason, .. } = ev {
2616 assert_eq!(reason, CompactionSkipReason::Error);
2617 found_error = true;
2618 }
2619 }
2620 assert!(found_error, "must emit CompactionSkipped::Error event");
2621 }
2622
2623 #[tokio::test]
2624 async fn maybe_compact_circuit_breaker_accumulates_to_threshold() {
2625 use crate::compact::{Compactor, MAX_CONSECUTIVE_COMPACT_FAILURES};
2627 use crate::error::Error;
2628 use crate::event::AgentEvent::{Compacted, CompactionSkipped};
2629 use crate::event::CompactionSkipReason;
2630 use crate::llm::MockProvider;
2631 use tokio::sync::mpsc;
2632
2633 let hooks = crate::hooks::HookRegistry::new();
2634 let messages = vec![
2636 Message::system("sys".to_string()),
2637 Message::user("msg1".to_string()),
2638 Message::assistant("rep1".to_string()),
2639 Message::user("msg2".to_string()),
2640 Message::assistant("rep2".to_string()),
2641 ];
2642 let mk_err = || Error::Llm {
2644 provider: "mock".into(),
2645 message: "simulated compaction failure".into(),
2646 };
2647 let provider = Arc::new(MockProvider::new(vec![]).with_errors(vec![
2648 mk_err(),
2649 mk_err(),
2650 mk_err(),
2651 mk_err(),
2652 ]));
2653 let (tx, mut rx) = mpsc::unbounded_channel();
2654 let mut core = make_test_core(messages, &hooks);
2655 core.llm = provider.clone();
2656 core.events = Some(tx);
2657 core.compactor = Some(Compactor::new(0).keep_recent_n(2));
2658 core.consecutive_compact_failures = 0;
2659
2660 for step in 0..(MAX_CONSECUTIVE_COMPACT_FAILURES - 1) as usize {
2662 core.maybe_compact(step).await;
2663 }
2664 assert_eq!(
2665 core.consecutive_compact_failures,
2666 MAX_CONSECUTIVE_COMPACT_FAILURES - 1,
2667 "after {} failures, counter should be {}",
2668 MAX_CONSECUTIVE_COMPACT_FAILURES - 1,
2669 MAX_CONSECUTIVE_COMPACT_FAILURES - 1
2670 );
2671
2672 while rx.try_recv().is_ok() {}
2675 core.maybe_compact((MAX_CONSECUTIVE_COMPACT_FAILURES - 1) as usize)
2676 .await;
2677
2678 assert_eq!(
2680 core.consecutive_compact_failures, MAX_CONSECUTIVE_COMPACT_FAILURES,
2681 "counter must not exceed threshold"
2682 );
2683
2684 let (tx2, mut rx2) = mpsc::unbounded_channel();
2686 core.events = Some(tx2);
2687 core.maybe_compact(MAX_CONSECUTIVE_COMPACT_FAILURES as usize)
2688 .await;
2689
2690 assert_eq!(
2691 core.consecutive_compact_failures, MAX_CONSECUTIVE_COMPACT_FAILURES,
2692 "breaker tripped: counter should stay at max"
2693 );
2694
2695 let mut breaker_count = 0;
2697 while let Ok(ev) = rx2.try_recv() {
2698 if let CompactionSkipped { ref reason, .. } = ev {
2699 assert_eq!(*reason, CompactionSkipReason::CircuitBreaker);
2700 breaker_count += 1;
2701 }
2702 if let Compacted { .. } = ev {
2703 panic!("circuit breaker should prevent compaction once tripped");
2704 }
2705 }
2706 assert!(
2707 breaker_count >= 1,
2708 "must emit CircuitBreaker event when tripped"
2709 );
2710 }
2711
2712 fn make_run_core_for_inner<'a>(
2723 messages: Vec<Message>,
2724 hooks: &'a crate::hooks::HookRegistry,
2725 provider: Arc<crate::llm::MockProvider>,
2726 max_steps: usize,
2727 ) -> RunCore<'a> {
2728 use std::sync::atomic::AtomicBool;
2729 RunCore {
2730 messages: Arc::new(messages),
2731 llm: provider,
2732 tools: Arc::new(crate::tools::ToolRegistry::default()),
2733 max_steps,
2734 max_transcript_chars: None,
2735 events: None,
2736 streaming: false,
2737 compactor: None,
2738 microcompactor: None,
2739 permission_hook: None,
2740 hooks,
2741 total_llm_latency_ms: 0,
2742 exploring_plan_mode: Arc::new(AtomicBool::new(false)),
2743 shutdown_token: None,
2744 mailbox: None,
2745 stuck_window: 3,
2746 stuck_error_rate: 1.0,
2747 turn: 0,
2748 globs_skills: vec![],
2749 prompt_segments: None,
2750 static_breakdown: StaticBreakdownCache::default(),
2751 last_prompt_tokens: 0,
2752 consecutive_compact_failures: 0,
2753 wall_timeout_secs: 0,
2754 wall_start: None,
2755 }
2756 }
2757
2758 #[tokio::test]
2765 async fn run_inner_returns_transcript_limit_when_chars_at_or_above_limit() {
2766 use crate::agent::FinishReason;
2767
2768 let hooks = crate::hooks::HookRegistry::new();
2769 let messages = vec![Message::system("hello".to_string())];
2771 let provider = Arc::new(crate::llm::MockProvider::new(vec![]));
2772 let mut core = make_run_core_for_inner(messages, &hooks, provider, 1);
2773 core.max_transcript_chars = Some(3); let outcome = core.run_inner().await.expect("run_inner must not error");
2776 assert!(
2777 matches!(outcome.finish_reason, FinishReason::TranscriptLimit { .. }),
2778 "expected TranscriptLimit, got {:?}",
2779 outcome.finish_reason,
2780 );
2781 }
2782
2783 #[tokio::test]
2790 async fn run_inner_emits_reasoning_event_when_reasoning_nonempty() {
2791 use crate::event::AgentEvent;
2792 use crate::llm::Completion;
2793 use tokio::sync::mpsc;
2794
2795 let hooks = crate::hooks::HookRegistry::new();
2796 let provider = Arc::new(crate::llm::MockProvider::new(vec![Completion {
2797 content: "done".to_string(),
2798 tool_calls: vec![],
2799 finish_reason: Some("stop".to_string()),
2800 usage: None,
2801 reasoning_content: Some("I thought about it".to_string()),
2802 }]));
2803 let messages = vec![Message::user("hello".to_string())];
2804 let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
2805 let mut core = make_run_core_for_inner(messages, &hooks, provider, 1);
2806 core.events = Some(tx);
2807
2808 let outcome = core.run_inner().await.expect("run_inner must not error");
2809 drop(outcome); let events: Vec<AgentEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
2812 assert!(
2813 events.iter().any(|e| matches!(e, AgentEvent::Reasoning { .. })),
2814 "AgentEvent::Reasoning must be emitted when reasoning_content is non-empty; got: {events:?}",
2815 );
2816 }
2817
2818 #[tokio::test]
2826 async fn run_inner_provider_stop_for_nonstandard_finish_reason() {
2827 use crate::agent::FinishReason;
2828 use crate::llm::Completion;
2829
2830 let hooks = crate::hooks::HookRegistry::new();
2831 let provider = Arc::new(crate::llm::MockProvider::new(vec![Completion {
2832 content: "provider paused".to_string(),
2833 tool_calls: vec![],
2834 finish_reason: Some("rate_limited".to_string()),
2835 usage: None,
2836 reasoning_content: None,
2837 }]));
2838 let messages = vec![Message::user("hello".to_string())];
2839 let core = make_run_core_for_inner(messages, &hooks, provider, 1);
2840
2841 let outcome = core.run_inner().await.expect("run_inner must not error");
2842 assert!(
2843 matches!(
2844 &outcome.finish_reason,
2845 FinishReason::ProviderStop(r) if r == "rate_limited"
2846 ),
2847 "expected ProviderStop(\"rate_limited\"), got {:?}",
2848 outcome.finish_reason,
2849 );
2850 }
2851
2852 #[tokio::test]
2855 async fn run_inner_no_provider_stop_for_stop_finish_reason() {
2856 use crate::agent::FinishReason;
2857 use crate::llm::Completion;
2858
2859 let hooks = crate::hooks::HookRegistry::new();
2860 let provider = Arc::new(crate::llm::MockProvider::new(vec![Completion {
2861 content: "all done".to_string(),
2862 tool_calls: vec![],
2863 finish_reason: Some("stop".to_string()),
2864 usage: None,
2865 reasoning_content: None,
2866 }]));
2867 let messages = vec![Message::user("hello".to_string())];
2868 let core = make_run_core_for_inner(messages, &hooks, provider, 1);
2869
2870 let outcome = core.run_inner().await.expect("run_inner must not error");
2871 assert!(
2872 matches!(outcome.finish_reason, FinishReason::NoMoreToolCalls),
2873 "expected NoMoreToolCalls, got {:?}",
2874 outcome.finish_reason,
2875 );
2876 }
2877
2878 #[tokio::test]
2888 async fn cancelled_mid_stream_persists_transcript_and_finish_reason() {
2889 use crate::agent::FinishReason;
2890 use crate::event::AgentEvent;
2891 use tokio::sync::mpsc;
2892
2893 let hooks = crate::hooks::HookRegistry::new();
2894 let provider = Arc::new(
2899 crate::llm::MockProvider::new(vec![]).with_errors(vec![crate::error::Error::Cancelled]),
2900 );
2901 let messages = vec![Message::user("hello".to_string())];
2902 let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
2903 let mut core = make_run_core_for_inner(messages, &hooks, provider, 3);
2904 core.events = Some(tx);
2905 core.shutdown_token = Some(tokio_util::sync::CancellationToken::new());
2908
2909 let outcome = core.run_inner().await.expect("run_inner must not error");
2910 assert!(
2911 matches!(outcome.finish_reason, FinishReason::Cancelled),
2912 "expected FinishReason::Cancelled, got {:?}",
2913 outcome.finish_reason,
2914 );
2915 assert_eq!(
2920 outcome.steps, 1,
2921 "mid-stream cancel counts the in-flight step (not step - 1)",
2922 );
2923
2924 assert_eq!(outcome.messages.len(), 1, "transcript must be preserved");
2926 assert_eq!(
2927 outcome.messages[0].role,
2928 crate::message::Role::User,
2929 "seeded user message must be in the outcome transcript",
2930 );
2931
2932 let events: Vec<AgentEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
2933 assert!(
2934 events.iter().any(|e| matches!(
2935 e,
2936 AgentEvent::TurnFinished { reason, steps }
2937 if reason.as_str() == "cancelled" && *steps == 1
2938 )),
2939 "TurnFinished(cancelled) must be emitted with the in-flight step; got: {events:?}",
2940 );
2941 }
2942
2943 #[tokio::test]
2952 async fn interrupted_completion_persists_partial_content_as_cancelled() {
2953 use crate::agent::FinishReason;
2954 use crate::llm::Completion;
2955 use crate::message::Role;
2956
2957 let hooks = crate::hooks::HookRegistry::new();
2958 let provider = Arc::new(crate::llm::MockProvider::new(vec![Completion {
2959 content: "partial reply…".to_string(),
2960 tool_calls: vec![],
2961 finish_reason: Some("interrupted".to_string()),
2962 usage: None,
2963 reasoning_content: Some("half a thought".to_string()),
2964 }]));
2965 let messages = vec![Message::user("hello".to_string())];
2966 let core = make_run_core_for_inner(messages, &hooks, provider, 3);
2967
2968 let outcome = core.run_inner().await.expect("run_inner must not error");
2969 assert!(
2970 matches!(outcome.finish_reason, FinishReason::Cancelled),
2971 "interrupted completion must finish as Cancelled, got {:?}",
2972 outcome.finish_reason,
2973 );
2974
2975 assert_eq!(
2978 outcome.final_message.as_deref(),
2979 Some("partial reply…"),
2980 "the partial reply must become the final_message",
2981 );
2982
2983 let assistant_msgs: Vec<&Message> = outcome
2987 .messages
2988 .iter()
2989 .filter(|m| m.role == Role::Assistant)
2990 .collect();
2991 assert_eq!(
2992 assistant_msgs.len(),
2993 1,
2994 "exactly one partial assistant message must be pushed",
2995 );
2996 assert_eq!(assistant_msgs[0].content, "partial reply…");
2997 assert_eq!(
2998 assistant_msgs[0].reasoning_content.as_deref(),
2999 Some("half a thought"),
3000 "reasoning must be attached to the partial assistant message",
3001 );
3002 }
3003
3004 #[tokio::test]
3011 async fn cancelled_between_steps_uses_check_shutdown_path() {
3012 use crate::agent::FinishReason;
3013
3014 let hooks = crate::hooks::HookRegistry::new();
3015 let provider = Arc::new(crate::llm::MockProvider::new(vec![]));
3019 let messages = vec![Message::user("hello".to_string())];
3020 let mut core = make_run_core_for_inner(messages, &hooks, provider, 3);
3021 core.shutdown_token = Some(tokio_util::sync::CancellationToken::new());
3022 core.shutdown_token
3024 .as_ref()
3025 .expect("token just set")
3026 .cancel();
3027
3028 let outcome = core.run_inner().await.expect("run_inner must not error");
3029 assert!(
3030 matches!(outcome.finish_reason, FinishReason::Cancelled),
3031 "expected FinishReason::Cancelled, got {:?}",
3032 outcome.finish_reason,
3033 );
3034 assert_eq!(
3035 outcome.steps, 0,
3036 "between-steps cancel happens before any LLM call → 0 steps",
3037 );
3038 }
3039
3040 #[tokio::test]
3047 async fn non_cancelled_error_still_bubbles() {
3048 let hooks = crate::hooks::HookRegistry::new();
3049 let provider = Arc::new(crate::llm::MockProvider::new(vec![]).with_errors(vec![
3053 crate::error::Error::Llm {
3054 provider: "mock".to_string(),
3055 message: "injected non-cancelled error".to_string(),
3056 },
3057 ]));
3058 let messages = vec![Message::user("hello".to_string())];
3059 let core = make_run_core_for_inner(messages, &hooks, provider, 1);
3060
3061 let err = match core.run_inner().await {
3062 Ok(_) => panic!("run_inner must error for non-Cancelled provider errors"),
3063 Err(e) => e,
3064 };
3065 assert!(
3066 matches!(err, crate::error::Error::Llm { .. }),
3067 "expected Error::Llm, got {err:?}",
3068 );
3069 }
3070
3071 #[tokio::test]
3085 async fn run_inner_stuck_detection_fires_after_window_of_errors() {
3086 use crate::agent::FinishReason;
3087 use crate::llm::{Completion, ToolCall};
3088
3089 let hooks = crate::hooks::HookRegistry::new();
3090 let make_tc_completion = |id: &str| Completion {
3092 content: String::new(),
3093 tool_calls: vec![ToolCall {
3094 id: id.to_string(),
3095 name: "NonExistentTool".to_string(),
3096 arguments: serde_json::json!({}),
3097 }],
3098 finish_reason: None,
3099 usage: None,
3100 reasoning_content: None,
3101 };
3102 let provider = Arc::new(crate::llm::MockProvider::new(vec![
3103 make_tc_completion("tc1"),
3104 make_tc_completion("tc2"),
3105 make_tc_completion("tc3"),
3106 ]));
3107 let messages = vec![Message::user("keep trying".to_string())];
3108 let mut core = make_run_core_for_inner(messages, &hooks, provider, 5);
3109 core.stuck_window = 3;
3110 core.stuck_error_rate = 1.0;
3111
3112 let outcome = core.run_inner().await.expect("run_inner must not error");
3113 assert!(
3114 matches!(outcome.finish_reason, FinishReason::Stuck { .. }),
3115 "expected Stuck after 3 consecutive errors, got {:?}",
3116 outcome.finish_reason,
3117 );
3118 }
3119
3120 #[tokio::test]
3131 async fn run_inner_stuck_fires_only_after_window_full() {
3132 use crate::agent::FinishReason;
3133 use crate::llm::{Completion, ToolCall};
3134
3135 let hooks = crate::hooks::HookRegistry::new();
3136 let make_tc = |id: &str| Completion {
3137 content: String::new(),
3138 tool_calls: vec![ToolCall {
3139 id: id.to_string(),
3140 name: "NonExistentTool".to_string(),
3141 arguments: serde_json::json!({}),
3142 }],
3143 finish_reason: None,
3144 usage: None,
3145 reasoning_content: None,
3146 };
3147 let provider = Arc::new(crate::llm::MockProvider::new(vec![
3149 make_tc("tc1"),
3150 make_tc("tc2"),
3151 ]));
3152 let messages = vec![Message::user("go".to_string())];
3153 let mut core = make_run_core_for_inner(messages, &hooks, provider, 2);
3154 core.stuck_window = 3;
3155 core.stuck_error_rate = 0.5; let outcome = core.run_inner().await.expect("run_inner must not error");
3158 assert!(
3159 matches!(outcome.finish_reason, FinishReason::BudgetExceeded),
3160 "expected BudgetExceeded (window not yet full), got {:?}",
3161 outcome.finish_reason,
3162 );
3163 }
3164
3165 #[tokio::test]
3175 async fn run_inner_stuck_rate_uses_division_not_multiplication() {
3176 use crate::agent::FinishReason;
3177 use crate::llm::ToolSpec;
3178 use crate::llm::{Completion, ToolCall};
3179 use crate::tools::{Tool, ToolRegistry};
3180 use async_trait::async_trait;
3181
3182 struct SuccessTool;
3183
3184 #[async_trait]
3185 impl Tool for SuccessTool {
3186 fn spec(&self) -> ToolSpec {
3187 ToolSpec {
3188 name: "SuccessTool".to_string(),
3189 description: "Returns ok".to_string(),
3190 parameters: serde_json::json!({ "type": "object", "properties": {} }),
3191 }
3192 }
3193 async fn execute(&self, _args: serde_json::Value) -> crate::error::Result<String> {
3194 Ok("ok".to_string())
3195 }
3196 }
3197
3198 let hooks = crate::hooks::HookRegistry::new();
3199 let registry = ToolRegistry::default().register(Arc::new(SuccessTool));
3200
3201 let make_ok = |id: &str| Completion {
3202 content: String::new(),
3203 tool_calls: vec![ToolCall {
3204 id: id.to_string(),
3205 name: "SuccessTool".to_string(),
3206 arguments: serde_json::json!({}),
3207 }],
3208 finish_reason: None,
3209 usage: None,
3210 reasoning_content: None,
3211 };
3212 let make_err = |id: &str| Completion {
3213 content: String::new(),
3214 tool_calls: vec![ToolCall {
3215 id: id.to_string(),
3216 name: "NonExistentTool".to_string(),
3217 arguments: serde_json::json!({}),
3218 }],
3219 finish_reason: None,
3220 usage: None,
3221 reasoning_content: None,
3222 };
3223 let provider = Arc::new(crate::llm::MockProvider::new(vec![
3225 make_ok("tc1"),
3226 make_ok("tc2"),
3227 make_err("tc3"),
3228 ]));
3229 let messages = vec![Message::user("test".to_string())];
3230 let mut core = make_run_core_for_inner(messages, &hooks, provider, 3);
3231 core.tools = Arc::new(registry);
3232 core.stuck_window = 3;
3233 core.stuck_error_rate = 0.5; let outcome = core.run_inner().await.expect("run_inner must not error");
3236 assert!(
3237 matches!(outcome.finish_reason, FinishReason::BudgetExceeded),
3238 "expected BudgetExceeded (rate below threshold), got {:?}",
3239 outcome.finish_reason,
3240 );
3241 }
3242
3243 #[tokio::test]
3256 async fn run_inner_denial_sentinel_emits_is_error_true() {
3257 use crate::event::AgentEvent;
3258 use crate::llm::{Completion, ToolCall, ToolSpec};
3259 use crate::tools::{Tool, ToolRegistry};
3260 use async_trait::async_trait;
3261 use tokio::sync::mpsc;
3262
3263 struct DenialTool;
3264
3265 #[async_trait]
3266 impl Tool for DenialTool {
3267 fn spec(&self) -> ToolSpec {
3268 ToolSpec {
3269 name: "DenialTool".to_string(),
3270 description: "Returns PermissionDeniedLimit".to_string(),
3271 parameters: serde_json::json!({ "type": "object", "properties": {} }),
3272 }
3273 }
3274 async fn execute(&self, _args: serde_json::Value) -> crate::error::Result<String> {
3275 Err(crate::error::Error::PermissionDeniedLimit {
3276 name: "DenialTool".to_string(),
3277 })
3278 }
3279 }
3280
3281 let hooks = crate::hooks::HookRegistry::new();
3282 let registry = ToolRegistry::default().register(Arc::new(DenialTool));
3283 let provider = Arc::new(crate::llm::MockProvider::new(vec![Completion {
3284 content: String::new(),
3285 tool_calls: vec![ToolCall {
3286 id: "tc1".to_string(),
3287 name: "DenialTool".to_string(),
3288 arguments: serde_json::json!({}),
3289 }],
3290 finish_reason: None,
3291 usage: None,
3292 reasoning_content: None,
3293 }]));
3294
3295 let messages = vec![Message::user("trigger denial".to_string())];
3296 let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
3297 let mut core = make_run_core_for_inner(messages, &hooks, provider, 3);
3298 core.tools = Arc::new(registry);
3299 core.events = Some(tx);
3300
3301 let outcome = core.run_inner().await.expect("run_inner must not error");
3302 assert!(
3303 matches!(
3304 outcome.finish_reason,
3305 crate::agent::FinishReason::PermissionDenialLimit
3306 ),
3307 "expected PermissionDenialLimit, got {:?}",
3308 outcome.finish_reason,
3309 );
3310
3311 let mut events = vec![];
3312 while let Ok(e) = rx.try_recv() {
3313 events.push(e);
3314 }
3315 let denial_is_error = events.iter().find_map(|e| {
3316 if let AgentEvent::ToolResult { name, is_error, .. } = e {
3317 if name == "DenialTool" {
3318 return Some(*is_error);
3319 }
3320 }
3321 None
3322 });
3323 assert_eq!(
3324 denial_is_error,
3325 Some(true),
3326 "ToolResult for DENIAL_LIMIT_SENTINEL must have is_error = true; events: {events:?}"
3327 );
3328 }
3329
3330 #[test]
3339 fn compute_breakdown_overhead_is_provider_total_minus_local_sum() {
3340 use crate::llm::ContextBreakdown;
3341
3342 let hooks = crate::hooks::HookRegistry::new();
3343 let messages = vec![
3345 Message::system("s".to_string()),
3346 Message::user("hello world this is a turn".to_string()),
3347 Message::assistant("ok".to_string()),
3348 ];
3349 let mut core = make_test_core(messages, &hooks);
3350 core.static_breakdown = StaticBreakdownCache {
3352 system_prompt: 100,
3353 rules: 50,
3354 skills: 25,
3355 subagents: 10,
3356 tools: 15,
3357 mcp_dynamic: 0,
3358 };
3359 let breakdown = core.compute_breakdown(1000);
3360
3361 let local = breakdown.local_sum();
3363 let expected_overhead = 1000u32.saturating_sub(local);
3364 assert_eq!(
3365 breakdown.overhead, expected_overhead,
3366 "overhead must be provider_total - local_sum"
3367 );
3368 assert!(
3370 breakdown.conversation > 0,
3371 "conversation bucket must be non-zero for a seeded transcript"
3372 );
3373 assert_eq!(breakdown.system_prompt, 100);
3375 assert_eq!(breakdown.rules, 50);
3376 assert_eq!(breakdown.skills, 25);
3377 assert_eq!(breakdown.subagents, 10);
3378 assert_eq!(breakdown.tools, 15);
3379 assert_eq!(breakdown.mcp_dynamic, 0);
3380 assert_eq!(breakdown.local_sum(), local);
3382 assert_eq!(breakdown.total(), local + expected_overhead);
3383 let _ = ContextBreakdown::default();
3385 }
3386
3387 #[test]
3391 fn compute_breakdown_overhead_saturates_at_zero_when_local_exceeds_provider() {
3392 let hooks = crate::hooks::HookRegistry::new();
3393 let mut core = make_test_core(vec![Message::user("hi".to_string())], &hooks);
3394 core.static_breakdown = StaticBreakdownCache {
3395 system_prompt: 5000,
3396 rules: 1000,
3397 skills: 0,
3398 subagents: 0,
3399 tools: 0,
3400 mcp_dynamic: 0,
3401 };
3402 let breakdown = core.compute_breakdown(100); assert_eq!(
3404 breakdown.overhead, 0,
3405 "overhead must saturate to 0 when local sum > provider total; got {}",
3406 breakdown.overhead
3407 );
3408 }
3409
3410 #[tokio::test]
3416 async fn dispatch_llm_step_emits_context_breakdown_after_usage() {
3417 use crate::event::AgentEvent;
3418 use crate::llm::TokenUsage;
3419 use tokio::sync::mpsc;
3420
3421 let hooks = crate::hooks::HookRegistry::new();
3422 let mut core = make_test_core(vec![Message::user("hello world".to_string())], &hooks);
3423 core.static_breakdown = StaticBreakdownCache {
3425 system_prompt: 100,
3426 rules: 50,
3427 skills: 25,
3428 subagents: 10,
3429 tools: 15,
3430 mcp_dynamic: 0,
3431 };
3432 let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
3433 core.events = Some(tx);
3434
3435 let usage = TokenUsage {
3440 prompt_tokens: 1000,
3441 completion_tokens: 50,
3442 total_tokens: 1050,
3443 cache_hit_tokens: 0,
3444 cache_miss_tokens: 0,
3445 reasoning_tokens: 0,
3446 };
3447 core.emit_breakdown(7, &usage);
3448
3449 let event = rx
3451 .try_recv()
3452 .expect("ContextBreakdown event must be emitted");
3453 match event {
3454 AgentEvent::ContextBreakdown { breakdown, step } => {
3455 assert_eq!(step, 7, "step must be forwarded verbatim");
3456 let local = breakdown.local_sum();
3457 let expected_overhead = 1000u32.saturating_sub(local);
3458 assert_eq!(breakdown.overhead, expected_overhead);
3459 assert!(
3460 breakdown.total() >= 1000,
3461 "total must be >= provider_total; got {}",
3462 breakdown.total()
3463 );
3464 }
3465 other => panic!("expected ContextBreakdown event, got {other:?}"),
3466 }
3467 }
3468
3469 #[tokio::test]
3474 async fn run_inner_emits_context_breakdown_once_per_llm_step() {
3475 use crate::agent::FinishReason;
3476 use crate::event::AgentEvent;
3477 use crate::llm::{Completion, TokenUsage};
3478 use tokio::sync::mpsc;
3479
3480 let hooks = crate::hooks::HookRegistry::new();
3481 let provider = Arc::new(crate::llm::MockProvider::new(vec![Completion {
3482 content: "done".to_string(),
3483 tool_calls: vec![],
3484 finish_reason: Some("stop".to_string()),
3485 usage: Some(TokenUsage {
3486 prompt_tokens: 1000,
3487 completion_tokens: 50,
3488 total_tokens: 1050,
3489 cache_hit_tokens: 0,
3490 cache_miss_tokens: 0,
3491 reasoning_tokens: 0,
3492 }),
3493 reasoning_content: None,
3494 }]));
3495 let messages = vec![Message::user("hi".to_string())];
3496 let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
3497 let mut core = make_run_core_for_inner(messages, &hooks, provider, 1);
3498 core.events = Some(tx);
3499
3500 let outcome = core.run_inner().await.expect("run_inner must not error");
3501 assert_eq!(outcome.finish_reason, FinishReason::NoMoreToolCalls);
3502
3503 let mut events = Vec::new();
3506 while let Ok(e) = rx.try_recv() {
3507 events.push(e);
3508 }
3509 let usage_idx = events
3510 .iter()
3511 .position(|e| matches!(e, AgentEvent::Usage { .. }))
3512 .expect("a Usage event must have been emitted");
3513 let breakdown_idx = events
3514 .iter()
3515 .position(|e| matches!(e, AgentEvent::ContextBreakdown { .. }))
3516 .expect("a ContextBreakdown event must have been emitted");
3517 assert!(
3518 breakdown_idx > usage_idx,
3519 "ContextBreakdown (idx={breakdown_idx}) must come AFTER Usage (idx={usage_idx})"
3520 );
3521 }
3522
3523 #[tokio::test]
3526 async fn run_inner_skips_context_breakdown_on_no_llm_step() {
3527 use crate::agent::FinishReason;
3528 use crate::event::AgentEvent;
3529 use tokio::sync::mpsc;
3530
3531 let hooks = crate::hooks::HookRegistry::new();
3532 let provider = Arc::new(crate::llm::MockProvider::new(vec![]));
3533 let messages = vec![Message::system("hello".to_string())];
3534 let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
3535 let mut core = make_run_core_for_inner(messages, &hooks, provider, 1);
3536 core.events = Some(tx);
3537 core.max_transcript_chars = Some(3);
3540
3541 let outcome = core.run_inner().await.expect("run_inner must not error");
3542 assert!(
3543 matches!(outcome.finish_reason, FinishReason::TranscriptLimit { .. }),
3544 "expected TranscriptLimit finish; got {:?}",
3545 outcome.finish_reason
3546 );
3547
3548 let mut events = Vec::new();
3549 while let Ok(e) = rx.try_recv() {
3550 events.push(e);
3551 }
3552 assert!(
3553 !events
3554 .iter()
3555 .any(|e| matches!(e, AgentEvent::ContextBreakdown { .. })),
3556 "no ContextBreakdown must be emitted on a no-LLM step; got: {events:?}"
3557 );
3558 assert!(
3559 !events.iter().any(|e| matches!(e, AgentEvent::Usage { .. })),
3560 "no Usage must be emitted on a no-LLM step; got: {events:?}"
3561 );
3562 }
3563
3564 #[tokio::test]
3572 async fn static_buckets_dont_change_across_steps_conversation_grows() {
3573 use crate::event::AgentEvent;
3574 use crate::llm::{Completion, TokenUsage, ToolCall, ToolSpec};
3575 use crate::tools::{Tool, ToolRegistry};
3576 use async_trait::async_trait;
3577 use serde_json::{json, Value};
3578 use tokio::sync::mpsc;
3579
3580 struct Adder;
3583 #[async_trait]
3584 impl Tool for Adder {
3585 fn spec(&self) -> ToolSpec {
3586 ToolSpec {
3587 name: "add".to_string(),
3588 description: "add two numbers".to_string(),
3589 parameters: json!({"type":"object","properties":{"a":{"type":"integer"},"b":{"type":"integer"}}}),
3590 }
3591 }
3592 async fn execute(&self, _args: Value) -> crate::error::Result<String> {
3593 Ok("7".to_string())
3594 }
3595 }
3596
3597 let hooks = crate::hooks::HookRegistry::new();
3598 let registry = ToolRegistry::default().register(Arc::new(Adder));
3599 let provider = Arc::new(crate::llm::MockProvider::new(vec![
3600 Completion {
3602 content: "calculating…".to_string(),
3603 tool_calls: vec![ToolCall {
3604 id: "tc1".to_string(),
3605 name: "add".to_string(),
3606 arguments: json!({"a": 3, "b": 4}),
3607 }],
3608 finish_reason: Some("tool_calls".to_string()),
3609 usage: Some(TokenUsage {
3610 prompt_tokens: 1000,
3611 completion_tokens: 30,
3612 total_tokens: 1030,
3613 cache_hit_tokens: 0,
3614 cache_miss_tokens: 0,
3615 reasoning_tokens: 0,
3616 }),
3617 reasoning_content: None,
3618 },
3619 Completion {
3621 content: "the answer is 7".to_string(),
3622 tool_calls: vec![],
3623 finish_reason: Some("stop".to_string()),
3624 usage: Some(TokenUsage {
3625 prompt_tokens: 1200, completion_tokens: 50,
3627 total_tokens: 1250,
3628 cache_hit_tokens: 0,
3629 cache_miss_tokens: 0,
3630 reasoning_tokens: 0,
3631 }),
3632 reasoning_content: None,
3633 },
3634 ]));
3635 let messages = vec![Message::user("hi".to_string())];
3636 let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
3637 let mut core = make_run_core_for_inner(messages, &hooks, provider, 5);
3638 core.tools = Arc::new(registry);
3639 core.events = Some(tx);
3640 core.static_breakdown = StaticBreakdownCache {
3643 system_prompt: 100,
3644 rules: 50,
3645 skills: 25,
3646 subagents: 10,
3647 tools: 15,
3648 mcp_dynamic: 0,
3649 };
3650 core.prompt_segments = Some(crate::system_prompt::PromptSegments {
3651 rules: "RR".into(),
3652 system_prompt: "SS".into(),
3653 skills: String::new(),
3654 subagents: String::new(),
3655 });
3656
3657 let outcome = core.run_inner().await.expect("run_inner must not error");
3658 drop(outcome); let mut breakdowns = Vec::new();
3662 while let Ok(e) = rx.try_recv() {
3663 if let AgentEvent::ContextBreakdown { breakdown, .. } = e {
3664 breakdowns.push(breakdown);
3665 }
3666 }
3667 assert_eq!(
3668 breakdowns.len(),
3669 2,
3670 "two ContextBreakdown events must have been emitted; got {breakdowns:?}"
3671 );
3672
3673 assert_eq!(breakdowns[0].system_prompt, breakdowns[1].system_prompt);
3675 assert_eq!(breakdowns[0].rules, breakdowns[1].rules);
3676 assert_eq!(breakdowns[0].skills, breakdowns[1].skills);
3677 assert_eq!(breakdowns[0].subagents, breakdowns[1].subagents);
3678 assert_eq!(breakdowns[0].tools, breakdowns[1].tools);
3679 assert_eq!(breakdowns[0].mcp_dynamic, breakdowns[1].mcp_dynamic);
3680 assert_eq!(
3681 breakdowns[0].system_prompt, 100,
3682 "static_breakdown cache must be honoured"
3683 );
3684
3685 assert!(
3688 breakdowns[1].conversation > breakdowns[0].conversation,
3689 "conversation must grow between steps: step1={} step2={}",
3690 breakdowns[0].conversation,
3691 breakdowns[1].conversation
3692 );
3693
3694 assert!(breakdowns[0].overhead > 0);
3698 assert!(breakdowns[1].overhead > 0);
3699 }
3700
3701 #[test]
3707 fn static_breakdown_cache_build_zero_specs_yields_zero_tokens() {
3708 let segments = crate::system_prompt::PromptSegments::default();
3709 let registry = crate::tools::ToolRegistry::default();
3710 let cache = StaticBreakdownCache::build(&segments, &[], ®istry);
3711 assert_eq!(cache.system_prompt, 0);
3712 assert_eq!(cache.rules, 0);
3713 assert_eq!(cache.skills, 0);
3714 assert_eq!(cache.subagents, 0);
3715 assert_eq!(cache.tools, 0);
3716 assert_eq!(cache.mcp_dynamic, 0);
3717 }
3718
3719 #[test]
3722 fn static_breakdown_cache_build_tokenises_segments() {
3723 let segments = crate::system_prompt::PromptSegments {
3725 rules: "012345678".into(), system_prompt: "1234567890".into(), skills: "abcde".into(), subagents: "abcdefgh".into(), };
3730 let registry = crate::tools::ToolRegistry::default();
3731 let cache = StaticBreakdownCache::build(&segments, &[], ®istry);
3732 assert_eq!(cache.rules, 3, "9 chars / 4 = 2.25 → ceil = 3");
3733 assert_eq!(cache.system_prompt, 3, "10 chars / 4 = 2.5 → ceil = 3");
3734 assert_eq!(cache.skills, 2, "5 chars / 4 = 1.25 → ceil = 2");
3735 assert_eq!(cache.subagents, 2, "8 chars / 4 = 2.0 → ceil = 2");
3736 }
3737
3738 #[test]
3739 fn estimate_tokens_by_bytes_rounds_up_by_four_bytes() {
3740 assert_eq!(estimate_tokens_by_bytes(0), 0);
3744 assert_eq!(estimate_tokens_by_bytes(1), 1);
3745 assert_eq!(estimate_tokens_by_bytes(4), 1);
3746 assert_eq!(estimate_tokens_by_bytes(5), 2);
3747 assert_eq!(estimate_tokens_by_bytes(8), 2);
3748 }
3749
3750 #[test]
3751 fn enforce_transcript_budget_fires_when_chars_exactly_equal_limit() {
3752 use crate::agent::FinishReason;
3756 use crate::llm::TokenUsage;
3757 let hooks = crate::hooks::HookRegistry::new();
3758 let mut core = make_test_core(vec![Message::user("hello".to_string())], &hooks);
3760 core.max_transcript_chars = Some(5);
3761 let usage = TokenUsage::default();
3762 let result = core.enforce_transcript_budget(0, &usage);
3763 assert!(
3764 result.is_some(),
3765 "chars (5) == limit (5) must return Some finish reason"
3766 );
3767 let (finish, _step) = result.unwrap();
3768 assert!(
3769 matches!(finish, FinishReason::TranscriptLimit { .. }),
3770 "finish reason must be TranscriptLimit; got {finish:?}"
3771 );
3772 }
3773
3774 #[test]
3775 fn check_wall_deadline_disabled_when_timeout_zero() {
3776 use crate::llm::TokenUsage;
3780 let hooks = crate::hooks::HookRegistry::new();
3781 let mut core = make_test_core(vec![Message::user("hi".to_string())], &hooks);
3782 core.wall_timeout_secs = 0;
3783 core.wall_start = Some(std::time::Instant::now() - std::time::Duration::from_secs(5));
3784 let usage = TokenUsage::default();
3785 assert!(
3786 core.check_wall_deadline(0, &usage).is_none(),
3787 "timeout 0 must disable the wall-clock deadline"
3788 );
3789 }
3790
3791 #[test]
3792 fn check_wall_deadline_fires_after_timeout_elapsed() {
3793 use crate::agent::FinishReason;
3796 use crate::llm::TokenUsage;
3797 let hooks = crate::hooks::HookRegistry::new();
3798 let mut core = make_test_core(vec![Message::user("hi".to_string())], &hooks);
3799 core.wall_timeout_secs = 3;
3800 core.wall_start = Some(std::time::Instant::now() - std::time::Duration::from_secs(5));
3801 let usage = TokenUsage::default();
3802 let result = core.check_wall_deadline(0, &usage);
3803 assert!(
3804 result.is_some(),
3805 "elapsed 5s > timeout 3s must fire the deadline"
3806 );
3807 let (finish, _step) = result.unwrap();
3808 assert!(
3809 matches!(finish, FinishReason::WallClockExceeded { .. }),
3810 "finish reason must be WallClockExceeded; got {finish:?}"
3811 );
3812 }
3813
3814 #[test]
3815 fn check_wall_deadline_fires_when_elapsed_equals_timeout() {
3816 use crate::llm::TokenUsage;
3823 let hooks = crate::hooks::HookRegistry::new();
3824 let mut core = make_test_core(vec![Message::user("hi".to_string())], &hooks);
3825 core.wall_timeout_secs = 3;
3826 core.wall_start = Some(std::time::Instant::now() - std::time::Duration::from_secs(3));
3827 let usage = TokenUsage::default();
3828 let result = core.check_wall_deadline(0, &usage);
3829 assert!(
3830 result.is_some(),
3831 "elapsed == timeout must fire the deadline"
3832 );
3833 }
3834}