Skip to main content

heartbit_core/agent/
runner.rs

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
30/// Callback for interactive mode. Called when the agent needs more user input
31/// (i.e., the LLM returned text without tool calls). Returns `Some(message)`
32/// to continue the conversation, or `None` to end the session.
33pub type OnInput = dyn Fn() -> std::pin::Pin<Box<dyn std::future::Future<Output = Option<String>> + Send>>
34    + Send
35    + Sync;
36
37/// Behavioral guidelines appended to every agent's system prompt.
38/// Ensures agents proactively discover capabilities and exhaust options
39/// before claiming they cannot do something.
40pub(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
49/// System prompt for context compaction. Unlike a generic summary, it pins the
50/// load-bearing state an agent needs to keep working — replacing the old
51/// positional "keep the last N messages" heuristic, which drops the weakest
52/// attention position (the middle) where that state often lives. Kept compact
53/// (bullets, no padding) so the summary itself doesn't reintroduce bloat.
54pub(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/// One tool execution record. Captures the full input + untruncated output
66/// of a single tool call.
67///
68/// Populated by [`AgentRunner`] as tools execute; read via
69/// [`AgentOutput::tool_call_results`]. The output here is the raw
70/// post-guardrail content, BEFORE [`Tool::redact_for_history`] is applied
71/// for conversation-history inclusion. Callers that need the original
72/// (e.g., to extract a base64 image marker) should read this field rather
73/// than rely on the agent's textual `result`.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct ToolCallRecord {
76    /// Name of the tool that was invoked.
77    pub tool_name: String,
78    /// Tool-call identifier issued by the LLM (used for tool-result pairing).
79    pub tool_call_id: String,
80    /// Raw input arguments passed to the tool.
81    pub input: serde_json::Value,
82    /// FULL, untruncated output content. May be large (base64 images,
83    /// research dumps). Use [`Tool::redact_for_history`] to get the
84    /// conversation-safe variant.
85    pub output: String,
86    /// Whether the tool produced an error.
87    pub is_error: bool,
88    /// Wall-clock duration of the tool's `execute` call.
89    pub duration_ms: u64,
90}
91
92/// Whether `input_tokens` has reached `fraction` of the context `window`.
93/// Returns false for a zero window (unknown → no trigger). `fraction` is
94/// clamped to [0.0, 1.0] defensively.
95fn 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
99/// Default hard cap (bytes) on each FRESH tool result entering the context.
100/// 256KB matches the `read` builtin's own `MAX_FILE_SIZE` — a legitimate
101/// full-file read must never be truncated by this net. A single uncapped
102/// result (e.g. a grep sweeping build artifacts) once reached ~1MB and blew a
103/// 262K-token window in one turn. When the model window is known, the
104/// effective cap is additionally clamped to `window_tokens` bytes (≈ ¼ of the
105/// window in tokens) so small-window models stay protected.
106pub(super) const DEFAULT_TOOL_RESULT_INGEST_CAP: usize = 256 * 1024;
107
108/// Emergency per-result bound applied on a context-overflow error, before
109/// retrying. Deliberately aggressive: the window is already blown, and full
110/// content stays restorable via `fetch_full_output` when a recall store is set.
111const EMERGENCY_TOOL_RESULT_MAX_BYTES: usize = 4_096;
112
113/// True when `text` is a multi-question prose battery aimed at the user —
114/// at least two non-empty lines ending with `?`. The threshold deliberately
115/// ignores single trailing questions ("Anything else?") and rhetorical asides;
116/// code blocks rarely produce two `?`-terminated lines.
117fn 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
125/// True when `text` announces imminent first-person action — the
126/// narrate-then-stop failure mode ("Je vais créer… Laisse-moi d'abord
127/// vérifier…" then end_turn with zero tool calls; live session 6a2552a9).
128/// Deliberately small fr/en marker list; combined with the zero-work
129/// condition by the caller, so a closing "let me know" after real work
130/// never triggers.
131fn 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
147/// True when the request is WISH-PHRASED — an intent expression ("je
148/// souhaite créer…", "I'd like to build…") rather than a direct imperative.
149/// Live finding (session 6a25578a): a wish-phrased, underspecified feature
150/// request was answered by a unilateral design decision + immediate build.
151/// Wish phrasing lowers the plan-gate to the FIRST mutation.
152fn 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
166/// Tools whose call constitutes a PLAN ARTIFACT — evidence the front half
167/// engaged (clarified, planned, or scoped) before building.
168const PLAN_ARTIFACT_TOOLS: &[&str] = &[
169    "question",
170    "todowrite",
171    "set_goal",
172    "set_scope",
173    "run_workflow",
174];
175
176/// Mutating tools the plan-gate counts (file mutations; bash is excluded —
177/// it is mostly used for exploration and a `mkdir` writes no content).
178const PLAN_GATE_MUTATING: &[&str] = &["edit", "write", "patch"];
179
180/// Cumulative mutations (without any plan artifact) at which the tier-2
181/// backstop fires regardless of request phrasing.
182const PLAN_GATE_BACKSTOP_AT: u32 = 3;
183
184// STUDY/ANSWER execution-deny backstop: mirrors the ReadOnly tool mask
185// (`tool_filter::is_read_only_tool`) as a WHITELIST — any call not in the
186// read-only set (edit/write/patch/bash, but also delegation, MCP, A2A…)
187// is refused before side effects. A blacklist here proved too narrow: it
188// covered 4 names while delegate_task/form_squad/MCP calls slipped through.
189
190/// Rustc failure classes the repair-hint gate recognizes (live finding
191/// 6a258ab2: the model iterated blind on E0405 sqlx API drift).
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
193enum RustcHintClass {
194    /// Unresolved name/trait/method in an external crate → the model's API
195    /// knowledge is stale; ground in the CURRENT docs before retrying.
196    StaleApi,
197    /// Type mismatch → read the exact signature, don't iterate on guesses.
198    TypeMismatch,
199    /// Borrow/move errors → restructure ownership, mechanical retries fail.
200    Ownership,
201    /// A shell command was not found (exit 127) → wrong binary name
202    /// (python vs python3), missing tool, or not on PATH. Live finding
203    /// 6a25d21b: the model ran `python` (only `python3` exists) and thrashed.
204    CommandNotFound,
205}
206
207/// Classify a failing tool output (cargo/rustc/shell) into a repair-hint class.
208fn classify_rustc_failure(output: &str) -> Option<RustcHintClass> {
209    // Shell command-not-found (exit 127) — not a rustc error, check first.
210    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
245/// True when a failing output looks like a failed BUILD (for the consecutive-
246/// failure escalation counter).
247fn is_build_failure(output: &str) -> bool {
248    output.contains("error[E") || output.contains("could not compile")
249}
250
251/// Consecutive failed-build batches after which the escalation hint fires.
252const ESCALATION_AFTER_FAILURES: u32 = 3;
253
254/// Extra identical-tool-call repeats allowed AFTER the soft doom-loop warning
255/// before the run is hard-aborted. The soft warning fires at the configured
256/// threshold; this many further repeats (the model ignoring it) trips the
257/// hard stop (live finding 6a25d21b: the soft warning was ignored 3→4→5).
258const DOOM_HARD_STOP_MARGIN: u32 = 2;
259
260/// Harness-barrier tools: they mutate the guard/goal state sibling calls are
261/// checked against, so a batch containing one is split — barriers execute
262/// FIRST, serially, before the rest is guard-checked and dispatched (TOCTOU
263/// fix, live finding 2026-06-07).
264const BARRIER_TOOLS: &[&str] = &["set_scope", "set_goal"];
265
266/// Fallback bound (bytes) for the summarization transcript when the model's
267/// context window is unknown. 256KB ≈ 64K tokens.
268const DEFAULT_SUMMARY_INPUT_MAX_BYTES: usize = 262_144;
269
270/// Deterministic delegation nudge. Prompt-only routing has repeatedly failed
271/// to make mid-tier models delegate organically — they grind through
272/// substantive work solo (live trace evidence, 2026-06-07: ~30 sessions, zero
273/// organic delegations across three models). After `after_tool_calls` direct
274/// tool calls on ONE user request with none of `tool_names` used, the runner
275/// injects a one-shot reminder that the squad exists. Deterministic and
276/// model-agnostic, like the doom-loop and replan gates.
277#[derive(Debug, Clone)]
278pub struct DelegationNudge {
279    /// Direct-tool-call count (per user request) at which the nudge fires.
280    pub after_tool_calls: u32,
281    /// Tool names that count as delegation — using any of them suppresses
282    /// the nudge for the rest of the request.
283    pub tool_names: Vec<String>,
284}
285
286/// Head+tail slice of an oversized summarization transcript: keeps the task
287/// (head) and the most recent activity (tail), drops the middle.
288fn 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/// Output of a completed agent run.
306///
307/// Returned by [`AgentRunner::execute`] on success. Contains the agent's
308/// final text response and usage accounting for the entire run.
309#[derive(Debug, Clone, Default, Serialize, Deserialize)]
310#[non_exhaustive]
311pub struct AgentOutput {
312    /// The agent's final text response.
313    pub result: String,
314    /// Total number of tool calls made during the run.
315    pub tool_calls_made: usize,
316    /// Aggregate token usage for the entire run.
317    pub tokens_used: TokenUsage,
318    /// Structured output when the agent was configured with a response schema.
319    /// Contains the validated JSON conforming to the schema.
320    pub structured: Option<serde_json::Value>,
321    /// Estimated cost in USD based on model pricing. `None` if the model is
322    /// unknown or cost estimation is not available.
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub estimated_cost_usd: Option<f64>,
325    /// The model name used for this run. For cascading providers, this is the
326    /// last model that produced a response.
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub model_name: Option<String>,
329    /// Per-tool-call records with full input + untruncated output. Empty
330    /// when no tools were called or for composite agents that don't track
331    /// per-tool detail. Order matches dispatch order (which may differ
332    /// from completion order due to parallel execution). Counts only
333    /// tools that were actually executed — denied/blocked calls do not
334    /// appear here.
335    #[serde(default)]
336    pub tool_call_results: Vec<ToolCallRecord>,
337    /// Whether the run's [`GoalCondition`](super::goal::GoalCondition) was met,
338    /// as decided by the independent goal judge. `None` when no goal was set;
339    /// `Some(true)` when the judge confirmed the objective; `Some(false)` when
340    /// the continuation cap was exhausted without the objective being met.
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub goal_met: Option<bool>,
343}
344
345impl AgentOutput {
346    /// Accumulate this output's usage, tool calls, and cost into running totals.
347    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
361/// Runs an agent loop: LLM call → tool execution → repeat until done.
362pub 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    /// Token threshold at which to trigger summarization. `None` = no summarization.
372    pub(super) summarize_threshold: Option<u32>,
373    /// Model context window (tokens) for the proactive-compaction backstop. When
374    /// `Some`, compaction triggers on real `usage.input_tokens` crossing
375    /// `compaction_threshold_fraction * window`. `None` -> fall back to the
376    /// `summarize_threshold` estimate path.
377    pub(super) context_window_tokens: Option<u32>,
378    /// Fraction of the context window at which the backstop fires (default 0.70).
379    pub(super) compaction_threshold_fraction: f32,
380    /// Optional callback for streaming text output.
381    pub(super) on_text: Option<Arc<crate::llm::OnText>>,
382    /// Optional callback for streaming reasoning (chain-of-thought) output.
383    pub(super) on_reasoning: Option<Arc<crate::llm::OnReasoning>>,
384    /// Optional callback for human-in-the-loop approval before tool execution.
385    pub(super) on_approval: Option<Arc<crate::llm::OnApproval>>,
386    /// Optional timeout for individual tool executions.
387    pub(super) tool_timeout: Option<Duration>,
388    /// Optional maximum byte size for tool output content. Oversized results
389    /// are truncated with a `[truncated: N bytes omitted]` suffix.
390    pub(super) max_tool_output_bytes: Option<usize>,
391    /// When set, a synthetic `respond` tool is injected with this JSON Schema.
392    /// The agent calls `respond` to produce structured output conforming to the schema.
393    pub(super) structured_schema: Option<serde_json::Value>,
394    /// Optional callback for structured agent events.
395    pub(super) on_event: Option<Arc<OnEvent>>,
396    /// Guardrails applied to LLM calls and tool executions.
397    pub(super) guardrails: Vec<Arc<dyn Guardrail>>,
398    /// Optional callback for interactive mode. When set and the LLM returns
399    /// text without tool calls, the callback is invoked to get the next user
400    /// message instead of returning immediately.
401    pub(super) on_input: Option<Arc<OnInput>>,
402    /// Optional re-armable per-turn interrupt. When set and triggered, the
403    /// in-flight LLM generation is aborted and the turn ends cleanly (the session
404    /// continues, awaiting the next `on_input` message).
405    pub(super) interrupt: Option<super::interrupt::InterruptHandle>,
406    /// Optional wall-clock deadline for the entire run. When set, the full
407    /// `execute` call (all turns) is wrapped in `tokio::time::timeout`.
408    pub(super) run_timeout: Option<Duration>,
409    /// Optional reasoning/thinking effort level for models that support it.
410    pub(super) reasoning_effort: Option<crate::llm::types::ReasoningEffort>,
411    /// When true, inject a reflection prompt after tool results to encourage
412    /// the agent to assess results before the next action (Reflexion/CRITIC pattern).
413    pub(super) enable_reflection: bool,
414    /// When set, tool outputs exceeding this byte threshold are compressed
415    /// via an LLM call that preserves factual content while removing redundancy.
416    pub(super) tool_output_compression_threshold: Option<usize>,
417    /// Hard cap (bytes) applied to each fresh tool result at ingestion into
418    /// the conversation context. Defaults to [`DEFAULT_TOOL_RESULT_INGEST_CAP`]
419    /// (64KB). Full output is preserved in `context_recall_store` (when set)
420    /// and in `AgentOutput::tool_call_records` regardless.
421    pub(super) tool_result_ingest_cap: Option<usize>,
422    /// Optional request-intent router: picks the response mode (answer/
423    /// execute/study/clarify) per fresh request, BEFORE the first LLM turn;
424    /// the harness then enforces the mode contract (tool masking + execution
425    /// deny). `None` = today's semantics (everything is EXECUTE).
426    pub(super) request_router: Option<Arc<super::router::RequestRouter>>,
427    /// Optional deterministic delegation nudge (see [`DelegationNudge`]).
428    pub(super) delegation_nudge: Option<DelegationNudge>,
429    /// When set, limits the number of tool definitions sent per LLM turn.
430    /// Tools are selected based on recent usage and keyword relevance.
431    pub(super) max_tools_per_turn: Option<usize>,
432    /// When set, pre-filters tool definitions based on query classification
433    /// before dynamic selection. Reduces token usage for simple queries.
434    pub(super) tool_profile: Option<tool_filter::ToolProfile>,
435    /// Maximum number of consecutive identical tool-call turns before the
436    /// agent receives an error result instead of executing the tools. `None`
437    /// disables doom loop detection.
438    pub(super) max_identical_tool_calls: Option<u32>,
439    /// Maximum number of consecutive fuzzy-identical tool-call turns before
440    /// doom loop detection triggers. Fuzzy matching compares sorted tool names
441    /// (ignoring inputs). `None` disables fuzzy detection.
442    pub(super) max_fuzzy_identical_tool_calls: Option<u32>,
443    /// Hard cap on the number of tool invocations per LLM turn. When the LLM
444    /// emits more tool_use blocks than this limit, the run fails with
445    /// `Error::Agent` (wrapped in `Error::WithPartialUsage`). `None` = unlimited.
446    pub(super) max_tool_calls_per_turn: Option<u32>,
447    /// Declarative permission rules evaluated per tool call before the
448    /// `on_approval` callback. `Allow` → execute, `Deny` → error result,
449    /// `Ask` → fall through to `on_approval`.
450    ///
451    /// Wrapped in `RwLock` for interior mutability: learned rules from
452    /// `AlwaysAllow`/`AlwaysDeny` are injected at runtime via `&self`.
453    /// Lock is never held across `.await`.
454    pub(super) permission_rules: parking_lot::RwLock<permission::PermissionRuleset>,
455    /// Optional learned permissions for persisting AlwaysAllow/AlwaysDeny decisions.
456    pub(super) learned_permissions: Option<Arc<std::sync::Mutex<permission::LearnedPermissions>>>,
457    /// Optional LSP manager for collecting diagnostics after file-modifying tools.
458    pub(super) lsp_manager: Option<Arc<crate::lsp::LspManager>>,
459    /// Optional session pruning config. When set, old tool results are truncated
460    /// before each LLM call to reduce token usage.
461    pub(super) session_prune_config: Option<pruner::SessionPruneConfig>,
462    /// Optional memory store reference for pre-compaction flush.
463    pub(super) memory: Option<Arc<dyn Memory>>,
464    /// Optional per-run context recall store. When set, every tool output is
465    /// indexed by `tool_call_id` so pruned results can be restored on demand.
466    pub(super) context_recall_store: Option<Arc<crate::agent::context_recall::ContextRecallStore>>,
467    /// Optional shared to-do store. When set, the runner recites the open
468    /// (Pending/InProgress) items at the context tail each turn — the
469    /// long-horizon-planning "recitation" mechanism that keeps the live plan in
470    /// recent attention and lets it survive compaction (re-recited from the
471    /// store, not a lossy summary).
472    pub(super) todo_store: Option<Arc<crate::tool::builtins::TodoStore>>,
473    /// When true, a RED verification (`VERIFY_RESULT: FAIL` as the latest
474    /// canonical sentinel in the transcript) blocks natural completion: the
475    /// runner re-injects a corrective nudge and continues (bounded by
476    /// `MAX_VERIFY_REPLANS`) instead of finishing on red. The long-horizon
477    /// "replan on out-of-plan" signal for the no-goal path (a `GoalCondition`,
478    /// if present, already gates on the same evidence via its judge).
479    pub(super) replan_on_verify_fail: bool,
480    /// When true, use recursive (cluster-then-summarize) summarization for
481    /// long conversations instead of single-shot.
482    pub(super) enable_recursive_summarization: bool,
483    /// When true, run memory consolidation at session end.
484    pub(super) consolidate_on_exit: bool,
485    /// Observability verbosity level controlling span attribute recording.
486    pub(super) observability_mode: observability::ObservabilityMode,
487    /// Hard limit on cumulative tokens (input + output) across all turns.
488    /// When exceeded, the agent returns `Error::BudgetExceeded`.
489    pub(super) max_total_tokens: Option<u64>,
490    /// Optional persistent goal: an independent judge gates EVERY natural stop
491    /// and the agent keeps working (bounded by `max_continuations` and
492    /// `max_turns`) until the objective is met. A shared slot so the
493    /// `set_goal` tool can install/replace the goal at runtime (e.g. from
494    /// `intake` acceptance criteria); a met or budget-exhausted goal
495    /// auto-clears (per-request semantics). `None` inside = no goal gating.
496    pub(super) goal: super::goal::GoalSlot,
497    /// Controls whether audit records include full content or metadata only.
498    pub(super) audit_mode: super::audit::AuditMode,
499    /// Optional audit trail for recording untruncated agent decisions.
500    pub(super) audit_trail: Option<Arc<dyn AuditTrail>>,
501    /// Optional user context for multi-tenant audit enrichment.
502    pub(super) audit_user_id: Option<String>,
503    pub(super) audit_tenant_id: Option<String>,
504    /// Delegation chain for audit records (e.g., `["heartbit-agent"]` when acting on behalf of user).
505    pub(super) audit_delegation_chain: Vec<String>,
506    /// Optional LRU cache for LLM completion responses. Skips the LLM call
507    /// when an identical request (system prompt + messages + tool names) is found.
508    pub(super) response_cache: Option<cache::ResponseCache>,
509    /// Optional per-tenant in-flight token tracker. When set, `adjust()` is called
510    /// after each LLM response to reconcile actual vs. estimated usage.
511    pub(super) tenant_tracker: Option<Arc<crate::agent::tenant_tracker::TenantTokenTracker>>,
512    /// Cumulative actual tokens (input + output) across all turns for this runner.
513    /// Used to compute signed deltas for `tenant_tracker.adjust()` and to release
514    /// the full amount on `Drop`.
515    pub(super) cumulative_actual_tokens: std::sync::atomic::AtomicUsize,
516}
517
518impl<P: LlmProvider> AgentRunner<P> {
519    /// Create a new [`AgentRunnerBuilder`] for an agent backed by `provider`.
520    ///
521    /// The builder uses sensible defaults (10 turns, 4096 tokens) so the
522    /// minimum required configuration is just a system prompt.
523    ///
524    /// # Example
525    ///
526    /// ```rust,no_run
527    /// use std::sync::Arc;
528    /// use heartbit_core::{AgentRunner, AnthropicProvider, BoxedProvider};
529    ///
530    /// # async fn run() -> Result<(), heartbit_core::Error> {
531    /// let provider = Arc::new(BoxedProvider::new(AnthropicProvider::new(
532    ///     "sk-...",
533    ///     "claude-sonnet-4-20250514",
534    /// )));
535    /// let agent = AgentRunner::builder(provider)
536    ///     .system_prompt("You are a helpful assistant.")
537    ///     .build()?;
538    /// # let _ = agent;
539    /// # Ok(()) }
540    /// ```
541    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    /// Returns the agent's name.
605    pub fn name(&self) -> &str {
606        &self.name
607    }
608
609    /// Read-access to the permission rules (acquires read lock).
610    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    /// Check if the permission ruleset has any rules.
619    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    /// Record an audit entry (best-effort). Failures are logged, never abort the agent.
630    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                // Owned variant skips the top-level + per-scalar clones
634                // (P-CROSS-7) — ~1 ms saved per record on 100 KB payloads.
635                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    /// Persist an AlwaysAllow/AlwaysDeny decision as a learned permission rule.
645    ///
646    /// For each distinct tool name in the tool calls, a tool-level rule is created
647    /// (`pattern: "*"`). The rule is added to both the in-memory ruleset and the
648    /// on-disk learned permissions file.
649    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        // Collect distinct tool names
660        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        // Inject into the live ruleset so the rule takes effect immediately
672        // within this session (not just after restart).
673        self.permission_rules.write().append_rules(&new_rules);
674        // Persist to disk if learned permissions are configured
675        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    /// Estimate cost in USD based on model pricing and accumulated token usage.
690    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    /// Run the agent on `task` and return the final output.
697    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    /// Execute with pre-built multimodal content blocks (e.g., text + images).
707    pub async fn execute_with_content(
708        &self,
709        content: Vec<ContentBlock>,
710    ) -> Result<AgentOutput, Error> {
711        // Extract text for event/span descriptions
712        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        // Shared accumulator so we can retrieve partial usage even when the
735        // future is dropped by tokio::time::timeout.
736        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        // Audit: run failed
758        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        // Session-end maintenance (best-effort, errors logged but not propagated).
776        if let Ok(ref mut output) = result {
777            // Consolidate related episodic memories into semantic summaries (opt-in).
778            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                // Add consolidation cost increment (uses static model name — consolidation
782                // always runs through the same provider, not cascade tiers).
783                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            // Prune weak/old memories.
790            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            // P1.3g: per-tool-call records (full input/output) accumulated
838            // across all turns; moved into AgentOutput.tool_call_results on
839            // run completion. Only includes tools that actually executed —
840            // not denied/permission-rejected calls.
841            let mut tool_call_records: Vec<ToolCallRecord> = Vec::new();
842            let mut total_usage = TokenUsage::default();
843            // Accumulate cost per-turn for accurate cascade pricing.
844            let mut total_cost: f64 = 0.0;
845            // Goal gating: how many extra continuations the independent judge has
846            // granted so far (bounded by the goal's `max_continuations`). The
847            // turn counter on `ctx` is the other bound — a goal continuation goes
848            // through the loop top, so it consumes a turn and respects max_turns.
849            let mut goal_continuations_used: u32 = 0;
850            // Last settled goal verdict (the gate clears the slot when a goal
851            // settles, so the verdict must outlive it for the final output).
852            let mut last_goal_met: Option<bool> = None;
853            // Long-horizon "replan on out-of-plan": bounded count of verify-fail
854            // continuations so a permanently-red verify can't loop forever.
855            // Per-REQUEST (reset at on_input) like the other gates.
856            let mut verify_replans_used: u32 = 0;
857            const MAX_VERIFY_REPLANS: u32 = 8;
858            // Index of the first message of the CURRENT request — the
859            // verify-replan gate scans only this suffix so stale verify
860            // results from earlier requests can't re-trigger it.
861            let mut request_start_msg: usize = 0;
862            // Track recently used tool names (last 2 turns) for dynamic tool selection
863            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            // Reactive overflow-recovery ladder (prevents both infinite
867            // compaction loops AND the single-shot dead-end): 0 = untried,
868            // 1 = deterministic truncation ran last turn, 2 = summarization
869            // ran last turn. A second consecutive overflow escalates to the
870            // next rung instead of failing; past rung 2 it is unrecoverable.
871            // Cleared at the start of each normal (non-recovering) iteration.
872            let mut overflow_recovery_stage: u8 = 0;
873            // Anti-thrash guard for proactive compaction: never fire two turns running.
874            let mut proactive_compacted_last_turn = false;
875            // Delegation-nudge state, scoped to ONE user request (reset when
876            // `on_input` delivers the next message in chat mode).
877            let mut nudge_tool_calls: u32 = 0;
878            let mut nudge_delegated = false;
879            let mut nudge_sent = false;
880            // Ask-gate: one prose-battery→question-tool redirect per request.
881            let mut prose_question_nudged = false;
882            // Act-gate: tools executed this request + one-shot redirect flag.
883            let mut request_tool_calls: u32 = 0;
884            let mut intent_nudged = false;
885            // Plan-gate state: wish phrasing of the CURRENT request, whether a
886            // plan artifact (question/todos/goal/scope/recipe) was produced,
887            // cumulative mutations, and the one-shot flag.
888            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            // Repair-hint gates (one per class per request) + the consecutive
894            // failed-build escalation counter.
895            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            // Hard escalation (C): once raised, edit/write/patch are BLOCKED
901            // until the advisor is consulted (live finding 6a25ca5e: 24 build
902            // failures, advisor never called — the soft suggestion was ignored).
903            let mut advisor_required = false;
904            // Request-intent mode (router): routed per fresh request; the
905            // harness enforces the contract. Without a router, everything is
906            // EXECUTE (today's semantics).
907            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            // STUDY contract: the go/no-go question must happen before the
928            // study can settle (one corrective per request).
929            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                // Provide turn context to stateful guardrails
953                for g in &self.guardrails {
954                    g.set_turn(ctx.current_turn());
955                }
956
957                // Session pruning: create a pruned view of messages for this LLM call
958                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                // Long-horizon planning (recitation): re-surface the live plan
986                // (open todos, read from the actual store) at the context tail
987                // each turn. This keeps the plan in recent attention (counters
988                // lost-in-the-middle) and means it survives compaction — the
989                // next turn re-recites from the store, not a lossy summary.
990                // Self-gating: no open todos (trivial/chat tasks) → no block.
991                // Appended to the LAST message (a user/tool-result), NOT the
992                // system prompt or a new message, so prompt-cache prefixes and
993                // role alternation are untouched.
994                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                // Mode contract, PRIMARY enforcement (tool masking): in
1003                // STUDY/ANSWER the model never RECEIVES mutating tools — it
1004                // cannot call what it never received (prompt directives don't
1005                // hold against non-compliant models; IFEval <50% mid-tier).
1006                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                // Tool profile pre-filter: narrow tool set based on query classification
1015                if let Some(profile) = self.tool_profile {
1016                    request.tools = tool_filter::filter_tools(&request.tools, profile);
1017                }
1018
1019                // Dynamic tool selection: filter tools when there are too many
1020                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                // Response cache: compute key for non-streaming requests.
1040                // SECURITY (F-AGENT-3): scope the cache by tenant_id+user_id
1041                // when known. Otherwise a runner shared across tenants could
1042                // serve tenant A's cached response to tenant B if their
1043                // (system_prompt, messages, tools) tuple coincides.
1044                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                // Check cache before calling LLM
1063                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                // TTFT: captured by the on_text wrapper below; ALSO read at the
1081                // LlmResponse event emission (not just the tracing span) — the
1082                // event is what the TUI trace and /stats consume. Stays 0 for
1083                // cache hits and non-streaming calls.
1084                let ttft_ms = Arc::new(std::sync::atomic::AtomicU64::new(0));
1085                // Whether THIS turn's response was synthesized because the user
1086                // interrupted. Read by the stop-gates below: an interrupted turn
1087                // must fall straight through to `on_input` — running the
1088                // corrective gates (goal judge, verify-replan, ask/act/study)
1089                // would override the interrupt and keep the run going.
1090                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                    // A cache hit consumes zero provider tokens — zero the
1101                    // stored usage so totals, cost, the max_total_tokens budget,
1102                    // and per-tenant accounting aren't billed a second time
1103                    // (the original call already accounted them).
1104                    cached.usage = TokenUsage::default();
1105                    Ok(cached)
1106                } else {
1107                    // TTFT: wrap on_text to capture time-to-first-token
1108                    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                                // Reasoning models: stream chain-of-thought live via
1129                                // the dedicated channel when a callback is wired.
1130                                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                    // A triggered interrupt aborts the in-flight generation: race the
1151                    // LLM call against the per-turn token. On interrupt, synthesize a
1152                    // clean end-of-turn (non-empty text — providers reject empty
1153                    // assistant content), rearm for the next turn, and let the
1154                    // existing no-tool-calls path await the next `on_input` message.
1155                    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                    // Store successful non-streaming responses in cache (never the
1179                    // synthetic interrupt response). Only EndTurn responses are
1180                    // cached — ToolUse responses trigger side-effecting execution.
1181                    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                // Record LLM call span attributes
1197                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                        // Context-overflow recovery. No message-count gate: the
1236                        // 2026-06-07 incident overflowed at EXACTLY 5 messages
1237                        // (one giant fresh tool result) and a `> 5` gate here
1238                        // skipped recovery entirely.
1239                        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                            // Rung 1, deterministic first: hard-truncate
1250                            // oversized tool results and retry WITHOUT an LLM
1251                            // call — a summarization request would resend the
1252                            // very context that just overflowed. Skipped when
1253                            // truncation already ran last turn (it saved bytes
1254                            // but the retry still overflowed): escalate to
1255                            // summarization instead of dead-ending.
1256                            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                            // Rung 2 — nothing oversized (aggregate bloat) or
1284                            // truncation already tried: fall back to LLM
1285                            // summarization — `generate_summary` bounds its
1286                            // transcript, so it cannot itself overflow.
1287                            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                                    // Re-anchor the request boundary past the
1297                                    // index-0 summary (see the proactive site).
1298                                    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                // Real post-prune input token count for the proactive compaction backstop.
1352                let last_input_tokens = response.usage.input_tokens;
1353
1354                // Reconcile per-tenant in-flight token estimate with actual usage.
1355                // Uses cumulative `total_usage` (not per-turn) so the tracker always
1356                // reflects the true running total and multi-turn deltas are correct.
1357                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                // Per-turn cost: prefer response.model (cascade) over static model_name()
1371                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                // Update shared accumulator so RunTimeout can retrieve partial usage
1384                *usage_acc.lock().expect("usage lock poisoned") = total_usage;
1385
1386                // Check token budget
1387                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                // SECURITY (F-AGENT-1): repair Levenshtein-close typos in tool names
1406                // BEFORE permissions and pre_tool guardrails see them. Otherwise an
1407                // LLM could emit `bask` to bypass a `bash` deny-rule and have it
1408                // silently dispatched to `bash` later. We mutate `call.name` here
1409                // and emit a `ToolNameRepaired` event so the audit trail records
1410                // the substitution. The repair only fires for unknown names; exact
1411                // matches are untouched.
1412                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                // Tool-call cap: reject turns that exceed max_tool_calls_per_turn.
1433                // Checked before dispatch so no tools are executed on a capped turn.
1434                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                // Surface the model's chain-of-thought (reasoning models only)
1450                // as a distinct event, ahead of the answer.
1451                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                // Audit: LLM response (untruncated)
1477                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                // post_llm guardrail: inspect response, first Deny discards it.
1498                // When denied, we insert a synthetic assistant message before the
1499                // denial feedback to maintain the alternating user/assistant message
1500                // invariant required by the Anthropic API.
1501                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                            // Continue — do NOT discard the response
1532                        }
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                            // Audit: guardrail denied
1541                            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                            // Maintain alternating roles: assistant placeholder, then user denial
1557                            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                // Add assistant message to context (move content, avoid clone)
1602                ctx.add_assistant_message(Message {
1603                    role: crate::llm::types::Role::Assistant,
1604                    content: response.content,
1605                });
1606
1607                // Evict base64 media from older messages to prevent context bloat.
1608                ctx.evict_media();
1609
1610                // Check for structured output: if the LLM called the synthetic `__respond__` tool,
1611                // validate its input against the schema, then extract as structured result.
1612                // Count ALL tool calls in this turn (including co-submitted ones) for parity
1613                // with the Restate path, even though non-__respond__ calls are not executed.
1614                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                    // Validate against the caller's schema before accepting.
1622                    if let Err(validation_error) =
1623                        crate::tool::validate_tool_input(schema, &structured)
1624                    {
1625                        // Count the failed attempt and feed the validation error
1626                        // back to the LLM so it can self-correct on the next turn.
1627                        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                        // AC1: every tool_use block in the assistant turn MUST
1634                        // get a matching tool_result, or the NEXT request carries
1635                        // an orphaned tool_use and the provider rejects it with a
1636                        // hard 400 (run-breaker). `tool_choice` is not forced to
1637                        // `__respond__`, so the model can co-submit real tools
1638                        // alongside it. Answer `__respond__` with the validation
1639                        // error and any co-submitted tools with an "ignored"
1640                        // result so none are left unanswered.
1641                        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                    // Audit: run completed (structured)
1679                    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                    // Check for truncation
1714                    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                    // Structured output was requested but LLM returned text without
1724                    // calling __respond__. This is a contract violation — the caller
1725                    // expects structured output but would get None silently.
1726                    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                    // STOP GATES come BEFORE awaiting the next user message —
1743                    // in chat mode `on_input().await` blocks until the user
1744                    // types again, so gates placed after it would only ever
1745                    // fire at session end (found inert on the TUI path).
1746
1747                    // Ask-gate: a stop that is a multi-question prose battery
1748                    // is a CLARIFICATION, not a completion — and the user
1749                    // can't answer options efficiently in free text. When the
1750                    // structured `question` tool is registered, redirect ONCE
1751                    // per request (live finding 6a254624: the model reliably
1752                    // asks, but in prose; prompt rules alone don't move it).
1753                    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                    // Act-gate: a stop that ANNOUNCES action with ZERO tools
1781                    // executed this request is narrate-then-stall, not an
1782                    // answer (live finding 6a2552a9: "Je vais créer… Laisse-
1783                    // moi d'abord vérifier…" then silence). One-shot redirect:
1784                    // do the work now, or ask properly.
1785                    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                    // STUDY contract: a study must END in a proposal + an
1811                    // explicit go/no-go via the question tool — a bare "j'ai
1812                    // fini d'étudier" stop is a contract violation (once per
1813                    // request).
1814                    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                    // Long-horizon \"replan on out-of-plan\": a RED verification is
1839                    // the canonical out-of-plan signal. Before allowing natural
1840                    // completion, if opted in and the latest canonical
1841                    // VERIFY_RESULT is FAIL, re-inject a corrective nudge and
1842                    // continue (bounded) instead of finishing on red. Deterministic
1843                    // — no judge call; a green/absent verify falls through. A
1844                    // GoalCondition, if present, gates on the same evidence via its
1845                    // judge, so this is the cheap pre-gate for the no-goal path.
1846                    // Scan only the CURRENT request's messages: a stale
1847                    // VERIFY_RESULT: FAIL from an earlier chat request must not
1848                    // re-trigger the gate on unrelated requests. `request_start_msg`
1849                    // is re-anchored at every compaction (see `reanchor` at the
1850                    // inject_summary sites) so it stays a valid index; the
1851                    // `.min(message_count())` is a defensive clamp.
1852                    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                    // Goal gating: an INDEPENDENT judge decides whether the
1880                    // objective is met before this natural stop is allowed (anti
1881                    // over-report). Not-met re-injects the judge's reason and
1882                    // continues (bounded by max_continuations AND the loop's
1883                    // max_turns guard). Met OR cap-exhausted records the verdict
1884                    // and CLEARS the slot (per-request semantics: a settled goal
1885                    // must not bill a judge call on every later chat turn).
1886                    // An interrupted turn skips goal gating entirely: the user
1887                    // asked the run to STOP — judging and auto-continuing here
1888                    // would force them to interrupt once per continuation.
1889                    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                        // The judge sees the whole conversation rendered to text —
1899                        // including tool results (the EVIDENCE) — not just the
1900                        // agent's final claim, so it grades what actually happened.
1901                        let transcript = ctx.conversation_text();
1902                        let (verdict, judge_usage) = goal.evaluate(&transcript).await;
1903                        // Account the judge's tokens against the run's usage.
1904                        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                            // Continuation budget exhausted without meeting the
1923                            // goal: report not-met, stop gating.
1924                            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                    // Interactive mode: if on_input is set, ask for more input
1937                    // instead of returning. This enables multi-turn conversations.
1938                    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                        // New user request: all per-request gates re-arm.
1943                        request_is_wish = is_wish_request(&next_message);
1944                        // Follow-up policy: a bare affirmation after a STUDY/
1945                        // CLARIFY proposal PROMOTES to EXECUTE carrying the
1946                        // prior plan (no re-routing, no re-clarifying — the
1947                        // front half already happened). A user-PINNED mode is
1948                        // exempt: the pin is an explicit instruction that
1949                        // outranks the promotion heuristic — "oui" in pinned
1950                        // STUDY answers the proposal, it doesn't lift the pin.
1951                        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                        // Per-request continuation budgets re-arm with the
1996                        // other gates: a second set_goal (or a new red-verify
1997                        // cycle) on a later request gets its full budget.
1998                        goal_continuations_used = 0;
1999                        verify_replans_used = 0;
2000                        // The carried plan from the prior request counts as
2001                        // the plan artifact (don't re-gate an approved plan).
2002                        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                    // Audit: run completed
2026                    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                // Permission rules + human-in-the-loop approval.
2060                //
2061                // When permission rules are set, each call is evaluated individually:
2062                //   Allow → execute without asking
2063                //   Deny  → error result
2064                //   Ask   → deferred to `on_approval` callback
2065                // Calls with no matching rule are also deferred to `on_approval`.
2066                //
2067                // When no rules are set, the legacy behavior applies: if `on_approval`
2068                // is set, the entire batch is sent for approval.
2069                // Doom-loop tracking must see fully-DENIED batches too: a model
2070                // hammering a denied tool would otherwise spin to max_turns
2071                // with the tracker never recording a turn (the all-denied
2072                // paths `continue` before the main doom check below).
2073                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                    // Ask for the remaining calls via the on_approval callback
2104                    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                            // Persist AlwaysAllow / AlwaysDeny as learned rules
2121                            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                            // No callback → allow
2136                            allowed.extend(needs_approval);
2137                        }
2138                    }
2139
2140                    // If ALL calls were denied, add results and continue
2141                    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                    // Legacy path: no permission rules, batch approval callback
2160                    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                    // Persist AlwaysAllow / AlwaysDeny as learned rules
2172                    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                // Doom loop detection: if the same set of tool calls is repeated
2209                // for N consecutive turns, return error results instead of executing.
2210                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                        // HARD stop: the soft warning (error results below) gives
2218                        // the model `DOOM_HARD_STOP_MARGIN` turns to change course;
2219                        // past that it has demonstrably ignored the warnings, so
2220                        // abort instead of spinning forever (live finding
2221                        // 6a25d21b: doom detected at 3/4/5, never stopped, user
2222                        // had to interrupt by hand).
2223                        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                        // Hard stop mirrors the exact path: a fuzzy loop that
2273                        // survives the soft warning by DOOM_HARD_STOP_MARGIN
2274                        // turns is aborted (the fuzzy threshold is already more
2275                        // lenient than exact).
2276                        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                // Plan-gate (live finding 6a25578a: wish-phrased "je souhaite
2331                // créer un petit crm" → unilateral design + immediate build,
2332                // zero plan artifacts). Building without ANY plan artifact is
2333                // blocked BEFORE execution, doom-loop style (the batch gets
2334                // error results): tier 1 — a wish-phrased request gates the
2335                // FIRST mutation; tier 2 — any request gates the
2336                // PLAN_GATE_BACKSTOP_AT-th cumulative mutation. A plan
2337                // artifact (question/todowrite/set_goal/set_scope/
2338                // run_workflow) disarms it; one-shot per request.
2339                // Batch contributions to the plan/ask/scope flags. COMMITTED
2340                // only after the refusal gates below pass: a refused batch
2341                // executed nothing, so arming from its CALLS would let the
2342                // model evade a gate by tripping another. Two same-batch
2343                // nuances: set_scope/set_goal are barrier tools (hoisted and
2344                // executed BEFORE their siblings), so their same-batch
2345                // presence legitimately satisfies the contracts — but a
2346                // `question` batched WITH mutations has NOT been answered when
2347                // the mutations run, so it satisfies neither ask-first nor the
2348                // plan-artifact requirement for this batch.
2349                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                    // Re-arm the escalation one-shot: a FRESH failure streak
2361                    // after this consult must be able to raise the block again
2362                    // (otherwise one consult unlocks unlimited failed builds
2363                    // for the rest of the request).
2364                    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                // Mode contract, BACKSTOP enforcement (execution deny): a
2395                // mutating call that slips past the masking (hallucinated
2396                // name, repaired name) is refused before side effects.
2397                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                // Under CLARIFY the gate ALSO counts bash as a mutation: a
2435                // bash-driven build (`cargo new`, mkdir, heredoc writes) would
2436                // otherwise satisfy the whole request without ask-first/scope
2437                // ever engaging. Outside CLARIFY bash stays exempt (mostly
2438                // exploration; the tier-2 cumulative backstop is unchanged).
2439                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                // CLARIFY discipline: an under-specified request must ALSO
2446                // declare its blast radius before mutating — live finding
2447                // 6a258ab2: the model honored "répertoire temporaire" for one
2448                // mkdir, then silently rebuilt INSIDE the host repo; a
2449                // declared scope would have denied every misplaced write.
2450                let needs_scope = request_mode == super::router::RequestMode::Clarify
2451                    && !(scope_declared || batch_scope)
2452                    && self.tools.contains_key("set_scope");
2453                // CLARIFY means ASK-FIRST (live finding 6a25947c: the model
2454                // wrote todos+scope and built a WEB app without ever asking —
2455                // a todo is a plan artifact, not the user's answer).
2456                // Pre-batch `question_called` ONLY: a question batched with
2457                // the mutations has not been ANSWERED when they run — the
2458                // ask-first contract requires the answer, not the call.
2459                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                // The batch passed every refusal gate (escalation, mode
2524                // contract, plan gate) and WILL execute — commit its flag
2525                // contributions now. (`question` arms here too: a passing
2526                // question executes and blocks for the user's answer.)
2527                plan_artifact_seen = plan_artifact_seen || batch_artifact;
2528                question_called = question_called || batch_question;
2529                scope_declared = scope_declared || batch_scope;
2530
2531                // Harness-barrier tools (set_scope / set_goal) mutate the very
2532                // state their sibling calls are checked against. Executing them
2533                // inside the parallel batch is a TOCTOU: live session 6a251f55
2534                // emitted set_scope + an out-of-scope write in ONE batch, and
2535                // the write's pre_tool ran on the still-empty allowlist. Hoist
2536                // barriers out and execute them FIRST (serially, in emission
2537                // order) so siblings are checked against the updated state.
2538                // Approval/permissions already ran for the whole batch above;
2539                // barrier tools mutate in-process harness state only, so the
2540                // pre_tool guardrail pass on them is deliberately skipped.
2541                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                // pre_tool guardrail: per-call fine-grained filter
2568                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                                    // Continue — do NOT deny the tool call
2602                                }
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                                    // Audit: pre_tool guardrail denied
2611                                    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                // Update recently-used tool list for dynamic tool selection
2673                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                // A triggered interrupt abandons the in-flight tool batch: race
2681                // the batch against the per-turn token. On interrupt, synthesize a
2682                // result for EVERY allowed call (so no tool_use is left without a
2683                // tool_result — providers reject that), drop the batch future (its
2684                // JoinSet drop kills any in-flight subprocess via kill_on_drop), and
2685                // leave the token CANCELLED so the next LLM call's own race ends the
2686                // turn cleanly → await `on_input` (history preserved).
2687                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                        // Snapshot once per batch — introspection tools (the
2700                        // advisor) read it from ExecutionContext.transcript.
2701                        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                // LSP diagnostics: after file-modifying tools, collect diagnostics
2733                // and append to the tool result so the LLM sees errors immediately.
2734                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                // Compress oversized tool outputs via LLM call
2742                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                // Error-aware fuzzy doom reset: a batch where every call
2757                // succeeded is normal sequential work — only consecutive
2758                // ERRORING same-name batches indicate a loop.
2759                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                // Repair-hint gates: deterministic scanners over the batch.
2764                // (a) rustc failure classes → one targeted hint per class per
2765                // request; (b) consecutive failed builds → advisor escalation;
2766                // (c) hand-written Cargo.toml deps → cargo-add hint.
2767                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                // Hard ingestion cap: a single fresh tool result must never be
2869                // able to blow the context window (pruning only trims OLD
2870                // results; the proactive trigger only sees the PREVIOUS call's
2871                // usage). Full content is already in the recall store (when
2872                // set) and in `tool_call_records`. MUST run while the tool
2873                // results are still the LAST message — injecting hints first
2874                // made `cap_last_tool_results` miss them entirely (regression
2875                // of the 7de5df6 layer-2 ordering).
2876                if let Some(cap) = self.tool_result_ingest_cap {
2877                    // Clamp to the model window when known: bytes ≈ tokens*4,
2878                    // so `window_tokens` BYTES bounds one result to ~¼ of the
2879                    // window in TOKENS (256KB default vs a 32K-token model
2880                    // would otherwise still overflow it).
2881                    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                // Reflection: inject a user-role prompt that nudges the LLM to assess
2903                // tool results before deciding the next action (Reflexion/CRITIC pattern).
2904                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                // Deterministic delegation nudge: after N direct tool calls on
2915                // one user request with no delegation tool used, remind ONCE
2916                // that the squad exists (prompt guidance alone has proven
2917                // insufficient on mid-tier models — same rationale as the
2918                // doom-loop and replan gates).
2919                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                    // Never nudge toward delegation in a read-only mode:
2929                    // delegated sub-agents run with side effects the
2930                    // STUDY/ANSWER contract forbids (the backstop would deny
2931                    // the delegate call anyway).
2932                    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                // Proactive compaction backstop. Prefer the REAL post-prune token
2965                // count vs the window fraction; fall back to the chars/4 estimate
2966                // vs summarize_threshold when no window is known. Never compact two
2967                // turns running (anti-thrash).
2968                let proactive_trigger = match self.context_window_tokens {
2969                    // max(real, estimate): the REAL count is from the PREVIOUS
2970                    // response and is blind to tool results that landed since;
2971                    // the chars/4 estimate of the live ctx catches fresh bloat.
2972                    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                        // Compaction collapses the message list into an index-0
3010                        // summary + verbatim tail, invalidating the absolute
3011                        // request boundary. Re-anchor it just after the summary
3012                        // so the verify-replan scan still covers the (current,
3013                        // recent) kept tail and excludes the older summary.
3014                        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        // Record final metrics on the run span
3029        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    /// Generate a summary of the conversation so far using the LLM.
3047    ///
3048    /// Returns `(Option<summary_text>, token_usage)`. The summary is `None` if
3049    /// truncated (MaxTokens), in which case the caller should skip compaction.
3050    /// Token usage is always returned so the caller can accumulate it.
3051    async fn generate_summary(
3052        &self,
3053        ctx: &AgentContext,
3054    ) -> Result<(Option<String>, TokenUsage), Error> {
3055        // Bound the transcript BEFORE summarizing: the summary call must never
3056        // itself overflow the window it is trying to rescue. chars ≈ tokens*4,
3057        // so window*2 bytes targets ~half the window; generous fixed fallback
3058        // when the window is unknown.
3059        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        // Use recursive summarization for long conversations (>20 lines)
3067        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    /// Single-shot summarization of a text block.
3076    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            // Headroom for the structured schema (goal/files/todos/errors/decisions
3082            // + narrative). A summary that hits MaxTokens is dropped (returns None
3083            // below), silently skipping compaction — so don't starve it.
3084            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    /// Recursive summarization: chunk messages into clusters, summarize each,
3102    /// then summarize the combined cluster summaries.
3103    ///
3104    /// Preserves 3-5x more detail than single-shot for long conversations.
3105    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        // Phase 1: Summarize each cluster
3114        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                    // If any cluster summary is truncated, fall back to single-shot
3122                    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        // Phase 2: Combine cluster summaries into final summary
3131        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    /// Build a `TenantScope` from the agent's audit identity fields.
3146    ///
3147    /// Falls back to single-tenant (empty `tenant_id`) when no audit context is set.
3148    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    /// Flush key tool results to memory before compaction.
3156    ///
3157    /// Extracts non-error tool results exceeding a minimum length from messages
3158    /// that are about to be compacted, storing them as episodic memories.
3159    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                    // Skip errors and very short results
3177                    if *is_error || content.len() < 50 {
3178                        continue;
3179                    }
3180                    // Truncate very long results to a reasonable size
3181                    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    /// Prune weak memory entries at session end.
3225    ///
3226    /// Runs Ebbinghaus-based pruning with default thresholds. Errors are logged
3227    /// but do not fail the session — pruning is best-effort maintenance.
3228    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    /// Run memory consolidation at session end (opt-in).
3252    ///
3253    /// Clusters related episodic memories by keyword overlap and merges them
3254    /// into semantic summaries via LLM. Returns accumulated token usage.
3255    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    /// Select the most relevant tools for the current turn.
3291    ///
3292    /// Strategy:
3293    /// 1. Always include tools used in the last 2 turns (momentum)
3294    /// 2. Score remaining tools by keyword overlap with recent messages
3295    /// 3. Cap at `max_tools`
3296    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        // Collect text from last 2 user/assistant messages for keyword matching
3308        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        // Partition into pinned (always included) and candidates.
3327        // Pinned: recently-used tools + __respond__ (structured output must never be dropped).
3328        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                // Score by keyword overlap with tool name + description
3338                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        // Sort candidates by score descending
3348        candidates.sort_by_key(|c| std::cmp::Reverse(c.1));
3349
3350        // Fill remaining slots (cap total at max_tools)
3351        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    /// Compress a tool output using the LLM when it exceeds the threshold.
3359    ///
3360    /// Returns the original content if below threshold or on compression error.
3361    /// On success, returns the compressed text with a byte-count annotation.
3362    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        // Bound the input BEFORE sending: the compression call must never
3373        // itself overflow the window it is protecting (same head+tail bound
3374        // as `generate_summary`).
3375        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    /// Record a fully-DENIED batch against the doom tracker and, if it has
3409    /// repeated past the hard-stop margin (EXACT *or* FUZZY), emit the abort
3410    /// events and return the repeat count for the caller to fail on. Without
3411    /// this, the all-denied paths `continue` before the main doom check and a
3412    /// model hammering a denied tool — byte-identical OR same-name/varying-
3413    /// input — spins to max_turns. Returns `None` when doom tracking is off
3414    /// or the batch has not yet crossed the hard-stop margin.
3415    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    /// Find the closest tool name match within a maximum edit distance.
3451    /// Returns the matching tool name if found within `max_distance`.
3452    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    /// After file-modifying tools, collect LSP diagnostics and append them
3462    /// to the corresponding tool results.
3463    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            // Skip LSP diagnostics for failed tool calls — the file wasn't modified
3474            if idx < results.len() && results[idx].is_error {
3475                continue;
3476            }
3477            // Extract the file path from the tool input
3478            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    /// Synthesize results for a tool batch abandoned by a user interrupt.
3511    ///
3512    /// Emits a `ToolCallCompleted` (so a TUI's in-flight ⏳ cell finalizes) and
3513    /// records an error `ToolResult`/`ToolCallRecord` for EVERY call — leaving no
3514    /// `tool_use` without a matching `tool_result`, which providers reject. The
3515    /// real batch future is dropped by the caller, killing any in-flight
3516    /// subprocess via `kill_on_drop`.
3517    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    /// Execute tools in parallel via JoinSet, returning results in original call order.
3547    ///
3548    /// Panicked tasks produce an error `ToolResult` so the LLM always gets a
3549    /// result for every `tool_use_id` it sent.
3550    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        // Construct per-turn ExecutionContext from runner's audit fields.
3561        // Phase 0: workspace, credentials, audit_sink are not yet populated on
3562        // AgentRunner — leave them None until persona/credential plumbing lands.
3563        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            // SECURITY (F-AGENT-1): names are already repaired upstream of the
3574            // permission and pre_tool guardrails. If the lookup fails here, the
3575            // name was unknown AND not Levenshtein-close to any tool — return a
3576            // "Tool not found" error and let the LLM correct itself. Repairing
3577            // at dispatch time would bypass the policy that just ran.
3578            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            // Audit: tool call (untruncated input)
3594            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            // Validate input against the tool's declared schema before dispatching.
3612            // On failure, produce an error result without executing the tool.
3613            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        // Collect (idx, output, duration) tuples from JoinSet
3653        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        // Apply post_tool guardrails and convert to ToolResult
3673        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            // post_tool guardrail: each guardrail can mutate the output
3680            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                    // Audit: post_tool guardrail denied
3689                    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                        // SECURITY (F-AGENT-5): attribute the deny to the
3701                        // identity the rest of the run is attributed to. All
3702                        // other AuditRecord sites in this file pass these
3703                        // fields; this one used to set them to None, leaving
3704                        // post_tool denials unattributable cross-tenant.
3705                        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                    // post_tool error: convert to error output instead of aborting
3711                    // the entire run (consistent with tool execution errors)
3712                    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            // Audit: tool result (untruncated output)
3727            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            // Index every tool output into the context recall store so pruned
3747            // results can be restored on demand via `fetch_full_output`.
3748            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            // Capture FULL post-guardrail content for AgentOutput.tool_call_results.
3755            // This is the raw output as the caller would expect to see it; the
3756            // redacted variant below is only what gets fed back to the LLM.
3757            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            // Compute the conversation-history-safe variant via the tool's
3767            // optional `redact_for_history` override. Tools without an
3768            // override return the content verbatim. Re-look-up by name —
3769            // the original `Arc<dyn Tool>` was moved into the JoinSet
3770            // earlier and is no longer in scope here. On miss (which can
3771            // happen for "Tool not found" stub outputs), fall through to
3772            // the original content.
3773            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    // Long-horizon planning (recitation): when a todo_store has open items,
3833    // the runner appends a plan block to the tail of the last message.
3834    #[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        // Populate via the tool so we exercise the real store path.
3838        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    // No open todos → no recitation block (trivial tasks pay nothing).
3886    #[tokio::test(flavor = "multi_thread")]
3887    async fn no_recitation_when_no_open_todos() {
3888        let store = Arc::new(crate::tool::builtins::TodoStore::new()); // empty
3889        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    /// A "verify" tool that always reports RED (for the replan gate tests).
3914    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    // Long-horizon "replan on out-of-plan": with the gate ON, a RED verify
3952    // blocks natural completion — the runner re-injects a nudge and continues,
3953    // BOUNDED (≤ MAX_VERIFY_REPLANS = 8 replans) so it can't loop forever.
3954    #[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        // 1 verify tool call + 9 completion attempts (8 replans, then fall-through).
3972        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    // With the gate OFF (default), a RED verify does NOT block completion: the
3980    // agent finishes on the first EndTurn.
3981    #[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        // AC1: when the model co-submits a real tool alongside `__respond__` and
4007        // `__respond__` fails schema validation, EVERY tool_use block must get a
4008        // matching tool_result — otherwise the next request has an orphaned
4009        // tool_use and a real provider rejects it with a 400, killing the run.
4010        let schema = serde_json::json!({
4011            "type": "object",
4012            "properties": { "answer": { "type": "integer" } },
4013            "required": ["answer"]
4014        });
4015
4016        // Turn 1: `__respond__` with the WRONG type + a co-submitted real tool.
4017        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        // Turn 2: a valid `__respond__` → the run completes.
4036        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        // The SECOND request must carry a tool_result for BOTH turn-1 tool_use ids.
4061        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    /// Trivial no-op tool so the runner can dispatch a tool_use response.
4083    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    /// Build a tool-use response so the runner loops back for a second LLM call.
4105    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        // Simulate the daemon's submit-time admission check (Task 7) — drop
4128        // the reservation immediately, matching admission-only semantics.
4129        drop(tracker.reserve(&scope, 5000).unwrap());
4130        assert_eq!(tracker.snapshot()[0].1.in_flight, 0);
4131
4132        // Build a mock provider that returns known TokenUsage in one turn.
4133        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        // After one turn: cumulative_actual_tokens = 300, so adjust(+300).
4148        let snap = tracker.snapshot();
4149        assert_eq!(snap[0].1.in_flight, 300);
4150
4151        // After runner Drop: in_flight returns to 0.
4152        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        // A single real response — it must only be consumed by the turn AFTER the
4162        // interrupted one (proving the interrupted turn never called the provider).
4163        let provider = Arc::new(MockProvider::new(vec![MockProvider::text_response(
4164            "real answer",
4165            3,
4166            2,
4167        )]));
4168
4169        // Pre-trigger: the biased select takes the cancel arm on the first turn.
4170        let interrupt = InterruptHandle::new();
4171        interrupt.interrupt();
4172
4173        // on_input yields one follow-up message, then ends the session.
4174        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        // The interrupted first turn produced no real assistant content; the
4200        // follow-up turn produced the real answer, and the run ended cleanly.
4201        assert_eq!(out.result, "real answer");
4202        // Only the real turn's tokens count (the synthetic interrupt added none).
4203        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        // A tool that signals when it starts running, sleeps, then records that
4213        // it ran to completion. If the batch is interrupted, `finished` stays false.
4214        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            // turn 1: ask for the slow tool.
4248            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            // turn 2 (after the follow-up message): the real answer.
4260            MockProvider::text_response("real answer", 3, 2),
4261        ]));
4262
4263        let interrupt = InterruptHandle::new();
4264        // Cancel the instant the slow tool starts — i.e. mid-batch.
4265        let canceller = {
4266            let interrupt = interrupt.clone();
4267            tokio::spawn(async move {
4268                start_rx.recv().await;
4269                interrupt.interrupt();
4270            })
4271        };
4272
4273        // on_input yields one follow-up message, then ends the session.
4274        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        // The interrupt abandoned the running batch and ended the turn; the
4305        // follow-up message was then answered normally (history preserved).
4306        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        // Every interrupted tool_use still gets a (synthetic) result, so the
4312        // conversation stays valid (no orphan tool_use).
4313        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        // Two-turn test: verifies cumulative semantics (not per-turn deltas).
4324        // Turn 1: tool_use response (300 tokens) → runner loops.
4325        // Turn 2: text response (200 tokens) → runner stops.
4326        // Expected: in_flight = 500 (cumulative), zeroed on Drop.
4327        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), // turn 1: +300 → 300 cumulative
4333            MockProvider::text_response("done", 50, 150), // turn 2: +200 → 500 cumulative
4334        ]));
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        // After two turns: cumulative = 300 + 200 = 500.
4348        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    /// P1.3g: a single-tool agent run populates `AgentOutput.tool_call_results`
4485    /// with one record carrying the right tool_name, input, and output.
4486    #[tokio::test]
4487    async fn agent_output_tool_call_results_populated_after_tool_call() {
4488        // Turn 1: LLM asks for `noop` tool. Turn 2: LLM returns final text.
4489        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    /// P1.3g: when a tool overrides `redact_for_history`, `tool_call_results`
4513    /// must contain the FULL untruncated output, while the conversation
4514    /// history sent to the next LLM turn carries the redacted variant.
4515    #[tokio::test]
4516    async fn agent_output_tool_call_results_uses_full_output_not_redacted() {
4517        use crate::llm::types::Role;
4518
4519        /// Tool that returns a long output and redacts to "REDACTED".
4520        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        // Turn 1: LLM calls `blob`. Turn 2: LLM returns final text.
4545        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        // 1) AgentOutput records the FULL untruncated output.
4574        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        // 2) The conversation history sent on turn 2 contains the REDACTED
4579        //    variant in the ContentBlock::ToolResult fed back to the LLM.
4580        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)); // unknown window -> no trigger
4622    }
4623
4624    #[test]
4625    fn compaction_summary_prompt_pins_the_preservation_schema() {
4626        // Guards the structured schema against accidental gutting. (Content guard;
4627        // summary QUALITY is verified live, not by a unit test.)
4628        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        // Trigger one proactive compaction and confirm the summary request carried
4637        // the structured preservation prompt. Both compaction paths (single-shot +
4638        // recursive) route through `summarize_text`, so this covers both. Proves
4639        // PLUMBING (the prompt is sent), not summary quality.
4640        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), // fires compaction
4644            MockProvider::text_response("summary text", 1, 1), // the summary call
4645            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    /// A tool that returns `size` bytes of output (for context-size tests).
4666    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    /// Tool-use response for tool `name` reporting `input_tokens`.
4689    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    /// Extract all ToolResult contents from a request's messages.
4708    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    // --- Layer 2: hard ingestion cap on fresh tool results ---
4729
4730    #[tokio::test(flavor = "multi_thread")]
4731    async fn ingest_cap_truncates_giant_fresh_tool_result_by_default() {
4732        // A 200KB fresh tool result must be capped at ingestion (default 64KB)
4733        // so it can never blow the next request — the 2026-06-07 incident.
4734        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        // Full content must be restorable from the store.
4789        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        // The static default (256KB) would blow a small model window on its
4796        // own — when the window is known, the per-result cap clamps to
4797        // window-tokens bytes (≈ ¼ of the window in tokens).
4798        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    // --- Layer 3: proactive trigger must see a fresh (uncounted) bloated ctx ---
4847
4848    #[tokio::test(flavor = "multi_thread")]
4849    async fn proactive_compaction_sees_fresh_context_estimate() {
4850        // The provider reports LOW input tokens (100 < 700 budget) but the
4851        // accumulated fresh tool results push the chars/4 estimate of the ctx
4852        // far over the window fraction — compaction must still fire.
4853        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), // turn 3: message_count > 5, estimate >> 700
4861            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    // --- Layer 4: bounded summary transcript ---
4895
4896    #[tokio::test(flavor = "multi_thread")]
4897    async fn summary_transcript_is_bounded() {
4898        // The summary request must never resend an unbounded transcript —
4899        // otherwise compaction itself overflows the window it's trying to save.
4900        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), // fires proactive compaction (800 >= 700)
4904            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        // window=1000 tokens → budget = 1000*2 bytes (half the window in chars)
4931        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    // --- Layer 5: reactive overflow recovery ---
4943
4944    #[tokio::test(flavor = "multi_thread")]
4945    async fn reactive_overflow_truncates_oversized_results_and_retries_without_summary() {
4946        // On a classified context-overflow error, the runner must FIRST
4947        // hard-truncate oversized tool results (deterministic, no LLM) and
4948        // retry — NOT summarize (the summary call would itself overflow).
4949        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        // The retried request carries the emergency-truncated result.
4970        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        // Overflow on the FIRST call (message_count == 1): nothing to truncate,
4981        // so the runner falls back to summarization and retries. The incident
4982        // failed here because of a `message_count > 5` gate — pinned removed.
4983        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        // Chat mode (on_input set): the goal judge must gate EACH natural stop
5007        // BEFORE the runner awaits the next user message — otherwise the gate
5008        // only fires at session end (inert mid-session). Once met, the goal
5009        // auto-clears (per-request semantics): later stops pay no judge call.
5010        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        // LIVE FINDING (session 6a251f55, 2026-06-07): the model emitted
5073        // set_scope + an out-of-scope write in ONE parallel batch — the
5074        // write's pre_tool ran on the still-empty allowlist (TOCTOU) and the
5075        // mutation went through. Harness-barrier tools must execute FIRST,
5076        // before sibling calls are guard-checked or dispatched.
5077        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        // The 2nd request carries the batch's tool results — find them by id.
5119        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        // Live finding (session 6a254624): the model asks its clarification
5153        // battery in PROSE — the user can't answer options efficiently and
5154        // the structured channel sits unused. When the stop is a multi-
5155        // question prose battery AND the question tool is registered, the
5156        // runner deterministically redirects to the tool (once per request).
5157        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), // the redirect worked
5164            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        // "Anything else?" endings are not clarification batteries.
5194        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        // Live finding (session 6a2552a9): "Je vais créer un petit CRM…
5238        // Laisse-moi d'abord vérifier…" then end_turn with ZERO tool calls —
5239        // the model narrates intent and stops. Deterministic one-shot redirect:
5240        // execute now or ask via the question tool.
5241        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), // the redirect worked: it acts
5248            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        // Once tools ran this request, a closing "let me know…" or summary
5278        // mentioning future steps must NOT loop the agent.
5279        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        // If the model announces again right after the redirect, let it
5305        // through — bounded, no loop.
5306        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        // Live finding (session 6a25578a): "je souhaite créer un petit crm…"
5328        // → the model unilaterally picked a web app and started writing files
5329        // with ZERO plan artifacts (no question/todos/goal). A wish-phrased
5330        // request gates the FIRST mutation: nothing is written before the
5331        // front half engages.
5332        let provider = Arc::new(MockProvider::new(vec![
5333            tool_use_named("write", 10),    // charge! → must be blocked
5334            tool_use_named("question", 10), // reacts to the gate: asks
5335            tool_use_named("write", 10),    // now allowed
5336            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        // After the question (plan artifact), the write executes for real.
5368        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        // Imperative phrasing escapes tier 1, but sustained building with no
5417        // plan artifact hits the tier-2 backstop at the 3rd mutation.
5418        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), // ← blocked (cumulative 3rd)
5422            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        // A wish request that PLANS first (todowrite) builds unimpeded.
5450        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        // STUDY contract, primary enforcement: the model never RECEIVES
5486        // edit/write/patch/bash. "étudie…" routes STUDY at L0.
5487        let provider = Arc::new(MockProvider::new(vec![
5488            tool_use_named("question", 10), // satisfies the go/no-go contract
5489            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        // Backstop: even a hallucinated mutating call is refused pre-execution.
5521        let provider = Arc::new(MockProvider::new(vec![
5522            tool_use_named("write", 10), // model tries anyway
5523            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        // STUDY must end in a proposal + go/no-go via the question tool; a
5554        // stop without any question call gets ONE corrective.
5555        let provider = Arc::new(MockProvider::new(vec![
5556            MockProvider::text_response("j'ai fini d'étudier.", 10, 5), // no question!
5557            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        // "construis-moi un crm" — imperative, NO wish marker → L0 CLARIFY →
5591        // the first mutation is gated even though is_wish_request is false.
5592        let provider = Arc::new(MockProvider::new(vec![
5593            tool_use_named("write", 10), // blocked by the plan gate
5594            tool_use_named("question", 10),
5595            tool_use_named("write", 10), // artifact exists → allowed
5596            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        // Live finding 6a258ab2: plan artifacts existed (todos+goal) but no
5624        // scope was declared — the model rebuilt INSIDE the host repo. In
5625        // CLARIFY mode the first mutation requires set_scope as well.
5626        let provider = Arc::new(MockProvider::new(vec![
5627            tool_use_named("todowrite", 10), // plan artifact, but NO scope
5628            tool_use_named("write", 10),     // → must still be plan-gated
5629            tool_use_named("set_scope", 10), // declares the blast radius
5630            tool_use_named("write", 10),     // → now allowed
5631            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        // After a STUDY proposal, "vas-y" promotes to EXECUTE carrying the
5664        // plan: the write runs, no re-clarification, no plan-gate.
5665        use std::sync::atomic::{AtomicUsize, Ordering};
5666        let provider = Arc::new(MockProvider::new(vec![
5667            tool_use_named("question", 10), // study: go/no-go asked
5668            MockProvider::text_response("proposition: 1) sqlite. go?", 10, 5),
5669            tool_use_named("write", 10), // after "vas-y": EXECUTE
5670            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        // After the affirmation, write must be available again (unmasked)…
5701        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        // …and execute without any gate.
5710        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        // No router configured → no masking, no mode contracts (library
5724        // users see zero behavior change).
5725        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        // Live finding 6a258ab2: E0405 (sqlx API drift) → the model guessed
5795        // repeatedly instead of grounding itself in the CURRENT docs.
5796        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    /// A bash tool that always reports a build failure (for escalation tests).
5946    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        // C: after the escalation, edit/write/patch are DENIED until the
5968        // advisor is called (live finding 6a25ca5e: the soft suggestion was
5969        // ignored through 24 failed builds).
5970        let provider = Arc::new(MockProvider::new(vec![
5971            tool_use_named("bash", 10),    // fail 1
5972            tool_use_named("bash", 10),    // fail 2
5973            tool_use_named("bash", 10),    // fail 3 → escalation + advisor_required
5974            tool_use_named("edit", 10),    // BLOCKED (advisor required)
5975            tool_use_named("advisor", 10), // consult → clears the block
5976            tool_use_named("edit", 10),    // now allowed
5977            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        // Reaching the terminal "done" proves the block was CLEARED by the
5990        // advisor — otherwise the edit stays denied and the run never settles.
5991        assert_eq!(out.result, "done");
5992        // The final transcript (last request) carries the escalation deny that
5993        // blocked the pre-advisor edit (an error ToolResult, not a suggestion).
5994        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        // B: gates were invisible (user-message injections). Now each fires a
6009        // GateFired event so a session can be audited.
6010        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), // wish request → plan gate blocks
6015            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    // --- Deterministic delegation nudge ---
6051
6052    /// A no-op tool with a configurable name (stands in for delegate_task).
6053    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        // 3 direct tool calls with after_tool_calls=2 → the nudge is injected
6087        // exactly ONCE (after the 2nd call), visible in later requests.
6088        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        // The first call IS a delegation tool → no nudge ever fires.
6123        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    /// Helper to build a tool-use response where the LLM reports `input_tokens`.
6180    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        // window=1000, fraction=0.70 → budget=700. Three tool-use turns drive
6201        // message_count to 7 (>5) while reporting 800 input tokens (>= 700).
6202        // Proactive compaction fires on turn 3, consuming the summary response.
6203        // Turn 4 is a terminal text response.
6204        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),                         // turn 1 main LLM
6210            tool_use_with_tokens(800),                         // turn 2 main LLM
6211            tool_use_with_tokens(800),                         // turn 3 main LLM — fires compaction
6212            MockProvider::text_response("summary text", 1, 1), // summary call
6213            MockProvider::text_response("done", 800, 1),       // turn 4 main LLM
6214        ]));
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        // Same shape but input_tokens=600 (< 700 budget) → ZERO ContextSummarized.
6250        // Three tool-use turns still needed so message_count crosses 5.
6251        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),                   // turn 1
6257            tool_use_with_tokens(600),                   // turn 2
6258            tool_use_with_tokens(600),                   // turn 3 — 600 < 700, no compact
6259            MockProvider::text_response("done", 600, 1), // turn 4 terminal
6260        ]));
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        // Turns 1-3 drive message_count >5. Turn 3 fires compaction (consuming
6296        // the summary response). Turn 4 is also a high-token tool-use turn but
6297        // is suppressed by the anti-thrash flag. Turn 5 is a terminal text
6298        // response. Net result: exactly 1 ContextSummarized.
6299        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),                         // turn 1 main LLM
6305            tool_use_with_tokens(800),                         // turn 2 main LLM
6306            tool_use_with_tokens(800),                         // turn 3 main LLM — fires compaction
6307            MockProvider::text_response("summary text", 1, 1), // summary call (turn 3)
6308            tool_use_with_tokens(800), // turn 4 main LLM — suppressed by anti-thrash
6309            MockProvider::text_response("done", 800, 1), // turn 5 terminal
6310        ]));
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    /// A streaming mock that emits its first token after a measurable delay —
6344    /// instant mocks would legitimately record a 0ms TTFT.
6345    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    // TTFT regression (found by the TUI's own /analyze on a live trace): the
6367    // LlmResponse EVENT hardcoded time_to_first_token_ms: 0 even though the
6368    // on_text wrapper captured it — the value only ever reached the tracing
6369    // span. The event is what the TUI trace and /stats consume.
6370    #[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    // ===== Multi-aspect audit (2026-06-09) regression tests =====
6403
6404    /// on_input helper: yields the given messages in order, then `None`.
6405    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    /// Stub tool that records whether it executed.
6423    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    // The verify-replan budget is PER-REQUEST: it re-arms at on_input, and the
6473    // gate scans only the current request's messages (a stale FAIL from an
6474    // earlier request must not re-trigger it).
6475    #[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    // Stop-gate ORDER pin: the verify-replan corrective must reach the agent
6507    // BEFORE the runner awaits the next user message (gates after on_input
6508    // were found inert on the TUI path — they only ever fired at session end).
6509    #[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    // The goal continuation budget is PER-REQUEST: a second goal installed in
6550    // the slot on a later chat request gets its full budget (it used to
6551    // inherit the exhausted counter and settle not-met with ZERO continuations).
6552    #[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                    // The next request installs goal B (set_goal / /goal).
6585                    *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    // A user interrupt must short-circuit the stop-gates: the goal judge runs
6616    // only on the REAL stop after the follow-up, never on the synthesized
6617    // "[interrupted by user]" turn (which would auto-continue the run the
6618    // user just asked to stop).
6619    #[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    // A response served from the cache consumed zero provider tokens — it
6665    // must not re-bill the original call's usage (totals, cost, budget).
6666    #[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    // A model hammering a permission-DENIED tool used to bypass doom-loop
6695    // tracking entirely (the all-denied path continued before the tracker
6696    // recorded the batch) and spun to max_turns. It must hard-stop.
6697    #[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    // Audit-review regression: a model hammering a permission-DENIED tool with
6729    // the SAME name but VARYING inputs must ALSO hard-stop (fuzzy doom), not
6730    // just byte-identical repeats. The all-denied paths previously discarded
6731    // the fuzzy signal and spun to max_turns.
6732    #[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        // Same tool name, DIFFERENT input each turn → fuzzy (not exact) loop.
6736        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        // It must NOT have run to max_turns (the bug would spin to 30).
6774        assert!(
6775            provider.captured_requests.lock().unwrap().len() < 30,
6776            "fuzzy doom must hard-stop well before max_turns"
6777        );
6778    }
6779
6780    // CLARIFY ask-first: a `question` batched WITH mutations has not been
6781    // answered when the writes run — the plan gate must refuse the batch
6782    // (and a refused batch must not arm the contract flags).
6783    #[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        // The reconciled scratch guidance (5c7f319) — never "OUTSIDE this repository".
6829        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    // STUDY/ANSWER deny backstop is a WHITELIST mirror of the mask: any
6842    // side-effecting call that slips past masking (delegation, MCP, repaired
6843    // names) is refused — not just edit/write/patch/bash.
6844    #[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    // The delegation nudge must stay silent in read-only modes — it would
6875    // push the model toward a delegate call the backstop then denies.
6876    #[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    // The delegation nudge re-arms per request (shipped behavior, was unpinned).
6907    #[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    // Regression of the 7de5df6 layer-2 ordering: the ingest cap must apply
6948    // even when a repair hint fires in the same turn (the hint message used
6949    // to displace the tool results as the "last message" and the cap missed).
6950    #[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    // Reactive overflow recovery escalates: truncation rung first; when the
6983    // retry STILL overflows, the summarization rung runs instead of failing
6984    // (the old boolean guard dead-ended after one recovery).
6985    #[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    // A user-PINNED Study mode must survive a bare affirmation: "vas-y"
7009    // answers the proposal, it does not lift the pin into EXECUTE.
7010    #[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    // Audit-review regression: the verify-replan gate's request-scoped scan
7043    // uses an ABSOLUTE message index (request_start_msg) that compaction
7044    // invalidates. After a mid-request summary collapses the message list,
7045    // the index used to point past the end → empty slice → the gate MISSED a
7046    // RED verify still present in the kept tail and the agent finished on red.
7047    // The fix re-anchors the index at every inject_summary; this drives the
7048    // reactive-overflow→summary path and asserts the gate still fires.
7049    #[tokio::test(flavor = "multi_thread")]
7050    async fn verify_replan_survives_midrequest_compaction() {
7051        // Request 1 grows the message list well past the post-compaction size
7052        // (summary + last 4) so request_start_msg for request 2 exceeds it.
7053        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), // completes request 1
7060        ];
7061        // Request 2: verify (RED), then an overflow that forces summarization,
7062        // then completion attempts that must be caught by the replan gate.
7063        responses.push(verify_tool_call());
7064        // generate_summary's provider.complete response (the summary text):
7065        responses.push(MockProvider::text_response(
7066            "[summary of the session]",
7067            1,
7068            1,
7069        ));
7070        // After re-anchored compaction the model tries to finish repeatedly;
7071        // the gate re-injects until the bound, then it settles.
7072        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        // Insert the overflow error right after the verify tool call (index 6:
7078        // 5 request-1 responses + 1 verify).
7079        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    // The hard-escalation one-shot re-arms after an advisor consult: a FRESH
7105    // 3-failure streak must raise the block again.
7106    #[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}