1use std::collections::HashMap;
2use std::sync::Arc;
3use std::time::{Duration, Instant};
4
5use serde::{Deserialize, Serialize};
6use tracing::{Instrument, debug, info_span};
7
8use crate::error::Error;
9use crate::llm::LlmProvider;
10use crate::llm::types::{
11 CompletionRequest, ContentBlock, Message, StopReason, TokenUsage, ToolCall, ToolDefinition,
12 ToolResult,
13};
14use crate::memory::Memory;
15use crate::tool::{Tool, ToolOutput, validate_tool_input};
16use crate::util::levenshtein;
17
18use super::audit::{AuditRecord, AuditTrail};
19use super::builder::AgentRunnerBuilder;
20use super::cache;
21use super::context::{AgentContext, ContextStrategy};
22use super::doom_loop::DoomLoopTracker;
23use super::events::{AgentEvent, EVENT_MAX_PAYLOAD_BYTES, OnEvent, truncate_for_event};
24use super::guardrail::{GuardAction, Guardrail};
25use super::observability;
26use super::permission;
27use super::pruner;
28use super::tool_filter;
29
30pub type OnInput = dyn Fn() -> std::pin::Pin<Box<dyn std::future::Future<Output = Option<String>> + Send>>
34 + Send
35 + Sync;
36
37pub(crate) const RESOURCEFULNESS_GUIDELINES: &str = "\n\n\
41## Resourcefulness\n\
42Before claiming you cannot do something or lack access to a tool:\n\
43- Use bash to check for installed CLIs (`which <tool>`, `command -v <tool>`).\n\
44- Search for files, configs, and resources before saying they don't exist.\n\
45- Read documentation, help output (`<tool> --help`), and man pages when unsure.\n\
46- Try alternative approaches when the first attempt fails.\n\
47Never say \"I don't have access\" or \"I can't\" without evidence. Investigate first.";
48
49pub(crate) const COMPACTION_SUMMARY_SYSTEM: &str = "You are compacting an agent's working \
55conversation to free up context WITHOUT losing anything the agent needs to continue. Produce a \
56summary that preserves, explicitly and completely:\n\
571. GOAL: the original task/objective and any refinements to it.\n\
582. FILES: every file created, modified, or deleted (exact paths) and what changed in each.\n\
593. TODOS: all tasks still open or in progress.\n\
604. UNRESOLVED: errors, test failures, blockers, and open questions not yet resolved.\n\
615. DECISIONS: key decisions and WHY, including approaches tried and abandoned (and the reason).\n\
62Then a brief narrative of progress so far. Be COMPLETE on items 1-5 — omitting one loses work the \
63agent must redo. Be concise elsewhere: compact bullet points, no preamble, no padding.";
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct ToolCallRecord {
76 pub tool_name: String,
78 pub tool_call_id: String,
80 pub input: serde_json::Value,
82 pub output: String,
86 pub is_error: bool,
88 pub duration_ms: u64,
90}
91
92fn over_window_fraction(input_tokens: u32, window: u32, fraction: f32) -> bool {
96 window > 0 && input_tokens as f32 >= fraction.clamp(0.0, 1.0) * window as f32
97}
98
99pub(super) const DEFAULT_TOOL_RESULT_INGEST_CAP: usize = 256 * 1024;
107
108const EMERGENCY_TOOL_RESULT_MAX_BYTES: usize = 4_096;
112
113fn is_prose_question_battery(text: &str) -> bool {
118 text.lines()
119 .map(str::trim_end)
120 .filter(|l| !l.is_empty() && l.ends_with('?'))
121 .count()
122 >= 2
123}
124
125fn announces_intent(text: &str) -> bool {
132 const MARKERS: &[&str] = &[
133 "je vais ",
134 "laisse-moi ",
135 "laissez-moi ",
136 "je commence par ",
137 "let me ",
138 "i'll ",
139 "i will ",
140 "i'm going to ",
141 "i am going to ",
142 ];
143 let lower = text.to_lowercase();
144 MARKERS.iter().any(|m| lower.contains(m))
145}
146
147fn is_wish_request(text: &str) -> bool {
153 const MARKERS: &[&str] = &[
154 "je souhaite",
155 "j'aimerais",
156 "je voudrais",
157 "i'd like",
158 "i would like",
159 "it would be nice",
160 "j'aurais besoin",
161 ];
162 let lower = text.to_lowercase();
163 MARKERS.iter().any(|m| lower.contains(m))
164}
165
166const PLAN_ARTIFACT_TOOLS: &[&str] = &[
169 "question",
170 "todowrite",
171 "set_goal",
172 "set_scope",
173 "run_workflow",
174];
175
176const PLAN_GATE_MUTATING: &[&str] = &["edit", "write", "patch"];
179
180const PLAN_GATE_BACKSTOP_AT: u32 = 3;
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
193enum RustcHintClass {
194 StaleApi,
197 TypeMismatch,
199 Ownership,
201 CommandNotFound,
205}
206
207fn classify_rustc_failure(output: &str) -> Option<RustcHintClass> {
209 if output.contains("command not found") || output.contains("(exit code: 127)") {
211 return Some(RustcHintClass::CommandNotFound);
212 }
213 if !output.contains("error[") && !output.contains("error:") {
214 return None;
215 }
216 const STALE: &[&str] = &[
217 "error[E0405]",
218 "error[E0412]",
219 "error[E0433]",
220 "error[E0599]",
221 "unresolved import",
222 "cannot find trait",
223 "cannot find type",
224 "cannot find function",
225 "no method named",
226 ];
227 if STALE.iter().any(|p| output.contains(p)) {
228 return Some(RustcHintClass::StaleApi);
229 }
230 if output.contains("error[E0308]") {
231 return Some(RustcHintClass::TypeMismatch);
232 }
233 const OWN: &[&str] = &[
234 "error[E0382]",
235 "error[E0499]",
236 "error[E0502]",
237 "error[E0505]",
238 ];
239 if OWN.iter().any(|p| output.contains(p)) {
240 return Some(RustcHintClass::Ownership);
241 }
242 None
243}
244
245fn is_build_failure(output: &str) -> bool {
248 output.contains("error[E") || output.contains("could not compile")
249}
250
251const ESCALATION_AFTER_FAILURES: u32 = 3;
253
254const DOOM_HARD_STOP_MARGIN: u32 = 2;
259
260const BARRIER_TOOLS: &[&str] = &["set_scope", "set_goal"];
265
266const DEFAULT_SUMMARY_INPUT_MAX_BYTES: usize = 262_144;
269
270#[derive(Debug, Clone)]
278pub struct DelegationNudge {
279 pub after_tool_calls: u32,
281 pub tool_names: Vec<String>,
284}
285
286fn bound_transcript(text: &str, budget: usize) -> String {
289 const MARKER: &str = "\n[transcript abridged for summary — middle omitted]\n";
290 if text.len() <= budget {
291 return text.to_string();
292 }
293 let head_budget = budget / 4;
294 let tail_budget = budget
295 .saturating_sub(head_budget)
296 .saturating_sub(MARKER.len());
297 let head_end = crate::tool::builtins::floor_char_boundary(text, head_budget);
298 let mut tail_start = text.len().saturating_sub(tail_budget);
299 while tail_start < text.len() && !text.is_char_boundary(tail_start) {
300 tail_start += 1;
301 }
302 format!("{}{}{}", &text[..head_end], MARKER, &text[tail_start..])
303}
304
305#[derive(Debug, Clone, Default, Serialize, Deserialize)]
310#[non_exhaustive]
311pub struct AgentOutput {
312 pub result: String,
314 pub tool_calls_made: usize,
316 pub tokens_used: TokenUsage,
318 pub structured: Option<serde_json::Value>,
321 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub estimated_cost_usd: Option<f64>,
325 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub model_name: Option<String>,
329 #[serde(default)]
336 pub tool_call_results: Vec<ToolCallRecord>,
337 #[serde(default, skip_serializing_if = "Option::is_none")]
342 pub goal_met: Option<bool>,
343}
344
345impl AgentOutput {
346 pub(crate) fn accumulate_into(
348 &self,
349 total_usage: &mut TokenUsage,
350 total_tool_calls: &mut usize,
351 total_cost: &mut Option<f64>,
352 ) {
353 *total_usage += self.tokens_used;
354 *total_tool_calls += self.tool_calls_made;
355 if let Some(cost) = self.estimated_cost_usd {
356 *total_cost.get_or_insert(0.0) += cost;
357 }
358 }
359}
360
361pub struct AgentRunner<P: LlmProvider> {
363 pub(super) provider: Arc<P>,
364 pub(super) name: String,
365 pub(super) system_prompt: String,
366 pub(super) tools: HashMap<String, Arc<dyn Tool>>,
367 pub(super) tool_defs: Vec<ToolDefinition>,
368 pub(super) max_turns: usize,
369 pub(super) max_tokens: u32,
370 pub(super) context_strategy: ContextStrategy,
371 pub(super) summarize_threshold: Option<u32>,
373 pub(super) context_window_tokens: Option<u32>,
378 pub(super) compaction_threshold_fraction: f32,
380 pub(super) on_text: Option<Arc<crate::llm::OnText>>,
382 pub(super) on_reasoning: Option<Arc<crate::llm::OnReasoning>>,
384 pub(super) on_approval: Option<Arc<crate::llm::OnApproval>>,
386 pub(super) tool_timeout: Option<Duration>,
388 pub(super) max_tool_output_bytes: Option<usize>,
391 pub(super) structured_schema: Option<serde_json::Value>,
394 pub(super) on_event: Option<Arc<OnEvent>>,
396 pub(super) guardrails: Vec<Arc<dyn Guardrail>>,
398 pub(super) on_input: Option<Arc<OnInput>>,
402 pub(super) interrupt: Option<super::interrupt::InterruptHandle>,
406 pub(super) run_timeout: Option<Duration>,
409 pub(super) reasoning_effort: Option<crate::llm::types::ReasoningEffort>,
411 pub(super) enable_reflection: bool,
414 pub(super) tool_output_compression_threshold: Option<usize>,
417 pub(super) tool_result_ingest_cap: Option<usize>,
422 pub(super) request_router: Option<Arc<super::router::RequestRouter>>,
427 pub(super) delegation_nudge: Option<DelegationNudge>,
429 pub(super) max_tools_per_turn: Option<usize>,
432 pub(super) tool_profile: Option<tool_filter::ToolProfile>,
435 pub(super) max_identical_tool_calls: Option<u32>,
439 pub(super) max_fuzzy_identical_tool_calls: Option<u32>,
443 pub(super) max_tool_calls_per_turn: Option<u32>,
447 pub(super) permission_rules: parking_lot::RwLock<permission::PermissionRuleset>,
455 pub(super) learned_permissions: Option<Arc<std::sync::Mutex<permission::LearnedPermissions>>>,
457 pub(super) lsp_manager: Option<Arc<crate::lsp::LspManager>>,
459 pub(super) session_prune_config: Option<pruner::SessionPruneConfig>,
462 pub(super) memory: Option<Arc<dyn Memory>>,
464 pub(super) context_recall_store: Option<Arc<crate::agent::context_recall::ContextRecallStore>>,
467 pub(super) todo_store: Option<Arc<crate::tool::builtins::TodoStore>>,
473 pub(super) replan_on_verify_fail: bool,
480 pub(super) enable_recursive_summarization: bool,
483 pub(super) consolidate_on_exit: bool,
485 pub(super) observability_mode: observability::ObservabilityMode,
487 pub(super) max_total_tokens: Option<u64>,
490 pub(super) goal: super::goal::GoalSlot,
497 pub(super) audit_mode: super::audit::AuditMode,
499 pub(super) audit_trail: Option<Arc<dyn AuditTrail>>,
501 pub(super) audit_user_id: Option<String>,
503 pub(super) audit_tenant_id: Option<String>,
504 pub(super) audit_delegation_chain: Vec<String>,
506 pub(super) response_cache: Option<cache::ResponseCache>,
509 pub(super) tenant_tracker: Option<Arc<crate::agent::tenant_tracker::TenantTokenTracker>>,
512 pub(super) cumulative_actual_tokens: std::sync::atomic::AtomicUsize,
516}
517
518impl<P: LlmProvider> AgentRunner<P> {
519 pub fn builder(provider: Arc<P>) -> AgentRunnerBuilder<P> {
542 AgentRunnerBuilder {
543 provider,
544 name: "agent".into(),
545 system_prompt: String::new(),
546 tools: Vec::new(),
547 max_turns: 10,
548 max_tokens: 4096,
549 context_strategy: None,
550 summarize_threshold: None,
551 context_window_tokens: None,
552 compaction_threshold_fraction: 0.70,
553 memory: None,
554 knowledge_base: None,
555 on_text: None,
556 on_reasoning: None,
557 on_approval: None,
558 tool_timeout: None,
559 max_tool_output_bytes: None,
560 structured_schema: None,
561 on_event: None,
562 guardrails: Vec::new(),
563 on_question: None,
564 on_input: None,
565 interrupt: None,
566 run_timeout: None,
567 reasoning_effort: None,
568 enable_reflection: false,
569 tool_output_compression_threshold: None,
570 tool_result_ingest_cap: Some(DEFAULT_TOOL_RESULT_INGEST_CAP),
571 delegation_nudge: None,
572 request_router: None,
573 max_tools_per_turn: None,
574 tool_profile: None,
575 max_identical_tool_calls: None,
576 max_fuzzy_identical_tool_calls: None,
577 max_tool_calls_per_turn: None,
578 permission_rules: permission::PermissionRuleset::default(),
579 instruction_text: None,
580 learned_permissions: None,
581 lsp_manager: None,
582 session_prune_config: None,
583 enable_recursive_summarization: false,
584 reflection_threshold: None,
585 consolidate_on_exit: false,
586 observability_mode: None,
587 workspace: None,
588 max_total_tokens: None,
589 goal: None,
590 goal_slot: None,
591 audit_mode: super::audit::AuditMode::Full,
592 audit_trail: None,
593 audit_user_id: None,
594 audit_tenant_id: None,
595 audit_delegation_chain: Vec::new(),
596 response_cache_size: None,
597 tenant_tracker: None,
598 context_recall_store: None,
599 todo_store: None,
600 replan_on_verify_fail: false,
601 }
602 }
603
604 pub fn name(&self) -> &str {
606 &self.name
607 }
608
609 fn eval_permission(
611 &self,
612 tool_name: &str,
613 input: &serde_json::Value,
614 ) -> Option<permission::PermissionAction> {
615 self.permission_rules.read().evaluate(tool_name, input)
616 }
617
618 fn has_permission_rules(&self) -> bool {
620 !self.permission_rules.read().is_empty()
621 }
622
623 fn emit(&self, event: AgentEvent) {
624 if let Some(ref cb) = self.on_event {
625 cb(event);
626 }
627 }
628
629 async fn audit(&self, mut record: AuditRecord) {
631 if let Some(ref trail) = self.audit_trail {
632 if self.audit_mode == super::audit::AuditMode::MetadataOnly {
633 let payload = std::mem::take(&mut record.payload);
636 record.payload = super::audit::strip_content_owned(payload);
637 }
638 if let Err(e) = trail.record(record).await {
639 tracing::warn!(error = %e, "audit record failed");
640 }
641 }
642 }
643
644 fn persist_approval_decision(
650 &self,
651 tool_calls: &[ToolCall],
652 decision: crate::llm::ApprovalDecision,
653 ) {
654 let action = if decision.is_allowed() {
655 permission::PermissionAction::Allow
656 } else {
657 permission::PermissionAction::Deny
658 };
659 let mut seen = std::collections::HashSet::new();
661 let mut new_rules = Vec::new();
662 for tc in tool_calls {
663 if seen.insert(tc.name.clone()) {
664 new_rules.push(permission::PermissionRule {
665 tool: tc.name.clone(),
666 pattern: "*".into(),
667 action,
668 });
669 }
670 }
671 self.permission_rules.write().append_rules(&new_rules);
674 if let Some(ref learned) = self.learned_permissions {
676 for rule in new_rules {
677 if let Ok(mut guard) = learned.lock()
678 && let Err(e) = guard.add_rule(rule)
679 {
680 tracing::warn!(
681 error = %e,
682 "failed to persist learned permission rule"
683 );
684 }
685 }
686 }
687 }
688
689 fn estimate_cost(&self, usage: &TokenUsage) -> Option<f64> {
691 self.provider
692 .model_name()
693 .and_then(|model| crate::llm::pricing::estimate_cost(model, usage))
694 }
695
696 pub async fn execute(&self, task: &str) -> Result<AgentOutput, Error> {
698 let ctx = AgentContext::new(&self.system_prompt, task, self.tool_defs.clone())
699 .with_max_turns(self.max_turns)
700 .with_max_tokens(self.max_tokens)
701 .with_context_strategy(self.context_strategy.clone())
702 .with_reasoning_effort(self.reasoning_effort);
703 self.execute_with_context(ctx, task).await
704 }
705
706 pub async fn execute_with_content(
708 &self,
709 content: Vec<ContentBlock>,
710 ) -> Result<AgentOutput, Error> {
711 let task_summary: String = content
713 .iter()
714 .filter_map(|b| match b {
715 ContentBlock::Text { text } => Some(text.as_str()),
716 _ => None,
717 })
718 .collect::<Vec<_>>()
719 .join(" ");
720
721 let ctx = AgentContext::from_content(&self.system_prompt, content, self.tool_defs.clone())
722 .with_max_turns(self.max_turns)
723 .with_max_tokens(self.max_tokens)
724 .with_context_strategy(self.context_strategy.clone())
725 .with_reasoning_effort(self.reasoning_effort);
726 self.execute_with_context(ctx, &task_summary).await
727 }
728
729 async fn execute_with_context(
730 &self,
731 ctx: AgentContext,
732 task_description: &str,
733 ) -> Result<AgentOutput, Error> {
734 let usage_acc = Arc::new(std::sync::Mutex::new(TokenUsage::default()));
737 let fut = {
738 let acc = usage_acc.clone();
739 async move {
740 match self.execute_inner(ctx, task_description, acc).await {
741 Ok(output) => Ok(output),
742 Err((e, usage)) => Err(e.with_partial_usage(usage)),
743 }
744 }
745 };
746 let mut result = match self.run_timeout {
747 Some(timeout) => match tokio::time::timeout(timeout, fut).await {
748 Ok(result) => result,
749 Err(_) => {
750 let usage = *usage_acc.lock().expect("usage lock poisoned");
751 Err(Error::RunTimeout(timeout).with_partial_usage(usage))
752 }
753 },
754 None => fut.await,
755 };
756
757 if let Err(ref e) = result {
759 self.audit(AuditRecord {
760 agent: self.name.clone(),
761 turn: 0,
762 event_type: "run_failed".into(),
763 payload: serde_json::json!({
764 "error": e.to_string(),
765 }),
766 usage: e.partial_usage(),
767 timestamp: chrono::Utc::now(),
768 user_id: self.audit_user_id.clone(),
769 tenant_id: self.audit_tenant_id.clone(),
770 delegation_chain: self.audit_delegation_chain.clone(),
771 })
772 .await;
773 }
774
775 if let Ok(ref mut output) = result {
777 let consolidation_usage = self.consolidate_memory_on_exit().await;
779 if consolidation_usage.input_tokens > 0 || consolidation_usage.output_tokens > 0 {
780 output.tokens_used += consolidation_usage;
781 if let Some(consolidation_cost) = self.estimate_cost(&consolidation_usage) {
784 output.estimated_cost_usd =
785 Some(output.estimated_cost_usd.unwrap_or(0.0) + consolidation_cost);
786 }
787 }
788
789 self.prune_memory_on_exit().await;
791 }
792
793 result
794 }
795
796 async fn execute_inner(
797 &self,
798 initial_ctx: AgentContext,
799 task: &str,
800 usage_acc: Arc<std::sync::Mutex<TokenUsage>>,
801 ) -> Result<AgentOutput, (Error, TokenUsage)> {
802 let mode = self.observability_mode;
803 let run_span = info_span!(
804 "heartbit.agent.run",
805 agent = %self.name,
806 max_turns = self.max_turns,
807 task = tracing::field::Empty,
808 model = tracing::field::Empty,
809 total_input_tokens = tracing::field::Empty,
810 total_output_tokens = tracing::field::Empty,
811 estimated_cost_usd = tracing::field::Empty,
812 );
813 if mode.includes_metrics()
814 && let Some(model) = self.provider.model_name()
815 {
816 run_span.record("model", model);
817 }
818 if mode.includes_payloads() {
819 run_span.record(
820 "task",
821 truncate_for_event(task, EVENT_MAX_PAYLOAD_BYTES).as_str(),
822 );
823 } else if mode.includes_metrics() {
824 let cut = crate::tool::builtins::floor_char_boundary(task, 256);
825 run_span.record("task", &task[..cut]);
826 }
827
828 let result = async {
829 self.emit(AgentEvent::RunStarted {
830 agent: self.name.clone(),
831 task: task.to_string(),
832 });
833
834 let mut ctx = initial_ctx;
835
836 let mut total_tool_calls = 0usize;
837 let mut tool_call_records: Vec<ToolCallRecord> = Vec::new();
842 let mut total_usage = TokenUsage::default();
843 let mut total_cost: f64 = 0.0;
845 let mut goal_continuations_used: u32 = 0;
850 let mut last_goal_met: Option<bool> = None;
853 let mut verify_replans_used: u32 = 0;
857 const MAX_VERIFY_REPLANS: u32 = 8;
858 let mut request_start_msg: usize = 0;
862 let mut recently_used_tools: Vec<String> = Vec::new();
864 let mut doom_tracker = DoomLoopTracker::new();
865 let mut last_model_name: Option<String> = None;
866 let mut overflow_recovery_stage: u8 = 0;
873 let mut proactive_compacted_last_turn = false;
875 let mut nudge_tool_calls: u32 = 0;
878 let mut nudge_delegated = false;
879 let mut nudge_sent = false;
880 let mut prose_question_nudged = false;
882 let mut request_tool_calls: u32 = 0;
884 let mut intent_nudged = false;
885 let mut request_is_wish = is_wish_request(task);
889 let mut plan_artifact_seen = false;
890 let mut scope_declared = false;
891 let mut mutating_calls: u32 = 0;
892 let mut plan_gate_fired = false;
893 let mut hint_fired: std::collections::HashSet<RustcHintClass> =
896 std::collections::HashSet::new();
897 let mut deps_hint_fired = false;
898 let mut consecutive_build_failures: u32 = 0;
899 let mut escalation_fired = false;
900 let mut advisor_required = false;
904 let mut request_mode = match &self.request_router {
908 Some(router) => {
909 let routed = router.route(task).await;
910 debug!(
911 agent = %self.name,
912 mode = routed.mode.label(),
913 source = ?routed.source,
914 confidence = routed.confidence,
915 "request routed"
916 );
917 self.emit(AgentEvent::RequestRouted {
918 agent: self.name.clone(),
919 mode: routed.mode.label().to_string(),
920 source: format!("{:?}", routed.source).to_lowercase(),
921 confidence: routed.confidence,
922 });
923 routed.mode
924 }
925 None => super::router::RequestMode::Execute,
926 };
927 let mut question_called = false;
930 let mut study_contract_nudged = false;
931
932 loop {
933 if ctx.current_turn() >= ctx.max_turns() {
934 self.emit(AgentEvent::RunFailed {
935 agent: self.name.clone(),
936 error: format!("Max turns ({}) exceeded", ctx.max_turns()),
937 partial_usage: total_usage,
938 });
939 return Err((Error::MaxTurnsExceeded(ctx.max_turns()), total_usage));
940 }
941
942 ctx.increment_turn();
943 let entry_recovery_stage = overflow_recovery_stage;
944 overflow_recovery_stage = 0;
945 debug!(agent = %self.name, turn = ctx.current_turn(), "executing turn");
946 self.emit(AgentEvent::TurnStarted {
947 agent: self.name.clone(),
948 turn: ctx.current_turn(),
949 max_turns: ctx.max_turns(),
950 });
951
952 for g in &self.guardrails {
954 g.set_turn(ctx.current_turn());
955 }
956
957 let mut request = if let Some(ref prune_config) = self.session_prune_config {
959 let mut req = ctx.to_request();
960 let (pruned_msgs, prune_stats) =
961 pruner::prune_old_tool_results(&req.messages, prune_config);
962 req.messages = pruned_msgs;
963 if prune_stats.did_prune() {
964 debug!(
965 agent = %self.name,
966 turn = ctx.current_turn(),
967 pruned = prune_stats.tool_results_pruned,
968 total = prune_stats.tool_results_total,
969 bytes_saved = prune_stats.bytes_saved,
970 "session pruning applied"
971 );
972 self.emit(AgentEvent::SessionPruned {
973 agent: self.name.clone(),
974 turn: ctx.current_turn(),
975 tool_results_pruned: prune_stats.tool_results_pruned,
976 bytes_saved: prune_stats.bytes_saved,
977 tool_results_total: prune_stats.tool_results_total,
978 });
979 }
980 req
981 } else {
982 ctx.to_request()
983 };
984
985 if let Some(ref store) = self.todo_store
995 && let Some(block) =
996 crate::tool::builtins::recite_open_todos(&store.open_items())
997 && let Some(last) = request.messages.last_mut()
998 {
999 last.content.push(ContentBlock::Text { text: block });
1000 }
1001
1002 if matches!(
1007 request_mode,
1008 super::router::RequestMode::Study | super::router::RequestMode::Answer
1009 ) {
1010 request.tools =
1011 tool_filter::filter_tools(&request.tools, tool_filter::ToolProfile::ReadOnly);
1012 }
1013
1014 if let Some(profile) = self.tool_profile {
1016 request.tools = tool_filter::filter_tools(&request.tools, profile);
1017 }
1018
1019 if let Some(max_tools) = self.max_tools_per_turn {
1021 request.tools = self.select_tools_for_turn(
1022 &request.tools,
1023 &request.messages,
1024 &recently_used_tools,
1025 max_tools,
1026 );
1027 }
1028
1029 for g in &self.guardrails {
1030 if let Err(e) = g.pre_llm(&mut request).await {
1031 self.emit(AgentEvent::RunFailed {
1032 agent: self.name.clone(),
1033 error: e.to_string(),
1034 partial_usage: total_usage,
1035 });
1036 return Err((e, total_usage));
1037 }
1038 }
1039 let cache_key = if self.response_cache.is_some() && self.on_text.is_none() {
1045 let tool_names: Vec<&str> =
1046 request.tools.iter().map(|t| t.name.as_str()).collect();
1047 let namespace = match (&self.audit_tenant_id, &self.audit_user_id) {
1048 (Some(t), Some(u)) => Some(format!("{t}:{u}")),
1049 (Some(t), None) => Some(t.clone()),
1050 (None, Some(u)) => Some(format!(":{u}")),
1051 (None, None) => None,
1052 };
1053 Some(cache::ResponseCache::compute_key_scoped(
1054 &request.system,
1055 &request.messages,
1056 &tool_names,
1057 namespace.as_deref(),
1058 ))
1059 } else {
1060 None
1061 };
1062 let cache_hit = cache_key
1064 .and_then(|k| self.response_cache.as_ref().and_then(|c| c.get(k)));
1065 let llm_start = Instant::now();
1066 let llm_span = info_span!(
1067 "heartbit.agent.llm_call",
1068 agent = %self.name,
1069 turn = ctx.current_turn(),
1070 { observability::GEN_AI_REQUEST_MODEL } = tracing::field::Empty,
1071 latency_ms = tracing::field::Empty,
1072 { observability::GEN_AI_USAGE_INPUT_TOKENS } = tracing::field::Empty,
1073 { observability::GEN_AI_USAGE_OUTPUT_TOKENS } = tracing::field::Empty,
1074 { observability::GEN_AI_RESPONSE_FINISH_REASON } = tracing::field::Empty,
1075 tool_call_count = tracing::field::Empty,
1076 ttft_ms = tracing::field::Empty,
1077 response_text = tracing::field::Empty,
1078 cache_hit = tracing::field::Empty,
1079 );
1080 let ttft_ms = Arc::new(std::sync::atomic::AtomicU64::new(0));
1085 let mut llm_interrupted = false;
1091 let llm_result = if let Some(mut cached) = cache_hit {
1092 tracing::debug!(
1093 agent = %self.name,
1094 turn = ctx.current_turn(),
1095 "response cache hit, skipping LLM call"
1096 );
1097 if mode.includes_metrics() {
1098 llm_span.record("cache_hit", true);
1099 }
1100 cached.usage = TokenUsage::default();
1105 Ok(cached)
1106 } else {
1107 let ttft_ms_inner = ttft_ms.clone();
1109 let ttft_ref = ttft_ms_inner.clone();
1110 let llm_future = async {
1111 match &self.on_text {
1112 Some(cb) => {
1113 let ttft_ref = ttft_ref.clone();
1114 let start = llm_start;
1115 let inner_cb = cb.clone();
1116 let wrapper: Box<crate::llm::OnText> =
1117 Box::new(move |text: &str| {
1118 ttft_ref
1119 .compare_exchange(
1120 0,
1121 start.elapsed().as_millis() as u64,
1122 std::sync::atomic::Ordering::Relaxed,
1123 std::sync::atomic::Ordering::Relaxed,
1124 )
1125 .ok();
1126 inner_cb(text);
1127 });
1128 match &self.on_reasoning {
1131 Some(rcb) => {
1132 let rcb = rcb.clone();
1133 let reasoning_wrapper: Box<crate::llm::OnReasoning> =
1134 Box::new(move |r: &str| rcb(r));
1135 self.provider
1136 .stream_complete_with_reasoning(
1137 request,
1138 &*wrapper,
1139 &*reasoning_wrapper,
1140 )
1141 .await
1142 }
1143 None => self.provider.stream_complete(request, &*wrapper).await,
1144 }
1145 }
1146 None => self.provider.complete(request).await,
1147 }
1148 }
1149 .instrument(llm_span.clone());
1150 let result = match self.interrupt.as_ref() {
1156 Some(handle) => {
1157 let token = handle.token();
1158 tokio::select! {
1159 biased;
1160 _ = token.cancelled() => {
1161 handle.rearm();
1162 llm_interrupted = true;
1163 Ok(crate::llm::types::CompletionResponse {
1164 content: vec![crate::llm::types::ContentBlock::Text {
1165 text: "[interrupted by user]".into(),
1166 }],
1167 stop_reason: crate::llm::types::StopReason::EndTurn,
1168 reasoning: None,
1169 usage: TokenUsage::default(),
1170 model: None,
1171 })
1172 }
1173 r = llm_future => r,
1174 }
1175 }
1176 None => llm_future.await,
1177 };
1178 if !llm_interrupted
1182 && let (Ok(resp), Some(key)) = (&result, cache_key)
1183 && resp.stop_reason == crate::llm::types::StopReason::EndTurn
1184 && let Some(ref c) = self.response_cache
1185 {
1186 c.put(key, resp.clone());
1187 }
1188 if mode.includes_metrics() {
1189 let ttft = ttft_ms_inner.load(std::sync::atomic::Ordering::Relaxed);
1190 llm_span.record("ttft_ms", ttft);
1191 llm_span.record("cache_hit", false);
1192 }
1193 result
1194 };
1195 let llm_latency_ms = llm_start.elapsed().as_millis() as u64;
1196 if mode.includes_metrics() {
1198 llm_span.record("latency_ms", llm_latency_ms);
1199 if let Ok(ref r) = llm_result {
1200 if let Some(ref model) = r.model {
1201 llm_span.record(observability::GEN_AI_REQUEST_MODEL, model.as_str());
1202 } else if let Some(model) = self.provider.model_name() {
1203 llm_span.record(observability::GEN_AI_REQUEST_MODEL, model);
1204 }
1205 } else if let Some(model) = self.provider.model_name() {
1206 llm_span.record(observability::GEN_AI_REQUEST_MODEL, model);
1207 }
1208 if let Ok(ref r) = llm_result {
1209 llm_span.record(
1210 observability::GEN_AI_USAGE_INPUT_TOKENS,
1211 r.usage.input_tokens,
1212 );
1213 llm_span.record(
1214 observability::GEN_AI_USAGE_OUTPUT_TOKENS,
1215 r.usage.output_tokens,
1216 );
1217 llm_span.record(
1218 observability::GEN_AI_RESPONSE_FINISH_REASON,
1219 format!("{:?}", r.stop_reason).as_str(),
1220 );
1221 llm_span.record("tool_call_count", r.tool_calls().len());
1222 }
1223 }
1224 if mode.includes_payloads()
1225 && let Ok(ref r) = llm_result
1226 {
1227 llm_span.record(
1228 "response_text",
1229 truncate_for_event(&r.text(), EVENT_MAX_PAYLOAD_BYTES).as_str(),
1230 );
1231 }
1232 let mut response = match llm_result {
1233 Ok(r) => r,
1234 Err(e) => {
1235 if crate::llm::error_class::classify(&e)
1240 == crate::llm::error_class::ErrorClass::ContextOverflow
1241 && entry_recovery_stage < 2
1242 {
1243 tracing::warn!(
1244 agent = %self.name,
1245 error = %e,
1246 recovery_stage = entry_recovery_stage,
1247 "context overflow detected, attempting recovery"
1248 );
1249 if entry_recovery_stage == 0 {
1257 let emergency_cap = self
1258 .session_prune_config
1259 .as_ref()
1260 .map(|c| c.pruned_tool_result_max_bytes)
1261 .unwrap_or(EMERGENCY_TOOL_RESULT_MAX_BYTES);
1262 let saved = ctx.truncate_oversized_tool_results(
1263 emergency_cap,
1264 self.context_recall_store.is_some(),
1265 );
1266 if saved > 0 {
1267 tracing::warn!(
1268 agent = %self.name,
1269 bytes_saved = saved,
1270 emergency_cap,
1271 "oversized tool results truncated, retrying"
1272 );
1273 self.emit(AgentEvent::AutoCompactionTriggered {
1274 agent: self.name.clone(),
1275 turn: ctx.current_turn(),
1276 success: true,
1277 usage: TokenUsage::default(),
1278 });
1279 overflow_recovery_stage = 1;
1280 continue;
1281 }
1282 }
1283 match self.generate_summary(&ctx).await {
1288 Ok((Some(summary), summary_usage)) => {
1289 total_usage += summary_usage;
1290 if let Some(c) = self.estimate_cost(&summary_usage) {
1291 total_cost += c;
1292 }
1293 *usage_acc.lock().expect("usage lock poisoned") = total_usage;
1294 self.flush_to_memory_before_compaction(&ctx, 4).await;
1295 ctx.inject_summary(summary, 4);
1296 request_start_msg = request_start_msg.min(1);
1299 self.emit(AgentEvent::AutoCompactionTriggered {
1300 agent: self.name.clone(),
1301 turn: ctx.current_turn(),
1302 success: true,
1303 usage: summary_usage,
1304 });
1305 self.emit(AgentEvent::ContextSummarized {
1306 agent: self.name.clone(),
1307 turn: ctx.current_turn(),
1308 usage: summary_usage,
1309 });
1310 overflow_recovery_stage = 2;
1311 continue;
1312 }
1313 Ok((None, summary_usage)) => {
1314 total_usage += summary_usage;
1315 *usage_acc.lock().expect("usage lock poisoned") = total_usage;
1316 self.emit(AgentEvent::AutoCompactionTriggered {
1317 agent: self.name.clone(),
1318 turn: ctx.current_turn(),
1319 success: false,
1320 usage: summary_usage,
1321 });
1322 tracing::warn!(
1323 agent = %self.name,
1324 "auto-compaction summary was truncated, cannot compact"
1325 );
1326 }
1327 Err(summary_err) => {
1328 self.emit(AgentEvent::AutoCompactionTriggered {
1329 agent: self.name.clone(),
1330 turn: ctx.current_turn(),
1331 success: false,
1332 usage: TokenUsage::default(),
1333 });
1334 tracing::warn!(
1335 agent = %self.name,
1336 error = %summary_err,
1337 "auto-compaction summary failed"
1338 );
1339 }
1340 }
1341 }
1342 self.emit(AgentEvent::RunFailed {
1343 agent: self.name.clone(),
1344 error: e.to_string(),
1345 partial_usage: total_usage,
1346 });
1347 return Err((e, total_usage));
1348 }
1349 };
1350 total_usage += response.usage;
1351 let last_input_tokens = response.usage.input_tokens;
1353
1354 if let (Some(tracker), Some(tid)) =
1358 (&self.tenant_tracker, &self.audit_tenant_id)
1359 {
1360 let actual =
1361 (total_usage.input_tokens + total_usage.output_tokens) as usize;
1362 let prev = self
1363 .cumulative_actual_tokens
1364 .swap(actual, std::sync::atomic::Ordering::SeqCst);
1365 let delta = actual as i64 - prev as i64;
1366 let scope = crate::auth::TenantScope::new(tid.clone());
1367 tracker.adjust(&scope, delta);
1368 }
1369
1370 let turn_model = response
1372 .model
1373 .as_deref()
1374 .or_else(|| self.provider.model_name());
1375 if let Some(model) = turn_model {
1376 last_model_name = Some(model.to_string());
1377 if let Some(cost) =
1378 crate::llm::pricing::estimate_cost(model, &response.usage)
1379 {
1380 total_cost += cost;
1381 }
1382 }
1383 *usage_acc.lock().expect("usage lock poisoned") = total_usage;
1385
1386 if let Some(max) = self.max_total_tokens {
1388 let used = total_usage.total();
1389 if used > max {
1390 self.emit(AgentEvent::BudgetExceeded {
1391 agent: self.name.clone(),
1392 used,
1393 limit: max,
1394 partial_usage: total_usage,
1395 });
1396 return Err((
1397 Error::BudgetExceeded { used, limit: max },
1398 total_usage,
1399 ));
1400 }
1401 }
1402
1403 let mut tool_calls = response.tool_calls();
1404
1405 for call in tool_calls.iter_mut() {
1413 if !self.tools.contains_key(&call.name)
1414 && let Some(repaired) = self.find_closest_tool(&call.name, 2)
1415 {
1416 let repaired = repaired.to_string();
1417 tracing::warn!(
1418 agent = %self.name,
1419 original = %call.name,
1420 repaired = %repaired,
1421 "tool name repaired via Levenshtein match (pre-policy)"
1422 );
1423 self.emit(AgentEvent::ToolNameRepaired {
1424 agent: self.name.clone(),
1425 original: call.name.clone(),
1426 repaired: repaired.clone(),
1427 });
1428 call.name = repaired;
1429 }
1430 }
1431
1432 if let Some(cap) = self.max_tool_calls_per_turn
1435 && tool_calls.len() as u32 > cap
1436 {
1437 let err = Error::Agent(format!(
1438 "tool-call cap exceeded: turn produced {} calls, max is {cap}",
1439 tool_calls.len()
1440 ));
1441 self.emit(AgentEvent::RunFailed {
1442 agent: self.name.clone(),
1443 error: err.to_string(),
1444 partial_usage: total_usage,
1445 });
1446 return Err((err, total_usage));
1447 }
1448
1449 if let Some(reasoning) = &response.reasoning
1452 && !reasoning.is_empty()
1453 {
1454 self.emit(AgentEvent::Reasoning {
1455 agent: self.name.clone(),
1456 turn: ctx.current_turn(),
1457 text: truncate_for_event(reasoning, EVENT_MAX_PAYLOAD_BYTES),
1458 });
1459 }
1460
1461 self.emit(AgentEvent::LlmResponse {
1462 agent: self.name.clone(),
1463 turn: ctx.current_turn(),
1464 usage: response.usage,
1465 stop_reason: response.stop_reason,
1466 tool_call_count: tool_calls.len(),
1467 text: truncate_for_event(&response.text(), EVENT_MAX_PAYLOAD_BYTES),
1468 latency_ms: llm_latency_ms,
1469 model: response
1470 .model
1471 .clone()
1472 .or_else(|| self.provider.model_name().map(|s| s.to_string())),
1473 time_to_first_token_ms: ttft_ms.load(std::sync::atomic::Ordering::Relaxed),
1474 });
1475
1476 self.audit(AuditRecord {
1478 agent: self.name.clone(),
1479 turn: ctx.current_turn(),
1480 event_type: "llm_response".into(),
1481 payload: serde_json::json!({
1482 "text": response.text(),
1483 "stop_reason": format!("{:?}", response.stop_reason),
1484 "tool_call_count": tool_calls.len(),
1485 "latency_ms": llm_latency_ms,
1486 "model": response.model.as_deref()
1487 .or_else(|| self.provider.model_name()),
1488 }),
1489 usage: response.usage,
1490 timestamp: chrono::Utc::now(),
1491 user_id: self.audit_user_id.clone(),
1492 tenant_id: self.audit_tenant_id.clone(),
1493 delegation_chain: self.audit_delegation_chain.clone(),
1494 })
1495 .await;
1496
1497 let mut post_llm_denied = false;
1502 for g in &self.guardrails {
1503 match g
1504 .post_llm(&mut response)
1505 .await
1506 .map_err(|e| (e, total_usage))?
1507 {
1508 GuardAction::Allow => {}
1509 GuardAction::Warn { reason } => {
1510 self.emit(AgentEvent::GuardrailWarned {
1511 agent: self.name.clone(),
1512 hook: "post_llm".into(),
1513 reason: reason.clone(),
1514 tool_name: None,
1515 });
1516 self.audit(AuditRecord {
1517 agent: self.name.clone(),
1518 turn: ctx.current_turn(),
1519 event_type: "guardrail_warned".into(),
1520 payload: serde_json::json!({
1521 "hook": "post_llm",
1522 "reason": reason,
1523 }),
1524 usage: TokenUsage::default(),
1525 timestamp: chrono::Utc::now(),
1526 user_id: self.audit_user_id.clone(),
1527 tenant_id: self.audit_tenant_id.clone(),
1528 delegation_chain: self.audit_delegation_chain.clone(),
1529 })
1530 .await;
1531 }
1533 GuardAction::Deny { reason } => {
1534 self.emit(AgentEvent::GuardrailDenied {
1535 agent: self.name.clone(),
1536 hook: "post_llm".into(),
1537 reason: reason.clone(),
1538 tool_name: None,
1539 });
1540 self.audit(AuditRecord {
1542 agent: self.name.clone(),
1543 turn: ctx.current_turn(),
1544 event_type: "guardrail_denied".into(),
1545 payload: serde_json::json!({
1546 "hook": "post_llm",
1547 "reason": reason,
1548 }),
1549 usage: TokenUsage::default(),
1550 timestamp: chrono::Utc::now(),
1551 user_id: self.audit_user_id.clone(),
1552 tenant_id: self.audit_tenant_id.clone(),
1553 delegation_chain: self.audit_delegation_chain.clone(),
1554 })
1555 .await;
1556 ctx.add_assistant_message(Message {
1558 role: crate::llm::types::Role::Assistant,
1559 content: vec![ContentBlock::Text {
1560 text: "[Response denied by guardrail]".into(),
1561 }],
1562 });
1563 ctx.add_user_message(format!(
1564 "[Guardrail denied your previous response: {reason}. Please try again.]"
1565 ));
1566 post_llm_denied = true;
1567 break;
1568 }
1569 GuardAction::Kill { reason } => {
1570 self.emit(AgentEvent::KillSwitchActivated {
1571 agent: self.name.clone(),
1572 reason: reason.clone(),
1573 guardrail_name: g.name().to_string(),
1574 });
1575 self.audit(AuditRecord {
1576 agent: self.name.clone(),
1577 turn: ctx.current_turn(),
1578 event_type: "guardrail_killed".into(),
1579 payload: serde_json::json!({
1580 "hook": "post_llm",
1581 "reason": reason,
1582 }),
1583 usage: TokenUsage::default(),
1584 timestamp: chrono::Utc::now(),
1585 user_id: self.audit_user_id.clone(),
1586 tenant_id: self.audit_tenant_id.clone(),
1587 delegation_chain: self.audit_delegation_chain.clone(),
1588 })
1589 .await;
1590 return Err((
1591 Error::KillSwitch(reason),
1592 total_usage,
1593 ));
1594 }
1595 }
1596 }
1597 if post_llm_denied {
1598 continue;
1599 }
1600
1601 ctx.add_assistant_message(Message {
1603 role: crate::llm::types::Role::Assistant,
1604 content: response.content,
1605 });
1606
1607 ctx.evict_media();
1609
1610 if let Some(ref schema) = self.structured_schema
1615 && let Some(respond_call) = tool_calls
1616 .iter()
1617 .find(|tc| tc.name == crate::llm::types::RESPOND_TOOL_NAME)
1618 {
1619 let structured = respond_call.input.clone();
1620
1621 if let Err(validation_error) =
1623 crate::tool::validate_tool_input(schema, &structured)
1624 {
1625 total_tool_calls += tool_calls.len();
1628 tracing::warn!(
1629 agent = %self.name,
1630 error = %validation_error,
1631 "structured output failed schema validation, retrying"
1632 );
1633 let validation_results = tool_calls
1642 .iter()
1643 .map(|tc| {
1644 if tc.id == respond_call.id {
1645 ToolResult {
1646 tool_use_id: tc.id.clone(),
1647 content: format!(
1648 "Structured output validation failed: \
1649 {validation_error}. Please fix the output to \
1650 match the schema and call __respond__ again."
1651 ),
1652 is_error: true,
1653 }
1654 } else {
1655 ToolResult {
1656 tool_use_id: tc.id.clone(),
1657 content: "Ignored: `__respond__` was co-submitted \
1658 but failed schema validation. Call \
1659 `__respond__` alone with a corrected output."
1660 .to_string(),
1661 is_error: true,
1662 }
1663 }
1664 })
1665 .collect();
1666 ctx.add_tool_results(validation_results);
1667 continue;
1668 }
1669
1670 total_tool_calls += tool_calls.len();
1671 let text = serde_json::to_string_pretty(&structured)
1672 .unwrap_or_else(|_| structured.to_string());
1673 self.emit(AgentEvent::RunCompleted {
1674 agent: self.name.clone(),
1675 total_usage,
1676 tool_calls_made: total_tool_calls,
1677 });
1678 let preview_end =
1680 crate::tool::builtins::floor_char_boundary(&text, 1000);
1681 self.audit(AuditRecord {
1682 agent: self.name.clone(),
1683 turn: ctx.current_turn(),
1684 event_type: "run_completed".into(),
1685 payload: serde_json::json!({
1686 "total_tool_calls": total_tool_calls,
1687 "result_preview": &text[..preview_end],
1688 }),
1689 usage: total_usage,
1690 timestamp: chrono::Utc::now(),
1691 user_id: self.audit_user_id.clone(),
1692 tenant_id: self.audit_tenant_id.clone(),
1693 delegation_chain: self.audit_delegation_chain.clone(),
1694 })
1695 .await;
1696 return Ok(AgentOutput {
1697 result: text,
1698 tool_calls_made: total_tool_calls,
1699 tokens_used: total_usage,
1700 structured: Some(structured),
1701 estimated_cost_usd: if total_cost > 0.0 {
1702 Some(total_cost)
1703 } else {
1704 self.estimate_cost(&total_usage)
1705 },
1706 model_name: last_model_name.clone(),
1707 tool_call_results: std::mem::take(&mut tool_call_records),
1708 goal_met: None,
1709 });
1710 }
1711
1712 if tool_calls.is_empty() {
1713 if response.stop_reason == StopReason::MaxTokens {
1715 self.emit(AgentEvent::RunFailed {
1716 agent: self.name.clone(),
1717 error: "Response truncated (max_tokens reached)".into(),
1718 partial_usage: total_usage,
1719 });
1720 return Err((Error::Truncated, total_usage));
1721 }
1722
1723 if self.structured_schema.is_some() {
1727 self.emit(AgentEvent::RunFailed {
1728 agent: self.name.clone(),
1729 error: "LLM returned text without calling __respond__".into(),
1730 partial_usage: total_usage,
1731 });
1732 return Err((
1733 Error::Agent(
1734 "LLM returned text without calling __respond__; \
1735 structured output was not produced"
1736 .into(),
1737 ),
1738 total_usage,
1739 ));
1740 }
1741
1742 if !llm_interrupted
1754 && !prose_question_nudged
1755 && self.tools.contains_key("question")
1756 && ctx
1757 .last_assistant_text()
1758 .is_some_and(|t| is_prose_question_battery(&t))
1759 {
1760 prose_question_nudged = true;
1761 debug!(agent = %self.name, "prose question battery; redirecting to the question tool");
1762 self.emit(AgentEvent::GateFired {
1763 agent: self.name.clone(),
1764 gate: "ask_gate".into(),
1765 reason: "prose question battery".into(),
1766 });
1767
1768 ctx.add_user_message(
1769 "[ask gate] You just asked the user several questions in free \
1770 text — they cannot answer options efficiently that way. Re-ask \
1771 NOW with the `question` tool: 1-4 batched questions, 2-4 \
1772 concrete options each (multiple=false unless choices combine). \
1773 For open questions, offer your best concrete proposals as the \
1774 options."
1775 .to_string(),
1776 );
1777 continue;
1778 }
1779
1780 if !llm_interrupted
1786 && !intent_nudged
1787 && request_tool_calls == 0
1788 && ctx.last_assistant_text().is_some_and(|t| announces_intent(&t))
1789 {
1790 intent_nudged = true;
1791 debug!(agent = %self.name, "announced intent with zero work; act gate");
1792 self.emit(AgentEvent::GateFired {
1793 agent: self.name.clone(),
1794 gate: "act_gate".into(),
1795 reason: "announced intent, zero tools".into(),
1796 });
1797
1798 ctx.add_user_message(
1799 "[act gate] You announced what you are about to do, then \
1800 stopped without doing it. If any requirement is unclear, ask \
1801 the user NOW with the `question` tool; if it is a feature \
1802 request, plan first (todos with acceptance criteria, \
1803 set_goal). Otherwise EXECUTE it now with your tools in this \
1804 same turn — never stop on an announcement."
1805 .to_string(),
1806 );
1807 continue;
1808 }
1809
1810 if !llm_interrupted
1815 && request_mode == super::router::RequestMode::Study
1816 && !study_contract_nudged
1817 && !question_called
1818 && self.tools.contains_key("question")
1819 {
1820 study_contract_nudged = true;
1821 debug!(agent = %self.name, "study contract: no go/no-go question; corrective injected");
1822 self.emit(AgentEvent::GateFired {
1823 agent: self.name.clone(),
1824 gate: "study_contract".into(),
1825 reason: "study ended without a go/no-go question".into(),
1826 });
1827
1828 ctx.add_user_message(
1829 "[study contract] End your study properly: give a NUMBERED \
1830 proposal (options + your recommendation), then ask the user \
1831 go/no-go with the `question` tool. Do not build anything in \
1832 this mode."
1833 .to_string(),
1834 );
1835 continue;
1836 }
1837
1838 let request_messages = ctx
1853 .messages()
1854 .get(request_start_msg.min(ctx.message_count())..)
1855 .unwrap_or(&[]);
1856 if !llm_interrupted
1857 && self.replan_on_verify_fail
1858 && verify_replans_used < MAX_VERIFY_REPLANS
1859 && let Some(outcome) = crate::codegen::parse_latest_verify(
1860 &super::context::messages_to_text(request_messages),
1861 )
1862 && !outcome.passed
1863 {
1864 verify_replans_used += 1;
1865 debug!(
1866 agent = %self.name,
1867 replan = verify_replans_used,
1868 "verification RED; replanning before completion"
1869 );
1870 ctx.add_user_message(
1871 "Verification is RED (VERIFY_RESULT: FAIL) — do NOT finish yet. \
1872 Update your plan/todos, fix the underlying failure, then re-run the \
1873 verify tool until it reports VERIFY_RESULT: PASS before completing."
1874 .to_string(),
1875 );
1876 continue;
1877 }
1878
1879 let goal_now = if llm_interrupted {
1890 None
1891 } else {
1892 self.goal
1893 .read()
1894 .unwrap_or_else(std::sync::PoisonError::into_inner)
1895 .clone()
1896 };
1897 if let Some(goal) = goal_now {
1898 let transcript = ctx.conversation_text();
1902 let (verdict, judge_usage) = goal.evaluate(&transcript).await;
1903 total_usage += judge_usage;
1905 if verdict.satisfied {
1906 last_goal_met = Some(true);
1907 *self
1908 .goal
1909 .write()
1910 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
1911 } else if goal_continuations_used < goal.max_continuations() {
1912 goal_continuations_used += 1;
1913 debug!(
1914 agent = %self.name,
1915 continuation = goal_continuations_used,
1916 reason = %verdict.reason,
1917 "goal not yet met; continuing"
1918 );
1919 ctx.add_user_message(goal.continuation_message(&verdict.reason));
1920 continue;
1921 } else {
1922 debug!(
1925 agent = %self.name,
1926 "goal continuation budget exhausted; goal cleared (not met)"
1927 );
1928 last_goal_met = Some(false);
1929 *self
1930 .goal
1931 .write()
1932 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
1933 }
1934 }
1935
1936 if let Some(ref on_input) = self.on_input
1939 && let Some(next_message) = on_input().await
1940 && !next_message.trim().is_empty()
1941 {
1942 request_is_wish = is_wish_request(&next_message);
1944 let carried = self
1952 .request_router
1953 .as_ref()
1954 .is_some_and(|r| r.pinned_mode().is_none())
1955 && matches!(
1956 request_mode,
1957 super::router::RequestMode::Study
1958 | super::router::RequestMode::Clarify
1959 )
1960 && super::router::is_bare_affirmation(&next_message);
1961 if carried {
1962 request_mode = super::router::RequestMode::Execute;
1963 debug!(agent = %self.name, "bare affirmation: promoted to execute under the prior plan");
1964 self.emit(AgentEvent::RequestRouted {
1965 agent: self.name.clone(),
1966 mode: "execute".to_string(),
1967 source: "affirmation".to_string(),
1968 confidence: 1.0,
1969 });
1970 } else if let Some(router) = &self.request_router {
1971 let routed = router.route(&next_message).await;
1972 debug!(
1973 agent = %self.name,
1974 mode = routed.mode.label(),
1975 source = ?routed.source,
1976 confidence = routed.confidence,
1977 "request routed"
1978 );
1979 self.emit(AgentEvent::RequestRouted {
1980 agent: self.name.clone(),
1981 mode: routed.mode.label().to_string(),
1982 source: format!("{:?}", routed.source).to_lowercase(),
1983 confidence: routed.confidence,
1984 });
1985 request_mode = routed.mode;
1986 }
1987 request_start_msg = ctx.message_count();
1988 ctx.add_user_message(next_message);
1989 nudge_tool_calls = 0;
1990 nudge_delegated = false;
1991 nudge_sent = false;
1992 prose_question_nudged = false;
1993 request_tool_calls = 0;
1994 intent_nudged = false;
1995 goal_continuations_used = 0;
1999 verify_replans_used = 0;
2000 plan_artifact_seen = carried;
2003 scope_declared = carried;
2004 mutating_calls = 0;
2005 plan_gate_fired = false;
2006 hint_fired.clear();
2007 deps_hint_fired = false;
2008 consecutive_build_failures = 0;
2009 escalation_fired = false;
2010 advisor_required = false;
2011 question_called = false;
2012 study_contract_nudged = false;
2013 continue;
2014 }
2015
2016 let goal_met: Option<bool> = last_goal_met;
2017
2018 self.emit(AgentEvent::RunCompleted {
2019 agent: self.name.clone(),
2020 total_usage,
2021 tool_calls_made: total_tool_calls,
2022 });
2023 let result_text =
2024 ctx.last_assistant_text().unwrap_or_default().to_string();
2025 let preview_end =
2027 crate::tool::builtins::floor_char_boundary(&result_text, 1000);
2028 self.audit(AuditRecord {
2029 agent: self.name.clone(),
2030 turn: ctx.current_turn(),
2031 event_type: "run_completed".into(),
2032 payload: serde_json::json!({
2033 "total_tool_calls": total_tool_calls,
2034 "result_preview": &result_text[..preview_end],
2035 }),
2036 usage: total_usage,
2037 timestamp: chrono::Utc::now(),
2038 user_id: self.audit_user_id.clone(),
2039 tenant_id: self.audit_tenant_id.clone(),
2040 delegation_chain: self.audit_delegation_chain.clone(),
2041 })
2042 .await;
2043 return Ok(AgentOutput {
2044 result: result_text,
2045 tool_calls_made: total_tool_calls,
2046 tokens_used: total_usage,
2047 structured: None,
2048 estimated_cost_usd: if total_cost > 0.0 {
2049 Some(total_cost)
2050 } else {
2051 self.estimate_cost(&total_usage)
2052 },
2053 model_name: last_model_name.clone(),
2054 tool_call_results: std::mem::take(&mut tool_call_records),
2055 goal_met,
2056 });
2057 }
2058
2059 let doom_snapshot: Option<Vec<ToolCall>> = self
2074 .max_identical_tool_calls
2075 .map(|_| tool_calls.clone());
2076 let (tool_calls, permission_denied_results) = if self.has_permission_rules() {
2077 let mut allowed = Vec::new();
2078 let mut denied = Vec::new();
2079 let mut needs_approval = Vec::new();
2080
2081 for call in tool_calls {
2082 match self.eval_permission(&call.name, &call.input) {
2083 Some(permission::PermissionAction::Allow) => {
2084 allowed.push(call);
2085 }
2086 Some(permission::PermissionAction::Deny) => {
2087 debug!(
2088 agent = %self.name,
2089 tool = %call.name,
2090 "tool call denied by permission rule"
2091 );
2092 denied.push(ToolResult::error(
2093 call.id.clone(),
2094 format!("Permission denied for tool '{}'", call.name),
2095 ));
2096 }
2097 Some(permission::PermissionAction::Ask) | None => {
2098 needs_approval.push(call);
2099 }
2100 }
2101 }
2102
2103 if !needs_approval.is_empty() {
2105 if let Some(ref cb) = self.on_approval {
2106 self.emit(AgentEvent::ApprovalRequested {
2107 agent: self.name.clone(),
2108 turn: ctx.current_turn(),
2109 tool_names: needs_approval
2110 .iter()
2111 .map(|tc| tc.name.clone())
2112 .collect(),
2113 });
2114 let decision = cb(&needs_approval);
2115 self.emit(AgentEvent::ApprovalDecision {
2116 agent: self.name.clone(),
2117 turn: ctx.current_turn(),
2118 approved: decision.is_allowed(),
2119 });
2120 if decision.is_persistent() {
2122 self.persist_approval_decision(&needs_approval, decision);
2123 }
2124 if decision.is_allowed() {
2125 allowed.extend(needs_approval);
2126 } else {
2127 for call in &needs_approval {
2128 denied.push(ToolResult::error(
2129 call.id.clone(),
2130 "Tool execution denied by human reviewer".to_string(),
2131 ));
2132 }
2133 }
2134 } else {
2135 allowed.extend(needs_approval);
2137 }
2138 }
2139
2140 if allowed.is_empty() && !denied.is_empty() {
2142 if let Some(batch) = doom_snapshot.as_ref()
2143 && let Some(n) = self.denied_batch_doom_abort(
2144 &mut doom_tracker,
2145 batch,
2146 ctx.current_turn(),
2147 total_usage,
2148 )
2149 {
2150 return Err((Error::DoomLoopAborted(n), total_usage));
2151 }
2152 total_tool_calls += denied.len();
2153 ctx.add_tool_results(denied);
2154 continue;
2155 }
2156
2157 (allowed, denied)
2158 } else if let Some(ref cb) = self.on_approval {
2159 self.emit(AgentEvent::ApprovalRequested {
2161 agent: self.name.clone(),
2162 turn: ctx.current_turn(),
2163 tool_names: tool_calls.iter().map(|tc| tc.name.clone()).collect(),
2164 });
2165 let decision = cb(&tool_calls);
2166 self.emit(AgentEvent::ApprovalDecision {
2167 agent: self.name.clone(),
2168 turn: ctx.current_turn(),
2169 approved: decision.is_allowed(),
2170 });
2171 if decision.is_persistent() {
2173 self.persist_approval_decision(&tool_calls, decision);
2174 }
2175 if !decision.is_allowed() {
2176 debug!(
2177 agent = %self.name,
2178 "tool execution denied by approval callback"
2179 );
2180 if let Some(batch) = doom_snapshot.as_ref()
2181 && let Some(n) = self.denied_batch_doom_abort(
2182 &mut doom_tracker,
2183 batch,
2184 ctx.current_turn(),
2185 total_usage,
2186 )
2187 {
2188 return Err((Error::DoomLoopAborted(n), total_usage));
2189 }
2190 let results: Vec<ToolResult> = tool_calls
2191 .iter()
2192 .map(|tc| {
2193 ToolResult::error(
2194 tc.id.clone(),
2195 "Tool execution denied by human reviewer".to_string(),
2196 )
2197 })
2198 .collect();
2199 total_tool_calls += tool_calls.len();
2200 ctx.add_tool_results(results);
2201 continue;
2202 }
2203 (tool_calls, Vec::new())
2204 } else {
2205 (tool_calls, Vec::new())
2206 };
2207
2208 if let Some(threshold) = self.max_identical_tool_calls {
2211 let (exact, fuzzy) = doom_tracker.record(
2212 &tool_calls,
2213 threshold,
2214 self.max_fuzzy_identical_tool_calls,
2215 );
2216 if exact {
2217 if doom_tracker.count() >= threshold + DOOM_HARD_STOP_MARGIN {
2224 self.emit(AgentEvent::DoomLoopDetected {
2225 agent: self.name.clone(),
2226 turn: ctx.current_turn(),
2227 consecutive_count: doom_tracker.count(),
2228 tool_names: tool_calls
2229 .iter()
2230 .map(|tc| tc.name.clone())
2231 .collect(),
2232 });
2233 let n = doom_tracker.count();
2234 self.emit(AgentEvent::RunFailed {
2235 agent: self.name.clone(),
2236 error: format!("doom loop aborted after {n} repeats"),
2237 partial_usage: total_usage,
2238 });
2239 return Err((Error::DoomLoopAborted(n), total_usage));
2240 }
2241 debug!(
2242 agent = %self.name,
2243 count = doom_tracker.count(),
2244 "doom loop detected, returning error results"
2245 );
2246 self.emit(AgentEvent::DoomLoopDetected {
2247 agent: self.name.clone(),
2248 turn: ctx.current_turn(),
2249 consecutive_count: doom_tracker.count(),
2250 tool_names: tool_calls
2251 .iter()
2252 .map(|tc| tc.name.clone())
2253 .collect(),
2254 });
2255 let results: Vec<ToolResult> = tool_calls
2256 .iter()
2257 .map(|tc| {
2258 ToolResult::error(
2259 tc.id.clone(),
2260 format!(
2261 "Doom loop detected: identical tool calls repeated {} \
2262 times consecutively. Try a different approach.",
2263 doom_tracker.count()
2264 ),
2265 )
2266 })
2267 .collect();
2268 total_tool_calls += tool_calls.len();
2269 ctx.add_tool_results(results);
2270 continue;
2271 } else if fuzzy {
2272 if let Some(fthresh) = self.max_fuzzy_identical_tool_calls
2277 && doom_tracker.fuzzy_count() >= fthresh + DOOM_HARD_STOP_MARGIN
2278 {
2279 self.emit(AgentEvent::FuzzyDoomLoopDetected {
2280 agent: self.name.clone(),
2281 turn: ctx.current_turn(),
2282 consecutive_count: doom_tracker.fuzzy_count(),
2283 tool_names: tool_calls
2284 .iter()
2285 .map(|tc| tc.name.clone())
2286 .collect(),
2287 });
2288 let n = doom_tracker.fuzzy_count();
2289 self.emit(AgentEvent::RunFailed {
2290 agent: self.name.clone(),
2291 error: format!("fuzzy doom loop aborted after {n} repeats"),
2292 partial_usage: total_usage,
2293 });
2294 return Err((Error::DoomLoopAborted(n), total_usage));
2295 }
2296 debug!(
2297 agent = %self.name,
2298 count = doom_tracker.fuzzy_count(),
2299 "fuzzy doom loop detected, returning error results"
2300 );
2301 self.emit(AgentEvent::FuzzyDoomLoopDetected {
2302 agent: self.name.clone(),
2303 turn: ctx.current_turn(),
2304 consecutive_count: doom_tracker.fuzzy_count(),
2305 tool_names: tool_calls
2306 .iter()
2307 .map(|tc| tc.name.clone())
2308 .collect(),
2309 });
2310 let results: Vec<ToolResult> = tool_calls
2311 .iter()
2312 .map(|tc| {
2313 ToolResult::error(
2314 tc.id.clone(),
2315 format!(
2316 "Fuzzy doom loop detected: same tools with different \
2317 inputs repeated {} times consecutively. Try a \
2318 completely different approach.",
2319 doom_tracker.fuzzy_count()
2320 ),
2321 )
2322 })
2323 .collect();
2324 total_tool_calls += tool_calls.len();
2325 ctx.add_tool_results(results);
2326 continue;
2327 }
2328 }
2329
2330 let batch_artifact = tool_calls
2350 .iter()
2351 .any(|c| PLAN_ARTIFACT_TOOLS.contains(&c.name.as_str()));
2352 let batch_artifact_nonquestion = tool_calls.iter().any(|c| {
2353 c.name != "question" && PLAN_ARTIFACT_TOOLS.contains(&c.name.as_str())
2354 });
2355 let batch_question = tool_calls.iter().any(|c| c.name == "question");
2356 let batch_scope = tool_calls.iter().any(|c| c.name == "set_scope");
2357 if tool_calls.iter().any(|c| c.name == "advisor") {
2358 advisor_required = false;
2359 consecutive_build_failures = 0;
2360 escalation_fired = false;
2365 }
2366 if advisor_required
2367 && tool_calls
2368 .iter()
2369 .any(|c| matches!(c.name.as_str(), "edit" | "write" | "patch"))
2370 {
2371 debug!(agent = %self.name, "hard escalation: mutations blocked until advisor consulted");
2372 self.emit(AgentEvent::GateFired {
2373 agent: self.name.clone(),
2374 gate: "escalation_block".into(),
2375 reason: format!("{consecutive_build_failures} failed builds; advisor required"),
2376 });
2377 let results: Vec<ToolResult> = tool_calls
2378 .iter()
2379 .map(|tc| {
2380 ToolResult::error(
2381 tc.id.clone(),
2382 "[escalation] Too many failed builds — STOP editing. Call \
2383 the `advisor` tool with the full error output and your last \
2384 attempt FIRST; edits are blocked until you do."
2385 .to_string(),
2386 )
2387 })
2388 .collect();
2389 total_tool_calls += tool_calls.len();
2390 ctx.add_tool_results(results);
2391 continue;
2392 }
2393
2394 if matches!(
2398 request_mode,
2399 super::router::RequestMode::Study | super::router::RequestMode::Answer
2400 ) && tool_calls
2401 .iter()
2402 .any(|c| !tool_filter::is_read_only_tool(&c.name))
2403 {
2404 debug!(agent = %self.name, mode = request_mode.label(), "mode contract: mutating batch denied");
2405 self.emit(AgentEvent::GateFired {
2406 agent: self.name.clone(),
2407 gate: "mode_contract".into(),
2408 reason: format!("{} mode: mutation denied", request_mode.label()),
2409 });
2410 let results: Vec<ToolResult> = tool_calls
2411 .iter()
2412 .map(|tc| {
2413 ToolResult::error(
2414 tc.id.clone(),
2415 format!(
2416 "[mode contract] This request is in {} mode (read-only): \
2417 investigate and PROPOSE — do not modify anything. End \
2418 with a numbered proposal and ask go/no-go via the \
2419 `question` tool; the user's approval switches to \
2420 execute mode.",
2421 request_mode.label()
2422 ),
2423 )
2424 })
2425 .collect();
2426 total_tool_calls += tool_calls.len();
2427 ctx.add_tool_results(results);
2428 continue;
2429 }
2430 let batch_mutations = tool_calls
2431 .iter()
2432 .filter(|c| PLAN_GATE_MUTATING.contains(&c.name.as_str()))
2433 .count() as u32;
2434 let gate_mutations = if request_mode == super::router::RequestMode::Clarify {
2440 batch_mutations
2441 + tool_calls.iter().filter(|c| c.name == "bash").count() as u32
2442 } else {
2443 batch_mutations
2444 };
2445 let needs_scope = request_mode == super::router::RequestMode::Clarify
2451 && !(scope_declared || batch_scope)
2452 && self.tools.contains_key("set_scope");
2453 let needs_ask = request_mode == super::router::RequestMode::Clarify
2460 && !question_called
2461 && self.tools.contains_key("question");
2462 if gate_mutations > 0
2463 && (!(plan_artifact_seen || batch_artifact_nonquestion)
2464 || needs_scope
2465 || needs_ask)
2466 && !plan_gate_fired
2467 {
2468 let would_be = mutating_calls + gate_mutations;
2469 let tier1 = request_is_wish
2470 || request_mode == super::router::RequestMode::Clarify;
2471 let tier2 = would_be >= PLAN_GATE_BACKSTOP_AT;
2472 if tier1 || tier2 {
2473 plan_gate_fired = true;
2474 debug!(
2475 agent = %self.name,
2476 wish = tier1,
2477 mutations = would_be,
2478 "plan gate: building without a plan artifact"
2479 );
2480 self.emit(AgentEvent::GateFired {
2481 agent: self.name.clone(),
2482 gate: "plan_gate".into(),
2483 reason: format!("mutation #{would_be} without a plan artifact"),
2484 });
2485 let design_check = if self.tools.contains_key("advisor") {
2486 " Finally, get a quick design review: call the `advisor` \
2487 tool with your plan (criteria, scope, dependency choices) — \
2488 one frontier review prevents over-engineering."
2489 } else {
2490 ""
2491 };
2492 let results: Vec<ToolResult> = tool_calls
2493 .iter()
2494 .map(|tc| {
2495 ToolResult::error(
2496 tc.id.clone(),
2497 format!(
2498 "[plan gate] You are building without a plan. This \
2499 request needs the front half FIRST: if any \
2500 requirement is unclear (interface, data model, \
2501 persistence, scope), ask the user with the \
2502 `question` tool; otherwise write todos with \
2503 acceptance criteria (todowrite) and install the \
2504 goal (set_goal) — the `intake` recipe does both \
2505 for a feature request. ALSO declare your working \
2506 scope with set_scope (the exact target directory \
2507 — honor every location constraint in the request; \
2508 'a temporary directory' means a fresh gitignored \
2509 scratch SUBDIRECTORY inside this workspace, e.g. \
2510 ./scratch-<name> — paths outside the workspace are \
2511 rejected by the file tools).{design_check} THEN \
2512 build."
2513 ),
2514 )
2515 })
2516 .collect();
2517 total_tool_calls += tool_calls.len();
2518 ctx.add_tool_results(results);
2519 continue;
2520 }
2521 }
2522 mutating_calls += batch_mutations;
2523 plan_artifact_seen = plan_artifact_seen || batch_artifact;
2528 question_called = question_called || batch_question;
2529 scope_declared = scope_declared || batch_scope;
2530
2531 let mut barrier_results: Vec<ToolResult> = Vec::new();
2542 let mut barrier_records: Vec<ToolCallRecord> = Vec::new();
2543 let tool_calls: Vec<crate::llm::types::ToolCall> = if tool_calls.len() > 1
2544 && tool_calls
2545 .iter()
2546 .any(|c| BARRIER_TOOLS.contains(&c.name.as_str()))
2547 {
2548 let (barriers, rest): (Vec<_>, Vec<_>) = tool_calls
2549 .into_iter()
2550 .partition(|c| BARRIER_TOOLS.contains(&c.name.as_str()));
2551 for call in barriers {
2552 let (mut r, mut rec) = self
2553 .execute_tools_parallel(
2554 std::slice::from_ref(&call),
2555 ctx.current_turn(),
2556 None,
2557 )
2558 .await;
2559 barrier_results.append(&mut r);
2560 barrier_records.append(&mut rec);
2561 }
2562 rest
2563 } else {
2564 tool_calls
2565 };
2566
2567 let (allowed_calls, denied_results) = if self.guardrails.is_empty() {
2569 (tool_calls, Vec::new())
2570 } else {
2571 let mut allowed = Vec::new();
2572 let mut denied = Vec::new();
2573 for call in tool_calls {
2574 let mut call_denied = false;
2575 for g in &self.guardrails {
2576 match g.pre_tool(&call).await.map_err(|e| (e, total_usage))? {
2577 GuardAction::Allow => {}
2578 GuardAction::Warn { reason } => {
2579 self.emit(AgentEvent::GuardrailWarned {
2580 agent: self.name.clone(),
2581 hook: "pre_tool".into(),
2582 reason: reason.clone(),
2583 tool_name: Some(call.name.clone()),
2584 });
2585 self.audit(AuditRecord {
2586 agent: self.name.clone(),
2587 turn: ctx.current_turn(),
2588 event_type: "guardrail_warned".into(),
2589 payload: serde_json::json!({
2590 "hook": "pre_tool",
2591 "reason": reason,
2592 "tool_name": call.name,
2593 }),
2594 usage: TokenUsage::default(),
2595 timestamp: chrono::Utc::now(),
2596 user_id: self.audit_user_id.clone(),
2597 tenant_id: self.audit_tenant_id.clone(),
2598 delegation_chain: self.audit_delegation_chain.clone(),
2599 })
2600 .await;
2601 }
2603 GuardAction::Deny { reason } => {
2604 self.emit(AgentEvent::GuardrailDenied {
2605 agent: self.name.clone(),
2606 hook: "pre_tool".into(),
2607 reason: reason.clone(),
2608 tool_name: Some(call.name.clone()),
2609 });
2610 self.audit(AuditRecord {
2612 agent: self.name.clone(),
2613 turn: ctx.current_turn(),
2614 event_type: "guardrail_denied".into(),
2615 payload: serde_json::json!({
2616 "hook": "pre_tool",
2617 "reason": reason,
2618 "tool_name": call.name,
2619 }),
2620 usage: TokenUsage::default(),
2621 timestamp: chrono::Utc::now(),
2622 user_id: self.audit_user_id.clone(),
2623 tenant_id: self.audit_tenant_id.clone(),
2624 delegation_chain: self.audit_delegation_chain.clone(),
2625 })
2626 .await;
2627 denied.push(ToolResult::error(
2628 call.id.clone(),
2629 format!("Guardrail denied: {reason}"),
2630 ));
2631 call_denied = true;
2632 break;
2633 }
2634 GuardAction::Kill { reason } => {
2635 self.emit(AgentEvent::KillSwitchActivated {
2636 agent: self.name.clone(),
2637 reason: reason.clone(),
2638 guardrail_name: g.name().to_string(),
2639 });
2640 self.audit(AuditRecord {
2641 agent: self.name.clone(),
2642 turn: ctx.current_turn(),
2643 event_type: "guardrail_killed".into(),
2644 payload: serde_json::json!({
2645 "hook": "pre_tool",
2646 "reason": reason,
2647 "tool_name": call.name,
2648 }),
2649 usage: TokenUsage::default(),
2650 timestamp: chrono::Utc::now(),
2651 user_id: self.audit_user_id.clone(),
2652 tenant_id: self.audit_tenant_id.clone(),
2653 delegation_chain: self.audit_delegation_chain.clone(),
2654 })
2655 .await;
2656 return Err((
2657 Error::KillSwitch(reason),
2658 total_usage,
2659 ));
2660 }
2661 }
2662 }
2663 if !call_denied {
2664 allowed.push(call);
2665 }
2666 }
2667 (allowed, denied)
2668 };
2669
2670 total_tool_calls +=
2671 allowed_calls.len() + denied_results.len() + permission_denied_results.len();
2672 recently_used_tools = allowed_calls.iter().map(|c| c.name.clone()).collect();
2674 let tool_batch_span = info_span!(
2675 "heartbit.agent.tool_batch",
2676 agent = %self.name,
2677 turn = ctx.current_turn(),
2678 tool_count = allowed_calls.len(),
2679 );
2680 let mut tool_interrupted = false;
2688 let (mut results, batch_records) = match self.interrupt.as_ref() {
2689 Some(handle) => {
2690 let token = handle.token();
2691 tracing::info!(
2692 target: "heartbit::interrupt",
2693 checkpoint = "CP4_before_tool_select",
2694 is_cancelled = token.is_cancelled(),
2695 turn = ctx.current_turn(),
2696 tool_count = allowed_calls.len(),
2697 "tool-batch interrupt race armed"
2698 );
2699 let transcript = Some(Arc::new(ctx.messages().to_vec()));
2702 tokio::select! {
2703 biased;
2704 _ = token.cancelled() => {
2705 tracing::info!(
2706 target: "heartbit::interrupt",
2707 checkpoint = "CP3_tool_cancel_arm_fired",
2708 turn = ctx.current_turn(),
2709 "tool-batch interrupted: synthesizing results, abandoning batch"
2710 );
2711 tool_interrupted = true;
2712 self.synthesize_interrupted_tool_batch(&allowed_calls)
2713 }
2714 r = self
2715 .execute_tools_parallel(&allowed_calls, ctx.current_turn(), transcript)
2716 .instrument(tool_batch_span) => r,
2717 }
2718 }
2719 None => {
2720 let transcript = Some(Arc::new(ctx.messages().to_vec()));
2721 self.execute_tools_parallel(&allowed_calls, ctx.current_turn(), transcript)
2722 .instrument(tool_batch_span)
2723 .await
2724 }
2725 };
2726 tool_call_records.extend(batch_records);
2727 tool_call_records.extend(barrier_records);
2728 results.extend(barrier_results);
2729 results.extend(denied_results);
2730 results.extend(permission_denied_results);
2731
2732 if !tool_interrupted
2735 && let Some(ref lsp) = self.lsp_manager
2736 {
2737 self.append_lsp_diagnostics(lsp, &allowed_calls, &mut results)
2738 .await;
2739 }
2740
2741 if !tool_interrupted
2743 && let Some(threshold) = self.tool_output_compression_threshold
2744 {
2745 for result in &mut results {
2746 if !result.is_error && result.content.len() > threshold {
2747 let compressed = self
2748 .compress_tool_output(&result.content, threshold, &mut total_usage)
2749 .await;
2750 result.content = compressed;
2751 }
2752 }
2753 *usage_acc.lock().expect("usage lock poisoned") = total_usage;
2754 }
2755
2756 if !tool_interrupted && self.max_identical_tool_calls.is_some() {
2760 doom_tracker.note_batch_outcome(results.iter().any(|r| r.is_error));
2761 }
2762
2763 let mut pending_hints: Vec<String> = Vec::new();
2768 {
2769 let mut batch_has_build_failure = false;
2770 for r in results.iter().filter(|r| r.is_error) {
2771 if is_build_failure(&r.content) {
2772 batch_has_build_failure = true;
2773 }
2774 if let Some(class) = classify_rustc_failure(&r.content)
2775 && hint_fired.insert(class)
2776 {
2777 let hint = match class {
2778 RustcHintClass::StaleApi => {
2779 let fetch = if self.tools.contains_key("webfetch") {
2780 "fetch the crate's CURRENT docs first \
2781 (webfetch https://docs.rs/<crate>/latest)"
2782 } else {
2783 "consult the crate's CURRENT docs first"
2784 };
2785 format!(
2786 "[repair hint] Unresolved name/method in an external \
2787 crate: your knowledge of its API is likely STALE — \
2788 do not guess again. {fetch}, and add dependencies \
2789 with `cargo add <crate>` (resolves the real current \
2790 version) instead of hand-writing versions."
2791 )
2792 }
2793 RustcHintClass::TypeMismatch => "[repair hint] Type \
2794 mismatch: read the EXACT signature/type definition \
2795 (read the source or docs) before editing — don't \
2796 iterate on guessed casts."
2797 .to_string(),
2798 RustcHintClass::Ownership => "[repair hint] Borrow/move \
2799 error: re-read the FULL error span and restructure \
2800 ownership (scope the borrows, clone deliberately) — \
2801 mechanical retries rarely fix these."
2802 .to_string(),
2803 RustcHintClass::CommandNotFound => "[repair hint] Command \
2804 not found: check the exact binary name (e.g. `python` \
2805 → `python3`, `pip` → `pip3`), verify it is installed \
2806 (`which <cmd>`), or use an alternative — do not retry \
2807 the same command."
2808 .to_string(),
2809 };
2810 self.emit(AgentEvent::GateFired {
2811 agent: self.name.clone(),
2812 gate: "repair_hint".into(),
2813 reason: format!("{class:?}"),
2814 });
2815 pending_hints.push(hint);
2816 }
2817 }
2818 if batch_has_build_failure {
2819 consecutive_build_failures += 1;
2820 if !escalation_fired
2821 && consecutive_build_failures >= ESCALATION_AFTER_FAILURES
2822 && self.tools.contains_key("advisor")
2823 {
2824 escalation_fired = true;
2825 advisor_required = true;
2826 self.emit(AgentEvent::GateFired {
2827 agent: self.name.clone(),
2828 gate: "escalation".into(),
2829 reason: format!("{consecutive_build_failures} consecutive failed builds"),
2830 });
2831 pending_hints.push(format!(
2832 "[escalation] {consecutive_build_failures} consecutive \
2833 failed builds on this request. STOP iterating: consult \
2834 the `advisor` tool with the FULL error output and your \
2835 last attempt — edits are now blocked until you do."
2836 ));
2837 }
2838 } else if results.iter().any(|r| !r.is_error) {
2839 consecutive_build_failures = 0;
2840 }
2841 }
2842 if !deps_hint_fired
2843 && allowed_calls.iter().any(|c| {
2844 matches!(c.name.as_str(), "write" | "edit")
2845 && c.input
2846 .get("file_path")
2847 .and_then(|v| v.as_str())
2848 .is_some_and(|p| p.ends_with("Cargo.toml"))
2849 && c.input.to_string().contains("dependencies")
2850 })
2851 {
2852 deps_hint_fired = true;
2853 self.emit(AgentEvent::GateFired {
2854 agent: self.name.clone(),
2855 gate: "deps_hint".into(),
2856 reason: "hand-written Cargo.toml deps".into(),
2857 });
2858 pending_hints.push(
2859 "[deps hint] You hand-wrote Cargo.toml dependency versions — \
2860 prefer `cargo add <crate>`: it resolves the CURRENT version and \
2861 features, avoiding stale-API guesswork."
2862 .to_string(),
2863 );
2864 }
2865
2866 ctx.add_tool_results(results);
2867
2868 if let Some(cap) = self.tool_result_ingest_cap {
2877 let cap = match self.context_window_tokens {
2882 Some(window) => cap.min(window as usize),
2883 None => cap,
2884 };
2885 let saved =
2886 ctx.cap_last_tool_results(cap, self.context_recall_store.is_some());
2887 if saved > 0 {
2888 debug!(
2889 agent = %self.name,
2890 turn = ctx.current_turn(),
2891 bytes_saved = saved,
2892 cap,
2893 "fresh tool results capped at ingestion"
2894 );
2895 }
2896 }
2897
2898 if !pending_hints.is_empty() {
2899 ctx.add_user_message(pending_hints.join("\n\n"));
2900 }
2901
2902 if !tool_interrupted && self.enable_reflection {
2905 ctx.add_user_message(
2906 "Before proceeding, briefly reflect on the tool results above:\n\
2907 1. Did you get the information you needed?\n\
2908 2. Are there any errors or unexpected results?\n\
2909 3. What is the best next step?"
2910 .to_string(),
2911 );
2912 }
2913
2914 request_tool_calls += allowed_calls.len() as u32;
2920 if let Some(ref nudge) = self.delegation_nudge {
2921 nudge_tool_calls += allowed_calls.len() as u32;
2922 if allowed_calls
2923 .iter()
2924 .any(|c| nudge.tool_names.contains(&c.name))
2925 {
2926 nudge_delegated = true;
2927 }
2928 let read_only_mode = matches!(
2933 request_mode,
2934 super::router::RequestMode::Study | super::router::RequestMode::Answer
2935 );
2936 if !tool_interrupted
2937 && !read_only_mode
2938 && !nudge_sent
2939 && !nudge_delegated
2940 && nudge_tool_calls >= nudge.after_tool_calls
2941 {
2942 nudge_sent = true;
2943 debug!(
2944 agent = %self.name,
2945 tool_calls = nudge_tool_calls,
2946 "delegation nudge injected"
2947 );
2948 self.emit(AgentEvent::GateFired {
2949 agent: self.name.clone(),
2950 gate: "delegation_nudge".into(),
2951 reason: format!("{nudge_tool_calls} direct calls, no delegation"),
2952 });
2953 ctx.add_user_message(format!(
2954 "[delegation check] You have made {nudge_tool_calls} direct tool \
2955 calls on this request without delegating. If meaningful work \
2956 remains — especially independent parts — delegate it now ({}) \
2957 instead of continuing alone. If the task is nearly finished, \
2958 ignore this and complete it.",
2959 nudge.tool_names.join(" / ")
2960 ));
2961 }
2962 }
2963
2964 let proactive_trigger = match self.context_window_tokens {
2969 Some(window) => over_window_fraction(
2973 last_input_tokens.max(ctx.total_tokens()),
2974 window,
2975 self.compaction_threshold_fraction,
2976 ),
2977 None => self
2978 .summarize_threshold
2979 .is_some_and(|t| ctx.needs_compaction(t)),
2980 };
2981 let do_proactive_compact = !tool_interrupted
2982 && ctx.message_count() > 5
2983 && !proactive_compacted_last_turn
2984 && proactive_trigger;
2985 if do_proactive_compact {
2986 debug!(agent = %self.name, "context exceeds threshold, summarizing");
2987 let summarize_span = info_span!(
2988 "heartbit.agent.summarize",
2989 agent = %self.name,
2990 turn = ctx.current_turn(),
2991 );
2992 let (summary, summary_usage) =
2993 match self.generate_summary(&ctx).instrument(summarize_span).await {
2994 Ok(r) => r,
2995 Err(e) => {
2996 self.emit(AgentEvent::RunFailed {
2997 agent: self.name.clone(),
2998 error: e.to_string(),
2999 partial_usage: total_usage,
3000 });
3001 return Err((e, total_usage));
3002 }
3003 };
3004 total_usage += summary_usage;
3005 *usage_acc.lock().expect("usage lock poisoned") = total_usage;
3006 if let Some(summary) = summary {
3007 self.flush_to_memory_before_compaction(&ctx, 4).await;
3008 ctx.inject_summary(summary, 4);
3009 request_start_msg = request_start_msg.min(1);
3015 self.emit(AgentEvent::ContextSummarized {
3016 agent: self.name.clone(),
3017 turn: ctx.current_turn(),
3018 usage: summary_usage,
3019 });
3020 }
3021 }
3022 proactive_compacted_last_turn = do_proactive_compact;
3023 }
3024 }
3025 .instrument(run_span.clone())
3026 .await;
3027
3028 if mode.includes_metrics() {
3030 let usage = match &result {
3031 Ok(output) => &output.tokens_used,
3032 Err((_, usage)) => usage,
3033 };
3034 run_span.record("total_input_tokens", usage.input_tokens);
3035 run_span.record("total_output_tokens", usage.output_tokens);
3036 if let Ok(ref output) = result
3037 && let Some(cost) = output.estimated_cost_usd
3038 {
3039 run_span.record("estimated_cost_usd", cost);
3040 }
3041 }
3042
3043 result
3044 }
3045
3046 async fn generate_summary(
3052 &self,
3053 ctx: &AgentContext,
3054 ) -> Result<(Option<String>, TokenUsage), Error> {
3055 let budget = self
3060 .context_window_tokens
3061 .map(|w| (w as usize).saturating_mul(2).max(2_048))
3062 .unwrap_or(DEFAULT_SUMMARY_INPUT_MAX_BYTES);
3063 let text = bound_transcript(&ctx.conversation_text(), budget);
3064 let lines: Vec<&str> = text.lines().collect();
3065
3066 const CLUSTER_SIZE: usize = 10;
3068 if self.enable_recursive_summarization && lines.len() > CLUSTER_SIZE * 2 {
3069 return self.generate_recursive_summary(&lines, CLUSTER_SIZE).await;
3070 }
3071
3072 self.summarize_text(&text).await
3073 }
3074
3075 async fn summarize_text(&self, text: &str) -> Result<(Option<String>, TokenUsage), Error> {
3077 let summary_request = CompletionRequest {
3078 system: COMPACTION_SUMMARY_SYSTEM.into(),
3079 messages: vec![Message::user(text.to_string())],
3080 tools: vec![],
3081 max_tokens: 2048,
3085 tool_choice: None,
3086 reasoning_effort: None,
3087 };
3088
3089 let response = self.provider.complete(summary_request).await?;
3090 let usage = response.usage;
3091 if response.stop_reason == StopReason::MaxTokens {
3092 tracing::warn!(
3093 agent = %self.name,
3094 "summarization truncated (max_tokens reached), skipping compaction"
3095 );
3096 return Ok((None, usage));
3097 }
3098 Ok((Some(response.text()), usage))
3099 }
3100
3101 async fn generate_recursive_summary(
3106 &self,
3107 lines: &[&str],
3108 cluster_size: usize,
3109 ) -> Result<(Option<String>, TokenUsage), Error> {
3110 let mut total_usage = TokenUsage::default();
3111 let mut cluster_summaries = Vec::new();
3112
3113 for chunk in lines.chunks(cluster_size) {
3115 let cluster_text = chunk.join("\n");
3116 let (summary, usage) = self.summarize_text(&cluster_text).await?;
3117 total_usage += usage;
3118 match summary {
3119 Some(s) => cluster_summaries.push(s),
3120 None => {
3121 let full_text = lines.join("\n");
3123 let (summary, usage) = self.summarize_text(&full_text).await?;
3124 total_usage += usage;
3125 return Ok((summary, total_usage));
3126 }
3127 }
3128 }
3129
3130 let combined = format!(
3132 "Summarize the following section summaries into one cohesive summary:\n\n{}",
3133 cluster_summaries
3134 .iter()
3135 .enumerate()
3136 .map(|(i, s)| format!("Section {}:\n{}", i + 1, s))
3137 .collect::<Vec<_>>()
3138 .join("\n\n")
3139 );
3140 let (final_summary, combine_usage) = self.summarize_text(&combined).await?;
3141 total_usage += combine_usage;
3142 Ok((final_summary, total_usage))
3143 }
3144
3145 fn memory_scope(&self) -> crate::auth::TenantScope {
3149 crate::auth::TenantScope::from_audit_fields(
3150 self.audit_tenant_id.as_deref(),
3151 self.audit_user_id.as_deref(),
3152 )
3153 }
3154
3155 async fn flush_to_memory_before_compaction(&self, ctx: &AgentContext, keep_last_n: usize) {
3160 let Some(ref memory) = self.memory else {
3161 return;
3162 };
3163
3164 let messages = ctx.messages_to_be_compacted(keep_last_n);
3165 let now = chrono::Utc::now();
3166
3167 for msg in messages {
3168 if msg.role != crate::llm::types::Role::User {
3169 continue;
3170 }
3171 for block in &msg.content {
3172 if let ContentBlock::ToolResult {
3173 content, is_error, ..
3174 } = block
3175 {
3176 if *is_error || content.len() < 50 {
3178 continue;
3179 }
3180 let stored_content = if content.len() > 500 {
3182 format!(
3183 "{}...",
3184 &content[..crate::tool::builtins::floor_char_boundary(content, 500)]
3185 )
3186 } else {
3187 content.clone()
3188 };
3189 let id = uuid::Uuid::new_v4().to_string();
3190 let entry = crate::memory::MemoryEntry {
3191 id,
3192 agent: self.name.clone(),
3193 content: stored_content,
3194 category: "fact".into(),
3195 tags: vec!["auto-flush".into()],
3196 created_at: now,
3197 last_accessed: now,
3198 access_count: 0,
3199 importance: 3,
3200 memory_type: crate::memory::MemoryType::Episodic,
3201 keywords: vec![],
3202 summary: None,
3203 strength: 0.8,
3204 related_ids: vec![],
3205 source_ids: vec![],
3206 embedding: None,
3207 confidentiality: crate::memory::Confidentiality::default(),
3208 author_user_id: None,
3209 author_tenant_id: None,
3210 };
3211 let scope = self.memory_scope();
3212 if let Err(e) = memory.store(&scope, entry).await {
3213 tracing::warn!(
3214 agent = %self.name,
3215 error = %e,
3216 "failed to flush tool result to memory before compaction"
3217 );
3218 }
3219 }
3220 }
3221 }
3222 }
3223
3224 async fn prune_memory_on_exit(&self) {
3229 let Some(ref memory) = self.memory else {
3230 return;
3231 };
3232 let scope = self.memory_scope();
3233 match crate::memory::pruning::prune_weak_entries(
3234 memory,
3235 &scope,
3236 crate::memory::pruning::DEFAULT_MIN_STRENGTH,
3237 crate::memory::pruning::default_min_age(),
3238 )
3239 .await
3240 {
3241 Ok(0) => {}
3242 Ok(n) => {
3243 tracing::debug!(agent = %self.name, pruned = n, "pruned weak memory entries at session end");
3244 }
3245 Err(e) => {
3246 tracing::warn!(agent = %self.name, error = %e, "memory pruning failed at session end");
3247 }
3248 }
3249 }
3250
3251 async fn consolidate_memory_on_exit(&self) -> TokenUsage {
3256 if !self.consolidate_on_exit {
3257 return TokenUsage::default();
3258 }
3259 let Some(ref memory) = self.memory else {
3260 return TokenUsage::default();
3261 };
3262 let pipeline = crate::memory::consolidation::ConsolidationPipeline::new(
3263 memory.clone(),
3264 self.provider.clone(),
3265 &self.name,
3266 );
3267 let scope = self.memory_scope();
3268 match pipeline.run(&scope).await {
3269 Ok((0, _, usage)) => usage,
3270 Ok((clusters, entries, usage)) => {
3271 tracing::debug!(
3272 agent = %self.name,
3273 clusters,
3274 entries,
3275 "consolidated memories at session end"
3276 );
3277 usage
3278 }
3279 Err(e) => {
3280 tracing::warn!(
3281 agent = %self.name,
3282 error = %e,
3283 "memory consolidation failed at session end"
3284 );
3285 TokenUsage::default()
3286 }
3287 }
3288 }
3289
3290 pub(super) fn select_tools_for_turn(
3297 &self,
3298 all_tools: &[ToolDefinition],
3299 messages: &[Message],
3300 recently_used: &[String],
3301 max_tools: usize,
3302 ) -> Vec<ToolDefinition> {
3303 if all_tools.len() <= max_tools {
3304 return all_tools.to_vec();
3305 }
3306
3307 let recent_text: String = messages
3309 .iter()
3310 .rev()
3311 .take(4)
3312 .flat_map(|m| m.content.iter())
3313 .filter_map(|block| match block {
3314 ContentBlock::Text { text } => Some(text.as_str()),
3315 _ => None,
3316 })
3317 .collect::<Vec<_>>()
3318 .join(" ")
3319 .to_lowercase();
3320
3321 let keywords: Vec<&str> = recent_text
3322 .split(|c: char| !c.is_alphanumeric() && c != '_')
3323 .filter(|w| w.len() > 2)
3324 .collect();
3325
3326 let mut selected: Vec<ToolDefinition> = Vec::new();
3329 let mut candidates: Vec<(ToolDefinition, usize)> = Vec::new();
3330
3331 for tool in all_tools {
3332 if recently_used.contains(&tool.name)
3333 || tool.name == crate::llm::types::RESPOND_TOOL_NAME
3334 {
3335 selected.push(tool.clone());
3336 } else {
3337 let tool_text = format!("{} {}", tool.name, tool.description).to_lowercase();
3339 let score = keywords
3340 .iter()
3341 .filter(|kw| tool_text.contains(**kw))
3342 .count();
3343 candidates.push((tool.clone(), score));
3344 }
3345 }
3346
3347 candidates.sort_by_key(|c| std::cmp::Reverse(c.1));
3349
3350 let remaining = max_tools.saturating_sub(selected.len());
3352 selected.extend(candidates.into_iter().take(remaining).map(|(t, _)| t));
3353
3354 selected.truncate(max_tools);
3355 selected
3356 }
3357
3358 async fn compress_tool_output(
3363 &self,
3364 content: &str,
3365 threshold: usize,
3366 usage_acc: &mut TokenUsage,
3367 ) -> String {
3368 if content.len() < threshold {
3369 return content.to_string();
3370 }
3371 let original_len = content.len();
3372 let budget = self
3376 .context_window_tokens
3377 .map(|w| (w as usize).saturating_mul(2).max(2_048))
3378 .unwrap_or(DEFAULT_SUMMARY_INPUT_MAX_BYTES);
3379 let bounded = bound_transcript(content, budget);
3380 let request = CompletionRequest {
3381 system: "Compress the following tool output, preserving all factual content, \
3382 key values, and actionable information. Remove redundancy and formatting \
3383 noise. Return ONLY the compressed content."
3384 .into(),
3385 messages: vec![Message::user(bounded)],
3386 tools: vec![],
3387 max_tokens: (self.max_tokens / 3).max(256),
3388 tool_choice: None,
3389 reasoning_effort: None,
3390 };
3391 match self.provider.complete(request).await {
3392 Ok(resp) => {
3393 *usage_acc += resp.usage;
3394 let compressed = resp.text();
3395 if compressed.is_empty() {
3396 content.to_string()
3397 } else {
3398 format!("{compressed}\n[compressed from {original_len} bytes]")
3399 }
3400 }
3401 Err(e) => {
3402 debug!(agent = %self.name, error = %e, "tool output compression failed, using original");
3403 content.to_string()
3404 }
3405 }
3406 }
3407
3408 fn denied_batch_doom_abort(
3416 &self,
3417 doom_tracker: &mut DoomLoopTracker,
3418 batch: &[ToolCall],
3419 turn: usize,
3420 total_usage: TokenUsage,
3421 ) -> Option<u32> {
3422 let threshold = self.max_identical_tool_calls?;
3423 let (exact, fuzzy) =
3424 doom_tracker.record(batch, threshold, self.max_fuzzy_identical_tool_calls);
3425 let n = if exact && doom_tracker.count() >= threshold + DOOM_HARD_STOP_MARGIN {
3426 doom_tracker.count()
3427 } else if fuzzy
3428 && self
3429 .max_fuzzy_identical_tool_calls
3430 .is_some_and(|ft| doom_tracker.fuzzy_count() >= ft + DOOM_HARD_STOP_MARGIN)
3431 {
3432 doom_tracker.fuzzy_count()
3433 } else {
3434 return None;
3435 };
3436 self.emit(AgentEvent::DoomLoopDetected {
3437 agent: self.name.clone(),
3438 turn,
3439 consecutive_count: n,
3440 tool_names: batch.iter().map(|tc| tc.name.clone()).collect(),
3441 });
3442 self.emit(AgentEvent::RunFailed {
3443 agent: self.name.clone(),
3444 error: format!("doom loop aborted after {n} denied repeats"),
3445 partial_usage: total_usage,
3446 });
3447 Some(n)
3448 }
3449
3450 pub(super) fn find_closest_tool(&self, name: &str, max_distance: usize) -> Option<&str> {
3453 self.tools
3454 .keys()
3455 .map(|k| (k.as_str(), levenshtein(name, k)))
3456 .filter(|(_, d)| *d <= max_distance && *d > 0)
3457 .min_by_key(|(_, d)| *d)
3458 .map(|(name, _)| name)
3459 }
3460
3461 async fn append_lsp_diagnostics(
3464 &self,
3465 lsp: &crate::lsp::LspManager,
3466 calls: &[ToolCall],
3467 results: &mut [ToolResult],
3468 ) {
3469 for (idx, call) in calls.iter().enumerate() {
3470 if !crate::lsp::is_file_modifying_tool(&call.name) {
3471 continue;
3472 }
3473 if idx < results.len() && results[idx].is_error {
3475 continue;
3476 }
3477 let path_str = match call
3479 .input
3480 .get("path")
3481 .or_else(|| call.input.get("file_path"))
3482 {
3483 Some(serde_json::Value::String(s)) => s.clone(),
3484 _ => continue,
3485 };
3486 let path = std::path::Path::new(&path_str);
3487 let diagnostics = lsp.notify_file_changed(path).await;
3488 if diagnostics.is_empty() {
3489 tracing::debug!(
3490 agent = %self.name,
3491 path = %path_str,
3492 "lsp: no diagnostics for file"
3493 );
3494 } else {
3495 let formatted = crate::lsp::format_diagnostics(&path_str, &diagnostics);
3496 tracing::info!(
3497 agent = %self.name,
3498 path = %path_str,
3499 count = diagnostics.len(),
3500 "lsp-diagnostics appended to tool result"
3501 );
3502 if idx < results.len() {
3503 results[idx].content.push('\n');
3504 results[idx].content.push_str(&formatted);
3505 }
3506 }
3507 }
3508 }
3509
3510 fn synthesize_interrupted_tool_batch(
3518 &self,
3519 calls: &[ToolCall],
3520 ) -> (Vec<ToolResult>, Vec<ToolCallRecord>) {
3521 const MSG: &str = "Interrupted by user before completion.";
3522 let mut results = Vec::with_capacity(calls.len());
3523 let mut records = Vec::with_capacity(calls.len());
3524 for call in calls {
3525 self.emit(AgentEvent::ToolCallCompleted {
3526 agent: self.name.clone(),
3527 tool_name: call.name.clone(),
3528 tool_call_id: call.id.clone(),
3529 is_error: true,
3530 duration_ms: 0,
3531 output: MSG.to_string(),
3532 });
3533 results.push(ToolResult::error(call.id.clone(), MSG.to_string()));
3534 records.push(ToolCallRecord {
3535 tool_name: call.name.clone(),
3536 tool_call_id: call.id.clone(),
3537 input: call.input.clone(),
3538 output: MSG.to_string(),
3539 is_error: true,
3540 duration_ms: 0,
3541 });
3542 }
3543 (results, records)
3544 }
3545
3546 async fn execute_tools_parallel(
3551 &self,
3552 calls: &[ToolCall],
3553 turn: usize,
3554 transcript: Option<Arc<Vec<Message>>>,
3555 ) -> (Vec<ToolResult>, Vec<ToolCallRecord>) {
3556 let call_ids: Vec<String> = calls.iter().map(|c| c.id.clone()).collect();
3557 let call_names: Vec<String> = calls.iter().map(|c| c.name.clone()).collect();
3558 let mut join_set = tokio::task::JoinSet::new();
3559
3560 let exec_ctx = crate::ExecutionContext {
3564 tenant_id: self.audit_tenant_id.clone(),
3565 user_id: self.audit_user_id.clone(),
3566 workspace: None,
3567 credentials: None,
3568 audit_sink: None,
3569 transcript,
3570 };
3571
3572 for (idx, call) in calls.iter().enumerate() {
3573 let tool = self.tools.get(&call.name).cloned();
3579 let input = call.input.clone();
3580 let call_name = call.name.clone();
3581 let timeout = self.tool_timeout;
3582
3583 self.emit(AgentEvent::ToolCallStarted {
3584 agent: self.name.clone(),
3585 tool_name: call.name.clone(),
3586 tool_call_id: call.id.clone(),
3587 input: truncate_for_event(
3588 &serde_json::to_string(&call.input).unwrap_or_default(),
3589 EVENT_MAX_PAYLOAD_BYTES,
3590 ),
3591 });
3592
3593 self.audit(AuditRecord {
3595 agent: self.name.clone(),
3596 turn,
3597 event_type: "tool_call".into(),
3598 payload: serde_json::json!({
3599 "tool_name": call.name,
3600 "tool_call_id": call.id,
3601 "input": call.input,
3602 }),
3603 usage: TokenUsage::default(),
3604 timestamp: chrono::Utc::now(),
3605 user_id: self.audit_user_id.clone(),
3606 tenant_id: self.audit_tenant_id.clone(),
3607 delegation_chain: self.audit_delegation_chain.clone(),
3608 })
3609 .await;
3610
3611 if let Some(ref t) = tool {
3614 let schema = &t.definition().input_schema;
3615 if let Err(msg) = validate_tool_input(schema, &input) {
3616 join_set.spawn(async move { (idx, Ok(ToolOutput::error(msg)), 0u64) });
3617 continue;
3618 }
3619 }
3620
3621 let tool_span = info_span!(
3622 "heartbit.agent.tool_call",
3623 agent = %self.name,
3624 tool_name = %call.name,
3625 );
3626 let task_ctx = exec_ctx.clone();
3627 join_set.spawn(
3628 async move {
3629 let start = std::time::Instant::now();
3630 let output = match tool {
3631 Some(t) => match timeout {
3632 Some(dur) => {
3633 match tokio::time::timeout(dur, t.execute(&task_ctx, input)).await {
3634 Ok(result) => result,
3635 Err(_) => Ok(ToolOutput::error(format!(
3636 "Tool execution timed out after {}s",
3637 dur.as_secs_f64()
3638 ))),
3639 }
3640 }
3641 None => t.execute(&task_ctx, input).await,
3642 },
3643 None => Ok(ToolOutput::error(format!("Tool not found: {call_name}"))),
3644 };
3645 let duration_ms = start.elapsed().as_millis() as u64;
3646 (idx, output, duration_ms)
3647 }
3648 .instrument(tool_span),
3649 );
3650 }
3651
3652 let mut outputs: Vec<Option<(ToolOutput, u64)>> = vec![None; calls.len()];
3654 while let Some(result) = join_set.join_next().await {
3655 match result {
3656 Ok((idx, Ok(output), duration_ms)) => {
3657 let output = match self.max_tool_output_bytes {
3658 Some(max) => output.truncated(max),
3659 None => output,
3660 };
3661 outputs[idx] = Some((output, duration_ms));
3662 }
3663 Ok((idx, Err(e), duration_ms)) => {
3664 outputs[idx] = Some((ToolOutput::error(e.to_string()), duration_ms));
3665 }
3666 Err(join_err) => {
3667 tracing::error!(error = %join_err, "tool task panicked");
3668 }
3669 }
3670 }
3671
3672 let mut results_vec = Vec::with_capacity(calls.len());
3674 let mut records_vec: Vec<ToolCallRecord> = Vec::with_capacity(calls.len());
3675 for (idx, slot) in outputs.into_iter().enumerate() {
3676 let (mut output, duration_ms) = slot
3677 .unwrap_or_else(|| (ToolOutput::error("Tool execution panicked".to_string()), 0));
3678
3679 for g in &self.guardrails {
3681 if let Err(e) = g.post_tool(&calls[idx], &mut output).await {
3682 self.emit(AgentEvent::GuardrailDenied {
3683 agent: self.name.clone(),
3684 hook: "post_tool".into(),
3685 reason: e.to_string(),
3686 tool_name: Some(call_names[idx].clone()),
3687 });
3688 self.audit(AuditRecord {
3690 agent: self.name.clone(),
3691 turn,
3692 event_type: "guardrail_denied".into(),
3693 payload: serde_json::json!({
3694 "hook": "post_tool",
3695 "reason": e.to_string(),
3696 "tool_name": call_names[idx],
3697 }),
3698 usage: TokenUsage::default(),
3699 timestamp: chrono::Utc::now(),
3700 user_id: self.audit_user_id.clone(),
3706 tenant_id: self.audit_tenant_id.clone(),
3707 delegation_chain: self.audit_delegation_chain.clone(),
3708 })
3709 .await;
3710 output = ToolOutput::error(format!("Guardrail error: {e}"));
3713 break;
3714 }
3715 }
3716
3717 let is_error = output.is_error;
3718 self.emit(AgentEvent::ToolCallCompleted {
3719 agent: self.name.clone(),
3720 tool_name: call_names[idx].clone(),
3721 tool_call_id: call_ids[idx].clone(),
3722 is_error,
3723 duration_ms,
3724 output: truncate_for_event(&output.content, EVENT_MAX_PAYLOAD_BYTES),
3725 });
3726 self.audit(AuditRecord {
3728 agent: self.name.clone(),
3729 turn,
3730 event_type: "tool_result".into(),
3731 payload: serde_json::json!({
3732 "tool_name": call_names[idx],
3733 "tool_call_id": call_ids[idx],
3734 "output": output.content,
3735 "is_error": is_error,
3736 "duration_ms": duration_ms,
3737 }),
3738 usage: TokenUsage::default(),
3739 timestamp: chrono::Utc::now(),
3740 user_id: self.audit_user_id.clone(),
3741 tenant_id: self.audit_tenant_id.clone(),
3742 delegation_chain: self.audit_delegation_chain.clone(),
3743 })
3744 .await;
3745
3746 if let Some(store) = &self.context_recall_store {
3749 store
3750 .index(&call_ids[idx], &call_names[idx], &output.content)
3751 .await;
3752 }
3753
3754 records_vec.push(ToolCallRecord {
3758 tool_name: call_names[idx].clone(),
3759 tool_call_id: call_ids[idx].clone(),
3760 input: calls[idx].input.clone(),
3761 output: output.content.clone(),
3762 is_error,
3763 duration_ms,
3764 });
3765
3766 let redacted_content = match self.tools.get(&calls[idx].name) {
3774 Some(t) => t.redact_for_history(&output.content),
3775 None => output.content.clone(),
3776 };
3777 let redacted_output = if is_error {
3778 ToolOutput::error(redacted_content)
3779 } else {
3780 ToolOutput::success(redacted_content)
3781 };
3782 results_vec.push(tool_output_to_result(
3783 call_ids[idx].clone(),
3784 redacted_output,
3785 ));
3786 }
3787
3788 (results_vec, records_vec)
3789 }
3790}
3791
3792impl<P: LlmProvider> Drop for AgentRunner<P> {
3793 fn drop(&mut self) {
3794 if let (Some(tracker), Some(tid)) =
3795 (self.tenant_tracker.as_ref(), self.audit_tenant_id.as_ref())
3796 {
3797 let actual = self
3798 .cumulative_actual_tokens
3799 .load(std::sync::atomic::Ordering::SeqCst) as i64;
3800 if actual > 0 {
3801 let scope = crate::auth::TenantScope::new(tid.clone());
3802 tracker.adjust(&scope, -actual);
3803 }
3804 }
3805 }
3806}
3807
3808pub(super) fn tool_output_to_result(tool_use_id: String, output: ToolOutput) -> ToolResult {
3809 if output.is_error {
3810 ToolResult::error(tool_use_id, output.content)
3811 } else {
3812 ToolResult::success(tool_use_id, output.content)
3813 }
3814}
3815
3816#[cfg(test)]
3817mod tests {
3818 use std::pin::Pin;
3819 use std::sync::Arc;
3820
3821 use crate::agent::tenant_tracker::TenantTokenTracker;
3822 use crate::auth::TenantScope;
3823 use crate::error::Error;
3824 use crate::llm::types::{
3825 CompletionResponse, ContentBlock, StopReason, TokenUsage, ToolDefinition,
3826 };
3827 use crate::tool::{Tool, ToolOutput};
3828
3829 use super::super::test_helpers::MockProvider;
3830 use super::{AgentRunner, DelegationNudge};
3831
3832 #[tokio::test(flavor = "multi_thread")]
3835 async fn recites_open_todos_at_context_tail() {
3836 let store = Arc::new(crate::tool::builtins::TodoStore::new());
3837 let tools = crate::tool::builtins::todo_tools(store.clone());
3839 let write = tools
3840 .iter()
3841 .find(|t| t.definition().name == "todowrite")
3842 .unwrap();
3843 write
3844 .execute(
3845 &crate::ExecutionContext::default(),
3846 serde_json::json!({"todos": [
3847 {"content": "finish the parser", "status": "in_progress", "priority": "high"},
3848 {"content": "write the docs", "status": "pending", "priority": "medium"}
3849 ]}),
3850 )
3851 .await
3852 .unwrap();
3853
3854 let provider = Arc::new(MockProvider::new(vec![MockProvider::text_response(
3855 "done", 1, 1,
3856 )]));
3857 let runner = AgentRunner::builder(provider.clone())
3858 .name("test")
3859 .system_prompt("sys")
3860 .todo_store(store)
3861 .max_turns(1)
3862 .build()
3863 .unwrap();
3864 runner.execute("go").await.unwrap();
3865
3866 let reqs = provider.captured_requests.lock().unwrap();
3867 let last_msg = reqs[0].messages.last().expect("at least one message");
3868 let tail_text: String = last_msg
3869 .content
3870 .iter()
3871 .filter_map(|b| match b {
3872 ContentBlock::Text { text } => Some(text.as_str()),
3873 _ => None,
3874 })
3875 .collect::<Vec<_>>()
3876 .join("\n");
3877 assert!(
3878 tail_text.contains("[plan — open items"),
3879 "recitation block missing from tail: {tail_text:?}"
3880 );
3881 assert!(tail_text.contains("[>] finish the parser"), "{tail_text:?}");
3882 assert!(tail_text.contains("[ ] write the docs"), "{tail_text:?}");
3883 }
3884
3885 #[tokio::test(flavor = "multi_thread")]
3887 async fn no_recitation_when_no_open_todos() {
3888 let store = Arc::new(crate::tool::builtins::TodoStore::new()); let provider = Arc::new(MockProvider::new(vec![MockProvider::text_response(
3890 "done", 1, 1,
3891 )]));
3892 let runner = AgentRunner::builder(provider.clone())
3893 .name("test")
3894 .system_prompt("sys")
3895 .todo_store(store)
3896 .max_turns(1)
3897 .build()
3898 .unwrap();
3899 runner.execute("hi").await.unwrap();
3900
3901 let reqs = provider.captured_requests.lock().unwrap();
3902 let has_plan = reqs[0].messages.iter().any(|m| {
3903 m.content.iter().any(
3904 |b| matches!(b, ContentBlock::Text { text } if text.contains("[plan — open items")),
3905 )
3906 });
3907 assert!(
3908 !has_plan,
3909 "no plan block expected when there are no open todos"
3910 );
3911 }
3912
3913 struct FailingVerifyTool;
3915 impl Tool for FailingVerifyTool {
3916 fn definition(&self) -> ToolDefinition {
3917 ToolDefinition {
3918 name: "verify".into(),
3919 description: "Runs verification.".into(),
3920 input_schema: serde_json::json!({"type": "object", "properties": {}}),
3921 }
3922 }
3923 fn execute(
3924 &self,
3925 _ctx: &crate::ExecutionContext,
3926 _input: serde_json::Value,
3927 ) -> Pin<Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>>
3928 {
3929 Box::pin(async {
3930 Ok(ToolOutput::success(
3931 "VERIFY_RESULT: FAIL exit_code=1 command=cargo test".to_string(),
3932 ))
3933 })
3934 }
3935 }
3936
3937 fn verify_tool_call() -> CompletionResponse {
3938 CompletionResponse {
3939 content: vec![ContentBlock::ToolUse {
3940 id: "v1".into(),
3941 name: "verify".into(),
3942 input: serde_json::json!({}),
3943 }],
3944 stop_reason: StopReason::ToolUse,
3945 reasoning: None,
3946 usage: TokenUsage::default(),
3947 model: None,
3948 }
3949 }
3950
3951 #[tokio::test(flavor = "multi_thread")]
3955 async fn replan_on_verify_fail_blocks_completion_but_is_bounded() {
3956 let mut responses = vec![verify_tool_call()];
3957 for _ in 0..12 {
3958 responses.push(MockProvider::text_response("done", 1, 1));
3959 }
3960 let provider = Arc::new(MockProvider::new(responses));
3961 let runner = AgentRunner::builder(provider.clone())
3962 .name("t")
3963 .system_prompt("s")
3964 .tools(vec![Arc::new(FailingVerifyTool)])
3965 .replan_on_verify_fail(true)
3966 .max_turns(50)
3967 .build()
3968 .unwrap();
3969 let out = runner.execute("do it").await.unwrap();
3970 assert_eq!(out.result, "done", "completes after the bound is hit");
3971 let n = provider.captured_requests.lock().unwrap().len();
3973 assert_eq!(
3974 n, 10,
3975 "expected 8 bounded replans before completion; got {n} provider calls"
3976 );
3977 }
3978
3979 #[tokio::test(flavor = "multi_thread")]
3982 async fn no_replan_when_gate_disabled() {
3983 let mut responses = vec![verify_tool_call()];
3984 for _ in 0..12 {
3985 responses.push(MockProvider::text_response("done", 1, 1));
3986 }
3987 let provider = Arc::new(MockProvider::new(responses));
3988 let runner = AgentRunner::builder(provider.clone())
3989 .name("t")
3990 .system_prompt("s")
3991 .tools(vec![Arc::new(FailingVerifyTool)])
3992 .max_turns(50)
3993 .build()
3994 .unwrap();
3995 let out = runner.execute("do it").await.unwrap();
3996 assert_eq!(out.result, "done");
3997 let n = provider.captured_requests.lock().unwrap().len();
3998 assert_eq!(
3999 n, 2,
4000 "without the gate, completes on the first EndTurn (got {n})"
4001 );
4002 }
4003
4004 #[tokio::test(flavor = "multi_thread")]
4005 async fn structured_validation_failure_answers_all_co_submitted_tool_calls() {
4006 let schema = serde_json::json!({
4011 "type": "object",
4012 "properties": { "answer": { "type": "integer" } },
4013 "required": ["answer"]
4014 });
4015
4016 let turn1 = CompletionResponse {
4018 content: vec![
4019 ContentBlock::ToolUse {
4020 id: "resp1".into(),
4021 name: crate::llm::types::RESPOND_TOOL_NAME.into(),
4022 input: serde_json::json!({ "answer": "not-an-integer" }),
4023 },
4024 ContentBlock::ToolUse {
4025 id: "other1".into(),
4026 name: "some_tool".into(),
4027 input: serde_json::json!({}),
4028 },
4029 ],
4030 stop_reason: StopReason::ToolUse,
4031 reasoning: None,
4032 usage: TokenUsage::default(),
4033 model: None,
4034 };
4035 let turn2 = CompletionResponse {
4037 content: vec![ContentBlock::ToolUse {
4038 id: "resp2".into(),
4039 name: crate::llm::types::RESPOND_TOOL_NAME.into(),
4040 input: serde_json::json!({ "answer": 42 }),
4041 }],
4042 stop_reason: StopReason::ToolUse,
4043 reasoning: None,
4044 usage: TokenUsage::default(),
4045 model: None,
4046 };
4047
4048 let provider = Arc::new(MockProvider::new(vec![turn1, turn2]));
4049 let runner = AgentRunner::builder(provider.clone())
4050 .name("t")
4051 .system_prompt("s")
4052 .structured_schema(schema)
4053 .max_turns(5)
4054 .build()
4055 .unwrap();
4056
4057 let out = runner.execute("do it").await.unwrap();
4058 assert_eq!(out.structured, Some(serde_json::json!({ "answer": 42 })));
4059
4060 let requests = provider.captured_requests.lock().unwrap();
4062 assert_eq!(requests.len(), 2, "expected a second request after retry");
4063 let result_ids: std::collections::HashSet<&str> = requests[1]
4064 .messages
4065 .iter()
4066 .flat_map(|m| m.content.iter())
4067 .filter_map(|b| match b {
4068 ContentBlock::ToolResult { tool_use_id, .. } => Some(tool_use_id.as_str()),
4069 _ => None,
4070 })
4071 .collect();
4072 assert!(
4073 result_ids.contains("resp1"),
4074 "missing tool_result for __respond__ id"
4075 );
4076 assert!(
4077 result_ids.contains("other1"),
4078 "co-submitted tool_use was orphaned — next request would 400"
4079 );
4080 }
4081
4082 struct NoopTool;
4084
4085 impl Tool for NoopTool {
4086 fn definition(&self) -> ToolDefinition {
4087 ToolDefinition {
4088 name: "noop".into(),
4089 description: "Does nothing.".into(),
4090 input_schema: serde_json::json!({"type": "object", "properties": {}}),
4091 }
4092 }
4093
4094 fn execute(
4095 &self,
4096 _ctx: &crate::ExecutionContext,
4097 _input: serde_json::Value,
4098 ) -> Pin<Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>>
4099 {
4100 Box::pin(async { Ok(ToolOutput::success("ok".to_string())) })
4101 }
4102 }
4103
4104 fn tool_use_response(input_tokens: u32, output_tokens: u32) -> CompletionResponse {
4106 CompletionResponse {
4107 content: vec![ContentBlock::ToolUse {
4108 id: "call-1".into(),
4109 name: "noop".into(),
4110 input: serde_json::json!({}),
4111 }],
4112 stop_reason: StopReason::ToolUse,
4113 reasoning: None,
4114 usage: TokenUsage {
4115 input_tokens,
4116 output_tokens,
4117 ..Default::default()
4118 },
4119 model: None,
4120 }
4121 }
4122
4123 #[tokio::test(flavor = "multi_thread")]
4124 async fn agent_runner_adjusts_tenant_tracker_per_turn() {
4125 let tracker = Arc::new(TenantTokenTracker::new(1_000_000));
4126 let scope = TenantScope::new("acme");
4127 drop(tracker.reserve(&scope, 5000).unwrap());
4130 assert_eq!(tracker.snapshot()[0].1.in_flight, 0);
4131
4132 let provider = Arc::new(MockProvider::new(vec![MockProvider::text_response(
4134 "done", 100, 200,
4135 )]));
4136
4137 let runner = AgentRunner::builder(provider)
4138 .name("test")
4139 .system_prompt("test")
4140 .audit_user_context("test-user", "acme")
4141 .tenant_tracker(tracker.clone())
4142 .max_turns(1)
4143 .build()
4144 .unwrap();
4145 let _output = runner.execute("hello").await.unwrap();
4146
4147 let snap = tracker.snapshot();
4149 assert_eq!(snap[0].1.in_flight, 300);
4150
4151 drop(runner);
4153 let snap = tracker.snapshot();
4154 assert_eq!(snap[0].1.in_flight, 0);
4155 }
4156
4157 #[tokio::test]
4158 async fn interrupt_aborts_turn_then_continues_with_next_input() {
4159 use crate::agent::interrupt::InterruptHandle;
4160
4161 let provider = Arc::new(MockProvider::new(vec![MockProvider::text_response(
4164 "real answer",
4165 3,
4166 2,
4167 )]));
4168
4169 let interrupt = InterruptHandle::new();
4171 interrupt.interrupt();
4172
4173 let inputs = Arc::new(std::sync::Mutex::new(vec![
4175 Some("follow up".to_string()),
4176 None,
4177 ]));
4178 let on_input: Arc<crate::agent::OnInput> = {
4179 let inputs = inputs.clone();
4180 Arc::new(move || {
4181 let inputs = inputs.clone();
4182 Box::pin(async move {
4183 let mut g = inputs.lock().expect("lock");
4184 if g.is_empty() { None } else { g.remove(0) }
4185 })
4186 })
4187 };
4188
4189 let runner = AgentRunner::builder(provider)
4190 .name("interruptible")
4191 .max_turns(10)
4192 .on_input(on_input)
4193 .interrupt(interrupt)
4194 .build()
4195 .unwrap();
4196
4197 let out = runner.execute("hello").await.unwrap();
4198
4199 assert_eq!(out.result, "real answer");
4202 assert_eq!(out.tokens_used.input_tokens, 3);
4204 assert_eq!(out.tokens_used.output_tokens, 2);
4205 }
4206
4207 #[tokio::test(flavor = "multi_thread")]
4208 async fn interrupt_during_tool_batch_abandons_it_and_ends_turn() {
4209 use crate::agent::interrupt::InterruptHandle;
4210 use std::sync::atomic::{AtomicBool, Ordering};
4211
4212 struct SlowTool {
4215 started: tokio::sync::mpsc::UnboundedSender<()>,
4216 finished: Arc<AtomicBool>,
4217 }
4218 impl Tool for SlowTool {
4219 fn definition(&self) -> ToolDefinition {
4220 ToolDefinition {
4221 name: "slow".into(),
4222 description: "Sleeps for a while.".into(),
4223 input_schema: serde_json::json!({"type": "object", "properties": {}}),
4224 }
4225 }
4226 fn execute(
4227 &self,
4228 _ctx: &crate::ExecutionContext,
4229 _input: serde_json::Value,
4230 ) -> Pin<Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>>
4231 {
4232 let started = self.started.clone();
4233 let finished = self.finished.clone();
4234 Box::pin(async move {
4235 let _ = started.send(());
4236 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
4237 finished.store(true, Ordering::SeqCst);
4238 Ok(ToolOutput::success("slept".to_string()))
4239 })
4240 }
4241 }
4242
4243 let (start_tx, mut start_rx) = tokio::sync::mpsc::unbounded_channel();
4244 let finished = Arc::new(AtomicBool::new(false));
4245
4246 let provider = Arc::new(MockProvider::new(vec![
4247 CompletionResponse {
4249 content: vec![ContentBlock::ToolUse {
4250 id: "call-slow".into(),
4251 name: "slow".into(),
4252 input: serde_json::json!({}),
4253 }],
4254 stop_reason: StopReason::ToolUse,
4255 reasoning: None,
4256 usage: TokenUsage::default(),
4257 model: None,
4258 },
4259 MockProvider::text_response("real answer", 3, 2),
4261 ]));
4262
4263 let interrupt = InterruptHandle::new();
4264 let canceller = {
4266 let interrupt = interrupt.clone();
4267 tokio::spawn(async move {
4268 start_rx.recv().await;
4269 interrupt.interrupt();
4270 })
4271 };
4272
4273 let inputs = Arc::new(std::sync::Mutex::new(vec![
4275 Some("follow up".to_string()),
4276 None,
4277 ]));
4278 let on_input: Arc<crate::agent::OnInput> = {
4279 let inputs = inputs.clone();
4280 Arc::new(move || {
4281 let inputs = inputs.clone();
4282 Box::pin(async move {
4283 let mut g = inputs.lock().expect("lock");
4284 if g.is_empty() { None } else { g.remove(0) }
4285 })
4286 })
4287 };
4288
4289 let runner = AgentRunner::builder(provider)
4290 .name("interruptible")
4291 .max_turns(10)
4292 .tool(Arc::new(SlowTool {
4293 started: start_tx,
4294 finished: finished.clone(),
4295 }))
4296 .on_input(on_input)
4297 .interrupt(interrupt)
4298 .build()
4299 .unwrap();
4300
4301 let out = runner.execute("please run slow").await.unwrap();
4302 canceller.await.unwrap();
4303
4304 assert_eq!(out.result, "real answer");
4307 assert!(
4308 !finished.load(Ordering::SeqCst),
4309 "the tool batch must be abandoned, not awaited to completion"
4310 );
4311 assert!(
4314 out.tool_call_results
4315 .iter()
4316 .any(|r| r.is_error && r.output.contains("Interrupted")),
4317 "the interrupted tool must record a synthetic result"
4318 );
4319 }
4320
4321 #[tokio::test(flavor = "multi_thread")]
4322 async fn agent_runner_adjusts_tracker_cumulatively_across_turns() {
4323 let tracker = Arc::new(TenantTokenTracker::new(1_000_000));
4328 let scope = TenantScope::new("acme");
4329 drop(tracker.reserve(&scope, 5000).unwrap());
4330
4331 let provider = Arc::new(MockProvider::new(vec![
4332 tool_use_response(100, 200), MockProvider::text_response("done", 50, 150), ]));
4335
4336 let runner = AgentRunner::builder(provider)
4337 .name("test")
4338 .system_prompt("test")
4339 .audit_user_context("test-user", "acme")
4340 .tenant_tracker(tracker.clone())
4341 .max_turns(2)
4342 .tool(Arc::new(NoopTool))
4343 .build()
4344 .unwrap();
4345 let _output = runner.execute("hello").await.unwrap();
4346
4347 let snap = tracker.snapshot();
4349 assert_eq!(snap[0].1.in_flight, 500);
4350
4351 drop(runner);
4352 assert_eq!(tracker.snapshot()[0].1.in_flight, 0);
4353 }
4354
4355 #[tokio::test]
4356 async fn execution_context_propagates_to_tool() {
4357 use std::sync::Mutex;
4358
4359 use crate::ExecutionContext;
4360 use crate::llm::types::ToolCall;
4361
4362 struct CtxCapturingTool {
4363 captured_tenant: Arc<Mutex<Option<String>>>,
4364 }
4365
4366 impl Tool for CtxCapturingTool {
4367 fn definition(&self) -> ToolDefinition {
4368 ToolDefinition {
4369 name: "ctx_capture".into(),
4370 description: "Captures the tenant_id from ExecutionContext.".into(),
4371 input_schema: serde_json::json!({"type": "object"}),
4372 }
4373 }
4374
4375 fn execute(
4376 &self,
4377 ctx: &ExecutionContext,
4378 _input: serde_json::Value,
4379 ) -> Pin<Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>>
4380 {
4381 let captured = self.captured_tenant.clone();
4382 let tenant = ctx.tenant_id.clone();
4383 Box::pin(async move {
4384 *captured.lock().unwrap() = tenant;
4385 Ok(ToolOutput::success("ok"))
4386 })
4387 }
4388 }
4389
4390 let captured = Arc::new(Mutex::new(None));
4391 let tool = Arc::new(CtxCapturingTool {
4392 captured_tenant: captured.clone(),
4393 });
4394
4395 let provider = Arc::new(MockProvider::new(vec![]));
4396 let runner = AgentRunner::builder(provider)
4397 .name("test")
4398 .system_prompt("test")
4399 .max_turns(1)
4400 .tools(vec![tool as Arc<dyn Tool>])
4401 .audit_user_context("test-user", "test-tenant")
4402 .build()
4403 .unwrap();
4404
4405 let calls = vec![ToolCall {
4406 id: "c1".into(),
4407 name: "ctx_capture".into(),
4408 input: serde_json::json!({}),
4409 }];
4410 let (_results, _records) = runner.execute_tools_parallel(&calls, 0, None).await;
4411
4412 assert_eq!(
4413 captured.lock().unwrap().as_deref(),
4414 Some("test-tenant"),
4415 "tool did not receive the tenant_id from ExecutionContext"
4416 );
4417 }
4418
4419 #[tokio::test]
4420 async fn transcript_snapshot_reaches_the_tool() {
4421 use std::sync::Mutex;
4422
4423 use crate::ExecutionContext;
4424 use crate::llm::types::ToolCall;
4425
4426 struct TranscriptCapturingTool {
4427 seen: Arc<Mutex<Option<usize>>>,
4428 }
4429
4430 impl Tool for TranscriptCapturingTool {
4431 fn definition(&self) -> ToolDefinition {
4432 ToolDefinition {
4433 name: "advisor_probe".into(),
4434 description: "Counts the transcript messages it can see.".into(),
4435 input_schema: serde_json::json!({"type": "object"}),
4436 }
4437 }
4438
4439 fn execute(
4440 &self,
4441 ctx: &ExecutionContext,
4442 _input: serde_json::Value,
4443 ) -> Pin<Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>>
4444 {
4445 let seen = self.seen.clone();
4446 let n = ctx.transcript.as_ref().map(|t| t.len());
4447 Box::pin(async move {
4448 *seen.lock().unwrap() = n;
4449 Ok(ToolOutput::success("ok"))
4450 })
4451 }
4452 }
4453
4454 let seen = Arc::new(Mutex::new(None));
4455 let tool = Arc::new(TranscriptCapturingTool { seen: seen.clone() });
4456 let provider = Arc::new(MockProvider::new(vec![]));
4457 let runner = AgentRunner::builder(provider)
4458 .name("test")
4459 .system_prompt("test")
4460 .max_turns(1)
4461 .tools(vec![tool as Arc<dyn Tool>])
4462 .build()
4463 .unwrap();
4464
4465 let calls = vec![ToolCall {
4466 id: "c1".into(),
4467 name: "advisor_probe".into(),
4468 input: serde_json::json!({}),
4469 }];
4470 let transcript = Arc::new(vec![
4471 crate::llm::types::Message::user("hello"),
4472 crate::llm::types::Message::user("second message"),
4473 ]);
4474 let (_r, _rec) = runner
4475 .execute_tools_parallel(&calls, 0, Some(transcript))
4476 .await;
4477 assert_eq!(
4478 *seen.lock().unwrap(),
4479 Some(2),
4480 "the tool must see the 2-message transcript snapshot"
4481 );
4482 }
4483
4484 #[tokio::test]
4487 async fn agent_output_tool_call_results_populated_after_tool_call() {
4488 let provider = Arc::new(MockProvider::new(vec![
4490 tool_use_response(10, 20),
4491 MockProvider::text_response("done", 5, 5),
4492 ]));
4493
4494 let runner = AgentRunner::builder(provider)
4495 .name("test")
4496 .system_prompt("test")
4497 .max_turns(2)
4498 .tool(Arc::new(NoopTool))
4499 .build()
4500 .unwrap();
4501 let output = runner.execute("hello").await.unwrap();
4502
4503 assert_eq!(output.tool_call_results.len(), 1, "expected one record");
4504 let rec = &output.tool_call_results[0];
4505 assert_eq!(rec.tool_name, "noop");
4506 assert_eq!(rec.tool_call_id, "call-1");
4507 assert_eq!(rec.input, serde_json::json!({}));
4508 assert_eq!(rec.output, "ok");
4509 assert!(!rec.is_error);
4510 }
4511
4512 #[tokio::test]
4516 async fn agent_output_tool_call_results_uses_full_output_not_redacted() {
4517 use crate::llm::types::Role;
4518
4519 struct BlobTool;
4521 impl Tool for BlobTool {
4522 fn definition(&self) -> ToolDefinition {
4523 ToolDefinition {
4524 name: "blob".into(),
4525 description: "Returns a large blob.".into(),
4526 input_schema: serde_json::json!({"type": "object", "properties": {}}),
4527 }
4528 }
4529
4530 fn execute(
4531 &self,
4532 _ctx: &crate::ExecutionContext,
4533 _input: serde_json::Value,
4534 ) -> Pin<Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>>
4535 {
4536 Box::pin(async { Ok(ToolOutput::success("FULL_BLOB_DATA_XYZ".to_string())) })
4537 }
4538
4539 fn redact_for_history(&self, _output: &str) -> String {
4540 "REDACTED".to_string()
4541 }
4542 }
4543
4544 let provider = Arc::new(MockProvider::new(vec![
4546 CompletionResponse {
4547 content: vec![ContentBlock::ToolUse {
4548 id: "call-blob".into(),
4549 name: "blob".into(),
4550 input: serde_json::json!({}),
4551 }],
4552 stop_reason: StopReason::ToolUse,
4553 reasoning: None,
4554 usage: TokenUsage {
4555 input_tokens: 10,
4556 output_tokens: 20,
4557 ..Default::default()
4558 },
4559 model: None,
4560 },
4561 MockProvider::text_response("done", 5, 5),
4562 ]));
4563
4564 let runner = AgentRunner::builder(provider.clone())
4565 .name("test")
4566 .system_prompt("test")
4567 .max_turns(2)
4568 .tool(Arc::new(BlobTool))
4569 .build()
4570 .unwrap();
4571 let output = runner.execute("hello").await.unwrap();
4572
4573 assert_eq!(output.tool_call_results.len(), 1);
4575 assert_eq!(output.tool_call_results[0].output, "FULL_BLOB_DATA_XYZ");
4576 assert!(!output.tool_call_results[0].output.contains("REDACTED"));
4577
4578 let captured = provider
4581 .captured_requests
4582 .lock()
4583 .expect("capture lock poisoned");
4584 assert!(
4585 captured.len() >= 2,
4586 "expected at least 2 LLM calls, got {}",
4587 captured.len()
4588 );
4589 let turn2 = &captured[1];
4590 let mut found_redacted = false;
4591 let mut found_full = false;
4592 for msg in &turn2.messages {
4593 if msg.role == Role::User {
4594 for block in &msg.content {
4595 if let ContentBlock::ToolResult { content, .. } = block {
4596 if content.contains("REDACTED") {
4597 found_redacted = true;
4598 }
4599 if content.contains("FULL_BLOB_DATA_XYZ") {
4600 found_full = true;
4601 }
4602 }
4603 }
4604 }
4605 }
4606 assert!(
4607 found_redacted,
4608 "expected REDACTED tool result in turn-2 conversation history"
4609 );
4610 assert!(
4611 !found_full,
4612 "FULL_BLOB_DATA_XYZ should NOT be in conversation history sent to LLM"
4613 );
4614 }
4615
4616 #[test]
4617 fn over_window_fraction_triggers_at_or_above_budget() {
4618 assert!(super::over_window_fraction(700, 1000, 0.70));
4619 assert!(super::over_window_fraction(800, 1000, 0.70));
4620 assert!(!super::over_window_fraction(699, 1000, 0.70));
4621 assert!(!super::over_window_fraction(10, 0, 0.70)); }
4623
4624 #[test]
4625 fn compaction_summary_prompt_pins_the_preservation_schema() {
4626 let p = super::COMPACTION_SUMMARY_SYSTEM;
4629 for marker in ["GOAL", "FILES", "TODOS", "UNRESOLVED", "DECISIONS"] {
4630 assert!(p.contains(marker), "compaction prompt must pin {marker}");
4631 }
4632 }
4633
4634 #[tokio::test]
4635 async fn compaction_sends_the_structured_summary_prompt() {
4636 let provider = Arc::new(MockProvider::new(vec![
4641 tool_use_with_tokens(800),
4642 tool_use_with_tokens(800),
4643 tool_use_with_tokens(800), MockProvider::text_response("summary text", 1, 1), MockProvider::text_response("done", 800, 1),
4646 ]));
4647 let runner = AgentRunner::builder(provider.clone())
4648 .name("test")
4649 .system_prompt("test")
4650 .tool(Arc::new(NoopTool))
4651 .context_window_tokens(1000)
4652 .max_turns(10)
4653 .build()
4654 .unwrap();
4655 let _ = runner.execute("do things").await.unwrap();
4656
4657 let reqs = provider.captured_requests.lock().expect("lock");
4658 assert!(
4659 reqs.iter()
4660 .any(|r| r.system.contains("GOAL") && r.system.contains("DECISIONS")),
4661 "the summary call must use the structured preservation prompt"
4662 );
4663 }
4664
4665 struct BigTool {
4667 size: usize,
4668 }
4669 impl Tool for BigTool {
4670 fn definition(&self) -> ToolDefinition {
4671 ToolDefinition {
4672 name: "big".into(),
4673 description: "Returns a large output.".into(),
4674 input_schema: serde_json::json!({"type": "object", "properties": {}}),
4675 }
4676 }
4677 fn execute(
4678 &self,
4679 _ctx: &crate::ExecutionContext,
4680 _input: serde_json::Value,
4681 ) -> Pin<Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>>
4682 {
4683 let size = self.size;
4684 Box::pin(async move { Ok(ToolOutput::success("x".repeat(size))) })
4685 }
4686 }
4687
4688 fn tool_use_named(name: &str, input_tokens: u32) -> crate::llm::types::CompletionResponse {
4690 crate::llm::types::CompletionResponse {
4691 content: vec![ContentBlock::ToolUse {
4692 id: "call-1".into(),
4693 name: name.into(),
4694 input: serde_json::json!({}),
4695 }],
4696 stop_reason: StopReason::ToolUse,
4697 reasoning: None,
4698 usage: TokenUsage {
4699 input_tokens,
4700 output_tokens: 1,
4701 ..Default::default()
4702 },
4703 model: None,
4704 }
4705 }
4706
4707 fn tool_result_contents(req: &crate::llm::types::CompletionRequest) -> Vec<String> {
4709 req.messages
4710 .iter()
4711 .flat_map(|m| m.content.iter())
4712 .filter_map(|b| match b {
4713 ContentBlock::ToolResult { content, .. } => Some(content.clone()),
4714 _ => None,
4715 })
4716 .collect()
4717 }
4718
4719 fn overflow_error() -> Error {
4720 Error::Api {
4721 status: 400,
4722 message: "Prompt contains 325070 tokens and 0 draft tokens, too large for model \
4723 with 262144 maximum context length"
4724 .into(),
4725 }
4726 }
4727
4728 #[tokio::test(flavor = "multi_thread")]
4731 async fn ingest_cap_truncates_giant_fresh_tool_result_by_default() {
4732 let provider = Arc::new(MockProvider::new(vec![
4735 tool_use_named("big", 100),
4736 MockProvider::text_response("done", 100, 1),
4737 ]));
4738 let runner = AgentRunner::builder(provider.clone())
4739 .name("test")
4740 .system_prompt("sys")
4741 .tool(Arc::new(BigTool { size: 400_000 }))
4742 .max_turns(5)
4743 .build()
4744 .unwrap();
4745 runner.execute("go").await.unwrap();
4746
4747 let reqs = provider.captured_requests.lock().unwrap();
4748 let contents = tool_result_contents(&reqs[1]);
4749 assert_eq!(contents.len(), 1);
4750 assert!(
4751 contents[0].len() <= super::DEFAULT_TOOL_RESULT_INGEST_CAP + 64,
4752 "fresh result must be capped: got {} bytes",
4753 contents[0].len()
4754 );
4755 assert!(contents[0].contains("[truncated:"), "non-restorable marker");
4756 assert!(
4757 !contents[0].contains("fetch_full_output"),
4758 "no restore promise without a recall store"
4759 );
4760 }
4761
4762 #[tokio::test(flavor = "multi_thread")]
4763 async fn ingest_cap_marker_is_restorable_with_recall_store() {
4764 let store = Arc::new(crate::agent::context_recall::ContextRecallStore::new());
4765 let provider = Arc::new(MockProvider::new(vec![
4766 tool_use_named("big", 100),
4767 MockProvider::text_response("done", 100, 1),
4768 ]));
4769 let runner = AgentRunner::builder(provider.clone())
4770 .name("test")
4771 .system_prompt("sys")
4772 .tool(Arc::new(BigTool { size: 400_000 }))
4773 .context_recall_store(store.clone())
4774 .max_turns(5)
4775 .build()
4776 .unwrap();
4777 runner.execute("go").await.unwrap();
4778
4779 {
4780 let reqs = provider.captured_requests.lock().unwrap();
4781 let contents = tool_result_contents(&reqs[1]);
4782 assert!(
4783 contents[0].contains("fetch_full_output(\"call-1\")"),
4784 "restorable marker must name the ref: {}",
4785 &contents[0][contents[0].len().saturating_sub(200)..]
4786 );
4787 }
4788 let full = store.get("call-1").await.expect("full output indexed");
4790 assert_eq!(full.len(), 400_000);
4791 }
4792
4793 #[tokio::test(flavor = "multi_thread")]
4794 async fn ingest_cap_clamped_by_model_window() {
4795 let provider = Arc::new(MockProvider::new(vec![
4799 tool_use_named("big", 100),
4800 MockProvider::text_response("done", 100, 1),
4801 ]));
4802 let runner = AgentRunner::builder(provider.clone())
4803 .name("test")
4804 .system_prompt("sys")
4805 .tool(Arc::new(BigTool { size: 10_000 }))
4806 .context_window_tokens(1000)
4807 .max_turns(5)
4808 .build()
4809 .unwrap();
4810 runner.execute("go").await.unwrap();
4811
4812 let reqs = provider.captured_requests.lock().unwrap();
4813 let contents = tool_result_contents(&reqs[1]);
4814 assert!(
4815 contents[0].len() <= 1_000 + 64,
4816 "cap must clamp to the window: got {} bytes",
4817 contents[0].len()
4818 );
4819 }
4820
4821 #[tokio::test(flavor = "multi_thread")]
4822 async fn ingest_cap_configurable_via_builder() {
4823 let provider = Arc::new(MockProvider::new(vec![
4824 tool_use_named("big", 100),
4825 MockProvider::text_response("done", 100, 1),
4826 ]));
4827 let runner = AgentRunner::builder(provider.clone())
4828 .name("test")
4829 .system_prompt("sys")
4830 .tool(Arc::new(BigTool { size: 50_000 }))
4831 .tool_result_ingest_cap(1_000)
4832 .max_turns(5)
4833 .build()
4834 .unwrap();
4835 runner.execute("go").await.unwrap();
4836
4837 let reqs = provider.captured_requests.lock().unwrap();
4838 let contents = tool_result_contents(&reqs[1]);
4839 assert!(
4840 contents[0].len() <= 1_000 + 64,
4841 "custom cap applies: got {} bytes",
4842 contents[0].len()
4843 );
4844 }
4845
4846 #[tokio::test(flavor = "multi_thread")]
4849 async fn proactive_compaction_sees_fresh_context_estimate() {
4850 let events: Arc<std::sync::Mutex<Vec<crate::agent::events::AgentEvent>>> =
4854 Arc::new(std::sync::Mutex::new(Vec::new()));
4855 let events_clone = events.clone();
4856
4857 let provider = Arc::new(MockProvider::new(vec![
4858 tool_use_named("big", 100),
4859 tool_use_named("big", 100),
4860 tool_use_named("big", 100), MockProvider::text_response("summary text", 1, 1),
4862 MockProvider::text_response("done", 100, 1),
4863 ]));
4864 let runner = AgentRunner::builder(provider)
4865 .name("test")
4866 .system_prompt("sys")
4867 .tool(Arc::new(BigTool { size: 10_000 }))
4868 .context_window_tokens(1000)
4869 .max_turns(10)
4870 .on_event(Arc::new(move |ev| {
4871 events_clone.lock().expect("lock").push(ev);
4872 }))
4873 .build()
4874 .unwrap();
4875 runner.execute("go").await.unwrap();
4876
4877 let summarized = events
4878 .lock()
4879 .expect("lock")
4880 .iter()
4881 .filter(|e| {
4882 matches!(
4883 e,
4884 crate::agent::events::AgentEvent::ContextSummarized { .. }
4885 )
4886 })
4887 .count();
4888 assert_eq!(
4889 summarized, 1,
4890 "estimate-driven proactive compaction must fire exactly once"
4891 );
4892 }
4893
4894 #[tokio::test(flavor = "multi_thread")]
4897 async fn summary_transcript_is_bounded() {
4898 let provider = Arc::new(MockProvider::new(vec![
4901 tool_use_named("big", 800),
4902 tool_use_named("big", 800),
4903 tool_use_named("big", 800), MockProvider::text_response("summary text", 1, 1),
4905 MockProvider::text_response("done", 800, 1),
4906 ]));
4907 let runner = AgentRunner::builder(provider.clone())
4908 .name("test")
4909 .system_prompt("sys")
4910 .tool(Arc::new(BigTool { size: 5_000 }))
4911 .context_window_tokens(1000)
4912 .max_turns(10)
4913 .build()
4914 .unwrap();
4915 runner.execute("go").await.unwrap();
4916
4917 let reqs = provider.captured_requests.lock().unwrap();
4918 let summary_req = reqs
4919 .iter()
4920 .find(|r| r.system.contains("GOAL"))
4921 .expect("summary request captured");
4922 let user_text: String = summary_req.messages[0]
4923 .content
4924 .iter()
4925 .filter_map(|b| match b {
4926 ContentBlock::Text { text } => Some(text.as_str()),
4927 _ => None,
4928 })
4929 .collect();
4930 assert!(
4932 user_text.len() <= 2_000 + 128,
4933 "summary transcript must be bounded: got {} bytes",
4934 user_text.len()
4935 );
4936 assert!(
4937 user_text.contains("[transcript abridged"),
4938 "abridge marker present"
4939 );
4940 }
4941
4942 #[tokio::test(flavor = "multi_thread")]
4945 async fn reactive_overflow_truncates_oversized_results_and_retries_without_summary() {
4946 let provider = Arc::new(MockProvider::new_with_results(vec![
4950 Ok(tool_use_named("big", 100)),
4951 Err(overflow_error()),
4952 Ok(MockProvider::text_response("done", 100, 1)),
4953 ]));
4954 let runner = AgentRunner::builder(provider.clone())
4955 .name("test")
4956 .system_prompt("sys")
4957 .tool(Arc::new(BigTool { size: 60_000 }))
4958 .max_turns(5)
4959 .build()
4960 .unwrap();
4961 let output = runner.execute("go").await.expect("run must recover");
4962 assert_eq!(output.result, "done");
4963
4964 let reqs = provider.captured_requests.lock().unwrap();
4965 assert!(
4966 !reqs.iter().any(|r| r.system.contains("GOAL")),
4967 "deterministic recovery must not call the summarizer"
4968 );
4969 let contents = tool_result_contents(&reqs[2]);
4971 assert!(
4972 contents[0].len() <= 4_096 + 64,
4973 "retried result emergency-truncated: got {} bytes",
4974 contents[0].len()
4975 );
4976 }
4977
4978 #[tokio::test(flavor = "multi_thread")]
4979 async fn reactive_overflow_recovers_with_few_messages_via_summary() {
4980 let provider = Arc::new(MockProvider::new_with_results(vec![
4984 Err(overflow_error()),
4985 Ok(MockProvider::text_response("summary text", 1, 1)),
4986 Ok(MockProvider::text_response("done", 100, 1)),
4987 ]));
4988 let runner = AgentRunner::builder(provider.clone())
4989 .name("test")
4990 .system_prompt("sys")
4991 .max_turns(5)
4992 .build()
4993 .unwrap();
4994 let output = runner.execute("go").await.expect("run must recover");
4995 assert_eq!(output.result, "done");
4996
4997 let reqs = provider.captured_requests.lock().unwrap();
4998 assert!(
4999 reqs.iter().any(|r| r.system.contains("GOAL")),
5000 "summary fallback must have been attempted"
5001 );
5002 }
5003
5004 #[tokio::test(flavor = "multi_thread")]
5005 async fn goal_gates_before_next_input_in_chat_mode() {
5006 use std::sync::atomic::{AtomicUsize, Ordering};
5011
5012 let main = Arc::new(MockProvider::new(vec![
5013 MockProvider::text_response("attempt 1", 10, 1),
5014 MockProvider::text_response("attempt 2", 10, 1),
5015 MockProvider::text_response("final", 10, 1),
5016 ]));
5017 let judge = Arc::new(MockProvider::new(vec![
5018 MockProvider::text_response("GOAL_MET: NO: no evidence yet", 1, 1),
5019 MockProvider::text_response("GOAL_MET: YES", 1, 1),
5020 ]));
5021 let inputs = Arc::new(AtomicUsize::new(0));
5022 let inputs_c = inputs.clone();
5023 let on_input: Arc<crate::agent::runner::OnInput> = Arc::new(move || {
5024 let n = inputs_c.fetch_add(1, Ordering::SeqCst);
5025 Box::pin(async move {
5026 if n == 0 {
5027 Some("another request".to_string())
5028 } else {
5029 None
5030 }
5031 })
5032 });
5033 let runner = AgentRunner::builder(main.clone())
5034 .name("test")
5035 .system_prompt("sys")
5036 .goal(crate::agent::goal::GoalCondition::new(
5037 "demonstrate the result",
5038 Arc::new(crate::llm::BoxedProvider::from_arc(judge.clone())),
5039 ))
5040 .on_input(on_input)
5041 .max_turns(10)
5042 .build()
5043 .unwrap();
5044 let out = runner.execute("do the thing").await.unwrap();
5045
5046 assert_eq!(out.result, "final");
5047 assert_eq!(out.goal_met, Some(true));
5048 let judge_calls = judge.captured_requests.lock().unwrap().len();
5049 assert_eq!(
5050 judge_calls, 2,
5051 "judge gates each stop until met, then auto-clears (no 3rd call)"
5052 );
5053 let main_reqs = main.captured_requests.lock().unwrap();
5054 assert_eq!(main_reqs.len(), 3, "continuation + chat turn both happened");
5055 let texts: Vec<String> = main_reqs[1]
5056 .messages
5057 .iter()
5058 .flat_map(|m| m.content.iter())
5059 .filter_map(|b| match b {
5060 ContentBlock::Text { text } => Some(text.clone()),
5061 _ => None,
5062 })
5063 .collect();
5064 assert!(
5065 texts.iter().any(|t| t.contains("not yet complete")),
5066 "the judge's continuation reached the agent BEFORE any input wait: {texts:?}"
5067 );
5068 }
5069
5070 #[tokio::test(flavor = "multi_thread")]
5071 async fn barrier_tool_seeds_guard_before_sibling_mutations() {
5072 let guard = Arc::new(crate::agent::guardrails::ScopeGuard::new(vec![]));
5078 let provider = Arc::new(MockProvider::new(vec![
5079 crate::llm::types::CompletionResponse {
5080 content: vec![
5081 ContentBlock::ToolUse {
5082 id: "c-scope".into(),
5083 name: "set_scope".into(),
5084 input: serde_json::json!({"paths": ["/tmp/x/utils.py"]}),
5085 },
5086 ContentBlock::ToolUse {
5087 id: "c-ok".into(),
5088 name: "write".into(),
5089 input: serde_json::json!({"file_path": "/tmp/x/utils.py", "content": "a"}),
5090 },
5091 ContentBlock::ToolUse {
5092 id: "c-deny".into(),
5093 name: "write".into(),
5094 input: serde_json::json!({"file_path": "/tmp/x/notes.txt", "content": "b"}),
5095 },
5096 ],
5097 stop_reason: StopReason::ToolUse,
5098 reasoning: None,
5099 usage: TokenUsage::default(),
5100 model: None,
5101 },
5102 MockProvider::text_response("done", 10, 1),
5103 ]));
5104 let runner = AgentRunner::builder(provider.clone())
5105 .name("test")
5106 .system_prompt("sys")
5107 .tool(Arc::new(crate::tool::set_scope::SetScopeTool::new(
5108 guard.clone(),
5109 )))
5110 .tool(Arc::new(NamedTool { name: "write" }))
5111 .guardrail(guard)
5112 .max_turns(5)
5113 .build()
5114 .unwrap();
5115 runner.execute("go").await.unwrap();
5116
5117 let reqs = provider.captured_requests.lock().unwrap();
5118 let results: Vec<(String, String, bool)> = reqs[1]
5120 .messages
5121 .iter()
5122 .flat_map(|m| m.content.iter())
5123 .filter_map(|b| match b {
5124 ContentBlock::ToolResult {
5125 tool_use_id,
5126 content,
5127 is_error,
5128 } => Some((tool_use_id.clone(), content.clone(), *is_error)),
5129 _ => None,
5130 })
5131 .collect();
5132 let by_id = |id: &str| {
5133 results
5134 .iter()
5135 .find(|(i, _, _)| i == id)
5136 .unwrap_or_else(|| panic!("missing result {id}: {results:?}"))
5137 };
5138 let (_, scope_out, scope_err) = by_id("c-scope");
5139 assert!(!scope_err, "set_scope itself succeeds: {scope_out}");
5140 let (_, ok_out, ok_err) = by_id("c-ok");
5141 assert!(!ok_err, "in-scope sibling write allowed: {ok_out}");
5142 let (_, deny_out, deny_err) = by_id("c-deny");
5143 assert!(
5144 *deny_err && deny_out.contains("scope guard"),
5145 "out-of-scope sibling write must be DENIED by the freshly-seeded \
5146 guard (TOCTOU fix): err={deny_err} out={deny_out}"
5147 );
5148 }
5149
5150 #[tokio::test(flavor = "multi_thread")]
5151 async fn prose_question_battery_triggers_ask_gate() {
5152 let provider = Arc::new(MockProvider::new(vec![
5158 MockProvider::text_response(
5159 "Avant de commencer :\n1. Quel langage ?\n2. Interface web ou CLI ?\n3. Quelle persistance ?",
5160 10,
5161 5,
5162 ),
5163 tool_use_named("question", 10), MockProvider::text_response("done", 10, 1),
5165 ]));
5166 let runner = AgentRunner::builder(provider.clone())
5167 .name("test")
5168 .system_prompt("sys")
5169 .tool(Arc::new(NamedTool { name: "question" }))
5170 .max_turns(6)
5171 .build()
5172 .unwrap();
5173 let out = runner.execute("crée un CRM").await.unwrap();
5174 assert_eq!(out.result, "done");
5175 let reqs = provider.captured_requests.lock().unwrap();
5176 let texts: Vec<String> = reqs[1]
5177 .messages
5178 .iter()
5179 .flat_map(|m| m.content.iter())
5180 .filter_map(|b| match b {
5181 ContentBlock::Text { text } => Some(text.clone()),
5182 _ => None,
5183 })
5184 .collect();
5185 assert!(
5186 texts.iter().any(|t| t.contains("[ask gate]")),
5187 "the prose battery must be redirected to the question tool: {texts:?}"
5188 );
5189 }
5190
5191 #[tokio::test(flavor = "multi_thread")]
5192 async fn single_trailing_question_does_not_trigger_ask_gate() {
5193 let provider = Arc::new(MockProvider::new(vec![MockProvider::text_response(
5195 "Voilà, c'est fait. Autre chose ?",
5196 10,
5197 5,
5198 )]));
5199 let runner = AgentRunner::builder(provider.clone())
5200 .name("test")
5201 .system_prompt("sys")
5202 .tool(Arc::new(NamedTool { name: "question" }))
5203 .max_turns(4)
5204 .build()
5205 .unwrap();
5206 runner.execute("petite tâche").await.unwrap();
5207 assert_eq!(
5208 provider.captured_requests.lock().unwrap().len(),
5209 1,
5210 "no redirect turn for a single trailing question"
5211 );
5212 }
5213
5214 #[tokio::test(flavor = "multi_thread")]
5215 async fn ask_gate_inactive_without_question_tool() {
5216 let provider = Arc::new(MockProvider::new(vec![MockProvider::text_response(
5217 "1. Quel langage ?\n2. Quelle interface ?",
5218 10,
5219 5,
5220 )]));
5221 let runner = AgentRunner::builder(provider.clone())
5222 .name("test")
5223 .system_prompt("sys")
5224 .max_turns(4)
5225 .build()
5226 .unwrap();
5227 runner.execute("crée un CRM").await.unwrap();
5228 assert_eq!(
5229 provider.captured_requests.lock().unwrap().len(),
5230 1,
5231 "no question tool registered → prose questions pass through"
5232 );
5233 }
5234
5235 #[tokio::test(flavor = "multi_thread")]
5236 async fn announced_intent_without_work_triggers_act_gate() {
5237 let provider = Arc::new(MockProvider::new(vec![
5242 MockProvider::text_response(
5243 "Je vais créer un petit CRM simple. Laisse-moi d'abord vérifier la structure.",
5244 10,
5245 5,
5246 ),
5247 tool_use_named("work", 10), MockProvider::text_response("done", 10, 1),
5249 ]));
5250 let runner = AgentRunner::builder(provider.clone())
5251 .name("test")
5252 .system_prompt("sys")
5253 .tool(Arc::new(NamedTool { name: "work" }))
5254 .max_turns(6)
5255 .build()
5256 .unwrap();
5257 let out = runner.execute("crée un CRM").await.unwrap();
5258 assert_eq!(out.result, "done");
5259 let reqs = provider.captured_requests.lock().unwrap();
5260 let texts: Vec<String> = reqs[1]
5261 .messages
5262 .iter()
5263 .flat_map(|m| m.content.iter())
5264 .filter_map(|b| match b {
5265 ContentBlock::Text { text } => Some(text.clone()),
5266 _ => None,
5267 })
5268 .collect();
5269 assert!(
5270 texts.iter().any(|t| t.contains("[act gate]")),
5271 "announced intent with zero work must be redirected: {texts:?}"
5272 );
5273 }
5274
5275 #[tokio::test(flavor = "multi_thread")]
5276 async fn act_gate_silent_after_real_work() {
5277 let provider = Arc::new(MockProvider::new(vec![
5280 tool_use_named("work", 10),
5281 MockProvider::text_response(
5282 "C'est fait. Je vais te laisser tester — dis-moi si tu veux des ajustements.",
5283 10,
5284 5,
5285 ),
5286 ]));
5287 let runner = AgentRunner::builder(provider.clone())
5288 .name("test")
5289 .system_prompt("sys")
5290 .tool(Arc::new(NamedTool { name: "work" }))
5291 .max_turns(6)
5292 .build()
5293 .unwrap();
5294 runner.execute("petite tâche").await.unwrap();
5295 assert_eq!(
5296 provider.captured_requests.lock().unwrap().len(),
5297 2,
5298 "no redirect once real work happened"
5299 );
5300 }
5301
5302 #[tokio::test(flavor = "multi_thread")]
5303 async fn act_gate_is_one_shot() {
5304 let provider = Arc::new(MockProvider::new(vec![
5307 MockProvider::text_response("Je vais créer le fichier maintenant.", 10, 5),
5308 MockProvider::text_response("Je vais vraiment le faire bientôt.", 10, 5),
5309 ]));
5310 let runner = AgentRunner::builder(provider.clone())
5311 .name("test")
5312 .system_prompt("sys")
5313 .tool(Arc::new(NamedTool { name: "work" }))
5314 .max_turns(6)
5315 .build()
5316 .unwrap();
5317 let out = runner.execute("crée un fichier").await.unwrap();
5318 assert!(
5319 out.result.contains("bientôt"),
5320 "second announce passes through"
5321 );
5322 assert_eq!(provider.captured_requests.lock().unwrap().len(), 2);
5323 }
5324
5325 #[tokio::test(flavor = "multi_thread")]
5326 async fn wish_request_first_mutation_is_plan_gated() {
5327 let provider = Arc::new(MockProvider::new(vec![
5333 tool_use_named("write", 10), tool_use_named("question", 10), tool_use_named("write", 10), MockProvider::text_response("done", 10, 1),
5337 ]));
5338 let runner = AgentRunner::builder(provider.clone())
5339 .name("test")
5340 .system_prompt("sys")
5341 .tool(Arc::new(NamedTool { name: "write" }))
5342 .tool(Arc::new(NamedTool { name: "question" }))
5343 .max_turns(8)
5344 .build()
5345 .unwrap();
5346 let out = runner
5347 .execute("je souhaite créer un petit crm dans un répertoire temporaire")
5348 .await
5349 .unwrap();
5350 assert_eq!(out.result, "done");
5351 let reqs = provider.captured_requests.lock().unwrap();
5352 let req2: Vec<(String, bool)> = reqs[1]
5353 .messages
5354 .iter()
5355 .flat_map(|m| m.content.iter())
5356 .filter_map(|b| match b {
5357 ContentBlock::ToolResult {
5358 content, is_error, ..
5359 } => Some((content.clone(), *is_error)),
5360 _ => None,
5361 })
5362 .collect();
5363 assert!(
5364 req2.iter().any(|(c, e)| *e && c.contains("[plan gate]")),
5365 "the first mutation must be blocked with plan guidance: {req2:?}"
5366 );
5367 let last_results: Vec<String> = reqs
5369 .last()
5370 .unwrap()
5371 .messages
5372 .iter()
5373 .flat_map(|m| m.content.iter())
5374 .filter_map(|b| match b {
5375 ContentBlock::ToolResult { content, .. } => Some(content.clone()),
5376 _ => None,
5377 })
5378 .collect();
5379 assert!(
5380 last_results.iter().any(|c| c == "ok"),
5381 "post-artifact write must execute: {last_results:?}"
5382 );
5383 }
5384
5385 #[tokio::test(flavor = "multi_thread")]
5386 async fn imperative_small_task_is_not_plan_gated() {
5387 let provider = Arc::new(MockProvider::new(vec![
5388 tool_use_named("write", 10),
5389 MockProvider::text_response("done", 10, 1),
5390 ]));
5391 let runner = AgentRunner::builder(provider.clone())
5392 .name("test")
5393 .system_prompt("sys")
5394 .tool(Arc::new(NamedTool { name: "write" }))
5395 .max_turns(5)
5396 .build()
5397 .unwrap();
5398 let out = runner
5399 .execute("corrige la typo dans src/a.rs")
5400 .await
5401 .unwrap();
5402 assert_eq!(out.result, "done");
5403 let reqs = provider.captured_requests.lock().unwrap();
5404 assert!(
5405 !reqs.iter().any(|r| r
5406 .messages
5407 .iter()
5408 .flat_map(|m| m.content.iter())
5409 .any(|b| matches!(b, ContentBlock::ToolResult { content, .. } if content.contains("[plan gate]")))),
5410 "an imperative small task must pass untouched"
5411 );
5412 }
5413
5414 #[tokio::test(flavor = "multi_thread")]
5415 async fn third_mutation_without_plan_hits_the_backstop() {
5416 let provider = Arc::new(MockProvider::new(vec![
5419 tool_use_named("write", 10),
5420 tool_use_named("write", 10),
5421 tool_use_named("write", 10), MockProvider::text_response("done", 10, 1),
5423 ]));
5424 let runner = AgentRunner::builder(provider.clone())
5425 .name("test")
5426 .system_prompt("sys")
5427 .tool(Arc::new(NamedTool { name: "write" }))
5428 .max_turns(8)
5429 .build()
5430 .unwrap();
5431 runner
5432 .execute("crée un site vitrine complet")
5433 .await
5434 .unwrap();
5435 let reqs = provider.captured_requests.lock().unwrap();
5436 let gated = reqs
5437 .iter()
5438 .flat_map(|r| r.messages.iter())
5439 .flat_map(|m| m.content.iter())
5440 .filter(
5441 |b| matches!(b, ContentBlock::ToolResult { content, .. } if content.contains("[plan gate]")),
5442 )
5443 .count();
5444 assert_eq!(gated, 1, "backstop fires exactly once at the 3rd mutation");
5445 }
5446
5447 #[tokio::test(flavor = "multi_thread")]
5448 async fn todowrite_disarms_the_plan_gate() {
5449 let provider = Arc::new(MockProvider::new(vec![
5451 tool_use_named("todowrite", 10),
5452 tool_use_named("write", 10),
5453 MockProvider::text_response("done", 10, 1),
5454 ]));
5455 let runner = AgentRunner::builder(provider.clone())
5456 .name("test")
5457 .system_prompt("sys")
5458 .tool(Arc::new(NamedTool { name: "todowrite" }))
5459 .tool(Arc::new(NamedTool { name: "write" }))
5460 .max_turns(6)
5461 .build()
5462 .unwrap();
5463 let out = runner
5464 .execute("j'aimerais créer un petit outil de notes")
5465 .await
5466 .unwrap();
5467 assert_eq!(out.result, "done");
5468 let reqs = provider.captured_requests.lock().unwrap();
5469 assert!(
5470 !reqs.iter().any(|r| r
5471 .messages
5472 .iter()
5473 .flat_map(|m| m.content.iter())
5474 .any(|b| matches!(b, ContentBlock::ToolResult { content, .. } if content.contains("[plan gate]")))),
5475 "planning first must disarm the gate"
5476 );
5477 }
5478
5479 fn study_router() -> Arc<crate::agent::router::RequestRouter> {
5480 Arc::new(crate::agent::router::RequestRouter::new(None))
5481 }
5482
5483 #[tokio::test(flavor = "multi_thread")]
5484 async fn study_mode_masks_mutating_tools_from_the_request() {
5485 let provider = Arc::new(MockProvider::new(vec![
5488 tool_use_named("question", 10), MockProvider::text_response("proposition: 1) sqlite 2) postgres", 10, 5),
5490 ]));
5491 let runner = AgentRunner::builder(provider.clone())
5492 .name("test")
5493 .system_prompt("sys")
5494 .tool(Arc::new(NamedTool { name: "write" }))
5495 .tool(Arc::new(NamedTool { name: "read" }))
5496 .tool(Arc::new(NamedTool { name: "question" }))
5497 .request_router(study_router())
5498 .max_turns(6)
5499 .build()
5500 .unwrap();
5501 runner
5502 .execute("étudie les options de persistance pour le module")
5503 .await
5504 .unwrap();
5505 let reqs = provider.captured_requests.lock().unwrap();
5506 let names: Vec<String> = reqs[0].tools.iter().map(|t| t.name.clone()).collect();
5507 assert!(
5508 !names.contains(&"write".to_string()),
5509 "write masked: {names:?}"
5510 );
5511 assert!(
5512 names.contains(&"read".to_string()),
5513 "read available: {names:?}"
5514 );
5515 assert!(names.contains(&"question".to_string()));
5516 }
5517
5518 #[tokio::test(flavor = "multi_thread")]
5519 async fn study_mode_denies_hallucinated_mutations() {
5520 let provider = Arc::new(MockProvider::new(vec![
5522 tool_use_named("write", 10), tool_use_named("question", 10),
5524 MockProvider::text_response("proposition: 1) a 2) b", 10, 5),
5525 ]));
5526 let runner = AgentRunner::builder(provider.clone())
5527 .name("test")
5528 .system_prompt("sys")
5529 .tool(Arc::new(NamedTool { name: "write" }))
5530 .tool(Arc::new(NamedTool { name: "question" }))
5531 .request_router(study_router())
5532 .max_turns(8)
5533 .build()
5534 .unwrap();
5535 runner.execute("étudie le cache du build").await.unwrap();
5536 let reqs = provider.captured_requests.lock().unwrap();
5537 let denied = reqs
5538 .iter()
5539 .flat_map(|r| r.messages.iter())
5540 .flat_map(|m| m.content.iter())
5541 .any(|b| {
5542 matches!(b, ContentBlock::ToolResult { content, is_error, .. }
5543 if *is_error && content.contains("[mode contract]"))
5544 });
5545 assert!(
5546 denied,
5547 "the hallucinated write must be denied with the contract message"
5548 );
5549 }
5550
5551 #[tokio::test(flavor = "multi_thread")]
5552 async fn study_stop_without_question_gets_contract_corrective() {
5553 let provider = Arc::new(MockProvider::new(vec![
5556 MockProvider::text_response("j'ai fini d'étudier.", 10, 5), tool_use_named("question", 10),
5558 MockProvider::text_response("proposition: 1) a 2) b — go?", 10, 5),
5559 ]));
5560 let runner = AgentRunner::builder(provider.clone())
5561 .name("test")
5562 .system_prompt("sys")
5563 .tool(Arc::new(NamedTool { name: "question" }))
5564 .request_router(study_router())
5565 .max_turns(8)
5566 .build()
5567 .unwrap();
5568 runner
5569 .execute("étudie les options de logging")
5570 .await
5571 .unwrap();
5572 let reqs = provider.captured_requests.lock().unwrap();
5573 let texts: Vec<String> = reqs
5574 .iter()
5575 .flat_map(|r| r.messages.iter())
5576 .flat_map(|m| m.content.iter())
5577 .filter_map(|b| match b {
5578 ContentBlock::Text { text } => Some(text.clone()),
5579 _ => None,
5580 })
5581 .collect();
5582 assert!(
5583 texts.iter().any(|t| t.contains("[study contract]")),
5584 "missing corrective: {texts:?}"
5585 );
5586 }
5587
5588 #[tokio::test(flavor = "multi_thread")]
5589 async fn clarify_mode_arms_plan_gate_without_wish_marker() {
5590 let provider = Arc::new(MockProvider::new(vec![
5593 tool_use_named("write", 10), tool_use_named("question", 10),
5595 tool_use_named("write", 10), MockProvider::text_response("done", 10, 1),
5597 ]));
5598 let runner = AgentRunner::builder(provider.clone())
5599 .name("test")
5600 .system_prompt("sys")
5601 .tool(Arc::new(NamedTool { name: "write" }))
5602 .tool(Arc::new(NamedTool { name: "question" }))
5603 .request_router(study_router())
5604 .max_turns(8)
5605 .build()
5606 .unwrap();
5607 let out = runner.execute("construis-moi un crm").await.unwrap();
5608 assert_eq!(out.result, "done");
5609 let reqs = provider.captured_requests.lock().unwrap();
5610 let gated = reqs
5611 .iter()
5612 .flat_map(|r| r.messages.iter())
5613 .flat_map(|m| m.content.iter())
5614 .any(|b| {
5615 matches!(b, ContentBlock::ToolResult { content, .. }
5616 if content.contains("[plan gate]"))
5617 });
5618 assert!(gated, "CLARIFY mode must gate the first mutation");
5619 }
5620
5621 #[tokio::test(flavor = "multi_thread")]
5622 async fn clarify_first_mutation_requires_declared_scope_too() {
5623 let provider = Arc::new(MockProvider::new(vec![
5627 tool_use_named("todowrite", 10), tool_use_named("write", 10), tool_use_named("set_scope", 10), tool_use_named("write", 10), MockProvider::text_response("done", 10, 1),
5632 ]));
5633 let runner = AgentRunner::builder(provider.clone())
5634 .name("test")
5635 .system_prompt("sys")
5636 .tool(Arc::new(NamedTool { name: "todowrite" }))
5637 .tool(Arc::new(NamedTool { name: "write" }))
5638 .tool(Arc::new(NamedTool { name: "set_scope" }))
5639 .request_router(study_router())
5640 .max_turns(10)
5641 .build()
5642 .unwrap();
5643 let out = runner.execute("construis-moi un crm").await.unwrap();
5644 assert_eq!(out.result, "done");
5645 let reqs = provider.captured_requests.lock().unwrap();
5646 let gated = reqs
5647 .iter()
5648 .flat_map(|r| r.messages.iter())
5649 .flat_map(|m| m.content.iter())
5650 .filter(|b| {
5651 matches!(b, ContentBlock::ToolResult { content, .. }
5652 if content.contains("[plan gate]"))
5653 })
5654 .count();
5655 assert!(
5656 gated >= 1,
5657 "the unscoped mutation must be plan-gated despite the todo artifact"
5658 );
5659 }
5660
5661 #[tokio::test(flavor = "multi_thread")]
5662 async fn bare_affirmation_promotes_study_to_execute() {
5663 use std::sync::atomic::{AtomicUsize, Ordering};
5666 let provider = Arc::new(MockProvider::new(vec![
5667 tool_use_named("question", 10), MockProvider::text_response("proposition: 1) sqlite. go?", 10, 5),
5669 tool_use_named("write", 10), MockProvider::text_response("done", 10, 1),
5671 ]));
5672 let inputs = Arc::new(AtomicUsize::new(0));
5673 let inputs_c = inputs.clone();
5674 let on_input: Arc<crate::agent::runner::OnInput> = Arc::new(move || {
5675 let n = inputs_c.fetch_add(1, Ordering::SeqCst);
5676 Box::pin(async move {
5677 if n == 0 {
5678 Some("vas-y".to_string())
5679 } else {
5680 None
5681 }
5682 })
5683 });
5684 let runner = AgentRunner::builder(provider.clone())
5685 .name("test")
5686 .system_prompt("sys")
5687 .tool(Arc::new(NamedTool { name: "write" }))
5688 .tool(Arc::new(NamedTool { name: "question" }))
5689 .request_router(study_router())
5690 .on_input(on_input)
5691 .max_turns(10)
5692 .build()
5693 .unwrap();
5694 let out = runner
5695 .execute("étudie le meilleur stockage pour le module")
5696 .await
5697 .unwrap();
5698 assert_eq!(out.result, "done");
5699 let reqs = provider.captured_requests.lock().unwrap();
5700 let last_tools: Vec<String> = reqs
5702 .last()
5703 .unwrap()
5704 .tools
5705 .iter()
5706 .map(|t| t.name.clone())
5707 .collect();
5708 assert!(last_tools.contains(&"write".to_string()), "{last_tools:?}");
5709 let blocked = reqs
5711 .iter()
5712 .flat_map(|r| r.messages.iter())
5713 .flat_map(|m| m.content.iter())
5714 .any(|b| {
5715 matches!(b, ContentBlock::ToolResult { content, .. }
5716 if content.contains("[plan gate]") || content.contains("[mode contract]"))
5717 });
5718 assert!(!blocked, "the approved plan must execute unimpeded");
5719 }
5720
5721 #[tokio::test(flavor = "multi_thread")]
5722 async fn router_absent_keeps_today_semantics() {
5723 let provider = Arc::new(MockProvider::new(vec![MockProvider::text_response(
5726 "ok", 10, 1,
5727 )]));
5728 let runner = AgentRunner::builder(provider.clone())
5729 .name("test")
5730 .system_prompt("sys")
5731 .tool(Arc::new(NamedTool { name: "write" }))
5732 .max_turns(3)
5733 .build()
5734 .unwrap();
5735 runner.execute("étudie les options de cache").await.unwrap();
5736 let reqs = provider.captured_requests.lock().unwrap();
5737 let names: Vec<String> = reqs[0].tools.iter().map(|t| t.name.clone()).collect();
5738 assert!(
5739 names.contains(&"write".to_string()),
5740 "no router → no masking: {names:?}"
5741 );
5742 }
5743
5744 #[tokio::test(flavor = "multi_thread")]
5745 async fn command_not_found_triggers_repair_hint() {
5746 struct CmdNotFound;
5747 impl Tool for CmdNotFound {
5748 fn definition(&self) -> ToolDefinition {
5749 ToolDefinition {
5750 name: "bash".into(),
5751 description: "bash".into(),
5752 input_schema: serde_json::json!({"type": "object", "properties": {}}),
5753 }
5754 }
5755 fn execute(
5756 &self,
5757 _ctx: &crate::ExecutionContext,
5758 _input: serde_json::Value,
5759 ) -> Pin<Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>>
5760 {
5761 Box::pin(async {
5762 Ok(ToolOutput::error(
5763 "bash: line 1: python: command not found\n(exit code: 127)",
5764 ))
5765 })
5766 }
5767 }
5768 let provider = Arc::new(MockProvider::new(vec![
5769 tool_use_named("bash", 10),
5770 MockProvider::text_response("ok", 10, 1),
5771 ]));
5772 let runner = AgentRunner::builder(provider.clone())
5773 .name("test")
5774 .system_prompt("sys")
5775 .tool(Arc::new(CmdNotFound))
5776 .max_turns(5)
5777 .build()
5778 .unwrap();
5779 runner.execute("run it").await.unwrap();
5780 let reqs = provider.captured_requests.lock().unwrap();
5781 let hinted = reqs
5782 .iter()
5783 .flat_map(|r| r.messages.iter())
5784 .flat_map(|m| m.content.iter())
5785 .any(|b| {
5786 matches!(b, ContentBlock::Text { text }
5787 if text.contains("[repair hint]") && text.contains("python3"))
5788 });
5789 assert!(hinted, "command-not-found must hint python→python3");
5790 }
5791
5792 #[tokio::test(flavor = "multi_thread")]
5793 async fn stale_api_error_triggers_docs_hint() {
5794 struct FailingBash;
5797 impl Tool for FailingBash {
5798 fn definition(&self) -> ToolDefinition {
5799 ToolDefinition {
5800 name: "bash".into(),
5801 description: "bash".into(),
5802 input_schema: serde_json::json!({"type": "object", "properties": {}}),
5803 }
5804 }
5805 fn execute(
5806 &self,
5807 _ctx: &crate::ExecutionContext,
5808 _input: serde_json::Value,
5809 ) -> Pin<Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>>
5810 {
5811 Box::pin(async {
5812 Ok(ToolOutput::error(
5813 "error[E0405]: cannot find trait `SqliteArgument` in crate `sqlx`",
5814 ))
5815 })
5816 }
5817 }
5818 let provider = Arc::new(MockProvider::new(vec![
5819 tool_use_named("bash", 10),
5820 MockProvider::text_response("hmm", 10, 1),
5821 ]));
5822 let runner = AgentRunner::builder(provider.clone())
5823 .name("test")
5824 .system_prompt("sys")
5825 .tool(Arc::new(FailingBash))
5826 .tool(Arc::new(NamedTool { name: "webfetch" }))
5827 .max_turns(5)
5828 .build()
5829 .unwrap();
5830 runner.execute("compile le projet").await.unwrap();
5831 let reqs = provider.captured_requests.lock().unwrap();
5832 let texts: Vec<String> = reqs
5833 .iter()
5834 .flat_map(|r| r.messages.iter())
5835 .flat_map(|m| m.content.iter())
5836 .filter_map(|b| match b {
5837 ContentBlock::Text { text } => Some(text.clone()),
5838 _ => None,
5839 })
5840 .collect();
5841 assert!(
5842 texts.iter().any(|t| t.contains("[repair hint]")
5843 && t.contains("docs.rs")
5844 && t.contains("cargo add")),
5845 "stale-API failure must inject the docs-first hint: {texts:?}"
5846 );
5847 }
5848
5849 #[tokio::test(flavor = "multi_thread")]
5850 async fn handwritten_cargo_toml_triggers_cargo_add_hint() {
5851 let provider = Arc::new(MockProvider::new(vec![
5852 crate::llm::types::CompletionResponse {
5853 content: vec![ContentBlock::ToolUse {
5854 id: "w1".into(),
5855 name: "write".into(),
5856 input: serde_json::json!({
5857 "file_path": "/tmp/x/Cargo.toml",
5858 "content": "[package]\nname=\"x\"\n[dependencies]\nsqlx = \"0.6\"\n"
5859 }),
5860 }],
5861 stop_reason: StopReason::ToolUse,
5862 reasoning: None,
5863 usage: TokenUsage::default(),
5864 model: None,
5865 },
5866 MockProvider::text_response("done", 10, 1),
5867 ]));
5868 let runner = AgentRunner::builder(provider.clone())
5869 .name("test")
5870 .system_prompt("sys")
5871 .tool(Arc::new(NamedTool { name: "write" }))
5872 .max_turns(5)
5873 .build()
5874 .unwrap();
5875 runner.execute("crée le projet dans /tmp/x").await.unwrap();
5876 let reqs = provider.captured_requests.lock().unwrap();
5877 let hinted = reqs
5878 .iter()
5879 .flat_map(|r| r.messages.iter())
5880 .flat_map(|m| m.content.iter())
5881 .any(|b| {
5882 matches!(b, ContentBlock::Text { text }
5883 if text.contains("[deps hint]") && text.contains("cargo add"))
5884 });
5885 assert!(
5886 hinted,
5887 "hand-written dependency versions must get the cargo-add hint"
5888 );
5889 }
5890
5891 #[tokio::test(flavor = "multi_thread")]
5892 async fn third_consecutive_build_failure_suggests_advisor() {
5893 struct AlwaysFailing;
5894 impl Tool for AlwaysFailing {
5895 fn definition(&self) -> ToolDefinition {
5896 ToolDefinition {
5897 name: "bash".into(),
5898 description: "bash".into(),
5899 input_schema: serde_json::json!({"type": "object", "properties": {}}),
5900 }
5901 }
5902 fn execute(
5903 &self,
5904 _ctx: &crate::ExecutionContext,
5905 _input: serde_json::Value,
5906 ) -> Pin<Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>>
5907 {
5908 Box::pin(async {
5909 Ok(ToolOutput::error(
5910 "error[E0308]: mismatched types — expected `String`, found `i32`",
5911 ))
5912 })
5913 }
5914 }
5915 let provider = Arc::new(MockProvider::new(vec![
5916 tool_use_named("bash", 10),
5917 tool_use_named("bash", 10),
5918 tool_use_named("bash", 10),
5919 MockProvider::text_response("stuck", 10, 1),
5920 ]));
5921 let runner = AgentRunner::builder(provider.clone())
5922 .name("test")
5923 .system_prompt("sys")
5924 .tool(Arc::new(AlwaysFailing))
5925 .tool(Arc::new(NamedTool { name: "advisor" }))
5926 .max_turns(8)
5927 .build()
5928 .unwrap();
5929 runner.execute("fixe le build").await.unwrap();
5930 let reqs = provider.captured_requests.lock().unwrap();
5931 let escalated = reqs
5932 .iter()
5933 .flat_map(|r| r.messages.iter())
5934 .flat_map(|m| m.content.iter())
5935 .any(|b| {
5936 matches!(b, ContentBlock::Text { text }
5937 if text.contains("[escalation]") && text.contains("advisor"))
5938 });
5939 assert!(
5940 escalated,
5941 "3 consecutive failed builds must suggest the advisor escalation"
5942 );
5943 }
5944
5945 struct FailingBuild;
5947 impl Tool for FailingBuild {
5948 fn definition(&self) -> ToolDefinition {
5949 ToolDefinition {
5950 name: "bash".into(),
5951 description: "bash".into(),
5952 input_schema: serde_json::json!({"type": "object", "properties": {}}),
5953 }
5954 }
5955 fn execute(
5956 &self,
5957 _ctx: &crate::ExecutionContext,
5958 _input: serde_json::Value,
5959 ) -> Pin<Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>>
5960 {
5961 Box::pin(async { Ok(ToolOutput::error("error[E0308]: mismatched types")) })
5962 }
5963 }
5964
5965 #[tokio::test(flavor = "multi_thread")]
5966 async fn hard_escalation_blocks_edits_until_advisor_is_consulted() {
5967 let provider = Arc::new(MockProvider::new(vec![
5971 tool_use_named("bash", 10), tool_use_named("bash", 10), tool_use_named("bash", 10), tool_use_named("edit", 10), tool_use_named("advisor", 10), tool_use_named("edit", 10), MockProvider::text_response("done", 10, 1),
5978 ]));
5979 let runner = AgentRunner::builder(provider.clone())
5980 .name("test")
5981 .system_prompt("sys")
5982 .tool(Arc::new(FailingBuild))
5983 .tool(Arc::new(NamedTool { name: "edit" }))
5984 .tool(Arc::new(NamedTool { name: "advisor" }))
5985 .max_turns(12)
5986 .build()
5987 .unwrap();
5988 let out = runner.execute("fixe le build").await.unwrap();
5989 assert_eq!(out.result, "done");
5992 let reqs = provider.captured_requests.lock().unwrap();
5995 let blocked = reqs
5996 .last()
5997 .unwrap()
5998 .messages
5999 .iter()
6000 .flat_map(|m| m.content.iter())
6001 .any(|b| matches!(b, ContentBlock::ToolResult { content, is_error, .. }
6002 if *is_error && content.contains("[escalation]") && content.contains("edits are blocked")));
6003 assert!(blocked, "the pre-advisor edit must be hard-denied");
6004 }
6005
6006 #[tokio::test(flavor = "multi_thread")]
6007 async fn gates_emit_gate_fired_events_for_the_trace() {
6008 let events: Arc<std::sync::Mutex<Vec<crate::agent::events::AgentEvent>>> =
6011 Arc::new(std::sync::Mutex::new(Vec::new()));
6012 let ev = events.clone();
6013 let provider = Arc::new(MockProvider::new(vec![
6014 tool_use_named("write", 10), tool_use_named("question", 10),
6016 tool_use_named("write", 10),
6017 MockProvider::text_response("done", 10, 1),
6018 ]));
6019 let runner = AgentRunner::builder(provider.clone())
6020 .name("test")
6021 .system_prompt("sys")
6022 .tool(Arc::new(NamedTool { name: "write" }))
6023 .tool(Arc::new(NamedTool { name: "question" }))
6024 .request_router(std::sync::Arc::new(
6025 crate::agent::router::RequestRouter::new(None),
6026 ))
6027 .on_event(Arc::new(move |e| ev.lock().expect("lock").push(e)))
6028 .max_turns(8)
6029 .build()
6030 .unwrap();
6031 runner
6032 .execute("je souhaite créer un petit crm")
6033 .await
6034 .unwrap();
6035 let gates: Vec<String> = events
6036 .lock()
6037 .expect("lock")
6038 .iter()
6039 .filter_map(|e| match e {
6040 crate::agent::events::AgentEvent::GateFired { gate, .. } => Some(gate.clone()),
6041 _ => None,
6042 })
6043 .collect();
6044 assert!(
6045 gates.iter().any(|g| g == "plan_gate"),
6046 "the plan gate must emit a GateFired event: {gates:?}"
6047 );
6048 }
6049
6050 struct NamedTool {
6054 name: &'static str,
6055 }
6056 impl Tool for NamedTool {
6057 fn definition(&self) -> ToolDefinition {
6058 ToolDefinition {
6059 name: self.name.into(),
6060 description: format!("Mock {}", self.name),
6061 input_schema: serde_json::json!({"type": "object", "properties": {}}),
6062 }
6063 }
6064 fn execute(
6065 &self,
6066 _ctx: &crate::ExecutionContext,
6067 _input: serde_json::Value,
6068 ) -> Pin<Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>>
6069 {
6070 Box::pin(async { Ok(ToolOutput::success("ok")) })
6071 }
6072 }
6073
6074 fn nudge_text_in(req: &crate::llm::types::CompletionRequest) -> usize {
6075 req.messages
6076 .iter()
6077 .flat_map(|m| m.content.iter())
6078 .filter(
6079 |b| matches!(b, ContentBlock::Text { text } if text.contains("[delegation check]")),
6080 )
6081 .count()
6082 }
6083
6084 #[tokio::test(flavor = "multi_thread")]
6085 async fn delegation_nudge_fires_once_after_threshold() {
6086 let provider = Arc::new(MockProvider::new(vec![
6089 tool_use_named("work", 100),
6090 tool_use_named("work", 100),
6091 tool_use_named("work", 100),
6092 MockProvider::text_response("done", 100, 1),
6093 ]));
6094 let runner = AgentRunner::builder(provider.clone())
6095 .name("test")
6096 .system_prompt("sys")
6097 .tool(Arc::new(NamedTool { name: "work" }))
6098 .delegation_nudge(DelegationNudge {
6099 after_tool_calls: 2,
6100 tool_names: vec!["delegate_task".into()],
6101 })
6102 .max_turns(10)
6103 .build()
6104 .unwrap();
6105 runner.execute("substantive task").await.unwrap();
6106
6107 let reqs = provider.captured_requests.lock().unwrap();
6108 assert_eq!(
6109 nudge_text_in(&reqs[2]),
6110 1,
6111 "nudge present after the threshold"
6112 );
6113 assert_eq!(
6114 nudge_text_in(reqs.last().unwrap()),
6115 1,
6116 "nudge injected exactly once, not repeated"
6117 );
6118 }
6119
6120 #[tokio::test(flavor = "multi_thread")]
6121 async fn delegation_nudge_suppressed_when_delegation_used() {
6122 let provider = Arc::new(MockProvider::new(vec![
6124 tool_use_named("delegate_task", 100),
6125 tool_use_named("work", 100),
6126 tool_use_named("work", 100),
6127 tool_use_named("work", 100),
6128 MockProvider::text_response("done", 100, 1),
6129 ]));
6130 let runner = AgentRunner::builder(provider.clone())
6131 .name("test")
6132 .system_prompt("sys")
6133 .tool(Arc::new(NamedTool { name: "work" }))
6134 .tool(Arc::new(NamedTool {
6135 name: "delegate_task",
6136 }))
6137 .delegation_nudge(DelegationNudge {
6138 after_tool_calls: 2,
6139 tool_names: vec!["delegate_task".into()],
6140 })
6141 .max_turns(10)
6142 .build()
6143 .unwrap();
6144 runner.execute("substantive task").await.unwrap();
6145
6146 let reqs = provider.captured_requests.lock().unwrap();
6147 assert_eq!(
6148 nudge_text_in(reqs.last().unwrap()),
6149 0,
6150 "delegating suppresses the nudge"
6151 );
6152 }
6153
6154 #[tokio::test(flavor = "multi_thread")]
6155 async fn delegation_nudge_absent_by_default() {
6156 let provider = Arc::new(MockProvider::new(vec![
6157 tool_use_named("work", 100),
6158 tool_use_named("work", 100),
6159 tool_use_named("work", 100),
6160 MockProvider::text_response("done", 100, 1),
6161 ]));
6162 let runner = AgentRunner::builder(provider.clone())
6163 .name("test")
6164 .system_prompt("sys")
6165 .tool(Arc::new(NamedTool { name: "work" }))
6166 .max_turns(10)
6167 .build()
6168 .unwrap();
6169 runner.execute("task").await.unwrap();
6170
6171 let reqs = provider.captured_requests.lock().unwrap();
6172 assert_eq!(
6173 nudge_text_in(reqs.last().unwrap()),
6174 0,
6175 "no nudge when not configured"
6176 );
6177 }
6178
6179 fn tool_use_with_tokens(input_tokens: u32) -> crate::llm::types::CompletionResponse {
6181 crate::llm::types::CompletionResponse {
6182 content: vec![crate::llm::types::ContentBlock::ToolUse {
6183 id: "call-1".into(),
6184 name: "noop".into(),
6185 input: serde_json::json!({}),
6186 }],
6187 stop_reason: StopReason::ToolUse,
6188 reasoning: None,
6189 usage: TokenUsage {
6190 input_tokens,
6191 output_tokens: 1,
6192 ..Default::default()
6193 },
6194 model: None,
6195 }
6196 }
6197
6198 #[tokio::test]
6199 async fn proactive_compaction_fires_when_real_tokens_cross_window_fraction() {
6200 let events: Arc<std::sync::Mutex<Vec<crate::agent::events::AgentEvent>>> =
6205 Arc::new(std::sync::Mutex::new(Vec::new()));
6206 let events_clone = events.clone();
6207
6208 let provider = Arc::new(MockProvider::new(vec![
6209 tool_use_with_tokens(800), tool_use_with_tokens(800), tool_use_with_tokens(800), MockProvider::text_response("summary text", 1, 1), MockProvider::text_response("done", 800, 1), ]));
6215
6216 let runner = AgentRunner::builder(provider)
6217 .name("test")
6218 .system_prompt("test")
6219 .tool(Arc::new(NoopTool))
6220 .context_window_tokens(1000)
6221 .max_turns(10)
6222 .on_event(Arc::new(move |ev| {
6223 events_clone.lock().expect("lock").push(ev);
6224 }))
6225 .build()
6226 .unwrap();
6227
6228 let _output = runner.execute("do things").await.unwrap();
6229
6230 let summarized = events
6231 .lock()
6232 .expect("lock")
6233 .iter()
6234 .filter(|e| {
6235 matches!(
6236 e,
6237 crate::agent::events::AgentEvent::ContextSummarized { .. }
6238 )
6239 })
6240 .count();
6241 assert_eq!(
6242 summarized, 1,
6243 "expected exactly 1 ContextSummarized event, got {summarized}"
6244 );
6245 }
6246
6247 #[tokio::test]
6248 async fn proactive_compaction_does_not_fire_below_fraction() {
6249 let events: Arc<std::sync::Mutex<Vec<crate::agent::events::AgentEvent>>> =
6252 Arc::new(std::sync::Mutex::new(Vec::new()));
6253 let events_clone = events.clone();
6254
6255 let provider = Arc::new(MockProvider::new(vec![
6256 tool_use_with_tokens(600), tool_use_with_tokens(600), tool_use_with_tokens(600), MockProvider::text_response("done", 600, 1), ]));
6261
6262 let runner = AgentRunner::builder(provider)
6263 .name("test")
6264 .system_prompt("test")
6265 .tool(Arc::new(NoopTool))
6266 .context_window_tokens(1000)
6267 .max_turns(10)
6268 .on_event(Arc::new(move |ev| {
6269 events_clone.lock().expect("lock").push(ev);
6270 }))
6271 .build()
6272 .unwrap();
6273
6274 let _output = runner.execute("do things").await.unwrap();
6275
6276 let summarized = events
6277 .lock()
6278 .expect("lock")
6279 .iter()
6280 .filter(|e| {
6281 matches!(
6282 e,
6283 crate::agent::events::AgentEvent::ContextSummarized { .. }
6284 )
6285 })
6286 .count();
6287 assert_eq!(
6288 summarized, 0,
6289 "expected 0 ContextSummarized events below fraction, got {summarized}"
6290 );
6291 }
6292
6293 #[tokio::test]
6294 async fn proactive_compaction_does_not_thrash_two_turns_running() {
6295 let events: Arc<std::sync::Mutex<Vec<crate::agent::events::AgentEvent>>> =
6300 Arc::new(std::sync::Mutex::new(Vec::new()));
6301 let events_clone = events.clone();
6302
6303 let provider = Arc::new(MockProvider::new(vec![
6304 tool_use_with_tokens(800), tool_use_with_tokens(800), tool_use_with_tokens(800), MockProvider::text_response("summary text", 1, 1), tool_use_with_tokens(800), MockProvider::text_response("done", 800, 1), ]));
6311
6312 let runner = AgentRunner::builder(provider)
6313 .name("test")
6314 .system_prompt("test")
6315 .tool(Arc::new(NoopTool))
6316 .context_window_tokens(1000)
6317 .max_turns(10)
6318 .on_event(Arc::new(move |ev| {
6319 events_clone.lock().expect("lock").push(ev);
6320 }))
6321 .build()
6322 .unwrap();
6323
6324 let _output = runner.execute("do things").await.unwrap();
6325
6326 let summarized = events
6327 .lock()
6328 .expect("lock")
6329 .iter()
6330 .filter(|e| {
6331 matches!(
6332 e,
6333 crate::agent::events::AgentEvent::ContextSummarized { .. }
6334 )
6335 })
6336 .count();
6337 assert_eq!(
6338 summarized, 1,
6339 "anti-thrash guard must cap at exactly 1 ContextSummarized, got {summarized}"
6340 );
6341 }
6342
6343 struct SlowStreamingProvider;
6346
6347 impl crate::llm::LlmProvider for SlowStreamingProvider {
6348 async fn complete(
6349 &self,
6350 _request: crate::llm::types::CompletionRequest,
6351 ) -> Result<CompletionResponse, Error> {
6352 Ok(MockProvider::text_response("hi", 1, 1))
6353 }
6354
6355 async fn stream_complete(
6356 &self,
6357 _request: crate::llm::types::CompletionRequest,
6358 on_text: &crate::llm::OnText,
6359 ) -> Result<CompletionResponse, Error> {
6360 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
6361 on_text("hi");
6362 Ok(MockProvider::text_response("hi", 1, 1))
6363 }
6364 }
6365
6366 #[tokio::test(flavor = "multi_thread")]
6371 async fn llm_response_event_carries_streaming_ttft() {
6372 let events: Arc<std::sync::Mutex<Vec<crate::agent::events::AgentEvent>>> =
6373 Arc::new(std::sync::Mutex::new(Vec::new()));
6374 let ev = events.clone();
6375 let runner = AgentRunner::builder(Arc::new(SlowStreamingProvider))
6376 .name("t")
6377 .system_prompt("s")
6378 .max_turns(1)
6379 .on_text(Arc::new(|_| {}))
6380 .on_event(Arc::new(move |e| ev.lock().expect("lock").push(e)))
6381 .build()
6382 .unwrap();
6383 runner.execute("go").await.unwrap();
6384
6385 let evs = events.lock().expect("lock");
6386 let ttft = evs
6387 .iter()
6388 .find_map(|e| match e {
6389 crate::agent::events::AgentEvent::LlmResponse {
6390 time_to_first_token_ms,
6391 ..
6392 } => Some(*time_to_first_token_ms),
6393 _ => None,
6394 })
6395 .expect("LlmResponse event emitted");
6396 assert!(
6397 ttft >= 10,
6398 "streaming TTFT must reach the LlmResponse event, got {ttft}ms"
6399 );
6400 }
6401
6402 fn scripted_inputs(msgs: &[&str]) -> Arc<crate::agent::runner::OnInput> {
6406 let queue = Arc::new(std::sync::Mutex::new(
6407 msgs.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
6408 ));
6409 Arc::new(move || {
6410 let queue = queue.clone();
6411 Box::pin(async move {
6412 let mut q = queue.lock().expect("inputs lock");
6413 if q.is_empty() {
6414 None
6415 } else {
6416 Some(q.remove(0))
6417 }
6418 })
6419 })
6420 }
6421
6422 struct FlagTool {
6424 name: String,
6425 output: ToolOutput,
6426 executed: Arc<std::sync::atomic::AtomicBool>,
6427 }
6428 impl FlagTool {
6429 fn new(name: &str, output: ToolOutput) -> (Arc<Self>, Arc<std::sync::atomic::AtomicBool>) {
6430 let executed = Arc::new(std::sync::atomic::AtomicBool::new(false));
6431 (
6432 Arc::new(Self {
6433 name: name.into(),
6434 output,
6435 executed: executed.clone(),
6436 }),
6437 executed,
6438 )
6439 }
6440 }
6441 impl Tool for FlagTool {
6442 fn definition(&self) -> ToolDefinition {
6443 ToolDefinition {
6444 name: self.name.clone(),
6445 description: "stub".into(),
6446 input_schema: serde_json::json!({"type": "object", "properties": {}}),
6447 }
6448 }
6449 fn execute(
6450 &self,
6451 _ctx: &crate::ExecutionContext,
6452 _input: serde_json::Value,
6453 ) -> Pin<Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>>
6454 {
6455 self.executed
6456 .store(true, std::sync::atomic::Ordering::SeqCst);
6457 let out = ToolOutput {
6458 content: self.output.content.clone(),
6459 is_error: self.output.is_error,
6460 };
6461 Box::pin(async move { Ok(out) })
6462 }
6463 }
6464
6465 fn pinned_router(
6466 mode: super::super::router::RequestMode,
6467 ) -> Arc<super::super::router::RequestRouter> {
6468 let pin = Arc::new(std::sync::atomic::AtomicU8::new(mode.as_pin_u8()));
6469 Arc::new(super::super::router::RequestRouter::new(None).with_pin(pin))
6470 }
6471
6472 #[tokio::test(flavor = "multi_thread")]
6476 async fn verify_replan_budget_rearms_and_is_request_scoped() {
6477 let mut responses = vec![verify_tool_call()];
6478 for _ in 0..9 {
6479 responses.push(MockProvider::text_response("done1", 1, 1));
6480 }
6481 responses.push(verify_tool_call());
6482 for _ in 0..9 {
6483 responses.push(MockProvider::text_response("done2", 1, 1));
6484 }
6485 responses.push(MockProvider::text_response("done3", 1, 1));
6486 let provider = Arc::new(MockProvider::new(responses));
6487 let runner = AgentRunner::builder(provider.clone())
6488 .name("t")
6489 .system_prompt("s")
6490 .tools(vec![Arc::new(FailingVerifyTool)])
6491 .replan_on_verify_fail(true)
6492 .on_input(scripted_inputs(&["re-verify it", "now just answer"]))
6493 .max_turns(50)
6494 .build()
6495 .unwrap();
6496 let out = runner.execute("do it").await.unwrap();
6497 assert_eq!(out.result, "done3");
6498 let n = provider.captured_requests.lock().unwrap().len();
6499 assert_eq!(
6500 n, 21,
6501 "10 (request 1) + 10 (request 2: budget re-armed) + 1 (request 3: \
6502 stale FAILs out of scope) provider calls; got {n}"
6503 );
6504 }
6505
6506 #[tokio::test(flavor = "multi_thread")]
6510 async fn replan_gates_before_next_input_in_chat_mode() {
6511 let mut responses = vec![verify_tool_call()];
6512 for _ in 0..9 {
6513 responses.push(MockProvider::text_response("done", 1, 1));
6514 }
6515 responses.push(MockProvider::text_response("after-input", 1, 1));
6516 let provider = Arc::new(MockProvider::new(responses));
6517 let runner = AgentRunner::builder(provider.clone())
6518 .name("t")
6519 .system_prompt("s")
6520 .tools(vec![Arc::new(FailingVerifyTool)])
6521 .replan_on_verify_fail(true)
6522 .on_input(scripted_inputs(&["follow-up"]))
6523 .max_turns(50)
6524 .build()
6525 .unwrap();
6526 let out = runner.execute("do it").await.unwrap();
6527 assert_eq!(out.result, "after-input");
6528 let reqs = provider.captured_requests.lock().unwrap();
6529 let has_text = |r: &crate::llm::types::CompletionRequest, needle: &str| {
6530 r.messages
6531 .iter()
6532 .flat_map(|m| m.content.iter())
6533 .any(|b| matches!(b, ContentBlock::Text { text } if text.contains(needle)))
6534 };
6535 let red_idx = reqs
6536 .iter()
6537 .position(|r| has_text(r, "Verification is RED"))
6538 .expect("replan corrective injected");
6539 let input_idx = reqs
6540 .iter()
6541 .position(|r| has_text(r, "follow-up"))
6542 .expect("follow-up reached the agent");
6543 assert!(
6544 red_idx < input_idx,
6545 "replan gate must fire BEFORE awaiting input (red at {red_idx}, input at {input_idx})"
6546 );
6547 }
6548
6549 #[tokio::test(flavor = "multi_thread")]
6553 async fn goal_continuation_budget_rearms_on_new_request() {
6554 use std::sync::atomic::{AtomicUsize, Ordering};
6555 let main = Arc::new(MockProvider::new(vec![
6556 MockProvider::text_response("a1", 1, 1),
6557 MockProvider::text_response("a2", 1, 1),
6558 MockProvider::text_response("b1", 1, 1),
6559 MockProvider::text_response("b2", 1, 1),
6560 ]));
6561 let judge = Arc::new(MockProvider::new(vec![
6562 MockProvider::text_response("GOAL_MET: NO: not yet", 1, 1),
6563 MockProvider::text_response("GOAL_MET: YES", 1, 1),
6564 MockProvider::text_response("GOAL_MET: NO: goal B not yet", 1, 1),
6565 MockProvider::text_response("GOAL_MET: YES", 1, 1),
6566 ]));
6567 let slot: crate::agent::goal::GoalSlot = Arc::new(std::sync::RwLock::new(None));
6568 *slot.write().unwrap() = Some(
6569 crate::agent::goal::GoalCondition::new(
6570 "goal A",
6571 Arc::new(crate::llm::BoxedProvider::from_arc(judge.clone())),
6572 )
6573 .with_max_continuations(1),
6574 );
6575 let slot_for_input = slot.clone();
6576 let judge_for_input = judge.clone();
6577 let n_inputs = Arc::new(AtomicUsize::new(0));
6578 let on_input: Arc<crate::agent::runner::OnInput> = Arc::new(move || {
6579 let slot = slot_for_input.clone();
6580 let judge = judge_for_input.clone();
6581 let n = n_inputs.clone();
6582 Box::pin(async move {
6583 if n.fetch_add(1, Ordering::SeqCst) == 0 {
6584 *slot.write().unwrap() = Some(
6586 crate::agent::goal::GoalCondition::new(
6587 "goal B",
6588 Arc::new(crate::llm::BoxedProvider::from_arc(judge)),
6589 )
6590 .with_max_continuations(1),
6591 );
6592 Some("second task".to_string())
6593 } else {
6594 None
6595 }
6596 })
6597 });
6598 let runner = AgentRunner::builder(main.clone())
6599 .name("t")
6600 .system_prompt("s")
6601 .goal_slot(slot)
6602 .on_input(on_input)
6603 .max_turns(20)
6604 .build()
6605 .unwrap();
6606 let out = runner.execute("first task").await.unwrap();
6607 assert_eq!(
6608 out.result, "b2",
6609 "goal B earned its continuation — the budget re-armed on the new request"
6610 );
6611 assert_eq!(out.goal_met, Some(true));
6612 assert_eq!(judge.captured_requests.lock().unwrap().len(), 4);
6613 }
6614
6615 #[tokio::test(flavor = "multi_thread")]
6620 async fn interrupted_turn_skips_goal_gate_and_awaits_input() {
6621 use crate::agent::interrupt::InterruptHandle;
6622 let main = Arc::new(MockProvider::new(vec![MockProvider::text_response(
6623 "real", 1, 1,
6624 )]));
6625 let judge = Arc::new(MockProvider::new(vec![MockProvider::text_response(
6626 "GOAL_MET: YES",
6627 1,
6628 1,
6629 )]));
6630 let interrupt = InterruptHandle::new();
6631 interrupt.interrupt();
6632 let runner = AgentRunner::builder(main.clone())
6633 .name("t")
6634 .system_prompt("s")
6635 .goal(crate::agent::goal::GoalCondition::new(
6636 "finish",
6637 Arc::new(crate::llm::BoxedProvider::from_arc(judge.clone())),
6638 ))
6639 .on_input(scripted_inputs(&["continue"]))
6640 .interrupt(interrupt)
6641 .max_turns(10)
6642 .build()
6643 .unwrap();
6644 let out = runner.execute("task").await.unwrap();
6645 assert_eq!(out.result, "real");
6646 let judge_reqs = judge.captured_requests.lock().unwrap();
6647 assert_eq!(judge_reqs.len(), 1, "judge gated only the real stop");
6648 let transcript: String = judge_reqs[0]
6649 .messages
6650 .iter()
6651 .flat_map(|m| m.content.iter())
6652 .filter_map(|b| match b {
6653 ContentBlock::Text { text } => Some(text.as_str()),
6654 _ => None,
6655 })
6656 .collect();
6657 assert!(
6658 transcript.contains("real"),
6659 "judge ran AFTER the real answer, not on the interrupted turn: {transcript:?}"
6660 );
6661 assert_eq!(out.goal_met, Some(true));
6662 }
6663
6664 #[tokio::test(flavor = "multi_thread")]
6667 async fn cache_hit_does_not_rebill_usage() {
6668 let provider = Arc::new(MockProvider::new(vec![MockProvider::text_response(
6669 "answer", 7, 5,
6670 )]));
6671 let runner = AgentRunner::builder(provider.clone())
6672 .name("t")
6673 .system_prompt("s")
6674 .response_cache_size(4)
6675 .max_turns(3)
6676 .build()
6677 .unwrap();
6678 let first = runner.execute("hi").await.unwrap();
6679 assert_eq!(first.tokens_used.input_tokens, 7);
6680 let second = runner.execute("hi").await.unwrap();
6681 assert_eq!(second.result, "answer", "served from cache");
6682 assert_eq!(
6683 provider.captured_requests.lock().unwrap().len(),
6684 1,
6685 "no second LLM call"
6686 );
6687 assert_eq!(
6688 second.tokens_used.total(),
6689 0,
6690 "a cache hit must not re-bill the original call's tokens"
6691 );
6692 }
6693
6694 #[tokio::test(flavor = "multi_thread")]
6698 async fn denied_tool_hammering_hits_doom_hard_stop() {
6699 use crate::agent::permission::{PermissionAction, PermissionRule, PermissionRuleset};
6700 let responses = (0..8)
6701 .map(|_| tool_use_named("bash", 1))
6702 .collect::<Vec<_>>();
6703 let provider = Arc::new(MockProvider::new(responses));
6704 let rules = PermissionRuleset::new(vec![PermissionRule {
6705 tool: "bash".into(),
6706 pattern: "*".into(),
6707 action: PermissionAction::Deny,
6708 }]);
6709 let runner = AgentRunner::builder(provider.clone())
6710 .name("t")
6711 .system_prompt("s")
6712 .permission_rules(rules)
6713 .max_identical_tool_calls(2)
6714 .max_turns(20)
6715 .build()
6716 .unwrap();
6717 let err = runner.execute("go").await.unwrap_err();
6718 let err = match err {
6719 Error::WithPartialUsage { source, .. } => *source,
6720 e => e,
6721 };
6722 assert!(
6723 matches!(err, Error::DoomLoopAborted(_)),
6724 "denied hammering must hard-stop, got: {err:?}"
6725 );
6726 }
6727
6728 #[tokio::test(flavor = "multi_thread")]
6733 async fn fuzzy_denied_tool_hammering_hits_doom_hard_stop() {
6734 use crate::agent::permission::{PermissionAction, PermissionRule, PermissionRuleset};
6735 let responses: Vec<_> = (0..10)
6737 .map(|i| crate::llm::types::CompletionResponse {
6738 content: vec![ContentBlock::ToolUse {
6739 id: format!("c{i}"),
6740 name: "bash".into(),
6741 input: serde_json::json!({ "command": format!("echo {i}") }),
6742 }],
6743 stop_reason: StopReason::ToolUse,
6744 reasoning: None,
6745 usage: TokenUsage::default(),
6746 model: None,
6747 })
6748 .collect();
6749 let provider = Arc::new(MockProvider::new(responses));
6750 let rules = PermissionRuleset::new(vec![PermissionRule {
6751 tool: "bash".into(),
6752 pattern: "*".into(),
6753 action: PermissionAction::Deny,
6754 }]);
6755 let runner = AgentRunner::builder(provider.clone())
6756 .name("t")
6757 .system_prompt("s")
6758 .permission_rules(rules)
6759 .max_identical_tool_calls(2)
6760 .max_fuzzy_identical_tool_calls(2)
6761 .max_turns(30)
6762 .build()
6763 .unwrap();
6764 let err = runner.execute("go").await.unwrap_err();
6765 let err = match err {
6766 Error::WithPartialUsage { source, .. } => *source,
6767 e => e,
6768 };
6769 assert!(
6770 matches!(err, Error::DoomLoopAborted(_)),
6771 "varying-input denied hammering must hard-stop via fuzzy doom, got: {err:?}"
6772 );
6773 assert!(
6775 provider.captured_requests.lock().unwrap().len() < 30,
6776 "fuzzy doom must hard-stop well before max_turns"
6777 );
6778 }
6779
6780 #[tokio::test(flavor = "multi_thread")]
6784 async fn clarify_batched_question_with_write_is_plan_gated() {
6785 let (question_tool, _) = FlagTool::new("question", ToolOutput::success("answer: A"));
6786 let (write_tool, write_executed) = FlagTool::new("write", ToolOutput::success("ok"));
6787 let batch = crate::llm::types::CompletionResponse {
6788 content: vec![
6789 ContentBlock::ToolUse {
6790 id: "q1".into(),
6791 name: "question".into(),
6792 input: serde_json::json!({}),
6793 },
6794 ContentBlock::ToolUse {
6795 id: "w1".into(),
6796 name: "write".into(),
6797 input: serde_json::json!({"file_path": "x.rs", "content": "y"}),
6798 },
6799 ],
6800 stop_reason: StopReason::ToolUse,
6801 reasoning: None,
6802 usage: TokenUsage::default(),
6803 model: None,
6804 };
6805 let provider = Arc::new(MockProvider::new(vec![
6806 batch,
6807 MockProvider::text_response("stopped", 1, 1),
6808 ]));
6809 let runner = AgentRunner::builder(provider.clone())
6810 .name("t")
6811 .system_prompt("s")
6812 .tools(vec![question_tool, write_tool])
6813 .request_router(pinned_router(super::super::router::RequestMode::Clarify))
6814 .max_turns(5)
6815 .build()
6816 .unwrap();
6817 runner.execute("je veux un crm").await.unwrap();
6818 assert!(
6819 !write_executed.load(std::sync::atomic::Ordering::SeqCst),
6820 "the write must NOT execute before the question is answered"
6821 );
6822 let reqs = provider.captured_requests.lock().unwrap();
6823 let results = tool_result_contents(&reqs[1]);
6824 assert!(
6825 results.iter().all(|r| r.contains("[plan gate]")),
6826 "the whole batch gets plan-gate error results: {results:?}"
6827 );
6828 assert!(
6830 results[0].contains("scratch SUBDIRECTORY"),
6831 "plan-gate text must carry the in-workspace scratch guidance: {}",
6832 results[0]
6833 );
6834 assert!(
6835 !results[0].contains("OUTSIDE this repository"),
6836 "the unsatisfiable outside-the-repo guidance must stay dead: {}",
6837 results[0]
6838 );
6839 }
6840
6841 #[tokio::test(flavor = "multi_thread")]
6845 async fn study_mode_denies_non_readonly_tools_at_execution() {
6846 let (delegate, delegate_executed) =
6847 FlagTool::new("delegate_task", ToolOutput::success("delegated"));
6848 let provider = Arc::new(MockProvider::new(vec![
6849 tool_use_named("delegate_task", 1),
6850 MockProvider::text_response("proposal", 1, 1),
6851 ]));
6852 let runner = AgentRunner::builder(provider.clone())
6853 .name("t")
6854 .system_prompt("s")
6855 .tools(vec![delegate])
6856 .request_router(pinned_router(super::super::router::RequestMode::Study))
6857 .max_turns(5)
6858 .build()
6859 .unwrap();
6860 let out = runner.execute("étudie le module").await.unwrap();
6861 assert_eq!(out.result, "proposal");
6862 assert!(
6863 !delegate_executed.load(std::sync::atomic::Ordering::SeqCst),
6864 "delegate_task must be refused in STUDY mode (side effects)"
6865 );
6866 let reqs = provider.captured_requests.lock().unwrap();
6867 let results = tool_result_contents(&reqs[1]);
6868 assert!(
6869 results.iter().any(|r| r.contains("[mode contract]")),
6870 "{results:?}"
6871 );
6872 }
6873
6874 #[tokio::test(flavor = "multi_thread")]
6877 async fn delegation_nudge_suppressed_in_readonly_mode() {
6878 let (read_tool, _) = FlagTool::new("read", ToolOutput::success("contents"));
6879 let provider = Arc::new(MockProvider::new(vec![
6880 tool_use_named("read", 1),
6881 tool_use_named("read", 1),
6882 MockProvider::text_response("proposal", 1, 1),
6883 ]));
6884 let runner = AgentRunner::builder(provider.clone())
6885 .name("t")
6886 .system_prompt("s")
6887 .tools(vec![read_tool])
6888 .request_router(pinned_router(super::super::router::RequestMode::Study))
6889 .delegation_nudge(DelegationNudge {
6890 after_tool_calls: 1,
6891 tool_names: vec!["delegate_task".into()],
6892 })
6893 .max_turns(6)
6894 .build()
6895 .unwrap();
6896 runner.execute("étudie le module").await.unwrap();
6897 let reqs = provider.captured_requests.lock().unwrap();
6898 let nudged = reqs.iter().any(|r| {
6899 r.messages.iter().flat_map(|m| m.content.iter()).any(
6900 |b| matches!(b, ContentBlock::Text { text } if text.contains("[delegation check]")),
6901 )
6902 });
6903 assert!(!nudged, "no delegation nudge in read-only STUDY mode");
6904 }
6905
6906 #[tokio::test(flavor = "multi_thread")]
6908 async fn delegation_nudge_rearms_on_new_request() {
6909 let (read_tool, _) = FlagTool::new("read", ToolOutput::success("contents"));
6910 let provider = Arc::new(MockProvider::new(vec![
6911 tool_use_named("read", 1),
6912 tool_use_named("read", 1),
6913 MockProvider::text_response("done1", 1, 1),
6914 tool_use_named("read", 1),
6915 tool_use_named("read", 1),
6916 MockProvider::text_response("done2", 1, 1),
6917 ]));
6918 let runner = AgentRunner::builder(provider.clone())
6919 .name("t")
6920 .system_prompt("s")
6921 .tools(vec![read_tool])
6922 .delegation_nudge(DelegationNudge {
6923 after_tool_calls: 2,
6924 tool_names: vec!["delegate_task".into()],
6925 })
6926 .on_input(scripted_inputs(&["next request"]))
6927 .max_turns(20)
6928 .build()
6929 .unwrap();
6930 runner.execute("first request").await.unwrap();
6931 let reqs = provider.captured_requests.lock().unwrap();
6932 let last = reqs.last().expect("requests captured");
6933 let nudges = last
6934 .messages
6935 .iter()
6936 .flat_map(|m| m.content.iter())
6937 .filter(
6938 |b| matches!(b, ContentBlock::Text { text } if text.contains("[delegation check]")),
6939 )
6940 .count();
6941 assert_eq!(
6942 nudges, 2,
6943 "one nudge per request — the counter re-arms at on_input"
6944 );
6945 }
6946
6947 #[tokio::test(flavor = "multi_thread")]
6951 async fn ingest_cap_applies_when_hints_fire_same_turn() {
6952 let big_failing = format!("bash: foo: command not found\n{}", "x".repeat(200_000));
6953 let original_len = big_failing.len();
6954 let (bash_tool, _) = FlagTool::new("bash", ToolOutput::error(big_failing));
6955 let provider = Arc::new(MockProvider::new(vec![
6956 tool_use_named("bash", 1),
6957 MockProvider::text_response("done", 1, 1),
6958 ]));
6959 let runner = AgentRunner::builder(provider.clone())
6960 .name("t")
6961 .system_prompt("s")
6962 .tools(vec![bash_tool])
6963 .tool_result_ingest_cap(1024)
6964 .max_turns(5)
6965 .build()
6966 .unwrap();
6967 runner.execute("run it").await.unwrap();
6968 let reqs = provider.captured_requests.lock().unwrap();
6969 let hint_present =
6970 reqs[1].messages.iter().flat_map(|m| m.content.iter()).any(
6971 |b| matches!(b, ContentBlock::Text { text } if text.contains("[repair hint]")),
6972 );
6973 assert!(hint_present, "the repair hint fired this turn");
6974 let contents = tool_result_contents(&reqs[1]);
6975 assert!(
6976 contents[0].len() < original_len / 2,
6977 "the fresh tool result must be capped even with a same-turn hint: {} bytes",
6978 contents[0].len()
6979 );
6980 }
6981
6982 #[tokio::test(flavor = "multi_thread")]
6986 async fn overflow_recovery_escalates_truncation_then_summary() {
6987 let provider = Arc::new(MockProvider::new_with_results(vec![
6988 Ok(tool_use_named("big", 1)),
6989 Err(overflow_error()),
6990 Err(overflow_error()),
6991 Ok(MockProvider::text_response("summary of it all", 1, 1)),
6992 Ok(MockProvider::text_response("done", 1, 1)),
6993 ]));
6994 let runner = AgentRunner::builder(provider.clone())
6995 .name("t")
6996 .system_prompt("s")
6997 .tool(Arc::new(BigTool { size: 400_000 }))
6998 .max_turns(10)
6999 .build()
7000 .unwrap();
7001 let out = runner.execute("go").await.unwrap();
7002 assert_eq!(
7003 out.result, "done",
7004 "second consecutive overflow escalates to summarization instead of failing"
7005 );
7006 }
7007
7008 #[tokio::test(flavor = "multi_thread")]
7011 async fn pinned_mode_survives_bare_affirmation() {
7012 let (write_tool, write_executed) = FlagTool::new("write", ToolOutput::success("ok"));
7013 let provider = Arc::new(MockProvider::new(vec![
7014 MockProvider::text_response("proposal: A or B", 1, 1),
7015 tool_use_named("write", 1),
7016 MockProvider::text_response("end", 1, 1),
7017 ]));
7018 let runner = AgentRunner::builder(provider.clone())
7019 .name("t")
7020 .system_prompt("s")
7021 .tools(vec![write_tool])
7022 .request_router(pinned_router(super::super::router::RequestMode::Study))
7023 .on_input(scripted_inputs(&["vas-y"]))
7024 .max_turns(10)
7025 .build()
7026 .unwrap();
7027 let out = runner.execute("étudie le module").await.unwrap();
7028 assert_eq!(out.result, "end");
7029 assert!(
7030 !write_executed.load(std::sync::atomic::Ordering::SeqCst),
7031 "pinned STUDY: the write after 'vas-y' must still be denied"
7032 );
7033 let reqs = provider.captured_requests.lock().unwrap();
7034 let denied = reqs.iter().any(|r| {
7035 tool_result_contents(r)
7036 .iter()
7037 .any(|c| c.contains("[mode contract]"))
7038 });
7039 assert!(denied, "the mode-contract backstop denied the write");
7040 }
7041
7042 #[tokio::test(flavor = "multi_thread")]
7050 async fn verify_replan_survives_midrequest_compaction() {
7051 let (noop, _) = FlagTool::new("noop", ToolOutput::success("ok"));
7054 let mut responses = vec![
7055 tool_use_named("noop", 1),
7056 tool_use_named("noop", 1),
7057 tool_use_named("noop", 1),
7058 tool_use_named("noop", 1),
7059 MockProvider::text_response("done1", 1, 1), ];
7061 responses.push(verify_tool_call());
7064 responses.push(MockProvider::text_response(
7066 "[summary of the session]",
7067 1,
7068 1,
7069 ));
7070 for _ in 0..12 {
7073 responses.push(MockProvider::text_response("done2", 1, 1));
7074 }
7075 let mut results: Vec<Result<crate::llm::types::CompletionResponse, Error>> =
7076 responses.into_iter().map(Ok).collect();
7077 results.insert(6, Err(overflow_error()));
7080 let provider = Arc::new(MockProvider::new_with_results(results));
7081 let runner = AgentRunner::builder(provider.clone())
7082 .name("t")
7083 .system_prompt("s")
7084 .tools(vec![noop, Arc::new(FailingVerifyTool)])
7085 .replan_on_verify_fail(true)
7086 .on_input(scripted_inputs(&["second request"]))
7087 .max_turns(60)
7088 .build()
7089 .unwrap();
7090 runner.execute("first request").await.unwrap();
7091 let reqs = provider.captured_requests.lock().unwrap();
7092 let fired = reqs.iter().any(|r| {
7093 r.messages.iter().flat_map(|m| m.content.iter()).any(
7094 |b| matches!(b, ContentBlock::Text { text } if text.contains("Verification is RED")),
7095 )
7096 });
7097 assert!(
7098 fired,
7099 "the verify-replan gate must still fire on the RED verify kept in \
7100 the tail after a mid-request compaction (re-anchored boundary)"
7101 );
7102 }
7103
7104 #[tokio::test(flavor = "multi_thread")]
7107 async fn escalation_rearms_after_advisor_consult() {
7108 let (advisor, _) = FlagTool::new("advisor", ToolOutput::success("advice: simplify"));
7109 let mut responses = Vec::new();
7110 for _ in 0..3 {
7111 responses.push(tool_use_named("bash", 1));
7112 }
7113 responses.push(tool_use_named("advisor", 1));
7114 for _ in 0..3 {
7115 responses.push(tool_use_named("bash", 1));
7116 }
7117 responses.push(MockProvider::text_response("stopping", 1, 1));
7118 let provider = Arc::new(MockProvider::new(responses));
7119 let runner = AgentRunner::builder(provider.clone())
7120 .name("t")
7121 .system_prompt("s")
7122 .tool(Arc::new(FailingBuild))
7123 .tool(advisor)
7124 .max_turns(20)
7125 .build()
7126 .unwrap();
7127 runner.execute("build it").await.unwrap();
7128 let reqs = provider.captured_requests.lock().unwrap();
7129 let last = reqs.last().expect("requests captured");
7130 let escalations = last
7131 .messages
7132 .iter()
7133 .flat_map(|m| m.content.iter())
7134 .filter(|b| matches!(b, ContentBlock::Text { text } if text.contains("[escalation]")))
7135 .count();
7136 assert_eq!(
7137 escalations, 2,
7138 "a fresh failure streak after the consult re-raises the escalation"
7139 );
7140 }
7141}