Skip to main content

supercode_harness/
agent.rs

1//! The agent loop.
2
3use std::collections::HashSet;
4
5use crate::config::{CachePlan, Config, SteeringMode, ToolAdvertising};
6use crate::error::{Error, Result};
7use crate::event::AgentEvent;
8use crate::message::{ChatMessage, Role};
9use crate::provider::{self, ChatRequest, OpenAiProvider, Provider, ToolSchema};
10use crate::reduce::rehydrate::CAP_NOTICE_MARKER;
11use crate::reduce::{self, ReductionLog, ReductionPolicy};
12use crate::session::Session;
13use crate::sidecar::SidecarWriter;
14use crate::tools::{ToolContext, ToolRegistry};
15
16/// Tool name of the `tool_search` agent intrinsic (B6). Never a registered
17/// [`crate::tools::Tool`] — intercepted in [`Agent::run_tool`] before registry
18/// lookup, so it works under any [`ToolAdvertising`] mode.
19const TOOL_SEARCH: &str = "tool_search";
20
21/// Tool name of the `expand_reduction` agent intrinsic (T12/TR-1) — the
22/// model-invocable rehydration counterpart to `tool_search`, same
23/// interception pattern. Advertised whenever a [`ReductionPolicy`] is
24/// installed, regardless of [`ToolAdvertising`] mode (see [`Self::tool_schemas`]).
25const EXPAND_REDUCTION: &str = "expand_reduction";
26
27/// Tool name of the `sidecar_search` agent intrinsic (T12/TR-1).
28const SIDECAR_SEARCH: &str = "sidecar_search";
29
30/// Tool name of the `spawn_subagent` agent intrinsic (P5-3, §2 module 9 D1
31/// "spawn tool"). Same interception pattern as [`TOOL_SEARCH`] — never a
32/// registered [`crate::tools::Tool`], intercepted in [`Agent::run_tool`]
33/// before registry lookup — but ALSO needs full `&mut self` async access
34/// (running a whole child agent loop, or `tokio::spawn`-ing one), which
35/// [`Agent::prepare_tool_call`]'s purely-synchronous intrinsics don't, so
36/// the interception point is `Self::run_tool`'s top, not
37/// `prepare_tool_call`.
38const SPAWN_SUBAGENT: &str = "spawn_subagent";
39
40/// Claude Code's native name for [`SPAWN_SUBAGENT`]. It is exposed only when
41/// `Config::subagents_claude_agent_alias` is enabled for a Claude import.
42const CLAUDE_AGENT: &str = "Agent";
43
44/// Claude Code spellings for core filesystem/shell tools. Imported Claude
45/// context frequently continues to call these names even when another model
46/// is driving the turn, so emulation must translate execution as well as
47/// preserve the original call/result names in the transcript.
48const CLAUDE_BASH: &str = "Bash";
49const CLAUDE_READ: &str = "Read";
50const CLAUDE_WRITE: &str = "Write";
51const CLAUDE_EDIT: &str = "Edit";
52const CLAUDE_GLOB: &str = "Glob";
53const CLAUDE_GREP: &str = "Grep";
54
55/// Claude Code scheduler compatibility intrinsics. They edit an imported
56/// [`crate::ClaudeRuntimeManifest`]; actual timer execution belongs to an
57/// embedding scheduler driver, never this agent loop.
58const CLAUDE_CRON_CREATE: &str = "CronCreate";
59const CLAUDE_CRON_DELETE: &str = "CronDelete";
60const CLAUDE_CRON_LIST: &str = "CronList";
61const CLAUDE_SCHEDULE_WAKEUP: &str = "ScheduleWakeup";
62
63/// Shared SDK steering mailbox. `accepting` and `queue` share one lock so a
64/// turn's final boundary can close acceptance atomically with its last drain;
65/// a steer can therefore never be acknowledged into the following turn.
66#[derive(Default)]
67pub(crate) struct SteerInbox {
68    queue: std::collections::VecDeque<QueuedSteer>,
69    accepting: bool,
70}
71
72struct QueuedSteer {
73    message: String,
74    sdk_bound: bool,
75}
76
77impl SteerInbox {
78    pub(crate) fn open(&mut self) {
79        self.queue.clear();
80        self.accepting = true;
81    }
82
83    pub(crate) fn enqueue(&mut self, message: String) -> bool {
84        if !self.accepting {
85            return false;
86        }
87        self.queue.push_back(QueuedSteer {
88            message,
89            sdk_bound: true,
90        });
91        true
92    }
93
94    pub(crate) fn close(&mut self) {
95        self.accepting = false;
96        self.queue.retain(|queued| !queued.sdk_bound);
97    }
98
99    fn drain(&mut self, mode: SteeringMode) -> Option<String> {
100        if self.queue.is_empty() {
101            return None;
102        }
103        match mode {
104            SteeringMode::All => Some(
105                self.queue
106                    .drain(..)
107                    .map(|queued| queued.message)
108                    .collect::<Vec<_>>()
109                    .join("\n\n"),
110            ),
111            SteeringMode::OneAtATime => self.queue.pop_front().map(|queued| queued.message),
112        }
113    }
114
115    fn drain_or_close(&mut self, mode: SteeringMode) -> Option<String> {
116        if !self.queue.iter().any(|queued| queued.sdk_bound) {
117            self.accepting = false;
118            return None;
119        }
120        self.drain(mode)
121    }
122
123    fn queue_unchecked(&mut self, message: String) {
124        self.queue.push_back(QueuedSteer {
125            message,
126            sdk_bound: false,
127        });
128    }
129
130    fn len(&self) -> usize {
131        self.queue.len()
132    }
133}
134
135struct SteerTurnGuard {
136    inbox: std::sync::Arc<std::sync::Mutex<SteerInbox>>,
137}
138
139impl SteerTurnGuard {
140    fn new(inbox: std::sync::Arc<std::sync::Mutex<SteerInbox>>) -> Self {
141        inbox
142            .lock()
143            .unwrap_or_else(std::sync::PoisonError::into_inner)
144            .accepting = true;
145        Self { inbox }
146    }
147}
148
149impl Drop for SteerTurnGuard {
150    fn drop(&mut self) {
151        self.inbox
152            .lock()
153            .unwrap_or_else(std::sync::PoisonError::into_inner)
154            .close();
155    }
156}
157
158/// Tool name of the `subagent_status` agent intrinsic (P5-3, D3
159/// "background+resume"): poll (and reap, once finished) a background child
160/// spawned via [`SPAWN_SUBAGENT`]. Only advertised when
161/// `Config::subagents_background` is on (see [`Agent::tool_schemas`]).
162const SUBAGENT_STATUS: &str = "subagent_status";
163
164/// Tool name of the `background_exec` agent intrinsic (P5-6, §2 module 4
165/// `tools.background` D1 "background exec"). Unlike [`SPAWN_SUBAGENT`], this
166/// needs no async child-agent loop — spawning a process
167/// (`tokio::process::Command::spawn`) is itself synchronous — so, like
168/// [`TOOL_SEARCH`], it is intercepted in [`Agent::prepare_tool_call`], not
169/// [`Agent::run_tool`].
170const BACKGROUND_EXEC: &str = "background_exec";
171
172/// Tool name of the `background_status` agent intrinsic (P5-6, D1 "monitor/
173/// event feed"): poll a background job's run status, drain its newly
174/// captured output as an [`AgentEvent::BackgroundOutput`] event, and reap it
175/// (remove it from [`Agent::background_jobs`]) once it has exited or been
176/// killed.
177const BACKGROUND_STATUS: &str = "background_status";
178
179/// Tool name of the `background_list` agent intrinsic (P5-6, D10
180/// "bg-manager"): list every background job this agent is currently
181/// tracking (running or finished-but-unreaped), without draining output or
182/// reaping anything.
183const BACKGROUND_LIST: &str = "background_list";
184
185/// Tool name of the `background_kill` agent intrinsic (P5-6, D10
186/// "bg-manager"): kill a background job's real OS process
187/// (`tokio::process::Child::start_kill`) and reap it immediately.
188const BACKGROUND_KILL: &str = "background_kill";
189
190/// P4e (§3.1 `core.parallel_tool_calls`): the synchronous outcome of
191/// [`Agent::prepare_tool_call`] — either a result already in hand (an
192/// intrinsic, or a call refused before it ever reached `Tool::execute`), or
193/// a plain registry-tool call ready for the (possibly concurrent) async
194/// `execute()` step.
195enum PreparedCall {
196    /// A final `(output, is_error)` result — no `Tool::execute` call is
197    /// coming for this one.
198    Done((String, bool)),
199    /// Passed every synchronous check; `execute(args, &ctx)` on the named
200    /// registry tool is the only remaining step.
201    Ready {
202        name: String,
203        args: serde_json::Value,
204    },
205}
206
207/// Marker prefix of the notice [`Agent::cap_tool_output`] appends to an
208/// oversized tool result kept in `history` (the recorder receives the full
209/// UX-26 (B7-warn): current wall-clock time as unix milliseconds, the same
210/// unit [`crate::sidecar::rfc3339_to_ms`] parses session timestamps into —
211/// lets [`Agent::build_request_messages`] compare "now" against a
212/// cross-process signal (a loaded session's last message timestamp) on
213/// equal footing with an in-process one (this agent's own last annotated
214/// send). Saturates to 0 on a pre-epoch clock rather than panicking (never
215/// happens on real hardware, but `duration_since` can theoretically error).
216fn now_ms() -> i64 {
217    std::time::SystemTime::now()
218        .duration_since(std::time::UNIX_EPOCH)
219        .map(|d| d.as_millis() as i64)
220        .unwrap_or(0)
221}
222
223/// P5-3: process-wide sequence number backing [`next_subagent_id`] —
224/// disambiguates two spawns landing in the same millisecond (which
225/// `now_ms()` alone cannot).
226static SUBAGENT_ID_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
227
228/// P5-3: a fresh, process-unique child agent id (`"agent-<hex-ts>-<hex-seq>"`
229/// — the native analog of Claude Code's `agent-<id>` naming, see
230/// `crate::session::SessionMeta::agent_id`'s doc comment).
231fn next_subagent_id() -> String {
232    let seq = SUBAGENT_ID_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
233    format!("agent-{:x}-{:x}", now_ms(), seq)
234}
235
236/// P5-4: the shape [`Agent::child_approval_handler_factory`]/
237/// [`Agent::set_child_approval_handler_factory`] share — factored into its
238/// own alias (clippy `type_complexity`) rather than spelled out inline at
239/// both use sites.
240type ChildApprovalHandlerFactory = dyn Fn(
241        String,
242        std::sync::Arc<std::sync::Mutex<Vec<crate::subagents::QueuedApproval>>>,
243    ) -> std::sync::Arc<dyn crate::permissions::PermissionsApprovalHandler>
244    + Send
245    + Sync;
246
247/// A stateful agent: configuration, a model transport, a tool set, and the
248/// running conversation. Drive it with [`Agent::send`].
249pub struct Agent {
250    config: Config,
251    provider: std::sync::Arc<dyn Provider>,
252    registry: ToolRegistry,
253    history: Vec<ChatMessage>,
254    ctx: ToolContext,
255    /// Cumulative output (completion) tokens across every `send` on this agent.
256    total_output_tokens: u64,
257    /// Names of non-core tools discovered via `tool_search` (B6): advertised
258    /// starting with the *next* request once populated.
259    activated_tools: HashSet<String>,
260    /// The live sidecar writer (A3), if this agent is recording. `None` is
261    /// today's behavior, at zero cost: every append point becomes a no-op.
262    recorder: Option<SidecarWriter>,
263    /// Reversible reduction policy (A5/A7/A10). `None` is today's behavior,
264    /// at zero cost: every provider request is built from `self.history`
265    /// verbatim, exactly as before this landed.
266    reduction_policy: Option<ReductionPolicy>,
267    /// The accumulating reduction log (A5): fed back into
268    /// [`reduce::project_messages`] on every request-build so already-applied
269    /// reductions reproduce verbatim across turns and `send` calls (prefix
270    /// stability). `history` itself is never touched by this — see
271    /// `Self::run_loop`.
272    reduction_log: ReductionLog,
273    /// B7: length of the stable, byte-identical-across-turns prefix at the
274    /// front of [`Self::history`] — this agent's own system message plus
275    /// every message of a previously-imported session — set by
276    /// [`Self::load_session`]. `None` (the default) means no session has been
277    /// loaded, so [`crate::provider::apply_cache_plan`] has nothing to
278    /// annotate even under [`CachePlan::ImportedPrefix`].
279    imported_prefix_len: Option<usize>,
280    /// TR-7 (T20): the injectable side-call ([`reduce::summarize::SpanSummarizer`])
281    /// used to summarize an A10 `TurnsCleared` span, if one is installed
282    /// ([`Self::set_span_summarizer`]). `None` is today's behavior, at zero
283    /// cost: `Self::build_request_messages` never calls
284    /// [`reduce::prepare_cleared_turns_summary`] without one, so
285    /// `policy.summarize_cleared_turns` being on with no summarizer
286    /// installed behaves exactly like it being off (deterministic stub only)
287    /// — never a panic, never a blocked request.
288    span_summarizer: Option<std::sync::Arc<dyn reduce::summarize::SpanSummarizer + Send + Sync>>,
289    /// TR-8 (T5): the tool-schema tier signature (global knob + per-tool
290    /// overrides) as of the last request this agent built, or `None` before
291    /// the first request. Compared against the CURRENT signature at the top
292    /// of every `Self::build_request_messages` call so a tier change made
293    /// mid-session (via [`Self::set_schema_tier`] /
294    /// [`Self::set_tool_schema_tier`]) is detected and flagged to the B7
295    /// cache planner as a cache-bust event (`provider::tier_change_is_cache_bust`).
296    last_tool_schema_tier_signature: Option<u64>,
297    /// PARITY-18 D4 — the target model's context-window size, if the caller
298    /// has armed the guard via [`Self::set_context_limit`]. `None` (the
299    /// default) means no guard: every request is sent unconditionally.
300    /// CLI entry points arm it for their resolved model; direct SDK callers
301    /// retain explicit control through [`Self::set_context_limit`].
302    /// Once set, `Self::run_loop` re-checks
303    /// [`crate::tokens::context_guard`] before EVERY request it builds —
304    /// not just the first — so "never sends an over-context request" holds
305    /// for the whole session, not only a one-shot preflight.
306    context_limit: Option<u64>,
307    /// PARITY-18 D3 — becomes `true` the first time `Self::run_loop`
308    /// actually reaches its real send site (immediately before
309    /// [`Provider::complete`]). Exposed via [`Self::request_issued`] so a
310    /// caller can report "request sent" truthfully — never asserted ahead
311    /// of time, so a pre-delivery failure (guard refusal, a build error) or
312    /// an interactive session that quits before any turn completes is
313    /// reported honestly as "not sent".
314    requests_issued: bool,
315    /// UX-26 (B7-warn): unix-ms wall-clock time this agent last knew the
316    /// active [`CachePlan::ImportedPrefix`] breakpoint to be warm. Seeded by
317    /// [`Self::load_session`] from the just-loaded session's OWN last
318    /// message timestamp (`metadata["timestamp"]`, parsed via
319    /// [`crate::sidecar::rfc3339_to_ms`]) — a cross-process signal: how long
320    /// the resumed conversation has sat idle since ANY tool last touched it,
321    /// which is exactly when Anthropic's server-side cache entry (if one
322    /// ever existed) was last capable of being warm. Refreshed to "now"
323    /// every time `Self::run_loop` actually sends a cache-annotated
324    /// request (an in-process signal: idle time between this agent's own
325    /// turns). `None` when no imported prefix exists yet, or the loaded
326    /// session's last message carries no parseable timestamp — never
327    /// guessed, so the TTL check in [`provider::cache_cold_reason`] simply
328    /// doesn't fire rather than risk a false positive.
329    last_cache_activity_ms: Option<i64>,
330    /// UX-26: whether a PRIOR request already carried a cache_control
331    /// annotation for the current [`Self::imported_prefix_len`] — i.e.
332    /// whether reuse is genuinely "expected" on the NEXT annotated request.
333    /// `false` until the first annotated request goes out (that one is
334    /// establishing the cache entry, a legitimate write, never a "miss") and
335    /// reset to `false` by [`Self::load_session`] whenever the imported
336    /// prefix itself changes.
337    cache_established: bool,
338    /// UX-26 scratch: this turn's cache-warmth context, computed once at the
339    /// top of `Self::build_request_messages` (before the request is sent,
340    /// while `effective_cache_plan`/`busted` are in scope) and consumed once
341    /// in `Self::run_loop` right after `usage` comes back — never read
342    /// across turns, so a stale value can't leak. `(will_annotate,
343    /// cache_established, idle_secs)` — see [`provider::cache_cold_reason`]
344    /// for what each of the first two independently gates.
345    pending_cache_turn: (bool, bool, Option<i64>),
346    /// P4b: the injectable auto-title side-call ([`Self::set_session_titler`]),
347    /// mirroring `Self::span_summarizer`'s "installing one alone changes
348    /// nothing" contract — `Config::auto_title` is the actual gate a caller
349    /// consults before invoking [`Self::auto_title`].
350    session_titler: Option<std::sync::Arc<dyn crate::session_title::SessionTitler + Send + Sync>>,
351    /// P4b (§1.6, catalog §4a "persisted per-turn usage records"): every
352    /// [`crate::usage_log::UsageRecord`] recorded so far this agent's
353    /// lifetime. Always accumulated (cheap, small) regardless of whether a
354    /// caller ever persists it — see [`Self::usage_records`]/
355    /// [`Self::save_usage_log`].
356    usage_log: Vec<crate::usage_log::UsageRecord>,
357    /// P4b: 0-based index of the NEXT model round-trip, for
358    /// [`crate::usage_log::UsageRecord::turn`].
359    turn_index: usize,
360    /// P4b (§1.7/§3.1 `core.steering`, pi§3 semantics): queued mid-turn
361    /// steering messages — drained at the top of `Self::run_loop`'s next
362    /// iteration (pi's "steer = after current tool calls"). Empty by
363    /// default, at zero cost: `Self::run_loop` skips the drain entirely
364    /// when empty.
365    steer_queue: std::sync::Arc<std::sync::Mutex<SteerInbox>>,
366    /// P4b: queued follow-up messages — drained only once the loop is
367    /// otherwise idle (pi's "follow-up = at idle"), i.e. exactly the point
368    /// `Self::run_loop` would otherwise return a final answer.
369    follow_up_queue: std::collections::VecDeque<String>,
370    /// P4c (§5.2 P4 "doom-loop breaker", §3.1 `core.doom_loop_threshold`):
371    /// `(tool name, canonical JSON args)` of the most recent tool call, if
372    /// [`Config::doom_loop_threshold`] is armed — `None` before the first
373    /// call this agent has run. See [`Self::check_doom_loop`].
374    doom_loop_last_call: Option<(String, String)>,
375    /// P4c: how many times [`Self::doom_loop_last_call`] has repeated
376    /// consecutively so far (starts at 1 on the call that SET it).
377    doom_loop_streak: u32,
378    /// P4c (§1.10/§3.1 `core.model_switch.allow_switch`): every
379    /// [`crate::model_change::ModelChangeRecord`] [`Self::switch_model`] has
380    /// created so far this agent's lifetime. Always empty when
381    /// `Config::model_switch_allow_switch` is off (the default) or no
382    /// switch has happened yet.
383    model_change_log: Vec<crate::model_change::ModelChangeRecord>,
384    /// P4e (§1.6/§3.1 `core.session.git_metadata`, catalog:331): captured
385    /// once at construction when [`Config::session_git_metadata`] is on;
386    /// `None` when the gate is off (the default) or the best-effort git
387    /// probe found nothing (not a repo, `git` missing). See
388    /// [`Self::git_metadata`]/[`Self::save_git_metadata`].
389    git_metadata: Option<crate::git_metadata::GitMetadataRecord>,
390    /// P5-1 (§2.10, session-scoped "approve for session" cache): populated
391    /// only when a [`crate::permissions::PermissionsApprovalHandler`]
392    /// returns [`crate::permissions::ApprovalOutcome::AllowForSession`] —
393    /// see [`Self::prepare_tool_call`]'s `Config::permissions_enabled`
394    /// branch. Always constructed (cheap, empty) regardless of whether the
395    /// engine is ever active — the same "zero cost when off" posture as
396    /// [`Self::doom_loop_last_call`].
397    permissions_approval_cache: crate::permissions::ApprovalCache,
398    /// P5-1: the non-interactive decision seam a caller installs via
399    /// [`Self::set_permissions_approval_handler`] — mirrors
400    /// `Self::span_summarizer`/[`Self::session_titler`]'s "installing one
401    /// alone changes nothing, `Config::permissions_enabled` is the actual
402    /// gate" pattern. `None` (the default) means every `Ask`-tier decision
403    /// is denied (fail-closed — see
404    /// `crate::permissions::approval::PermissionsApprovalHandler`'s doc
405    /// comment).
406    permissions_approval_handler:
407        Option<std::sync::Arc<dyn crate::permissions::PermissionsApprovalHandler>>,
408    /// P5-2 (§2 module 15 D7 row 4 "prompts-as-commands"): MCP server
409    /// prompts registered via [`Self::register_mcp_prompt`], keyed by their
410    /// ALREADY-NAMESPACED command name (`mcp__<server>__<prompt>` — see
411    /// [`crate::mcp::McpPromptSource`]'s doc comment for why that namespace
412    /// is what keeps an untrusted server's prompt from ever colliding with
413    /// a trusted `Config::prompts` entry). Empty by default, at zero cost:
414    /// [`Self::expand_prompt_async`] only consults this after
415    /// `Config::prompts` finds no match.
416    mcp_prompts: std::collections::HashMap<String, Box<dyn crate::sdk::SdkPromptSource>>,
417    /// P5-3 (§2 module 9): how deep in the spawn tree THIS agent is — `0`
418    /// for a top-level agent. Set from [`Config::subagent_depth`] at
419    /// construction; `Self::run_spawn_subagent` builds a child `Config`
420    /// with `subagent_depth = self.subagent_depth + 1` and ALSO overwrites
421    /// the freshly-built child `Agent`'s own field to match (belt-and-
422    /// suspenders — the child never has to trust its own `Config` alone).
423    subagent_depth: usize,
424    /// P5-3 (resource bound, "must not fork-bomb"): the shared, tree-wide
425    /// concurrency gauge every spawn (this agent's own, and every
426    /// descendant's) increments/decrements against
427    /// (`crate::subagents::try_acquire`/`ConcurrencyGuard`). A TOP-level
428    /// agent gets a fresh `Arc::new(AtomicUsize::new(0))` at construction;
429    /// `Self::run_spawn_subagent` clones this SAME `Arc` into every child it
430    /// spawns (never a fresh one), so a cap of N holds across the WHOLE
431    /// tree regardless of its branching shape — a parent with 3 children
432    /// each spawning 3 more shares one counter, not nine independent ones.
433    subagent_concurrency_gauge: std::sync::Arc<std::sync::atomic::AtomicUsize>,
434    /// P5-3 (D3 "background+resume"): background subagents this agent has
435    /// spawned and not yet reaped via `subagent_status`, keyed by their
436    /// `child_agent_id`. Each entry's `JoinHandle` moves its own
437    /// [`crate::subagents::ConcurrencyGuard`] into the spawned task, so the
438    /// concurrency slot is held for exactly as long as the child is
439    /// actually running, independent of whether/when the parent polls.
440    background_subagents: std::collections::HashMap<String, BackgroundSubagent>,
441    /// P5-3 (§2.2 C6 "parent-surfaced queue"): approval requests a
442    /// `background_prompts = "parent"` child raised, queued here rather
443    /// than blocking (see [`crate::subagents::QueuedApproval`]'s doc
444    /// comment — each is already resolved `Deny` by the time it lands
445    /// here). Exposed read-only via [`Self::pending_child_approvals`].
446    /// Always constructed (cheap, empty) regardless of whether background
447    /// spawning is ever used, same "zero cost when off" posture as
448    /// [`Self::permissions_approval_cache`].
449    pending_child_approvals:
450        std::sync::Arc<std::sync::Mutex<Vec<crate::subagents::QueuedApproval>>>,
451    /// P5-4 (tui, closes the P5-3 §2.2 C6 deferred chain — see
452    /// [`crate::subagents::ParentQueueApprovalHandler`]'s doc comment for
453    /// the "never blocks" contract this OVERRIDES only when a factory is
454    /// installed): when `Some`, `Self::run_spawn_subagent` uses THIS
455    /// factory — instead of constructing the default never-blocking
456    /// [`crate::subagents::ParentQueueApprovalHandler`] — to build the
457    /// `PermissionsApprovalHandler` a `background_prompts = "parent"`
458    /// child gets. Installed via
459    /// [`Self::set_child_approval_handler_factory`] by a `tui` embedder
460    /// that wants queued child approvals to be genuinely ANSWERABLE
461    /// (blocks the child's tool call until the parent resolves it, or
462    /// denies if the factory's handler's channel is ever dropped/closed —
463    /// still fail-closed, never a hang past process lifetime). `None` (the
464    /// default) preserves P5-3's shipped behavior byte-for-byte: every
465    /// `background_prompts = "parent"` child still gets the immediate-deny
466    /// `ParentQueueApprovalHandler`, and [`Self::pending_child_approvals`]
467    /// stays exactly the read-only audit view it already is.
468    child_approval_handler_factory: Option<std::sync::Arc<ChildApprovalHandlerFactory>>,
469    /// P5-3 (D5 "subagent transcripts… persisted + linked"): an optional
470    /// `(store, this agent's own session name)` pair installed via
471    /// [`Self::set_subagent_store`] — mirrors [`Self::set_recorder`]/
472    /// [`Self::set_span_summarizer`]'s "installing one alone changes
473    /// nothing" pattern. `None` (the default) means a spawned child's
474    /// transcript/lineage is still joined back into THIS agent's context
475    /// (the foreground/background mechanics work either way) but nothing
476    /// is written to a [`crate::store::SessionStore`] — no behavior change
477    /// for any caller that never installs one (e.g. every pre-P5-3 caller).
478    subagent_store: Option<(std::sync::Arc<crate::store::SessionStore>, String)>,
479    /// Imported Claude runtime state. The manifest can be paused or active,
480    /// but this Agent contains no scheduler or timer handle; an embedding
481    /// driver owns execution and persistence.
482    claude_runtime_manifest: Option<crate::claude_runtime_state::ClaudeRuntimeManifest>,
483    /// P5-6 (§2 module 4 `tools.background`, D10 "bg-manager"): background
484    /// OS processes spawned via `background_exec`, keyed by job id, tracked
485    /// until reaped (a terminal `background_status` poll, or an explicit
486    /// `background_kill`) — see [`BackgroundJob`]'s doc comment. Always
487    /// constructed (cheap, empty), same "zero cost when off" posture as
488    /// [`Self::background_subagents`].
489    background_jobs: std::collections::HashMap<String, BackgroundJob>,
490    /// P5-6 (resource bound, mirroring [`Self::subagent_concurrency_gauge`]'s
491    /// own precedent): the shared concurrency gauge every `background_exec`
492    /// call on this agent increments/decrements against
493    /// (`crate::subagents::try_acquire`/`ConcurrencyGuard` — reused
494    /// verbatim, a second independent gauge instance scoped to background
495    /// JOBS rather than subagent SPAWNS).
496    background_concurrency_gauge: std::sync::Arc<std::sync::atomic::AtomicUsize>,
497    /// P5-9 (§2 module 20 `checkpoint`): the write-path-interception
498    /// observer installed on [`Self::ctx`]'s `write_observer` (as a
499    /// `dyn WriteObserver`), held here ADDITIONALLY as its concrete type so
500    /// `Self::run_loop` can call
501    /// [`crate::checkpoint::CheckpointObserver::begin_turn`] once per turn.
502    /// `None` when `Config::checkpoint_enabled` is `false` (the default) or
503    /// the shadow store failed to open — see
504    /// [`crate::checkpoint::observer_for_config`].
505    checkpoint_observer: Option<std::sync::Arc<crate::checkpoint::CheckpointObserver>>,
506    /// P5-11 (§2 module 28 `lsp`): the LSP server registry installed (via
507    /// `crate::lsp::LspDiagnosticsObserver`) on `Self::ctx`'s
508    /// `write_observer` chain, held here ADDITIONALLY as its concrete type
509    /// so `impl Drop for Agent` can reach
510    /// [`crate::lsp::LspManager::kill_all_sync`] (no orphaned language-
511    /// server processes) and a clean-exit caller can reach
512    /// [`crate::lsp::LspManager::shutdown_all`] for a graceful handshake.
513    /// `None` when `Config::lsp_enabled` is `false` (the default).
514    lsp_manager: Option<std::sync::Arc<crate::lsp::LspManager>>,
515}
516
517/// P5-6 (§2 module 4 `tools.background`): one background-spawned OS process
518/// this agent is tracking, awaiting a `background_status`/`background_list`
519/// poll (or `background_kill`/agent drop) to reap or terminate it.
520///
521/// **Real process, not a child agent.** Unlike [`BackgroundSubagent`] (which
522/// wraps a whole recursive child [`Agent`] loop against the SAME mock/real
523/// provider), this wraps a plain OS subprocess spawned via
524/// `crate::tools::build_sandboxed_sh` — the exact function
525/// [`crate::tools::BashTool::execute`] itself calls, so a background
526/// command gets byte-identical sandboxing/cwd/env handling to a foreground
527/// `bash` call (build brief: "reuse the bash tool's execution + sandbox
528/// path").
529struct BackgroundJob {
530    /// The live process handle — kept directly on the job (not moved into a
531    /// spawned task) so [`Agent::run_background_status`]/
532    /// [`Agent::run_background_list`] can call the SYNCHRONOUS,
533    /// non-blocking `Child::try_wait` to observe exit status, and
534    /// [`Agent::run_background_kill`]/[`impl Drop for Agent`] can call the
535    /// SYNCHRONOUS `Child::start_kill` for a REAL process kill — never just
536    /// a `tokio::task::JoinHandle::abort` (which would only cancel a Rust
537    /// future, not the OS process it spawned). `kill_on_drop(true)` was set
538    /// at spawn time as defense-in-depth: even a `BackgroundJob` dropped
539    /// through some path OTHER than the explicit kill call sites below
540    /// still kills its child (a documented tokio behavior; a no-op if the
541    /// process already exited).
542    child: tokio::process::Child,
543    /// The exact command text this job is running — the SAME text that was
544    /// already checked against the permissions engine at spawn time (see
545    /// [`Agent::background_permission_denial`]).
546    command: String,
547    /// The OS process id, captured once at spawn time (before `child` is
548    /// ever mutated) — surfaced in every status/list/kill result, and the
549    /// only thing an OUTSIDE observer (e.g. a test proving real
550    /// termination) needs to check liveness independent of this process's
551    /// own bookkeeping.
552    pid: Option<u32>,
553    /// Bounded, incrementally-appended combined stdout+stderr capture —
554    /// written to by the reader tasks [`Agent::run_background_exec`] spawns
555    /// right after `child.stdout`/`child.stderr` are taken, read by every
556    /// status/list poll. Shared via `Arc` since the reader tasks outlive
557    /// this method call.
558    output: std::sync::Arc<crate::background::CapturedOutput>,
559    /// Unix-ms wall-clock time the spawn happened.
560    started_at_ms: i64,
561    /// Set by [`Agent::run_background_kill`] — [`Agent::run_background_status`]/
562    /// [`Agent::run_background_list`] report [`crate::background::JobStatus::Killed`]
563    /// unconditionally once this is `true`, rather than racing
564    /// `Child::try_wait` to see whether the kill signal has landed yet.
565    killed: bool,
566    /// The concurrency-gauge slot this job holds for as long as it remains
567    /// in [`Agent::background_jobs`] — dropped (freeing the slot) when this
568    /// `BackgroundJob` is removed from the map (a terminal reap, or an
569    /// explicit kill), exactly mirroring [`BackgroundSubagent`]'s own
570    /// "guard held for as long as it's tracked, not just while the process
571    /// is alive" posture (§2 module 9 precedent, kept consistent here).
572    _guard: crate::subagents::ConcurrencyGuard,
573}
574
575/// P5-6: the non-blocking status read [`Agent::run_background_status`]/
576/// [`Agent::run_background_list`] share — `job.killed` (set by
577/// [`Agent::run_background_kill`]) always wins over a fresh `try_wait`,
578/// since a kill signal racing the OS reaping the process is otherwise
579/// indistinguishable from "still running" for one poll cycle; reporting
580/// `Killed` unconditionally once requested avoids that race entirely. A
581/// `try_wait` error (would only happen if this job's id were somehow
582/// double-reaped, which the map ownership below already prevents) is
583/// treated as "no news yet" — `Running` — rather than inventing a made-up
584/// exit code.
585fn background_job_status(job: &mut BackgroundJob) -> crate::background::JobStatus {
586    if job.killed {
587        return crate::background::JobStatus::Killed;
588    }
589    match job.child.try_wait() {
590        Ok(Some(status)) => crate::background::JobStatus::Exited(status.code()),
591        Ok(None) | Err(_) => crate::background::JobStatus::Running,
592    }
593}
594
595/// Fable-5 review (HIGH, "grandchildren orphaned on kill AND agent-drop"):
596/// the shared real-kill body for both [`Agent::run_background_kill`] and
597/// `impl Drop for Agent` — sends `SIGKILL` to `job`'s ENTIRE process group,
598/// not just the one directly-tracked pid, so a surviving `&` job, pipeline
599/// stage, or double-forking daemon spawned by the job is killed too, then
600/// reaps the group leader so it doesn't linger as a zombie.
601///
602/// Relies on the spawn site (`Agent::run_background_exec`) having put the
603/// job in its OWN new process group via `Command::process_group(0)` — which
604/// makes the leader's pgid equal to its own pid, so `job.pid` doubles as the
605/// group id here.
606#[cfg(unix)]
607fn kill_job_process_group(job: &mut BackgroundJob) {
608    if let Some(pid) = job.pid {
609        // SAFETY: `libc::kill` with a negative pid is `killpg` — it only
610        // ever sends a signal (never dereferences memory), so this is safe
611        // regardless of whether the group is still alive. A `-1`/`ESRCH`
612        // return means the leader (and thus the whole group, since a group
613        // can't outlive its leader) already exited — not an error, just
614        // "already dead", exactly like `Child::start_kill`'s own documented
615        // no-op-on-already-exited contract.
616        unsafe {
617            libc::kill(-(pid as libc::pid_t), libc::SIGKILL);
618        }
619    }
620    // Belt-and-suspenders for the leader itself — `kill_on_drop(true)` set
621    // at spawn time is the same outcome via a different (implicit) path —
622    // then reap it so the SIGKILL we just delivered doesn't leave a zombie
623    // behind.
624    let _ = job.child.start_kill();
625    let _ = job.child.try_wait();
626}
627
628/// Non-unix fallback: no portable process-group primitive is wired up here
629/// (same posture as `crate::tools::build_sandboxed_sh`'s own platform
630/// split) — falls back to the pre-fix per-child kill. A background job that
631/// spawns a surviving grandchild process on a non-Unix target is a
632/// documented residual, not silently claimed fixed by this cfg arm.
633#[cfg(not(unix))]
634fn kill_job_process_group(job: &mut BackgroundJob) {
635    let _ = job.child.start_kill();
636}
637
638/// P5-6 (D1 "monitor/event feed", "output captured incrementally +
639/// BOUNDED"): spawn a fire-and-forget reader task that continuously drains
640/// `reader` (a piped `ChildStdout`/`ChildStderr`) into `output`, bounded at
641/// `cap` bytes. Reading NEVER stops at the cap — only what's RETAINED is
642/// bounded ([`crate::background::CapturedOutput::append`]'s own contract)
643/// — because a background job's child process would otherwise block
644/// forever writing to a full, undrained OS pipe once this stopped reading
645/// it, silently hanging real work behind an apparently-"running" job. The
646/// task exits on its own once the pipe reaches EOF (the process closed the
647/// descriptor, whether by exiting or being killed) — no explicit
648/// abort/cleanup call site is needed; a detached `tokio::spawn` this short-
649/// lived is not the kind of orphaned-task risk `impl Drop for Agent`'s own
650/// doc comment is about (that one concerns a whole recursive provider-
651/// calling child AGENT loop, not a bounded byte-copy loop that ends the
652/// instant its source pipe closes).
653fn spawn_output_reader<R>(
654    reader: R,
655    output: std::sync::Arc<crate::background::CapturedOutput>,
656    cap: usize,
657) -> tokio::task::JoinHandle<()>
658where
659    R: tokio::io::AsyncRead + Unpin + Send + 'static,
660{
661    tokio::spawn(async move {
662        use tokio::io::AsyncReadExt;
663        let mut reader = reader;
664        let mut buf = [0u8; 8192];
665        loop {
666            match reader.read(&mut buf).await {
667                Ok(0) => break,
668                Ok(n) => {
669                    let chunk = String::from_utf8_lossy(&buf[..n]);
670                    output.append(&chunk, cap);
671                }
672                Err(_) => break,
673            }
674        }
675    })
676}
677
678/// P5-3: one background-spawned child this agent is tracking, awaiting a
679/// `subagent_status` poll (or agent drop) to reap it.
680struct BackgroundSubagent {
681    /// Resolves to `(child_agent_id, child's final result, the child's own
682    /// post-system-prompt history — for D5 transcript persistence once
683    /// reaped)` — the concurrency-guard slot for this child is held INSIDE
684    /// the spawned future (moved in at spawn time), so it releases the
685    /// instant the child's own run loop finishes, not when the parent gets
686    /// around to polling.
687    handle: tokio::task::JoinHandle<(String, Result<String>, Vec<ChatMessage>)>,
688    /// The task/prompt text the child was spawned with (surfaced by a
689    /// `"pending"` status poll, since the handle alone can't answer "what
690    /// is it doing").
691    task: String,
692    /// The named `agent_type` spawned, if any.
693    agent_type: Option<String>,
694    /// Unix-ms wall-clock time the spawn happened.
695    started_at_ms: i64,
696}
697
698/// P5-3 safety hardening (Fable-5 review, MEDIUM-LOW "orphaned billed
699/// spend"): a dropped parent must not leave a detached background child
700/// running against a REAL provider. Without this, a parent dropped
701/// mid-run (the caller's own process exits the scope, panics, or simply
702/// stops polling) leaves every still-running `BackgroundSubagent::handle`
703/// as an orphaned `tokio::spawn` task: nothing had ever awaited or
704/// aborted it, so it runs to its own (`max_iterations`-bounded)
705/// completion regardless — bounded but real provider spend nobody is
706/// paying attention to.
707///
708/// `.abort()` on a [`tokio::task::JoinHandle`] is safe to call
709/// unconditionally, including on an ALREADY-finished task (a documented
710/// no-op there — see tokio's `JoinHandle::abort` docs) — so this never
711/// needs to distinguish "still running" from "already done"; a background
712/// child that already finished and is merely awaiting a `subagent_status`
713/// reap is untouched in practice (aborting a finished task changes
714/// nothing observable). For a task still mid-flight, tokio cancels it at
715/// its next `.await` point, which drops that future in place — including
716/// the `_guard: ConcurrencyGuard` moved into it at spawn time (see
717/// `Self::run_spawn_subagent`'s `tokio::spawn` body) — so the
718/// concurrency-gauge slot is released exactly the same way a normal
719/// completion releases it (`ConcurrencyGuard`'s own `Drop`, in
720/// `crate::subagents`). No separate cleanup call site to forget.
721///
722/// Deliberately does NOT touch [`Self::pending_child_approvals]` or
723/// `Self::subagent_store` — this is purely "stop burning provider
724/// calls on behalf of a caller who's gone", not a transcript-persistence
725/// path (a child aborted mid-flight has no finished result to persist;
726/// see this build's named residual on abort-time transcript loss).
727impl Drop for Agent {
728    fn drop(&mut self) {
729        for (child_id, bg) in self.background_subagents.drain() {
730            // Named, not silent: a child that was still running gets its
731            // provider calls cut off here — worth a trace even though
732            // there's no transcript left to persist (the future is
733            // dropped mid-flight, before it ever returns a result).
734            if !bg.handle.is_finished() {
735                tracing::debug!(
736                    child_id = %child_id,
737                    "parent Agent dropped: aborting still-running background subagent \
738                     to stop further provider spend"
739                );
740            }
741            bg.handle.abort();
742        }
743        // P5-6 (§2 module 4 `tools.background`, build brief "on agent drop
744        // / session end, jobs MUST be killed... real process kill via the
745        // child handle's kill(), not just tokio task abort"): a REAL OS
746        // process, not a Rust task — `Child::start_kill` (synchronous, no
747        // `.await` needed, so callable from this non-async `Drop::drop`)
748        // sends the actual kill signal; a no-op, per its own docs, on a
749        // job that already exited. `kill_on_drop(true)` (set at spawn
750        // time) is a second, independent line of defense for the same
751        // outcome, but this explicit loop is what makes the guarantee
752        // provable/traceable rather than relying solely on an implicit
753        // tokio runtime behavior.
754        for (job_id, mut job) in self.background_jobs.drain() {
755            if !job.killed {
756                tracing::debug!(
757                    job_id = %job_id,
758                    command = %job.command,
759                    "parent Agent dropped: killing still-tracked background job's real \
760                     OS process (and its whole process group — see \
761                     `kill_job_process_group`)"
762                );
763            }
764            kill_job_process_group(&mut job);
765        }
766        // P5-11 (§2 module 28 `lsp`, build brief "no orphaned language-
767        // server processes"): a REAL OS process, same rationale as the
768        // background-job loop just above — `kill_all_sync` is
769        // synchronous (`Child::start_kill`, no `.await` needed, so
770        // callable from this non-async `Drop::drop`), SIGKILLs each
771        // server's WHOLE process group (unix — same `kill_job_process_group`
772        // mechanism as the background-job loop above, so worker
773        // grandchildren like rust-analyzer's proc-macro server or
774        // typescript-language-server's `tsserver` are killed too, not just
775        // the one directly-tracked pid), and is provable/traceable rather
776        // than relying solely on `kill_on_drop(true)`'s implicit tokio
777        // runtime behavior (which remains a second, independent line of
778        // defense on every spawned `LspClient`).
779        if let Some(lsp) = &self.lsp_manager {
780            lsp.kill_all_sync();
781        }
782    }
783}
784
785/// P4 (§1.8 credential-helper indirection, D6 row): run an `api_key_cmd`
786/// through the shell and return its trimmed stdout. Runs via `sh -c` (POSIX
787/// shell, matching pi's `!command` precedent) so the configured string can
788/// use pipes/substitution, e.g. `pass show api-key`. Never panics or
789/// propagates an error: a spawn failure or non-zero exit is reported via
790/// `tracing::warn!` and returns an empty `String`, which
791/// `Agent::new`'s resolution chain treats exactly like an unset helper —
792/// falling through to `Config::api_key_env`.
793fn run_api_key_cmd(cmd: &str) -> String {
794    match std::process::Command::new("sh").arg("-c").arg(cmd).output() {
795        Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_string(),
796        Ok(out) => {
797            tracing::warn!(
798                "api_key_cmd exited with status {:?}; falling back to api_key_env",
799                out.status.code()
800            );
801            String::new()
802        }
803        Err(e) => {
804            tracing::warn!("api_key_cmd failed to run ({e}); falling back to api_key_env");
805            String::new()
806        }
807    }
808}
809
810/// P4c (§1.2/§3.1 `core.shell_env_snapshot`, SPLIT CC+CX row, catalog:338):
811/// capture the user's interactive login-shell environment ONCE, best-effort.
812/// Runs `$SHELL -lc env` (falling back to `sh -lc env` when `$SHELL` is
813/// unset) — a LOGIN shell (`-l`) sources the user's rc files, which is
814/// exactly the sourcing `bash` calls should no longer need to repeat once
815/// this snapshot is in hand. Never panics: any failure (spawn error,
816/// non-zero exit, unparseable output) returns an empty map, which
817/// `ToolContext::shell_env`'s "no-op when `None`/empty" contract already
818/// treats as harmless.
819fn capture_shell_env() -> std::collections::HashMap<String, String> {
820    let shell = std::env::var("SHELL").unwrap_or_else(|_| "sh".to_string());
821    let out = match std::process::Command::new(&shell)
822        .arg("-lc")
823        .arg("env")
824        .output()
825    {
826        Ok(o) if o.status.success() => o.stdout,
827        Ok(o) => {
828            tracing::warn!(
829                "shell_env_snapshot: `{shell} -lc env` exited with status {:?}; snapshot is empty",
830                o.status.code()
831            );
832            return std::collections::HashMap::new();
833        }
834        Err(e) => {
835            tracing::warn!(
836                "shell_env_snapshot: failed to run `{shell} -lc env` ({e}); snapshot is empty"
837            );
838            return std::collections::HashMap::new();
839        }
840    };
841    let text = String::from_utf8_lossy(&out);
842    let mut map = std::collections::HashMap::new();
843    for line in text.lines() {
844        if let Some((k, v)) = line.split_once('=') {
845            if !k.is_empty() {
846                map.insert(k.to_string(), v.to_string());
847            }
848        }
849    }
850    map
851}
852
853/// Build the [`ToolContext`] an [`Agent`] hands to every tool call, folding
854/// in every P4c per-tool config knob (§1.2) alongside the pre-existing
855/// `cwd`/`sandbox` — shared by [`Agent::with_parts`]/[`Agent::with_provider_arc`]
856/// so the two construction paths can never drift apart on which config
857/// fields reach the context. Also builds (P5-9) the
858/// [`crate::checkpoint::CheckpointObserver`], if `config.checkpoint_enabled`
859/// — installed on the returned context's `write_observer` AND returned
860/// separately (as the concrete type) so `Agent::run_loop` can call
861/// [`crate::checkpoint::CheckpointObserver::begin_turn`] once per turn.
862/// `None`/no-op end to end when the module is off — see
863/// [`crate::checkpoint::observer_for_config`]'s own doc comment for the
864/// default-off byte-identity guarantee.
865///
866/// P5-11 (§2 modules 28/29, D-5 "shared write-path interception seam"):
867/// `crate::formatters::observer_for_config`/`crate::lsp::manager_for_config`
868/// are folded into the SAME `write_observer` slot via
869/// [`crate::tools::WriteObserverChain`], in the design's required order —
870/// `checkpoint -> formatters -> lsp` (checkpoint's pre-image capture must
871/// see the file before ANY mutation; lsp's diagnostics must see the file
872/// AFTER formatting, never before). When 0 or 1 of the three modules is
873/// active, this degrades to exactly what P5-9 shipped (`None`, or the
874/// single concrete observer installed directly) — no chain wrapper is
875/// introduced unless there is actually more than one observer to order,
876/// keeping every single-module (or all-off) configuration byte-identical
877/// to before this function grew multi-observer support. The `lsp` manager
878/// is ALSO returned separately (like `checkpoint_observer`), so
879/// `Agent`'s `Drop` impl can reach `crate::lsp::LspManager::kill_all_sync`
880/// regardless of how the chain is shaped.
881fn build_tool_context(
882    config: &Config,
883) -> (
884    ToolContext,
885    Option<std::sync::Arc<crate::checkpoint::CheckpointObserver>>,
886    Option<std::sync::Arc<crate::lsp::LspManager>>,
887) {
888    let shell_env = if config.shell_env_snapshot {
889        Some(std::sync::Arc::new(capture_shell_env()))
890    } else {
891        None
892    };
893    let checkpoint_observer = crate::checkpoint::observer_for_config(config);
894    let format_observer = crate::formatters::observer_for_config(config);
895    let lsp_manager = crate::lsp::manager_for_config(config);
896    let lsp_observer = lsp_manager
897        .clone()
898        .map(|m| std::sync::Arc::new(crate::lsp::LspDiagnosticsObserver::new(m)));
899    let mut observers: Vec<std::sync::Arc<dyn crate::tools::WriteObserver>> = Vec::new();
900    if let Some(cp) = &checkpoint_observer {
901        observers.push(cp.clone() as std::sync::Arc<dyn crate::tools::WriteObserver>);
902    }
903    if let Some(f) = &format_observer {
904        observers.push(f.clone() as std::sync::Arc<dyn crate::tools::WriteObserver>);
905    }
906    if let Some(l) = &lsp_observer {
907        observers.push(l.clone() as std::sync::Arc<dyn crate::tools::WriteObserver>);
908    }
909    let write_observer: Option<std::sync::Arc<dyn crate::tools::WriteObserver>> =
910        match observers.len() {
911            0 => None,
912            1 => observers.into_iter().next(),
913            _ => Some(std::sync::Arc::new(crate::tools::WriteObserverChain::new(
914                observers,
915            ))),
916        };
917    let ctx = ToolContext {
918        cwd: config.cwd.clone(),
919        sandbox: config.sandbox,
920        multimodal_read: config.read_file_multimodal,
921        require_read_before_edit: config.edit_file_require_read_before_edit,
922        read_paths: std::sync::Arc::new(std::sync::Mutex::new(HashSet::new())),
923        notebook_aware: config.edit_file_notebook_aware,
924        shell_env,
925        nested_instructions: config.nested_instructions,
926        injected_instruction_dirs: std::sync::Arc::new(std::sync::Mutex::new(HashSet::new())),
927        // P5-1 (§2 module 12 carry-forward): now sourced from real config
928        // (`capabilities.permissions.sandbox.network.*`, wired by
929        // `configfile::materialize_config`) instead of always `None`. `None`
930        // (the default, unchanged when the config never sets it) is still
931        // byte-identical to today's behavior.
932        network_policy: config.network_policy.clone(),
933        // P4e (S3.1 `core.tools.bash.timeout_secs`, S14): folds the `bash`
934        // `ToolOverride`'s `timeout_secs`, if set, into the context every
935        // `BashTool::execute` call receives -- `None` (no override
936        // configured) is byte-identical to today's behavior.
937        bash_timeout_secs: config
938            .tool_overrides
939            .get("bash")
940            .and_then(|o| o.timeout_secs),
941        write_observer,
942        // P5-10 (§2 module 12): sourced from real config
943        // (`capabilities.permissions.sandbox.{enabled,escalation,env_policy}`,
944        // wired by `configfile::materialize_config`). `sandbox_approval_handler`
945        // starts `None` here (no handler is installed yet at `Agent`
946        // construction time) and is kept in sync by
947        // `Agent::set_permissions_approval_handler` — see that method's doc
948        // comment.
949        sandbox_os_enabled: config.sandbox_os_enabled,
950        sandbox_escalation: config.sandbox_escalation,
951        sandbox_env_policy: config.sandbox_env_policy,
952        sandbox_approval_handler: None,
953    };
954    (ctx, checkpoint_observer, lsp_manager)
955}
956
957/// P4b (§1.4/§3.1, catalog §4a "Global/user-level instruction file tier"):
958/// where the user/global instruction tier lives — `$SUPERCODE_HOME`, else
959/// `$XDG_CONFIG_HOME/supercode`, else `~/.config/supercode`. Deliberately
960/// duplicates `crates/cli/src/userconfig.rs::config_home`'s exact precedence
961/// rather than depending on the `cli` crate from `core` (wrong dependency
962/// direction — `cli` depends on `core`, never the reverse). `pub(crate)`:
963/// also the DEFAULT shadow-store root `crate::checkpoint::observer_for_config`
964/// (P5-9) derives from when `Config::checkpoint_dir` is unset — one
965/// `$SUPERCODE_HOME` resolver, not a second hand-rolled one.
966pub(crate) fn global_instructions_dir() -> std::path::PathBuf {
967    if let Ok(h) = std::env::var("SUPERCODE_HOME") {
968        if !h.is_empty() {
969            return std::path::PathBuf::from(h);
970        }
971    }
972    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
973        if !xdg.is_empty() {
974            return std::path::PathBuf::from(xdg).join("supercode");
975        }
976    }
977    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
978    std::path::PathBuf::from(home)
979        .join(".config")
980        .join("supercode")
981}
982
983/// P4b (§1.4, catalog §4a "Instruction imports"): is `rel` (an `@`-import
984/// target found inside a PROJECT-sourced instruction file) LEXICALLY safe to
985/// resolve? Mirrors `configfile::is_safe_project_dir`'s posture (LOW-1
986/// precedent): rejects absolute paths, `~`-relative paths, and any `..`
987/// component — an untrusted repo's own CLAUDE.md/AGENTS.md must not be able
988/// to `@import` its way to an arbitrary file on disk (e.g. `@/etc/passwd`,
989/// `@../../.ssh/id_rsa`). Global-tier files (the user's own machine, same
990/// trust level as the user's shell) are NOT run through this check.
991///
992/// This is a cheap PRE-FILTER only — it operates on the literal token text
993/// and cannot see through a symlink committed in the repo whose *target*
994/// escapes the root while the *link itself* has a clean, traversal-free
995/// relative name (e.g. `@link.md` where `link.md -> /etc/passwd`). See
996/// [`import_target_is_contained`] for the canonicalizing check that closes
997/// that gap; the project-scoped resolution path runs both.
998fn import_path_is_safe(rel: &str) -> bool {
999    if rel.is_empty() || rel.contains('\0') {
1000        return false;
1001    }
1002    let path = std::path::Path::new(rel);
1003    if path.is_absolute() || rel.starts_with('~') {
1004        return false;
1005    }
1006    !path
1007        .components()
1008        .any(|c| matches!(c, std::path::Component::ParentDir))
1009}
1010
1011/// P4b security fix (Fable-5 review, MEDIUM: symlink bypass of the
1012/// project-scoped `@`-import boundary): does `candidate` — after resolving
1013/// symlinks — stay inside `root` — also after resolving symlinks? This is
1014/// what actually enforces [`import_path_is_safe`]'s doc-comment guarantee
1015/// ("must not be able to `@import` its way to an arbitrary file on disk"):
1016/// the lexical check alone rejects `@/etc/passwd` and `@../../secret`, but a
1017/// repo can commit a symlink (e.g. `link.md -> /etc/passwd`) whose own
1018/// relative name is perfectly clean, defeating a purely lexical check.
1019///
1020/// Both sides are canonicalized before the comparison — not just
1021/// `candidate` — because `root` itself can legitimately be a symlink (a
1022/// tempdir under macOS's `/tmp` -> `/private/tmp`, or any other symlinked
1023/// project checkout); comparing a canonicalized candidate against a
1024/// non-canonicalized root would falsely reject genuinely-in-root files.
1025///
1026/// Fails CLOSED: a `canonicalize()` failure (broken symlink, a target that
1027/// doesn't exist, a permission error) returns `false` — never inlined,
1028/// mirroring [`expand_instruction_imports`]'s existing "unreadable file ⇒
1029/// left as literal text" posture rather than panicking or defaulting open.
1030pub(crate) fn import_target_is_contained(
1031    candidate: &std::path::Path,
1032    root: &std::path::Path,
1033) -> bool {
1034    let (Ok(real_root), Ok(real_candidate)) = (
1035        std::fs::canonicalize(root),
1036        std::fs::canonicalize(candidate),
1037    ) else {
1038        return false;
1039    };
1040    real_candidate.starts_with(&real_root)
1041}
1042
1043/// P4b (§1.4/§3.1 `core.instruction_imports`, catalog:85): inline `@path`
1044/// import tokens found in `text` with the referenced file's own (trimmed)
1045/// content, resolved relative to `dir` (the directory the CONTAINING file
1046/// lives in — so a chain of imports each resolves relative to its own
1047/// location, not the original file's). `depth` bounds recursion (CC's own
1048/// default of 4, cited in the cc-parity preset) so a cyclical or
1049/// deeply-nested import chain can't blow the stack or loop forever.
1050/// `project_scoped` gates [`import_path_is_safe`] AND
1051/// [`import_target_is_contained`] — see their doc comments; `root` is the
1052/// containment boundary those checks canonicalize against (the SAME root
1053/// for every level of a nested import chain, even though `dir` itself walks
1054/// deeper with each level — an import three levels deep must still resolve
1055/// under the original project root, not merely under its own immediate
1056/// parent). Ignored when `!project_scoped` (the global/user tier, trusted,
1057/// unrestricted — see [`append_instruction_file`]'s doc comment).
1058/// Any token that isn't `@`-prefixed, doesn't resolve to a readable file, or
1059/// (project-scoped) fails the safety/containment check is left as literal
1060/// text — an import is best-effort, never a hard error that could make
1061/// instruction loading fail outright.
1062fn expand_instruction_imports(
1063    text: &str,
1064    dir: &std::path::Path,
1065    root: &std::path::Path,
1066    project_scoped: bool,
1067    depth: u8,
1068) -> String {
1069    if depth >= 4 {
1070        return text.to_string();
1071    }
1072    let mut out = String::with_capacity(text.len());
1073    for token in split_preserving_whitespace(text) {
1074        if let Some(rel) = token.strip_prefix('@') {
1075            if !rel.is_empty()
1076                && !rel.contains(char::is_whitespace)
1077                && (!project_scoped || import_path_is_safe(rel))
1078            {
1079                let candidate = dir.join(rel);
1080                if !project_scoped || import_target_is_contained(&candidate, root) {
1081                    if let Ok(imported) = std::fs::read_to_string(&candidate) {
1082                        let imported = imported.trim();
1083                        if !imported.is_empty() {
1084                            let imported_dir = candidate.parent().unwrap_or(dir);
1085                            out.push_str(&expand_instruction_imports(
1086                                imported,
1087                                imported_dir,
1088                                root,
1089                                project_scoped,
1090                                depth + 1,
1091                            ));
1092                            continue;
1093                        }
1094                    }
1095                }
1096            }
1097        }
1098        out.push_str(token);
1099    }
1100    out
1101}
1102
1103/// Split `text` into tokens that, concatenated, reproduce it exactly —
1104/// alternating runs of non-whitespace and whitespace. Used by
1105/// [`expand_instruction_imports`] so `@import` tokens can be located and
1106/// replaced without disturbing surrounding formatting/whitespace.
1107fn split_preserving_whitespace(text: &str) -> Vec<&str> {
1108    let mut out = Vec::new();
1109    let mut start = 0;
1110    let mut in_ws = None;
1111    for (i, c) in text.char_indices() {
1112        let ws = c.is_whitespace();
1113        match in_ws {
1114            None => in_ws = Some(ws),
1115            Some(prev) if prev != ws => {
1116                out.push(&text[start..i]);
1117                start = i;
1118                in_ws = Some(ws);
1119            }
1120            _ => {}
1121        }
1122    }
1123    if start < text.len() {
1124        out.push(&text[start..]);
1125    }
1126    out
1127}
1128
1129/// P4b (§1.4): append one instruction file's (trimmed, import-expanded)
1130/// content to `blob` as a labeled section, exactly like the pre-P4b inline
1131/// loop did — a no-op when `path` doesn't exist or is empty (the common
1132/// case). `project_scoped` distinguishes the project tier (imports bounded
1133/// to `root`, canonicalized-and-contained — see
1134/// [`import_target_is_contained`]) from the global tier (imports
1135/// unrestricted, same trust level as the user's own machine — `root` is
1136/// unused in that case). `root` is normally `path`'s own parent (the tier
1137/// root `path` was discovered under, e.g. `cwd` or an `additional_dirs`
1138/// entry) — see [`assemble_project_instructions`]'s call sites.
1139fn append_instruction_file(
1140    blob: &mut String,
1141    path: &std::path::Path,
1142    root: &std::path::Path,
1143    label: &str,
1144    imports_enabled: bool,
1145    project_scoped: bool,
1146) {
1147    let Ok(text) = std::fs::read_to_string(path) else {
1148        return;
1149    };
1150    let text = text.trim();
1151    if text.is_empty() {
1152        return;
1153    }
1154    let dir = path.parent().unwrap_or(std::path::Path::new("."));
1155    let content = if imports_enabled {
1156        expand_instruction_imports(text, dir, root, project_scoped, 0)
1157    } else {
1158        text.to_string()
1159    };
1160    blob.push_str(&format!("\n\n# {label}\n{content}"));
1161}
1162
1163/// P4b (§1.4, obligation 4 assembly site): the full instruction-file blob —
1164/// global/user tier (catalog §4a "Global/user-level instruction file tier")
1165/// FIRST, then the existing cwd + `additional_dirs` project tier (root-first
1166/// ordering: nearer-to-cwd wins by appearing later, cx§2 precedent) — capped
1167/// by [`Config::project_doc_max_bytes`] if set (catalog §4a "hygiene caps
1168/// (`project_doc_max_bytes` analog)"). Byte-identical to the pre-P4b inline
1169/// loop when the global tier has no files, `instruction_imports` is off, and
1170/// `project_doc_max_bytes` is unset — i.e. for every config that doesn't
1171/// touch the new keys.
1172fn assemble_project_instructions(config: &Config) -> String {
1173    let mut blob = String::new();
1174    let global_dir = global_instructions_dir();
1175    for name in ["CLAUDE.md", "AGENTS.md"] {
1176        append_instruction_file(
1177            &mut blob,
1178            &global_dir.join(name),
1179            // Global tier is trusted/unrestricted (project_scoped=false
1180            // below) — `root` is never consulted, but pass `global_dir`
1181            // rather than a bogus value for clarity.
1182            &global_dir,
1183            name,
1184            config.instruction_imports,
1185            false,
1186        );
1187    }
1188    for root in std::iter::once(&config.cwd).chain(config.additional_dirs.iter()) {
1189        for name in ["CLAUDE.md", "AGENTS.md"] {
1190            append_instruction_file(
1191                &mut blob,
1192                &root.join(name),
1193                // Project tier: `@`-imports from THIS file must stay under
1194                // THIS root (canonicalized) — see
1195                // `import_target_is_contained`.
1196                root,
1197                name,
1198                config.instruction_imports,
1199                true,
1200            );
1201        }
1202    }
1203    if let Some(max) = config.project_doc_max_bytes {
1204        if blob.len() > max {
1205            let mut end = max;
1206            while end > 0 && !blob.is_char_boundary(end) {
1207                end -= 1;
1208            }
1209            blob.truncate(end);
1210            blob.push_str(
1211                "\n\n[supercode: instruction content truncated at core.project_doc_max_bytes]",
1212            );
1213        }
1214    }
1215    blob
1216}
1217
1218/// P4b (§1.4/§3.1 `core.env_context`, catalog §4a "Environment context block
1219/// injection"): cwd, platform, date, and a best-effort git branch/dirty
1220/// status (silently absent when `cwd` isn't a git repo or `git` isn't on
1221/// `PATH` — never blocks agent construction).
1222fn env_context_block(config: &Config) -> String {
1223    let mut lines = vec![
1224        format!("cwd: {}", config.cwd.display()),
1225        format!("platform: {}", std::env::consts::OS),
1226        format!(
1227            "date: {}",
1228            crate::sidecar::now_rfc3339().get(..10).unwrap_or("")
1229        ),
1230    ];
1231    if let Some(status) = env_context_git_status(&config.cwd) {
1232        lines.push(status);
1233    }
1234    format!("\n\n# Environment\n{}", lines.join("\n"))
1235}
1236
1237/// Best-effort `git branch (dirty|clean)` for [`env_context_block`]. `None`
1238/// on anything short of a clean success (not a repo, `git` missing, a
1239/// detached/errored state) — this is informational context, never worth
1240/// failing agent construction over.
1241fn env_context_git_status(cwd: &std::path::Path) -> Option<String> {
1242    let branch_out = std::process::Command::new("git")
1243        .args(["rev-parse", "--abbrev-ref", "HEAD"])
1244        .current_dir(cwd)
1245        .output()
1246        .ok()?;
1247    if !branch_out.status.success() {
1248        return None;
1249    }
1250    let branch = String::from_utf8_lossy(&branch_out.stdout)
1251        .trim()
1252        .to_string();
1253    if branch.is_empty() {
1254        return None;
1255    }
1256    let dirty = std::process::Command::new("git")
1257        .args(["status", "--porcelain"])
1258        .current_dir(cwd)
1259        .output()
1260        .ok()
1261        .map(|o| !o.stdout.is_empty())
1262        .unwrap_or(false);
1263    Some(format!(
1264        "git branch: {branch} ({})",
1265        if dirty { "dirty" } else { "clean" }
1266    ))
1267}
1268
1269impl Agent {
1270    /// Build an agent backed by an OpenAI-compatible endpoint (OpenRouter by
1271    /// default). The API key is taken from [`Config::api_key`], then
1272    /// [`Config::api_key_cmd`] (P4: a credential-helper command, run via the
1273    /// shell — see `run_api_key_cmd`), then the configured environment
1274    /// variable ([`Config::api_key_env`]).
1275    pub fn new(config: Config) -> Result<Self> {
1276        let api_key = match &config.api_key {
1277            Some(k) if !k.is_empty() => k.clone(),
1278            _ => match config
1279                .api_key_cmd
1280                .as_deref()
1281                .filter(|c| !c.is_empty())
1282                .map(run_api_key_cmd)
1283            {
1284                // P4 (§1.8 credential-helper indirection, D6 row): the
1285                // helper ran and produced a non-empty key — use it. A
1286                // failed/empty helper falls through to `api_key_env` rather
1287                // than erroring outright, same "try the next source"
1288                // posture as every other layer in this resolution chain.
1289                Some(k) if !k.is_empty() => k,
1290                _ => std::env::var(&config.api_key_env)
1291                    .ok()
1292                    .filter(|k| !k.is_empty())
1293                    .ok_or_else(|| Error::MissingApiKey(config.api_key_env.clone()))?,
1294            },
1295        };
1296        // P4b (§1.1/§3.1 `core.retry`, pi§3 shape): `Config.retry_*` now
1297        // reaches the pre-existing transport-layer retry mechanism (see
1298        // `provider::HttpOptions::from_retry_config`'s doc comment for the
1299        // exact "byte-identical when unset" contract).
1300        let http_options = provider::HttpOptions::from_retry_config(
1301            config.retry_enabled,
1302            config.retry_max_retries,
1303            config.retry_base_delay_ms,
1304        );
1305        let provider = OpenAiProvider::new_with_options(
1306            config.base_url.clone(),
1307            api_key,
1308            config.extra_headers.clone(),
1309            http_options,
1310        );
1311        // P3 (design §5.2): `ToolRegistry::from_config` replaces the
1312        // unconditional `with_builtins()` call — a no-op when
1313        // `config.module_registry` is off (the default, §5.3 risk 2).
1314        let registry = ToolRegistry::from_config(&config);
1315        Ok(Self::with_parts(config, Box::new(provider), registry))
1316    }
1317
1318    /// Build an agent with an explicit provider and the built-in tools. Handy
1319    /// for tests (inject a mock provider) or custom transports.
1320    pub fn with_provider(config: Config, provider: Box<dyn Provider>) -> Self {
1321        let registry = ToolRegistry::from_config(&config);
1322        Self::with_parts(config, provider, registry)
1323    }
1324
1325    /// Build an agent from all three parts.
1326    pub fn with_parts(
1327        config: Config,
1328        provider: Box<dyn Provider>,
1329        mut registry: ToolRegistry,
1330    ) -> Self {
1331        // P5-12 (§2 module 18 `plugins`, D-10): register every trusted,
1332        // loaded plugin's declared tools — the same "unconditional, config-
1333        // gated" wiring `build_tool_context` just below gives
1334        // checkpoint/formatters/lsp. `crate::plugins::register_into` is a
1335        // true no-op (no filesystem read, no subprocess) whenever
1336        // `config.plugins_enabled` is `false` (the default) — byte-identical
1337        // to before this module existed. Runs here (the one tail every
1338        // `Agent` construction path funnels through — `new`/`with_provider`
1339        // both call this) rather than in `ToolRegistry::from_config`, so it
1340        // is NOT entangled with that function's unrelated `module_registry`
1341        // experimental gate.
1342        crate::plugins::register_into(&config, &mut registry);
1343        let (ctx, checkpoint_observer, lsp_manager) = build_tool_context(&config);
1344        // Auto-load project context files (CLAUDE.md / AGENTS.md) from the
1345        // working directory (and any extra roots), appending them to the system
1346        // prompt — the analog of how Claude Code / Codex discover them.
1347        // P4b (§1.4): also the global/user tier + instruction imports + the
1348        // `project_doc_max_bytes` hygiene cap — see `assemble_project_instructions`.
1349        let mut system = config.system_prompt.clone();
1350        if config.load_project_context {
1351            system.push_str(&assemble_project_instructions(&config));
1352        }
1353        // P4b (§1.4/§3.1 `core.env_context`, catalog §4a "Environment
1354        // context block injection"): `false` (the default) is a no-op —
1355        // byte-identical to today's behavior.
1356        if config.env_context {
1357            system.push_str(&env_context_block(&config));
1358        }
1359        // P4e (§1.4/§3.1 `core.context_injections`, catalog:91 "Synthetic
1360        // context-injection blocks"): same assembly site, right after
1361        // `env_context`. `false` (the default) is a no-op — byte-identical
1362        // to today's behavior; an empty `context_injection_blocks` list is
1363        // ALSO a no-op even with the gate on (nothing to append).
1364        if config.context_injections {
1365            for block in &config.context_injection_blocks {
1366                system.push_str(&format!("\n\n# {}\n{}", block.name, block.content));
1367            }
1368        }
1369        // P3 (design §5.2, §1.4 obligation 4, D-7): the skills prompt
1370        // section is a MODULE-GATED prompt section, the design's own
1371        // illustration of "a disabled module contributes no prompt
1372        // sections" — only assembled at all under
1373        // `[experimental] module_registry = true` (§5.3 risk 2: flag-off is
1374        // byte-for-byte today's behavior, and today's behavior never emits
1375        // this section, since it doesn't exist pre-P3). Gated further by
1376        // D-7 itself: `core.skills` requires a read pathway (`read_file` or
1377        // `bash`) — absent either, no section is appended, matching the
1378        // hard-dependency shape `configfile::validate_modules` enforces at
1379        // resolve time.
1380        if config.module_registry && config.skills_enabled {
1381            let has_read_pathway = config
1382                .core_tools_enabled
1383                .iter()
1384                .any(|t| t == "read_file" || t == "bash");
1385            if has_read_pathway {
1386                let mut names: Vec<&str> = config.prompts.keys().map(String::as_str).collect();
1387                names.sort_unstable();
1388                if !names.is_empty() {
1389                    system.push_str("\n\n# Skills\nAvailable skill/prompt templates (today: named `[core.prompts]` templates, D-7 — invoke via `/name args`):\n");
1390                    for name in names {
1391                        system.push_str(&format!("- {name}\n"));
1392                    }
1393                }
1394            }
1395        }
1396        let history = vec![ChatMessage::system(system)];
1397        // P4e (§1.6/§3.1 `core.session.git_metadata`, catalog:331): captured
1398        // once here, alongside `env_context`'s own git probe — `false` (the
1399        // default) is a no-op, byte-identical to today's behavior.
1400        let git_metadata = if config.session_git_metadata {
1401            crate::git_metadata::capture(&config.cwd, now_ms())
1402        } else {
1403            None
1404        };
1405        // P5-3: captured before `config` moves into the literal below (a
1406        // `usize` field READ, not a move, but it must happen before the
1407        // `config` shorthand field consumes the binding).
1408        let subagent_depth = config.subagent_depth;
1409        Agent {
1410            config,
1411            provider: std::sync::Arc::from(provider),
1412            registry,
1413            history,
1414            ctx,
1415            total_output_tokens: 0,
1416            activated_tools: HashSet::new(),
1417            recorder: None,
1418            reduction_policy: None,
1419            reduction_log: ReductionLog::default(),
1420            imported_prefix_len: None,
1421            span_summarizer: None,
1422            last_tool_schema_tier_signature: None,
1423            context_limit: None,
1424            requests_issued: false,
1425            last_cache_activity_ms: None,
1426            cache_established: false,
1427            pending_cache_turn: (false, false, None),
1428            session_titler: None,
1429            usage_log: Vec::new(),
1430            turn_index: 0,
1431            steer_queue: std::sync::Arc::new(std::sync::Mutex::new(SteerInbox::default())),
1432            follow_up_queue: std::collections::VecDeque::new(),
1433            doom_loop_last_call: None,
1434            doom_loop_streak: 0,
1435            model_change_log: Vec::new(),
1436            git_metadata,
1437            permissions_approval_cache: crate::permissions::ApprovalCache::new(),
1438            permissions_approval_handler: None,
1439            mcp_prompts: std::collections::HashMap::new(),
1440            subagent_depth,
1441            subagent_concurrency_gauge: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
1442            background_subagents: std::collections::HashMap::new(),
1443            pending_child_approvals: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
1444            child_approval_handler_factory: None,
1445            subagent_store: None,
1446            claude_runtime_manifest: None,
1447            background_jobs: std::collections::HashMap::new(),
1448            background_concurrency_gauge: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(
1449                0,
1450            )),
1451            checkpoint_observer,
1452            lsp_manager,
1453        }
1454    }
1455
1456    /// A handle to this agent's model transport, for sharing with subagents.
1457    pub fn provider_arc(&self) -> std::sync::Arc<dyn Provider> {
1458        self.provider.clone()
1459    }
1460
1461    /// Read-only access to this agent's resolved [`Config`] — e.g. so a
1462    /// caller (`crates/cli`'s `attach_mcp`) can consult
1463    /// [`Config::module_registry`]/[`Config::module_activation`] AFTER
1464    /// construction without having to separately thread the config through
1465    /// every call site that builds an `Agent` and later needs it again.
1466    /// Same trust boundary as every other already-public `Agent` accessor
1467    /// (`history`, `provider_arc`) — the caller is the same process that
1468    /// built this `Config` in the first place, not a new exposure surface.
1469    pub fn config(&self) -> &Config {
1470        &self.config
1471    }
1472
1473    /// P5-9 (§2 module 20 `checkpoint`): this agent's checkpoint engine, if
1474    /// `Config::checkpoint_enabled` is `true` and the shadow store opened
1475    /// successfully — `None` otherwise (the default-off case, or a
1476    /// graceful-degrade after an I/O failure). A caller (CLI/TUI/embedder)
1477    /// uses this to `list`/`turn_diff`/`restore` WITHOUT re-deriving the
1478    /// shadow-store root itself. Deliberately named `checkpoint_observer`,
1479    /// not `checkpoint` — [`Self::checkpoint`] already names the unrelated
1480    /// in-memory conversation-position marker (see that method's doc
1481    /// comment).
1482    pub fn checkpoint_observer(&self) -> Option<&crate::checkpoint::CheckpointObserver> {
1483        self.checkpoint_observer.as_deref()
1484    }
1485
1486    /// P5-11 (§2 module 28 `lsp`): this agent's LSP server registry, if
1487    /// `Config::lsp_enabled` is `true` — `None` otherwise (the default-off
1488    /// case). `impl Drop for Agent` already covers production teardown via
1489    /// [`crate::lsp::LspManager::kill_all_sync`] (a real, group-killing OS
1490    /// process kill — see `crate::lsp`'s module doc). This accessor exists
1491    /// for an OPTIONAL caller (CLI/TUI/embedder) that manages its own
1492    /// `Agent` lifecycle and additionally wants to reach
1493    /// [`crate::lsp::LspManager::shutdown_all`] for a graceful LSP
1494    /// `shutdown`/`exit` handshake BEFORE dropping the agent — nothing
1495    /// calls `shutdown_all` automatically today.
1496    pub fn lsp_manager(&self) -> Option<&crate::lsp::LspManager> {
1497        self.lsp_manager.as_deref()
1498    }
1499
1500    /// Spawn a subagent that shares this agent's model transport, runs `task`
1501    /// to completion with its own fresh conversation (seeded with `system`), and
1502    /// returns its final answer. The analog of `Agent` / `spawn_agent`.
1503    pub async fn run_subagent(
1504        &self,
1505        system: impl Into<String>,
1506        task: impl Into<String>,
1507    ) -> Result<String> {
1508        let mut sub_config = Config::builder()
1509            .model(self.config.model.clone())
1510            .system_prompt(system)
1511            .cwd(self.config.cwd.clone())
1512            .sandbox(self.config.sandbox)
1513            .max_iterations(self.config.max_iterations)
1514            .build();
1515        sub_config.base_url = self.config.base_url.clone();
1516        let mut sub = Agent::with_provider_arc(sub_config, self.provider.clone());
1517        sub.send(task).await
1518    }
1519
1520    /// Like [`Self::with_provider`] but sharing an existing transport handle.
1521    pub fn with_provider_arc(config: Config, provider: std::sync::Arc<dyn Provider>) -> Self {
1522        let (ctx, checkpoint_observer, lsp_manager) = build_tool_context(&config);
1523        let history = vec![ChatMessage::system(config.system_prompt.clone())];
1524        // P3 (design §5.2): see the `Self::new` doc note — a no-op when
1525        // `config.module_registry` is off (the default).
1526        let mut registry = ToolRegistry::from_config(&config);
1527        // P5-12: see `Self::with_parts`'s identical call — a no-op when
1528        // `config.plugins_enabled` is `false` (the default).
1529        crate::plugins::register_into(&config, &mut registry);
1530        // P4e: see `Self::with_parts`'s identical capture.
1531        let git_metadata = if config.session_git_metadata {
1532            crate::git_metadata::capture(&config.cwd, now_ms())
1533        } else {
1534            None
1535        };
1536        // P5-3: captured before `config` moves into the literal below (a
1537        // `usize` field READ, not a move, but it must happen before the
1538        // `config` shorthand field consumes the binding).
1539        let subagent_depth = config.subagent_depth;
1540        Agent {
1541            config,
1542            provider,
1543            registry,
1544            history,
1545            ctx,
1546            total_output_tokens: 0,
1547            activated_tools: HashSet::new(),
1548            recorder: None,
1549            reduction_policy: None,
1550            reduction_log: ReductionLog::default(),
1551            imported_prefix_len: None,
1552            span_summarizer: None,
1553            last_tool_schema_tier_signature: None,
1554            context_limit: None,
1555            requests_issued: false,
1556            last_cache_activity_ms: None,
1557            cache_established: false,
1558            pending_cache_turn: (false, false, None),
1559            session_titler: None,
1560            usage_log: Vec::new(),
1561            turn_index: 0,
1562            steer_queue: std::sync::Arc::new(std::sync::Mutex::new(SteerInbox::default())),
1563            follow_up_queue: std::collections::VecDeque::new(),
1564            doom_loop_last_call: None,
1565            doom_loop_streak: 0,
1566            model_change_log: Vec::new(),
1567            git_metadata,
1568            permissions_approval_cache: crate::permissions::ApprovalCache::new(),
1569            permissions_approval_handler: None,
1570            mcp_prompts: std::collections::HashMap::new(),
1571            subagent_depth,
1572            subagent_concurrency_gauge: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
1573            background_subagents: std::collections::HashMap::new(),
1574            pending_child_approvals: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
1575            child_approval_handler_factory: None,
1576            subagent_store: None,
1577            claude_runtime_manifest: None,
1578            background_jobs: std::collections::HashMap::new(),
1579            background_concurrency_gauge: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(
1580                0,
1581            )),
1582            checkpoint_observer,
1583            lsp_manager,
1584        }
1585    }
1586
1587    /// Run a prompt on a background task, returning a handle that resolves to
1588    /// the final answer (and the agent, so the caller can continue it). The
1589    /// analog of background/async agent runs.
1590    pub fn run_in_background(
1591        mut self,
1592        prompt: impl Into<String>,
1593    ) -> tokio::task::JoinHandle<(Self, Result<String>)>
1594    where
1595        Self: Send + 'static,
1596    {
1597        let prompt = prompt.into();
1598        tokio::spawn(async move {
1599            let result = self.send(prompt).await;
1600            (self, result)
1601        })
1602    }
1603
1604    /// Build an agent and seed it with a previously-recorded [`Session`] so it
1605    /// can continue where Claude Code or Codex left off.
1606    pub fn resume(config: Config, session: Session) -> Result<Self> {
1607        let mut agent = Agent::new(config)?;
1608        agent.load_session(session);
1609        Ok(agent)
1610    }
1611
1612    /// Like [`Self::resume`], but also begins recording (A2/A3): a fresh
1613    /// native-v2 sidecar is created at `sidecar_path` from `session` (header +
1614    /// `session.raw` verbatim — the imported prefix's own fidelity), and every
1615    /// subsequent turn this agent produces is appended to it at full fidelity,
1616    /// independent of whatever `cap_tool_output`/`maybe_compact` (D6) do to
1617    /// `history`.
1618    ///
1619    /// Invariant this establishes ONLY once [`Self::set_reduction_policy`] is
1620    /// also called (the D6/A7 supersession gate, `Self::run_loop`): at any
1621    /// instant, `Session::from_native_str(sidecar).messages` equals
1622    /// `session.messages` (the imported prefix) followed by every message
1623    /// appended since — i.e. `self.history()[1..]` (`history[0]` is this
1624    /// agent's own system prompt, per [`Self::load_session`]; it is never
1625    /// part of `session` and is never written to the sidecar). Recording
1626    /// alone (no policy) leaves the gate off: `cap_tool_output` still runs on
1627    /// oversized tool results, and `history` can diverge from the sidecar for
1628    /// them — honestly, via the notice's "full output in session sidecar"
1629    /// label, never silently.
1630    pub fn resume_recorded(
1631        config: Config,
1632        session: Session,
1633        sidecar_path: &std::path::Path,
1634    ) -> Result<Self> {
1635        let mut agent = Agent::new(config)?;
1636        let recorder = SidecarWriter::create(sidecar_path, &session)?;
1637        agent.load_session(session);
1638        agent.recorder = Some(recorder);
1639        Ok(agent)
1640    }
1641
1642    /// Install (or replace) this agent's sidecar recorder (A3).
1643    pub fn set_recorder(&mut self, w: SidecarWriter) {
1644        self.recorder = Some(w);
1645    }
1646
1647    /// Install (or replace) this agent's reduction policy (A5/A7/A10). Once
1648    /// set, every provider request is built from a *projected* view of
1649    /// `history[1..]` (`reduce::project_messages`) rather than `history`
1650    /// verbatim — `history` itself is never shrunk or mutated by this; only
1651    /// the request view does.
1652    pub fn set_reduction_policy(&mut self, policy: ReductionPolicy) {
1653        self.reduction_policy = Some(policy);
1654    }
1655
1656    /// This agent's reduction policy, if one is installed.
1657    pub fn reduction_policy(&self) -> Option<&ReductionPolicy> {
1658        self.reduction_policy.as_ref()
1659    }
1660
1661    /// Change the global tool-schema tier (TR-8/T5) mid-session. Takes effect
1662    /// starting with the NEXT request this agent builds. Under
1663    /// [`CachePlan::ImportedPrefix`], the first request built after a change
1664    /// is flagged as a cache-bust event and its cache-control annotation is
1665    /// skipped for that one request (see [`provider::tier_change_is_cache_bust`],
1666    /// consulted in `Self::build_request_messages`) — normal annotation
1667    /// resumes on the next request if the tier doesn't change again.
1668    pub fn set_schema_tier(&mut self, tier: crate::tools::SchemaTier) {
1669        self.config.tool_schema_tier = tier;
1670    }
1671
1672    /// Override the schema tier for a single tool (TR-8/T5) mid-session, same
1673    /// cache-bust interaction as [`Self::set_schema_tier`].
1674    pub fn set_tool_schema_tier(
1675        &mut self,
1676        name: impl Into<String>,
1677        tier: crate::tools::SchemaTier,
1678    ) {
1679        self.config
1680            .tool_overrides
1681            .entry(name.into())
1682            .or_default()
1683            .schema_tier = Some(tier);
1684    }
1685
1686    /// A deterministic fingerprint of the current tool-schema tier
1687    /// configuration (global knob + every per-tool override), used to detect
1688    /// a mid-session tier change (TR-8/T5, dev/05). Order-independent over
1689    /// `tool_overrides` (sorted by name before hashing) so insertion order
1690    /// never spuriously changes the signature.
1691    fn schema_tier_signature(&self) -> u64 {
1692        use std::hash::{Hash, Hasher};
1693        let mut hasher = std::collections::hash_map::DefaultHasher::new();
1694        self.config.tool_schema_tier.hash(&mut hasher);
1695        let mut overrides: Vec<(&str, crate::tools::SchemaTier)> = self
1696            .config
1697            .tool_overrides
1698            .iter()
1699            .filter_map(|(name, o)| o.schema_tier.map(|t| (name.as_str(), t)))
1700            .collect();
1701        overrides.sort_by_key(|(name, _)| *name);
1702        for (name, tier) in overrides {
1703            name.hash(&mut hasher);
1704            tier.hash(&mut hasher);
1705        }
1706        hasher.finish()
1707    }
1708
1709    /// Install (or replace) this agent's TR-7 span summarizer — the
1710    /// injectable side-call `Self::build_request_messages` uses to turn an
1711    /// A10 `TurnsCleared` span into an LLM-written summary paragraph when
1712    /// `policy.summarize_cleared_turns` is on. Installing one alone changes
1713    /// nothing: [`ReductionPolicy::summarize_cleared_turns`] (off by
1714    /// default) is the actual gate, so tests/callers that want the
1715    /// deterministic stub can simply never call this.
1716    pub fn set_span_summarizer(
1717        &mut self,
1718        summarizer: impl reduce::summarize::SpanSummarizer + Send + Sync + 'static,
1719    ) {
1720        self.span_summarizer = Some(std::sync::Arc::new(summarizer));
1721    }
1722
1723    /// Prepare TR-7 metadata with this agent's installed summarizer for a
1724    /// projection performed by an outer driver before session history/log
1725    /// are loaded (the CLI foreign-resume preflight). `None` preserves the
1726    /// deterministic fallback when the gate is off, no summarizer exists,
1727    /// the span is below the cost floor, or the side-call fails.
1728    pub fn prepare_cleared_turns_summary(
1729        &self,
1730        msgs: &[ChatMessage],
1731        policy: &ReductionPolicy,
1732        prior: &ReductionLog,
1733    ) -> Option<reduce::PreparedClearSummary> {
1734        let summarizer = self.span_summarizer.as_deref()?;
1735        reduce::prepare_cleared_turns_summary(msgs, policy, prior, summarizer)
1736    }
1737
1738    /// P5-4: install (or replace) this agent's [`crate::EventSink`] AFTER
1739    /// construction — `Config::event_sink` is otherwise only set at
1740    /// `Config`-build time (before `Agent::new`), which is too early for a
1741    /// `tui` embedder that only knows it's activating (and needs to
1742    /// replace whatever print-mode/REPL sink was already installed with
1743    /// one that feeds its own render loop instead of writing straight to
1744    /// stdout) once it already holds a live `Agent`. Mirrors [`Self::
1745    /// set_permissions_approval_handler`]'s "installing one alone changes
1746    /// nothing beyond what already consults `Config::event_sink`" pattern
1747    /// — this is a plain replacement, not a new activation gate.
1748    pub fn set_event_sink(&mut self, sink: crate::EventSink) {
1749        self.config.event_sink = Some(sink);
1750    }
1751
1752    /// P5-1: install (or replace) this agent's permissions-engine approval
1753    /// handler — see [`crate::permissions::PermissionsApprovalHandler`].
1754    /// This is the non-interactive decision seam a CLI/TUI/SDK embedder
1755    /// implements for the `Ask`-tier prompt; the TUI's actual interactive
1756    /// UI is a separate module (P5 row 4), not built here. Installing one
1757    /// alone changes nothing: [`Config::permissions_enabled`] (off by
1758    /// default) is the actual gate — with no handler installed, every
1759    /// `Ask`-tier decision denies (fail-closed, see that trait's doc
1760    /// comment).
1761    ///
1762    /// P5-10 (§2 module 12, `escalation = "ask"`): the SAME handler also
1763    /// backs a sandbox-unenforceable `ask` decision
1764    /// (`crate::sandbox::decide_fs`'s `approval` parameter) — one installed
1765    /// seam serves both `permissions.rules`' `Ask` tier and
1766    /// `permissions.sandbox`'s `escalation = "ask"`, rather than requiring
1767    /// an embedder to install two near-identical handlers. Kept in sync on
1768    /// `self.ctx` (not just `self.permissions_approval_handler`) because
1769    /// `BashTool::execute`/`PersistentShellTool::execute` only ever see
1770    /// `&ToolContext`, never `&Agent` — see `ToolContext::
1771    /// sandbox_approval_handler`'s doc comment.
1772    pub fn set_permissions_approval_handler(
1773        &mut self,
1774        handler: impl crate::permissions::PermissionsApprovalHandler + 'static,
1775    ) {
1776        let handler: std::sync::Arc<dyn crate::permissions::PermissionsApprovalHandler> =
1777            std::sync::Arc::new(handler);
1778        self.permissions_approval_handler = Some(handler.clone());
1779        self.ctx.sandbox_approval_handler = Some(crate::sandbox::SandboxApprovalHandler(handler));
1780    }
1781
1782    /// Install the compatibility approval seam used when the composable
1783    /// permissions engine is disabled. SDK-owned interactive frontends call
1784    /// this alongside [`Self::set_permissions_approval_handler`] so the same
1785    /// authenticated request channel works under either policy engine; the
1786    /// selected engine remains entirely a configuration decision.
1787    pub fn set_legacy_approval_handler(&mut self, handler: crate::config::ApprovalHandler) {
1788        self.config.approval_handler = Some(handler);
1789    }
1790
1791    /// P5-3 (§2 module 9 D5 "subagent transcripts… persisted + linked"):
1792    /// install a [`crate::store::SessionStore`] (+ this agent's own session
1793    /// name in it) so `spawn_subagent` persists each child's transcript
1794    /// (via [`crate::store::SessionStore::save_subagent_transcript`]) and
1795    /// lineage record (via
1796    /// [`crate::store::SessionStore::save_subagent_lineage`]) once the
1797    /// child finishes. Installing one alone changes nothing about whether
1798    /// spawning WORKS — [`Config::subagents_enabled`] is the actual gate;
1799    /// this only controls whether a completed spawn's transcript additionally
1800    /// lands on disk.
1801    pub fn set_subagent_store(
1802        &mut self,
1803        store: std::sync::Arc<crate::store::SessionStore>,
1804        session_name: impl Into<String>,
1805    ) {
1806        self.subagent_store = Some((store, session_name.into()));
1807    }
1808
1809    /// Seed the Claude runtime manifest reconstructed during resume.
1810    ///
1811    /// Installing state enables the matching Claude runtime tool schemas so
1812    /// a disk-reloaded continuation does not lose that vocabulary, but never
1813    /// starts a timer by itself. The supplied execution posture is preserved:
1814    /// an embedding scheduler may deliberately activate before installing it.
1815    pub fn set_claude_runtime_manifest(
1816        &mut self,
1817        manifest: crate::claude_runtime_state::ClaudeRuntimeManifest,
1818    ) {
1819        // A persisted manifest is itself the compatibility capability marker.
1820        // Reopening a Supercode session must not retain its timers while
1821        // silently dropping Claude's Cron*/ScheduleWakeup vocabulary.
1822        self.config.claude_runtime_tools_enabled = true;
1823        self.claude_runtime_manifest = Some(manifest);
1824    }
1825
1826    /// Reinstall project-scoped Claude named-agent definitions when a
1827    /// Supercode continuation carrying a Claude runtime manifest is reopened
1828    /// from disk. The manifest is the durable capability marker; definitions
1829    /// themselves remain authoritative in `<cwd>/.claude/agents/*.md`.
1830    pub fn restore_claude_project_agents(&mut self) -> Result<usize> {
1831        let definitions = crate::claude_compat::load_project_agents(&self.config.cwd)?;
1832        crate::claude_compat::enable_claude_subagent_compatibility(&mut self.config);
1833        for imported in &definitions {
1834            self.config.subagents_definitions.insert(
1835                imported.definition.name.clone(),
1836                imported.definition.clone(),
1837            );
1838        }
1839        Ok(definitions.len())
1840    }
1841
1842    /// Current imported Claude runtime state, including paused mutations made
1843    /// by `Cron*`/`ScheduleWakeup`, for persistence by the embedding loop.
1844    pub fn claude_runtime_manifest(
1845        &self,
1846    ) -> Option<&crate::claude_runtime_state::ClaudeRuntimeManifest> {
1847        self.claude_runtime_manifest.as_ref()
1848    }
1849
1850    /// Mutable access for an embedding scheduler driver to atomically claim
1851    /// due events and persist the resulting manifest. Merely borrowing this
1852    /// state does not start a timer; execution remains the driver's explicit
1853    /// responsibility.
1854    pub fn claude_runtime_manifest_mut(
1855        &mut self,
1856    ) -> Option<&mut crate::claude_runtime_state::ClaudeRuntimeManifest> {
1857        self.claude_runtime_manifest.as_mut()
1858    }
1859
1860    /// P5-4 (tui, closes the P5-3 §2.2 C6 deferred chain): install a
1861    /// factory this agent's `Self::run_spawn_subagent` calls (with the
1862    /// fresh child's own id and this agent's shared
1863    /// [`Self::pending_child_approvals`] queue) to build the
1864    /// `PermissionsApprovalHandler` a `background_prompts = "parent"`
1865    /// child gets, INSTEAD of the default
1866    /// [`crate::subagents::ParentQueueApprovalHandler`]. Installing one
1867    /// alone changes nothing about whether background spawning works —
1868    /// [`Config::subagents_background_prompts`] being
1869    /// [`crate::subagents::BackgroundPromptsPolicy::Parent`] is the actual
1870    /// gate that reaches this factory at all; a `Parent`-policy child
1871    /// spawned before this is installed (or on an agent that never installs
1872    /// it) still gets the immediate-deny default, unchanged.
1873    ///
1874    /// **Security note.** The factory only controls WHICH handler answers
1875    /// an `Ask`-tier request — it can never widen what gets asked in the
1876    /// first place: [`crate::permissions::approval::resolve_ask`] only
1877    /// calls a handler's `ask` when the rule engine has already resolved
1878    /// the call to `Ask` (`Deny` short-circuits before any handler is
1879    /// consulted; `Allow` never needs one), so a parent's "allow" answer
1880    /// here can only grant what the policy already routed to a prompt —
1881    /// never override a `Deny` the engine already decided.
1882    pub fn set_child_approval_handler_factory(
1883        &mut self,
1884        factory: impl Fn(
1885                String,
1886                std::sync::Arc<std::sync::Mutex<Vec<crate::subagents::QueuedApproval>>>,
1887            ) -> std::sync::Arc<dyn crate::permissions::PermissionsApprovalHandler>
1888            + Send
1889            + Sync
1890            + 'static,
1891    ) {
1892        self.child_approval_handler_factory = Some(std::sync::Arc::new(factory));
1893    }
1894
1895    /// P5-3 (§2.2 C6 "parent-surfaced queue"): every approval request a
1896    /// `background_prompts = "parent"` child has raised so far, oldest
1897    /// first — a read-only audit view, not a mutable queue the caller
1898    /// answers. Under P5-3's own default handler (no P5-4 TUI factory
1899    /// installed) every entry here WAS already resolved `Deny` (a
1900    /// background call can't wait for an answer with no handler
1901    /// installed) — but once a `crate::tui::TuiChildApprovalHandler`
1902    /// factory is installed (P5-4,
1903    /// [`Self::set_child_approval_handler_factory`]), the underlying call
1904    /// genuinely blocks and may resolve `Allow`/`AllowForSession`; this
1905    /// method still records the SAME entry for the audit trail either
1906    /// way, so "queued here" no longer implies "was denied" in general —
1907    /// see [`crate::subagents::QueuedApproval`]'s doc comment.
1908    pub fn pending_child_approvals(&self) -> Vec<crate::subagents::QueuedApproval> {
1909        self.pending_child_approvals
1910            .lock()
1911            .map(|q| q.clone())
1912            .unwrap_or_default()
1913    }
1914
1915    /// P4b: install (or replace) this agent's auto-title side-call — see
1916    /// [`crate::session_title::SessionTitler`]. Installing one alone changes
1917    /// nothing: [`Config::auto_title`] (off by default) is the actual gate a
1918    /// caller should consult before calling [`Self::auto_title`].
1919    pub fn set_session_titler(
1920        &mut self,
1921        titler: impl crate::session_title::SessionTitler + Send + Sync + 'static,
1922    ) {
1923        self.session_titler = Some(std::sync::Arc::new(titler));
1924    }
1925
1926    /// P4b: produce a title for this agent's current conversation via the
1927    /// installed [`Self::set_session_titler`] side-call. Returns `None` (never
1928    /// panics, never blocks longer than the titler itself does) if no
1929    /// titler is installed, or the side-call itself declined (see
1930    /// [`crate::session_title::auto_title`]). Does NOT consult
1931    /// [`Config::auto_title`] itself — that gate is the caller's
1932    /// responsibility, matching `Self::span_summarizer`'s precedent of
1933    /// keeping the mechanism and the policy gate separate.
1934    pub fn auto_title(&self) -> Option<String> {
1935        let titler = self.session_titler.as_deref()?;
1936        crate::session_title::auto_title(&self.history, titler)
1937    }
1938
1939    /// P4b (§1.6, catalog §4a "persisted per-turn usage records"): every
1940    /// [`crate::usage_log::UsageRecord`] this agent has accumulated so far.
1941    pub fn usage_records(&self) -> &[crate::usage_log::UsageRecord] {
1942        &self.usage_log
1943    }
1944
1945    /// P4b: persist this agent's accumulated usage log to `store` under
1946    /// `name` — a thin wrapper over [`crate::store::SessionStore::save_usage_log`]
1947    /// so callers don't need to import both types.
1948    pub fn save_usage_log(&self, store: &crate::store::SessionStore, name: &str) -> Result<()> {
1949        store.save_usage_log(name, &self.usage_log)
1950    }
1951
1952    /// P4b (§1.7, pi§3 semantics): queue a mid-turn steering message —
1953    /// delivered "after current tool calls" (pi's phrasing): at the top of
1954    /// `Self::run_loop`'s NEXT iteration, before the next model request is
1955    /// built, regardless of whether this turn is still mid-flight with
1956    /// pending tool calls. Drained per [`Config::steering_mode`].
1957    pub fn queue_steer(&self, message: impl Into<String>) {
1958        self.steer_queue
1959            .lock()
1960            .unwrap_or_else(std::sync::PoisonError::into_inner)
1961            .queue_unchecked(message.into());
1962    }
1963
1964    /// Crate-internal shared steering handle used by the canonical SDK
1965    /// runtime. It remains writable while an active turn holds `&mut Agent`,
1966    /// allowing local and remote frontends to steer without owning the loop.
1967    pub(crate) fn steer_queue_handle(&self) -> std::sync::Arc<std::sync::Mutex<SteerInbox>> {
1968        self.steer_queue.clone()
1969    }
1970
1971    /// P4b: queue a follow-up message — delivered "at idle" (pi's phrasing):
1972    /// only once `Self::run_loop` would otherwise return a final answer
1973    /// (no more tool calls pending). Drained per [`Config::follow_up_mode`].
1974    pub fn queue_follow_up(&mut self, message: impl Into<String>) {
1975        self.follow_up_queue.push_back(message.into());
1976    }
1977
1978    /// P4b: how many steering messages are currently queued (mid-turn +
1979    /// follow-up combined) — mostly for tests/diagnostics.
1980    pub fn queued_steer_count(&self) -> usize {
1981        self.steer_queue
1982            .lock()
1983            .unwrap_or_else(std::sync::PoisonError::into_inner)
1984            .len()
1985            + self.follow_up_queue.len()
1986    }
1987
1988    /// The accumulating reduction log (A5) — every reduction applied to any
1989    /// projected request view so far. Combined with a full-fidelity sidecar
1990    /// Session, this is enough to `reduce::invert` any projected view back to
1991    /// the exact original.
1992    pub fn reduction_log(&self) -> &ReductionLog {
1993        &self.reduction_log
1994    }
1995
1996    /// PARITY-18 D4 — arm the per-send context guard: `Self::run_loop`
1997    /// will refuse (via [`Error::ContextLimitExceeded`]) to build and issue
1998    /// ANY request — the first or any later turn — whose
1999    /// [`crate::tokens::context_guard`] verdict is "does not fit" against
2000    /// `limit`. Call this once the target model's context-window size is
2001    /// known (`resume --reduced`'s preflight already computes it). Leaving
2002    /// this unset (the default) is a no-op: no guard runs, exactly today's
2003    /// pre-PARITY-18 behavior.
2004    pub fn set_context_limit(&mut self, limit: u64) {
2005        self.context_limit = Some(limit);
2006    }
2007
2008    /// This agent's armed context limit, if [`Self::set_context_limit`] has
2009    /// been called.
2010    pub fn context_limit(&self) -> Option<u64> {
2011        self.context_limit
2012    }
2013
2014    /// The model identifier this agent sends on its next request
2015    /// ([`Config::model`], as of construction/resume or the last
2016    /// [`Self::set_model`] call).
2017    pub fn model(&self) -> &str {
2018        &self.config.model
2019    }
2020
2021    /// UX-30 dev/02 — switch the model this agent sends, starting with the
2022    /// NEXT request it builds (and every one after, until changed again).
2023    /// `Self::run_loop` reads `self.config.model` fresh on every request
2024    /// (see its `ChatRequest` construction), so this alone is enough —
2025    /// there is no cached/baked-in copy anywhere else to also update.
2026    /// Takes effect immediately; safe to call only between turns (the
2027    /// REPL's `/model` picker runs at the prompt, never mid-turn). Touches
2028    /// nothing else: history, the sidecar, and reduction state are exactly
2029    /// as untouched as [`Self::set_schema_tier`] leaves them for a
2030    /// mid-session tier change.
2031    ///
2032    /// P4c-review note: this is the LOW-LEVEL primitive — it swaps
2033    /// [`Config::model`] and nothing else. It does NOT run dep 8's
2034    /// reasoning-artifact filter
2035    /// ([`reduce::rehydrate::filter_reasoning_artifacts`]) and does NOT
2036    /// create a [`crate::model_change::ModelChangeRecord`], so calling it
2037    /// directly for a mid-session handoff between two DIFFERENT models
2038    /// leaves model-A's reasoning artifacts in `history` for model-B to
2039    /// inherit. [`Self::switch_model`] is the safe superset — gated by
2040    /// [`Config::model_switch_allow_switch`], it filters and records the
2041    /// switch before delegating to this method — and is what callers
2042    /// performing a governed mid-session model switch should use instead.
2043    pub fn set_model(&mut self, model: impl Into<String>) {
2044        self.config.model = model.into();
2045    }
2046
2047    /// P4c (§1.10/§3.1 `core.model_switch.allow_switch`, D9 row, dep 8,
2048    /// design's "core NEW-significant" item): the mid-session model
2049    /// switch — a superset of [`Self::set_model`] gated by
2050    /// [`Config::model_switch_allow_switch`].
2051    ///
2052    /// **`allow_switch = false` (the default): EXACTLY [`Self::set_model`]**
2053    /// — same single field write, nothing else touched, no
2054    /// [`crate::model_change::ModelChangeRecord`] created. Byte-identical to
2055    /// calling `set_model` directly.
2056    ///
2057    /// **`allow_switch = true`:** additionally, before the swap takes
2058    /// effect, runs [`reduce::rehydrate::filter_reasoning_artifacts`] over
2059    /// [`Self::history`] — model-A's reasoning/thinking artifacts (any
2060    /// [`crate::message::ChatMessage::metadata`] key in
2061    /// [`reduce::rehydrate::REASONING_METADATA_KEYS`], any `content_parts`
2062    /// block whose `"type"` is in
2063    /// [`reduce::rehydrate::REASONING_CONTENT_PART_TYPES`]) are stripped
2064    /// BEFORE model-B ever builds a request from this history — then
2065    /// appends a typed, translatable [`crate::model_change::ModelChangeRecord`]
2066    /// to [`Self::model_change_records`] (persist it via
2067    /// [`Self::save_model_change_log`]). A switch TO the current model
2068    /// (`model == Self::model()`) is treated as a no-op — still exactly
2069    /// `set_model`'s mechanics, no record for a switch that didn't actually
2070    /// change anything (and nothing to filter FOR, since there was no
2071    /// handoff).
2072    pub fn switch_model(&mut self, model: impl Into<String>) {
2073        let to = model.into();
2074        if !self.config.model_switch_allow_switch || self.config.model == to {
2075            self.set_model(to);
2076            return;
2077        }
2078        let from = self.config.model.clone();
2079        let touched = reduce::rehydrate::filter_reasoning_artifacts(&mut self.history);
2080        self.set_model(to.clone());
2081        self.model_change_log
2082            .push(crate::model_change::ModelChangeRecord::new(
2083                self.turn_index,
2084                from,
2085                to,
2086                true,
2087                touched,
2088                now_ms(),
2089            ));
2090    }
2091
2092    /// P4c: every [`crate::model_change::ModelChangeRecord`] this agent has
2093    /// accumulated so far (via [`Self::switch_model`] with `allow_switch`
2094    /// on). Empty when the knob is off or no switch has happened yet.
2095    pub fn model_change_records(&self) -> &[crate::model_change::ModelChangeRecord] {
2096        &self.model_change_log
2097    }
2098
2099    /// P4c: persist this agent's accumulated model-change log to `store`
2100    /// under `name` — the [`crate::model_change::ModelChangeRecord`] analog
2101    /// of [`Self::save_usage_log`].
2102    pub fn save_model_change_log(
2103        &self,
2104        store: &crate::store::SessionStore,
2105        name: &str,
2106    ) -> Result<()> {
2107        store.save_model_change_log(name, &self.model_change_log)
2108    }
2109
2110    /// P4e (§1.6/§3.1 `core.session.git_metadata`, catalog:331): this
2111    /// agent's captured git provenance, if [`Config::session_git_metadata`]
2112    /// was on at construction and the best-effort probe found a repo.
2113    pub fn git_metadata(&self) -> Option<&crate::git_metadata::GitMetadataRecord> {
2114        self.git_metadata.as_ref()
2115    }
2116
2117    /// P4e: persist this agent's captured git metadata to `store` under
2118    /// `name` — a thin wrapper over
2119    /// [`crate::store::SessionStore::save_git_metadata`], the
2120    /// [`crate::git_metadata::GitMetadataRecord`] analog of
2121    /// [`Self::save_usage_log`]. A no-op (`Ok(())`, nothing written) when
2122    /// [`Self::git_metadata`] is `None`.
2123    pub fn save_git_metadata(&self, store: &crate::store::SessionStore, name: &str) -> Result<()> {
2124        match &self.git_metadata {
2125            Some(record) => store.save_git_metadata(name, record),
2126            None => Ok(()),
2127        }
2128    }
2129
2130    /// P4e DEFECT-FIX (independent Fable-5 review of P4e: `core.session.persist`
2131    /// had a `Config` field and CLI plumbing at `ConfigProfile` → `Config` but
2132    /// no consumer at all): whether a CLI caller's session-store save sites
2133    /// (`persist_session`, `persist_full_view`) should actually write to
2134    /// disk. `true` (the default) is byte-identical to pre-fix behavior —
2135    /// every session persists. `false` makes a session ephemeral: it runs
2136    /// exactly as before, but no `<name>.jsonl`/sidecar family is ever
2137    /// written for it. A plain getter, same posture as [`Self::model`] —
2138    /// this crate itself never reads or enforces it; the CLI's save sites do.
2139    pub fn session_persist(&self) -> bool {
2140        self.config.session_persist
2141    }
2142
2143    /// P4e DEFECT-FIX (independent Fable-5 review of P4e: `core.session.name`
2144    /// had a `Config` field and CLI plumbing but no consumer): the
2145    /// caller-configured session name, if `[core.session] name` was set.
2146    /// `None` (the default) leaves session naming exactly as before —
2147    /// `mint_session_name`'s auto-generated `<tag>-<adjective>-<noun>` shape.
2148    /// A plain getter, same posture as [`Self::session_persist`].
2149    pub fn session_name(&self) -> Option<&str> {
2150        self.config.session_name.as_deref()
2151    }
2152
2153    /// PARITY-18 D3 — whether this agent has actually issued at least one
2154    /// live request to its [`Provider`] so far (set the instant
2155    /// `Self::run_loop` reaches its real send site, regardless of whether
2156    /// that call then succeeds or fails). Callers should report
2157    /// "request sent" from THIS, never from having merely passed the
2158    /// context guard or having called [`Self::send`] — either of those can
2159    /// happen with zero requests actually issued (a guard refusal, an
2160    /// interactive session quit before any turn completes).
2161    pub fn request_issued(&self) -> bool {
2162        self.requests_issued
2163    }
2164
2165    /// P5-2 (§2.2 C2): whether this agent currently considers its
2166    /// [`CachePlan::ImportedPrefix`] cache entry warm — mirrors
2167    /// [`Self::request_issued`]'s read-only-observability precedent, so a
2168    /// caller (or a test) can confirm [`Self::register_tool`]'s C2
2169    /// invalidation actually took effect without reaching into private
2170    /// state.
2171    pub fn cache_established(&self) -> bool {
2172        self.cache_established
2173    }
2174
2175    /// B7: length of the imported-prefix protected by [`CachePlan::ImportedPrefix`]
2176    /// (this agent's own system message plus every message of a
2177    /// previously-imported session), set by [`Self::load_session`]. `None`
2178    /// until a session has been loaded.
2179    pub fn imported_prefix_len(&self) -> Option<usize> {
2180        self.imported_prefix_len
2181    }
2182
2183    /// Replace this agent's accumulating reduction log (C4: `/expand`/`/reduce`
2184    /// mutate the log directly via `reduce::invert_one`/`reduce::project_messages`
2185    /// and must feed the result back here so the *next* request build or
2186    /// persist sees the updated state instead of silently recomputing from an
2187    /// empty log). Also lets a caller (`resume_cmd`, C1) seed the log with the
2188    /// initial projection it already computed for the entry banner, so
2189    /// `reduction_log()` reflects reality even before this agent's first
2190    /// `send()` (which is otherwise the only place `build_request_messages`
2191    /// populates it).
2192    pub fn set_reduction_log(&mut self, log: ReductionLog) {
2193        self.reduction_log = log;
2194    }
2195
2196    /// Replace the conversation with a loaded session, keeping this agent's own
2197    /// system prompt at the front. The session's own system/developer turns are
2198    /// preserved after it for context.
2199    pub fn load_session(&mut self, session: Session) {
2200        let system = self.history.first().cloned();
2201        self.history.clear();
2202        if let Some(sys) = system {
2203            self.history.push(sys);
2204        }
2205        self.history.extend(session.messages);
2206        // B7: the whole of `history` at this point — this agent's own system
2207        // message plus every imported message — is the stable prefix a
2208        // resumed session resends byte-identically every turn.
2209        self.imported_prefix_len = Some(self.history.len());
2210        // UX-26 (B7-warn): a freshly loaded prefix has no established cache
2211        // entry of THIS agent's own making yet (even if this agent was
2212        // resumed once before — that earlier prefix is gone). Seed the
2213        // activity clock from the loaded session's own last message
2214        // timestamp (walking backward past any trailing message that
2215        // carries none), so a session that's been sitting idle since
2216        // Claude Code/Codex/a prior supercode run last touched it is
2217        // correctly treated as already-cold on its very first turn here —
2218        // `None` (no timestamp anywhere in the loaded messages) leaves the
2219        // TTL check disarmed rather than guessing.
2220        self.cache_established = false;
2221        self.last_cache_activity_ms = self
2222            .history
2223            .iter()
2224            .rev()
2225            .find_map(|m| m.metadata.get("timestamp"))
2226            .and_then(|ts| crate::sidecar::rfc3339_to_ms(ts));
2227    }
2228
2229    /// Append `msg` to the sidecar recorder (A3), if one is installed — a
2230    /// no-op, at zero cost, when `recorder` is `None` (today's behavior).
2231    fn record(&mut self, msg: &ChatMessage) -> Result<()> {
2232        if let Some(recorder) = self.recorder.as_mut() {
2233            recorder.append(msg)?;
2234        }
2235        Ok(())
2236    }
2237
2238    /// Persist the live conversation to `path` as JSONL (one [`ChatMessage`]
2239    /// per line) so the session can be resumed later — supercode's own sessions
2240    /// become first-class, resumable artifacts.
2241    pub fn save_transcript(&self, path: impl AsRef<std::path::Path>) -> Result<()> {
2242        let mut out = String::new();
2243        for m in &self.history {
2244            out.push_str(&serde_json::to_string(m).map_err(Error::Decode)?);
2245            out.push('\n');
2246        }
2247        std::fs::write(path, out)?;
2248        Ok(())
2249    }
2250
2251    /// Restore a conversation previously written with [`Self::save_transcript`],
2252    /// replacing the current history.
2253    pub fn load_transcript(&mut self, path: impl AsRef<std::path::Path>) -> Result<()> {
2254        let text = std::fs::read_to_string(path)?;
2255        let mut history = Vec::new();
2256        for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) {
2257            history.push(serde_json::from_str::<ChatMessage>(line).map_err(Error::Decode)?);
2258        }
2259        self.history = history;
2260        Ok(())
2261    }
2262
2263    /// Take a checkpoint of the current conversation position. Pass it to
2264    /// [`Self::rewind_to`] to discard everything sent since (the rewind/undo
2265    /// analog of `fork`/checkpoint).
2266    pub fn checkpoint(&self) -> usize {
2267        self.history.len()
2268    }
2269
2270    /// Rewind the conversation to a [`Self::checkpoint`], discarding later turns.
2271    pub fn rewind_to(&mut self, checkpoint: usize) {
2272        self.history.truncate(checkpoint.min(self.history.len()));
2273    }
2274
2275    /// Send a message with file inputs attached — the `--file` / `-i` analog.
2276    /// Each file's contents are injected into the prompt: UTF-8 text inline,
2277    /// binary (e.g. images) noted with a size marker. (Native image *vision*
2278    /// would additionally require multimodal content parts.)
2279    pub async fn send_with_files(
2280        &mut self,
2281        text: impl Into<String>,
2282        files: &[std::path::PathBuf],
2283    ) -> Result<String> {
2284        let mut prompt = text.into();
2285        for path in files {
2286            let block = match std::fs::read(path) {
2287                Ok(bytes) => match String::from_utf8(bytes.clone()) {
2288                    Ok(s) => format!("\n\n[file: {}]\n{}", path.display(), s),
2289                    Err(_) => format!(
2290                        "\n\n[file: {} — {} bytes, binary content omitted]",
2291                        path.display(),
2292                        bytes.len()
2293                    ),
2294                },
2295                Err(e) => format!("\n\n[file: {} — could not read: {e}]", path.display()),
2296            };
2297            prompt.push_str(&block);
2298        }
2299        let expanded = self.expand_prompt_async(&prompt).await;
2300        let msg = ChatMessage::user(expanded);
2301        self.guard_candidate_message(&msg)?;
2302        self.record(&msg)?;
2303        self.history.push(msg);
2304        self.run_loop().await
2305    }
2306
2307    /// Send a message with image inputs to a vision model — the `-i/--image`
2308    /// analog. `image_urls` may be `https://…` links or `data:image/…;base64,…`
2309    /// URLs; they're attached as multimodal `image_url` content parts.
2310    pub async fn send_with_images(
2311        &mut self,
2312        text: impl Into<String>,
2313        image_urls: &[String],
2314    ) -> Result<String> {
2315        let expanded = self.expand_prompt_async(&text.into()).await;
2316        let msg = ChatMessage::user_with_images(expanded, image_urls);
2317        self.guard_candidate_message(&msg)?;
2318        self.record(&msg)?;
2319        self.history.push(msg);
2320        self.run_loop().await
2321    }
2322
2323    /// Expand a `/<name> <args>` slash command against the registered prompt
2324    /// templates (`{args}` is replaced with the trailing text). Non-matching
2325    /// input is returned unchanged.
2326    pub fn expand_prompt(&self, input: &str) -> String {
2327        let trimmed = input.trim_start();
2328        let Some(rest) = trimmed.strip_prefix('/') else {
2329            return input.to_string();
2330        };
2331        let (name, args) = match rest.split_once(char::is_whitespace) {
2332            Some((n, a)) => (n, a.trim()),
2333            None => (rest, ""),
2334        };
2335        match self.config.prompts.get(name) {
2336            Some(template) => template.replace("{args}", args),
2337            None => input.to_string(),
2338        }
2339    }
2340
2341    /// P5-2 (§2 module 15 D7 row 4 "prompts-as-commands"): like
2342    /// [`Self::expand_prompt`], but also consults MCP-server-sourced
2343    /// prompts registered via [`Self::register_mcp_prompt`] when the local
2344    /// `Config::prompts` table has no match — a live `prompts/get`
2345    /// round-trip, which is why this is async and [`Self::expand_prompt`]
2346    /// itself stays synchronous (its public sync signature is unchanged,
2347    /// for every existing caller that doesn't need MCP prompts).
2348    ///
2349    /// **Argument mapping (a scope decision, not a protocol requirement —
2350    /// the MCP spec leaves "how does free CLI text become named prompt
2351    /// arguments" to the client):** a prompt with zero or one declared
2352    /// arguments gets the whole trailing text (empty string if the prompt
2353    /// takes no arguments and none was given); a prompt with two or more
2354    /// declared arguments expects `key=value` pairs, whitespace-separated
2355    /// (`/mcp__server__prompt lang=rust topic=async`) — an unparseable pair
2356    /// (no `=`) is simply skipped, never a hard error (matches this
2357    /// method's "non-matching input passes through" fail-open posture for
2358    /// the LOCAL-prompt case above).
2359    pub async fn expand_prompt_async(&self, input: &str) -> String {
2360        let local = self.expand_prompt(input);
2361        if local != input {
2362            return local; // a local `Config::prompts` template matched
2363        }
2364        let trimmed = input.trim_start();
2365        let Some(rest) = trimmed.strip_prefix('/') else {
2366            return input.to_string();
2367        };
2368        let (name, args) = match rest.split_once(char::is_whitespace) {
2369            Some((n, a)) => (n, a.trim()),
2370            None => (rest, ""),
2371        };
2372        let Some(source) = self.mcp_prompts.get(name) else {
2373            return input.to_string();
2374        };
2375        let arg_map = match source.arg_names() {
2376            [] => std::collections::BTreeMap::new(),
2377            [single] => {
2378                let mut m = std::collections::BTreeMap::new();
2379                if !args.is_empty() {
2380                    m.insert(single.clone(), args.to_string());
2381                }
2382                m
2383            }
2384            _ => args
2385                .split_whitespace()
2386                .filter_map(|pair| pair.split_once('='))
2387                .map(|(k, v)| (k.to_string(), v.to_string()))
2388                .collect(),
2389        };
2390        match source.render(arg_map).await {
2391            Ok(rendered) => rendered,
2392            Err(e) => format!("Error: mcp prompt `{name}` failed: {e}"),
2393        }
2394    }
2395
2396    /// P5-2 (§2 module 15 D7 row 4): register an MCP server's prompt as a
2397    /// slash-command source — `command_name` MUST already be the
2398    /// namespaced `mcp__<server>__<prompt>` form
2399    /// ([`crate::mcp::McpServerHandle::prompts`] produces exactly that
2400    /// shape); this method does not re-namespace or validate it, so a
2401    /// caller that hands it a bare name defeats the collision protection
2402    /// [`crate::mcp::McpPromptSource`]'s doc comment describes. Overwrites
2403    /// any prior registration under the same command name (re-attaching
2404    /// the same server replaces its own earlier prompt list; this can
2405    /// never touch a NON-`mcp__`-prefixed key, i.e. never a local
2406    /// `Config::prompts` entry).
2407    pub fn register_mcp_prompt(
2408        &mut self,
2409        command_name: impl Into<String>,
2410        source: impl crate::sdk::SdkPromptSource + 'static,
2411    ) {
2412        self.mcp_prompts
2413            .insert(command_name.into(), Box::new(source));
2414    }
2415
2416    /// P5-2 (§2 module 15 D7 row 5 "instructions"): fold an MCP server's
2417    /// `initialize`-time instructions (or any other free-text note) into
2418    /// this agent's system message — the context-assembly site every other
2419    /// `core.*`/`capabilities.*` prompt-section append already uses
2420    /// (`Self::with_parts`), except this one fires AFTER construction
2421    /// (attaching MCP servers happens once the agent already exists — see
2422    /// `crates/cli/src/main.rs`'s `attach_mcp`). A no-op if `history` is
2423    /// somehow empty or its first message isn't a system message (never
2424    /// true for an `Agent` built via `Self::new`/`Self::with_parts`, but
2425    /// checked rather than assumed).
2426    pub fn append_system_note(&mut self, text: &str) {
2427        if let Some(system) = self.history.first_mut() {
2428            if system.role == Role::System {
2429                system
2430                    .content
2431                    .get_or_insert_with(String::new)
2432                    .push_str(text);
2433            }
2434        }
2435    }
2436
2437    /// Compact the conversation if it has grown past the configured
2438    /// threshold.
2439    ///
2440    /// **Re-founded (A10):** with a [`ReductionPolicy`] installed
2441    /// ([`Self::set_reduction_policy`]), this no longer touches `self.history`
2442    /// at all. It derives `policy.clear_turns_older_than` from
2443    /// `compact_after_messages` so the *next* projected request view
2444    /// (`reduce::project_messages`, built in `Self::run_loop`) collapses the
2445    /// old turns into one reversible `TurnsCleared` stub instead —
2446    /// `history()` and the sidecar keep every message forever; only the view
2447    /// shrinks. Returns whether the live (unreduced) history currently
2448    /// exceeds the threshold, i.e. whether a clearing will actually be
2449    /// visible in the next projected view.
2450    ///
2451    /// **Legacy path (no policy) — LOSSY, kept only for byte-identical
2452    /// backward compatibility (D6):** destructively rewrites `self.history`,
2453    /// permanently discarding the dropped middle turns (replaced by a single
2454    /// non-reversible summary marker that becomes their SOLE remaining copy —
2455    /// exactly the lossy compaction this reduction layer differentiates
2456    /// against). Once a sidecar/recorder or a [`ReductionPolicy`] is in play,
2457    /// prefer installing a policy so this method takes the re-founded path
2458    /// above instead.
2459    pub fn maybe_compact(&mut self) -> bool {
2460        // P4e (§1.5/§3.1 `core.compaction.enabled`, "no master gate exists
2461        // yet"): checked FIRST, before either trigger — `false` disables
2462        // every auto-compaction trigger unconditionally (message-count AND
2463        // pressure), composing with them rather than replacing their own
2464        // logic. `true` (the default, matching today's pre-P4e behavior,
2465        // where nothing ever gated compaction) falls straight through to
2466        // the existing trigger checks below, unchanged.
2467        if !self.config.compaction_enabled {
2468            return false;
2469        }
2470        let threshold = self.config.compact_after_messages;
2471        // P4b (§1.5/§3.1 `core.compaction.reserve_tokens`, pi§2 shape): a
2472        // SECOND, independent trigger — context-window pressure — alongside
2473        // (not instead of) the message-count one above. `None` (the
2474        // default) is byte-identical to today's message-count-only
2475        // behavior; this whole block is a no-op then.
2476        let message_trigger = threshold.is_some_and(|t| self.history.len() > t);
2477        let pressure_trigger = self.compaction_pressure_triggered();
2478        if threshold.is_none() && self.config.compaction_reserve_tokens.is_none() {
2479            return false;
2480        }
2481        if !message_trigger && !pressure_trigger {
2482            return false;
2483        }
2484        if let Some(policy) = self.reduction_policy.as_mut() {
2485            if let Some(t) = threshold {
2486                policy.clear_turns_older_than = Some(t);
2487            }
2488            // P4b scope note: the token-PRESSURE trigger's "how much to
2489            // clear" derivation (below, for the legacy in-place path) has no
2490            // `ReductionPolicy`/A10 analog yet — that mechanism decides its
2491            // own clearing window once `clear_turns_older_than` is set, so
2492            // pressure firing alone (no message threshold configured) has
2493            // nothing new to hand it in this pass. Report the message-count
2494            // verdict only, matching today's pre-P4b behavior exactly when
2495            // only `threshold` is set.
2496            return message_trigger;
2497        }
2498        // Legacy in-place compaction (no `ReductionPolicy` installed) below.
2499        // `keep_recent`: the message-count trigger's own `threshold / 2`
2500        // shape when it's what fired (or both fired); otherwise (pressure
2501        // fired alone) a token-budget-derived count.
2502        let keep_recent = if message_trigger {
2503            (threshold.unwrap() / 2).max(2)
2504        } else {
2505            self.keep_recent_count_by_tokens()
2506        };
2507        if self.history.len() <= keep_recent {
2508            return false;
2509        }
2510        // Indices: 0 is the system prompt; collapse [first .. len-keep_recent).
2511        // `first` is 1 (only the system prompt is ever auto-preserved) unless
2512        // B7's coordination clamp widens it.
2513        let mut first = 1usize;
2514        // B7 coordination clamp: this legacy (no-`ReductionPolicy`) path
2515        // mutates `self.history` directly, so — unlike the re-founded A10
2516        // path (clamped inside `reduce::project_messages`, threaded from
2517        // `build_request_messages`) — it must clamp itself. Widening `first`
2518        // (not `cut`) is what actually protects the imported prefix: the
2519        // drop range is `[first, cut)`, so raising `cut` alone would only
2520        // drop MORE messages, not fewer. `imported_prefix_len` is already an
2521        // absolute `history` index count (it protects `history[0..len]`), so
2522        // no offset conversion is needed here.
2523        if matches!(self.config.cache_plan, CachePlan::ImportedPrefix) {
2524            if let Some(protected) = self.imported_prefix_len {
2525                first = first.max(protected);
2526            }
2527        }
2528        let mut cut = self.history.len() - keep_recent;
2529        if cut <= first {
2530            return false;
2531        }
2532        // Never begin the kept window on a tool result: its originating
2533        // assistant turn (with the matching `tool_calls`) is about to be
2534        // dropped, which would orphan the tool message and make the replayed
2535        // conversation invalid. Advance past any leading tool results.
2536        while cut < self.history.len() && self.history[cut].role == Role::Tool {
2537            cut += 1;
2538        }
2539        if cut >= self.history.len() {
2540            return false;
2541        }
2542        let dropped = cut - first;
2543        // P4b (§1.5/§3.1 `core.compaction.focus_instructions`, catalog D2
2544        // "no instruction steering" gap): appended to the marker whenever
2545        // set, regardless of which trigger fired. `None` (the default)
2546        // leaves this byte-identical to the pre-P4b marker text.
2547        let summary_text = match &self.config.compaction_focus_instructions {
2548            Some(focus) if !focus.is_empty() => format!(
2549                "[earlier conversation compacted: {dropped} message(s) summarized to save context]\n\nFocus: {focus}"
2550            ),
2551            _ => format!(
2552                "[earlier conversation compacted: {dropped} message(s) summarized to save context]"
2553            ),
2554        };
2555        let summary = ChatMessage::system(summary_text);
2556        let mut new_history = Vec::with_capacity(first + keep_recent + 2);
2557        new_history.extend(self.history[..first].iter().cloned());
2558        new_history.push(summary);
2559        new_history.extend(self.history.split_off(cut));
2560        self.history = new_history;
2561        true
2562    }
2563
2564    /// P4b (§1.5/§3.1 `core.compaction.reserve_tokens`, pi§2 shape:
2565    /// `contextTokens > contextWindow - reserveTokens`): whether the
2566    /// estimated token size of the live history is within `reserve_tokens`
2567    /// of the model's context window. `false` when
2568    /// [`Config::compaction_reserve_tokens`] is unset (the default).
2569    fn compaction_pressure_triggered(&self) -> bool {
2570        let Some(reserve) = self.config.compaction_reserve_tokens else {
2571            return false;
2572        };
2573        let limit = provider::model_context_limit(&self.config.model)
2574            .unwrap_or(provider::UNKNOWN_MODEL_CONTEXT_FLOOR);
2575        let used = crate::tokens::estimate_view_tokens(&self.history);
2576        used.saturating_add(reserve) > limit
2577    }
2578
2579    /// P4b (§1.5/§3.1 `core.compaction.keep_recent_tokens`): how many of the
2580    /// most recent messages (walking backward from the end of `self.history`,
2581    /// skipping the system prompt) fit within the configured token budget
2582    /// (default 20,000, pi§6 precedent). Always keeps at least 2 messages,
2583    /// matching the message-count trigger's own floor.
2584    fn keep_recent_count_by_tokens(&self) -> usize {
2585        let budget = self.config.compaction_keep_recent_tokens.unwrap_or(20_000);
2586        let mut used = 0u64;
2587        let mut count = 0usize;
2588        for msg in self.history.iter().skip(1).rev() {
2589            let t = crate::tokens::estimate_view_tokens(std::slice::from_ref(msg));
2590            if used.saturating_add(t) > budget && count > 0 {
2591                break;
2592            }
2593            used = used.saturating_add(t);
2594            count += 1;
2595        }
2596        count.max(2)
2597    }
2598
2599    /// Register an additional tool (e.g. your own capability).
2600    ///
2601    /// P5-2 (§2.2 C2 "connect invalidates cache prefix"): registering a
2602    /// tool AFTER this agent has already issued a request
2603    /// ([`Self::request_issued`]) changes the tools schema every
2604    /// subsequent request carries — the exact prefix-churn shape C2
2605    /// describes, MCP-sourced or not. Resets [`Self::cache_established`] so
2606    /// the next cache-warmth check (`provider::cache_cold_reason`) doesn't
2607    /// wrongly assume the entry is still warm. A no-op call before the
2608    /// first request (the common case: `attach_mcp` registers tools once at
2609    /// startup, before any turn runs) changes nothing — byte-identical to
2610    /// today.
2611    pub fn register_tool(&mut self, tool: impl crate::tools::Tool + 'static) {
2612        self.registry.register(tool);
2613        if self.requests_issued {
2614            self.cache_established = false;
2615        }
2616    }
2617
2618    /// The current conversation, including the system prompt.
2619    pub fn history(&self) -> &[ChatMessage] {
2620        &self.history
2621    }
2622
2623    /// Send a user message and run the loop until the model produces a final
2624    /// answer (text with no tool calls) or the iteration budget is exhausted.
2625    pub async fn send(&mut self, user_input: impl Into<String>) -> Result<String> {
2626        let expanded = self.expand_prompt_async(&user_input.into()).await;
2627        let msg = ChatMessage::user(expanded);
2628        self.guard_candidate_message(&msg)?;
2629        self.record(&msg)?;
2630        self.history.push(msg);
2631        self.run_loop().await
2632    }
2633
2634    /// Refuse an oversized new user turn before it mutates canonical history
2635    /// or an attached sidecar. The in-loop guard remains authoritative for
2636    /// every actual request; this preflight closes the first-request seam
2637    /// where `send*` used to record/push the message before that guard ran.
2638    fn guard_candidate_message(&self, msg: &ChatMessage) -> Result<()> {
2639        let Some(limit) = self.context_limit else {
2640            return Ok(());
2641        };
2642
2643        let messages = match &self.reduction_policy {
2644            None => {
2645                let mut messages = self.history.clone();
2646                messages.push(msg.clone());
2647                messages
2648            }
2649            Some(policy) => {
2650                let has_system = self.history.first().is_some_and(|m| m.role == Role::System);
2651                let mut reducible = self.history[usize::from(has_system)..].to_vec();
2652                reducible.push(msg.clone());
2653                let mut prepared = policy.clone();
2654                reduce::prepare_read_freshness(&mut prepared, &reducible);
2655                let (view, _) =
2656                    reduce::project_messages(&reducible, &prepared, &self.reduction_log);
2657                let mut messages = Vec::with_capacity(view.len() + usize::from(has_system));
2658                if has_system {
2659                    messages.push(self.history[0].clone());
2660                }
2661                messages.extend(view);
2662                messages
2663            }
2664        };
2665        let messages =
2666            provider::apply_cache_plan(&messages, self.config.cache_plan, self.imported_prefix_len);
2667        let tools = self.tool_schemas();
2668        let (fits, projected_tokens) = crate::tokens::context_guard(&messages, &tools, limit);
2669        if !fits {
2670            return Err(Error::ContextLimitExceeded {
2671                projected_tokens,
2672                reserve_tokens: crate::tokens::CONTEXT_RESPONSE_RESERVE_TOKENS,
2673                context_limit: limit,
2674                model: self.config.model.clone(),
2675            });
2676        }
2677        Ok(())
2678    }
2679
2680    /// The messages a provider request should carry for the CURRENT turn
2681    /// (A5/A7/A8/A10): with no [`ReductionPolicy`] installed, exactly
2682    /// `self.history.clone()` — byte-identical to every version of this
2683    /// method before reduction landed. With a policy installed, `history[0]`
2684    /// (this agent's own system prompt, never a reduction target) followed by
2685    /// [`reduce::project_messages`]'s projected view of `history[1..]`, fed
2686    /// with `self.reduction_log` so already-applied reductions reproduce
2687    /// verbatim across turns (prefix stability, A5) — the updated log is
2688    /// stored back onto `self` so the NEXT call (this turn, next turn, or a
2689    /// later `send`) sees the same accumulating state. `self.history` itself
2690    /// is never read back into or mutated by this: it stays the full
2691    /// canonical view, in lockstep with the sidecar (A3).
2692    ///
2693    /// When `policy.elide_stale_reads` is set, this re-runs
2694    /// [`reduce::probe_read_freshness`] (the one place A8's disk I/O happens)
2695    /// against `history[1..]` before projecting, so every request sees
2696    /// up-to-date freshness verdicts — `project_messages` itself stays pure.
2697    ///
2698    /// Finally, B7's [`provider::apply_cache_plan`] runs over the assembled
2699    /// view (regardless of whether a [`ReductionPolicy`] is installed) — a
2700    /// pure, cloning annotation step, so this method's `&mut self` mutations
2701    /// above (`self.reduction_log`) are already committed before it runs and
2702    /// its own output is never written back onto `self.history` or the log:
2703    /// purity for B7's cache breakpoints holds independently of A5's.
2704    fn build_request_messages(&mut self) -> Vec<ChatMessage> {
2705        let messages = match self.reduction_policy.clone() {
2706            None => self.history.clone(),
2707            Some(mut policy) => {
2708                reduce::prepare_read_freshness(&mut policy, &self.history[1..]);
2709                // B7 coordination clamp: while `CachePlan::ImportedPrefix` is
2710                // active, A10 turn-clearing must never establish a range
2711                // that dips into the imported prefix (protects the cache
2712                // breakpoint the request build will place there below).
2713                // `imported_prefix_len` counts `history[0]` (this agent's own
2714                // system message) plus the imported messages, but
2715                // `project_messages` only ever sees `history[1..]` — hence
2716                // the `- 1`.
2717                if matches!(self.config.cache_plan, CachePlan::ImportedPrefix) {
2718                    policy.protect_imported_prefix =
2719                        self.imported_prefix_len.map(|n| n.saturating_sub(1));
2720                }
2721                // TR-7 (T20): the one side-call site, run BEFORE
2722                // `project_messages` (which stays pure/I-O-free) — mirrors
2723                // `elide_stale_reads`/`probe_read_freshness` immediately
2724                // above. Only ever does anything when both the policy gate
2725                // AND a summarizer are present; either being absent means
2726                // `cleared_turns_summary` stays `None` and `project_messages`
2727                // renders the deterministic stub, same as before TR-7
2728                // existed.
2729                if policy.summarize_cleared_turns {
2730                    if let Some(summarizer) = self.span_summarizer.as_deref() {
2731                        policy.cleared_turns_summary = reduce::prepare_cleared_turns_summary(
2732                            &self.history[1..],
2733                            &policy,
2734                            &self.reduction_log,
2735                            summarizer,
2736                        );
2737                    }
2738                }
2739                let (view, log) =
2740                    reduce::project_messages(&self.history[1..], &policy, &self.reduction_log);
2741                self.reduction_log = log;
2742                let mut messages = Vec::with_capacity(view.len() + 1);
2743                messages.push(self.history[0].clone());
2744                messages.extend(view);
2745                messages
2746            }
2747        };
2748        // TR-8 (T5): a tool-schema tier change since the last request is a
2749        // cache-bust event under `CachePlan::ImportedPrefix` — the `tools`
2750        // array is part of the cache key alongside `messages`, so flag it by
2751        // skipping this one request's cache annotation rather than claiming
2752        // a prefix hit that won't actually land. Recorded unconditionally
2753        // (even under `CachePlan::Off`) so the signature stays current
2754        // regardless of which plan is active.
2755        let tier_sig = self.schema_tier_signature();
2756        let busted =
2757            provider::tier_change_is_cache_bust(self.last_tool_schema_tier_signature, tier_sig);
2758        self.last_tool_schema_tier_signature = Some(tier_sig);
2759        let effective_cache_plan = if busted {
2760            CachePlan::Off
2761        } else {
2762            self.config.cache_plan
2763        };
2764        // UX-26 (B7-warn): mirror `apply_cache_plan`'s own placement gate
2765        // (`ImportedPrefix` AND a non-zero prefix) to know whether THIS
2766        // request will actually carry a `cache_control` annotation. `busted`
2767        // requests (schema-tier change) and `CachePlan::Off` never annotate,
2768        // so `provider::cache_cold_reason` can never flag them — there was
2769        // nothing to reuse, by construction. `idle_secs` is computed
2770        // whenever a signal exists at all (even before this agent's first
2771        // annotated send — see `Self::last_cache_activity_ms`'s doc comment
2772        // on why the pre-establishment case matters); `cache_established`
2773        // additionally gates the usage-ratio check specifically (see
2774        // `provider::cache_cold_reason`'s doc comment for why those two
2775        // checks need independent gates).
2776        let will_annotate = matches!(effective_cache_plan, CachePlan::ImportedPrefix)
2777            && self.imported_prefix_len.is_some_and(|n| n > 0);
2778        let idle_secs = self
2779            .last_cache_activity_ms
2780            .map(|last| (now_ms() - last).max(0) / 1000);
2781        self.pending_cache_turn = (will_annotate, self.cache_established, idle_secs);
2782        provider::apply_cache_plan(&messages, effective_cache_plan, self.imported_prefix_len)
2783    }
2784
2785    /// Run the model/tool loop over the current history until a final answer or
2786    /// the iteration budget is exhausted. (Shared by `send`, `send_with_files`,
2787    /// and `send_with_images`.)
2788    /// P4b (§1.7, pi§3 semantics): pop the next message(s) to deliver from
2789    /// `queue` per `mode` — `All` drains everything and joins it with a
2790    /// blank line, `OneAtATime` pops exactly one. `None` when `queue` is
2791    /// empty (the default state, at zero cost).
2792    fn drain_steer_queue(
2793        queue: &mut std::collections::VecDeque<String>,
2794        mode: SteeringMode,
2795    ) -> Option<String> {
2796        if queue.is_empty() {
2797            return None;
2798        }
2799        match mode {
2800            SteeringMode::All => Some(queue.drain(..).collect::<Vec<_>>().join("\n\n")),
2801            SteeringMode::OneAtATime => queue.pop_front(),
2802        }
2803    }
2804
2805    async fn run_loop(&mut self) -> Result<String> {
2806        let _steer_turn = SteerTurnGuard::new(self.steer_queue.clone());
2807        let mut output_tokens_used: u64 = 0;
2808
2809        // P5-9 (§2 module 20, cc's "per-prompt file-history-snapshot"):
2810        // open a fresh checkpoint for THIS turn — `run_loop` is called
2811        // exactly once per `send`/`send_with_files`/`send_with_images`
2812        // call (never recursively for the same turn), so this fires once
2813        // per user prompt, matching the design's per-prompt granularity.
2814        // `self.history.last()` is the user message that call just pushed.
2815        // `None` (`checkpoint_observer` unset, the default) is a no-op —
2816        // zero cost, no disk touched.
2817        if let Some(cp) = &self.checkpoint_observer {
2818            let label = self
2819                .history
2820                .last()
2821                .and_then(|m| m.content.as_deref())
2822                .unwrap_or("")
2823                .to_string();
2824            cp.begin_turn(&label);
2825        }
2826
2827        for _ in 0..self.config.max_iterations {
2828            self.maybe_compact();
2829            // P4b (§1.7, pi§3 "steer = after current tool calls"): drain any
2830            // queued mid-turn steering message(s) BEFORE building the next
2831            // request — the top of every loop iteration is exactly "after
2832            // whatever tool calls the previous iteration just ran" (or, on
2833            // the very first iteration, before anything has happened yet,
2834            // which is an equally valid "deliver immediately" reading).
2835            // Empty queue (today's default state) is a no-op.
2836            let steer_msg = {
2837                let mut inbox = self
2838                    .steer_queue
2839                    .lock()
2840                    .unwrap_or_else(std::sync::PoisonError::into_inner);
2841                inbox.drain(self.config.steering_mode)
2842            };
2843            if let Some(steer_msg) = steer_msg {
2844                let msg = ChatMessage::user(steer_msg);
2845                self.record(&msg)?;
2846                self.history.push(msg);
2847            }
2848            // Recomputed every iteration (not hoisted): under `Deferred`
2849            // advertising, a `tool_search` call earlier in this same loop
2850            // activates tools that must be advertised starting with the very
2851            // next request (B6).
2852            let tools = self.tool_schemas();
2853            let messages = self.build_request_messages();
2854
2855            // PARITY-18 D4 — re-check the context guard before EVERY
2856            // request this loop builds, not just the caller's one-shot
2857            // preflight: interactive turns 2+, `/expand all`, and any
2858            // mid-loop tool round-trip that grows `messages` can push a
2859            // barely-passing session over the limit between sends. Only
2860            // armed when a caller has opted in via `set_context_limit`.
2861            // Uses the exact same `tokens::context_guard`
2862            // formula the CLI preflight uses, so the two can never disagree.
2863            if let Some(limit) = self.context_limit {
2864                let (fits, projected_tokens) =
2865                    crate::tokens::context_guard(&messages, &tools, limit);
2866                if !fits {
2867                    return Err(Error::ContextLimitExceeded {
2868                        projected_tokens,
2869                        reserve_tokens: crate::tokens::CONTEXT_RESPONSE_RESERVE_TOKENS,
2870                        context_limit: limit,
2871                        model: self.config.model.clone(),
2872                    });
2873                }
2874            }
2875
2876            let req = ChatRequest {
2877                model: self.config.model.clone(),
2878                messages,
2879                tools,
2880                temperature: self.config.temperature,
2881                max_tokens: self.config.max_tokens,
2882                effort: self.config.effort.clone(),
2883                response_format: self.config.response_format.clone(),
2884                extra_body: self.config.extra_body.clone(),
2885            };
2886
2887            let (mut assistant, usage) = {
2888                let sink = self.config.event_sink.as_ref();
2889                let on_delta = move |s: &str| {
2890                    if let Some(sink) = sink {
2891                        sink(AgentEvent::TextDelta(s.to_string()));
2892                    }
2893                };
2894                // PARITY-18 D3 — the real send site: flip the flag
2895                // immediately before issuing the request, regardless of
2896                // whether `complete` then succeeds or fails, so
2897                // `request_issued()` truthfully reflects "a live request
2898                // was attempted" rather than "the run reached this line and
2899                // later succeeded."
2900                self.requests_issued = true;
2901                // P4b (§1.1/§3.1 `core.retry`, pi§3 shape): retry-with-
2902                // backoff already lives at the TRANSPORT layer
2903                // (`provider::OpenAiProvider::send_with_retry`, pre-existing
2904                // — connection failures and 5xx responses are retried
2905                // there); `Config.retry_*` (see `Agent::new`) makes that
2906                // EXISTING mechanism config-file-settable instead of
2907                // duplicating a second retry loop here, which would nest
2908                // retries confusingly on top of the transport's own.
2909                self.provider.complete(&req, &on_delta).await?
2910            };
2911            // Persist the actual generating model on the message itself.
2912            // A resumed foreign session keeps its original model in
2913            // `SessionMeta`; using only that session-level value on export
2914            // misattributes every Supercode continuation turn to the source
2915            // harness model. Per-message provenance lets native exporters
2916            // preserve the boundary accurately (for example, Claude history
2917            // followed by a GLM continuation).
2918            assistant
2919                .metadata
2920                .insert("model".to_string(), self.config.model.clone());
2921            output_tokens_used += usage.completion_tokens;
2922            self.total_output_tokens += usage.completion_tokens;
2923
2924            // UX-26 (B7-warn): consult the verdict computed at build time
2925            // (before this request was sent) now that `usage` — the only
2926            // piece that couldn't be known pre-send — is in hand. Gated on
2927            // `Config::cache_warnings` (default on; `--no-cache-warnings` /
2928            // `SUPERCODE_CACHE_WARNINGS=0` at the CLI layer, dev/03) so this
2929            // stays a zero-behavior-change no-op for every caller that
2930            // hasn't opted into `CachePlan::ImportedPrefix` in the first
2931            // place (`pending_cache_turn.0` is `false` whenever
2932            // `CachePlan::Off`, so the predicate always returns `None` then
2933            // regardless of this flag).
2934            let (will_annotate, cache_established, idle_secs) = self.pending_cache_turn;
2935            // UX-26 T2 (accuracy fold-in): `CacheColdReason::message` asserts
2936            // Anthropic-specific facts (a fixed 5-minute ephemeral TTL, and
2937            // cache-read-ratio semantics that assume Anthropic's exact-count
2938            // billing) that are only true for Anthropic-family models. This
2939            // is a WARNING-only gate, deliberately not folded into
2940            // `will_annotate`/the breakpoint-placement gate above: whether a
2941            // `cache_control` breakpoint is safe/inert to send to a
2942            // non-Anthropic model through OpenRouter is a separate cache-
2943            // behavior question this ticket doesn't touch (see
2944            // `.volter/tracker/markdown/UX-26.md`'s T2 note) — narrowing only
2945            // the warning keeps this fix scoped to warning ACCURACY, with
2946            // zero change to what gets sent on the wire.
2947            let warning_applies_to_this_model =
2948                provider::is_anthropic_family_model(&self.config.model);
2949            if self.config.cache_warnings && warning_applies_to_this_model {
2950                if let Some(reason) =
2951                    provider::cache_cold_reason(will_annotate, cache_established, idle_secs, &usage)
2952                {
2953                    self.emit(AgentEvent::CacheWarning {
2954                        message: reason.message(),
2955                    });
2956                }
2957            }
2958            // Refresh the activity clock / establish-once flag for the NEXT
2959            // turn's comparison, but only when THIS request actually carried
2960            // the annotation — an unannotated (busted/Off) request neither
2961            // warms nor cools a cache entry it never touched.
2962            if will_annotate {
2963                self.last_cache_activity_ms = Some(now_ms());
2964                self.cache_established = true;
2965            }
2966
2967            // UX-23: emitted before `TurnCompleted` so a `--trace`/
2968            // `stream-json` consumer sees "this round-trip cost N tokens"
2969            // land right alongside the round-trip it describes, rather than
2970            // needing to correlate it with a later event.
2971            self.emit(AgentEvent::Usage(usage.clone()));
2972            self.emit(AgentEvent::TurnCompleted);
2973            // P4b (§1.6, catalog §4a "persisted per-turn usage records"):
2974            // EventSink already streamed `Usage` above — this durably
2975            // accumulates the same data as a typed record (see
2976            // `Self::usage_records`/`Self::save_usage_log`), never a lossy
2977            // display-only channel.
2978            self.usage_log
2979                .push(crate::usage_log::UsageRecord::from_usage(
2980                    self.turn_index,
2981                    &self.config.model,
2982                    &usage,
2983                    now_ms(),
2984                ));
2985            self.turn_index += 1;
2986            self.record(&assistant)?;
2987            self.history.push(assistant.clone());
2988
2989            let calls = assistant.tool_calls().to_vec();
2990            if calls.is_empty() {
2991                // Close steering acceptance under the same lock as the last
2992                // drain. A message accepted before this boundary extends the
2993                // current turn; anything later is rejected by the SDK and
2994                // can never leak into a future turn.
2995                let steer_msg = self
2996                    .steer_queue
2997                    .lock()
2998                    .unwrap_or_else(std::sync::PoisonError::into_inner)
2999                    .drain_or_close(self.config.steering_mode);
3000                if let Some(steer_msg) = steer_msg {
3001                    let msg = ChatMessage::user(steer_msg);
3002                    self.record(&msg)?;
3003                    self.history.push(msg);
3004                    continue;
3005                }
3006                // P4b (§1.7, pi§3 "follow-up = at idle"): a queued follow-up
3007                // message takes priority over the stop-gate — it's more
3008                // input to answer, not a veto of an answer already given.
3009                if let Some(follow_up_msg) =
3010                    Self::drain_steer_queue(&mut self.follow_up_queue, self.config.follow_up_mode)
3011                {
3012                    let msg = ChatMessage::user(follow_up_msg);
3013                    self.record(&msg)?;
3014                    self.history.push(msg);
3015                    continue;
3016                }
3017                // P4b (§1.9/§3.1 `[core] stop_gate`, D3 "stop/completion
3018                // gating"): consulted exactly once per iteration that would
3019                // otherwise return — computed into an owned `Option<String>`
3020                // so the immutable borrow of `self.config.stop_gate` ends
3021                // before the `self.record`/`self.history.push` calls below
3022                // need `&mut self`.
3023                let final_content = assistant.content.clone().unwrap_or_default();
3024                let veto_reason: Option<String> = self
3025                    .config
3026                    .stop_gate
3027                    .as_ref()
3028                    .and_then(|gate| gate(&final_content));
3029                if let Some(reason) = veto_reason {
3030                    let msg = ChatMessage::user(reason);
3031                    self.record(&msg)?;
3032                    self.history.push(msg);
3033                    continue;
3034                }
3035                return Ok(assistant.content.unwrap_or_default());
3036            }
3037
3038            // Output-token budget (output only — input tokens are not counted,
3039            // so this does not bound cost): stop spawning further model turns
3040            // once the cumulative output-token budget for this `send` is
3041            // exhausted.
3042            if let Some(budget) = self.config.max_total_output_tokens {
3043                if output_tokens_used >= budget {
3044                    // The assistant turn we just pushed carries unanswered
3045                    // tool_calls. Leaving them dangling yields an invalid
3046                    // history (assistant tool_calls with no tool results) that
3047                    // the provider rejects on the next `send`/resume. Emit
3048                    // synthetic results so the transcript stays well-formed.
3049                    for call in &calls {
3050                        let msg = ChatMessage::tool_result(
3051                            call.id.clone(),
3052                            call.function.name.clone(),
3053                            "[skipped: output token budget reached]".to_string(),
3054                        );
3055                        self.record(&msg)?;
3056                        self.history.push(msg);
3057                    }
3058                    return Ok(assistant.content.clone().unwrap_or_default());
3059                }
3060            }
3061
3062            // P4e (§3.1 `core.parallel_tool_calls`, catalog:59): off (the
3063            // default) or a single call takes the EXACT pre-P4e sequential
3064            // path below, byte-identical. Only `true` with 2+ calls in this
3065            // turn takes `Self::run_tools_concurrently` — see its doc
3066            // comment for exactly what does and doesn't run concurrently.
3067            if self.config.parallel_tool_calls && calls.len() > 1 {
3068                for call in &calls {
3069                    self.emit(AgentEvent::tool_started(call));
3070                }
3071                let results = self.run_tools_concurrently(&calls).await;
3072                for (call, (output, is_error)) in calls.iter().zip(results) {
3073                    self.emit(AgentEvent::ToolCallCompleted {
3074                        id: call.id.clone(),
3075                        name: call.function.name.clone(),
3076                        output: output.clone(),
3077                        is_error,
3078                    });
3079                    self.apply_tool_result(call, output, is_error)?;
3080                }
3081            } else {
3082                for call in &calls {
3083                    self.emit(AgentEvent::tool_started(call));
3084                    let (output, is_error) = self.run_tool(call).await;
3085                    self.emit(AgentEvent::ToolCallCompleted {
3086                        id: call.id.clone(),
3087                        name: call.function.name.clone(),
3088                        output: output.clone(),
3089                        is_error,
3090                    });
3091                    self.apply_tool_result(call, output, is_error)?;
3092                }
3093            }
3094        }
3095
3096        Err(Error::MaxIterations(self.config.max_iterations))
3097    }
3098
3099    /// The exact post-execution handling every tool result gets, regardless
3100    /// of whether it was produced by the sequential loop or
3101    /// [`Self::run_tools_concurrently`] — factored out of `Self::run_loop`'s
3102    /// tool-dispatch section (P4e) so both paths share one copy: multimodal
3103    /// image-marker detection, A7 output capping (gated exactly as before),
3104    /// TR-10 error stamping, and the `record`/`history` append. Always
3105    /// called in ORIGINAL call order, one call at a time, so the lossless
3106    /// sidecar's append-order invariant (S1.13) holds regardless of which
3107    /// dispatch path produced the result.
3108    fn apply_tool_result(
3109        &mut self,
3110        call: &crate::message::ToolCall,
3111        output: String,
3112        is_error: bool,
3113    ) -> Result<()> {
3114        // P4c (§1.2 `core.tools.read_file.multimodal` / `view_image`):
3115        // a successful tool result carrying the image-data-URL
3116        // marker becomes a `content_parts` image block instead of
3117        // plain text — checked BEFORE `cap_tool_output` (a data URL
3118        // is not meaningfully "capped" by a byte-length text notice)
3119        // and recorded identically on both the full and history
3120        // copies, mirroring `ImageRedacted`'s "images are their own
3121        // axis, orthogonal to A7 text truncation" treatment
3122        // (reduce.rs). An ERRORED call never carries the marker (a
3123        // tool only emits it on success), so `is_error` is not
3124        // re-checked here.
3125        if let Some(data_url) = output.strip_prefix(crate::tools::MULTIMODAL_IMAGE_MARKER) {
3126            let notice = format!("[{}: image content attached below]", call.function.name);
3127            let full_result = ChatMessage::tool_result_with_image(
3128                call.id.clone(),
3129                call.function.name.clone(),
3130                notice.clone(),
3131                data_url.to_string(),
3132            );
3133            let hist_result = ChatMessage::tool_result_with_image(
3134                call.id.clone(),
3135                call.function.name.clone(),
3136                notice,
3137                data_url.to_string(),
3138            );
3139            self.record(&full_result)?;
3140            self.history.push(hist_result);
3141            return Ok(());
3142        }
3143        // Record the FULL output before capping (A3): what the
3144        // sidecar keeps must never be the already-lossy, truncated
3145        // copy (#8/#40) — `history` alone governs what shrinks.
3146        let mut full_result =
3147            ChatMessage::tool_result(call.id.clone(), call.function.name.clone(), output.clone());
3148        // D6/A7 supersession gate (TR-12 land-blocker fix): `history`
3149        // is the exact slice `reduce::project_messages` mints A7/A10
3150        // reduction hashes from (`Self::build_request_messages`
3151        // below). Capping it here — as this unconditionally used to
3152        // do — would silently shrink the bytes those hashes cover, so
3153        // a hash minted now could never recompute the same way once
3154        // the sidecar is reloaded from disk later (`verify_log`/
3155        // `invert`, offline). Gate `cap_tool_output` off in exactly
3156        // the combination where reductions can be minted over
3157        // `history` AND the full bytes are durably retained: a
3158        // recorder AND a `ReductionPolicy` both installed. A7 then
3159        // owns tool-output bounding, reversibly, at projection time
3160        // (SPEC.md D6/A7) — `history`/the sidecar keep everything,
3161        // only the request view shrinks. With a policy but no
3162        // recorder (constructible via `set_reduction_policy` alone),
3163        // nothing durable backs the full bytes, so capping stays on —
3164        // the same honest-labeling spirit as `cap_tool_output`'s own
3165        // retention branch below, just applied at the gate instead of
3166        // the notice text. With no policy at all, this is untouched:
3167        // today's byte-identical legacy cap.
3168        let for_history = if self.recorder.is_some() && self.reduction_policy.is_some() {
3169            output
3170        } else {
3171            self.cap_tool_output(output)
3172        };
3173        let mut hist_result =
3174            ChatMessage::tool_result(call.id.clone(), call.function.name.clone(), for_history);
3175        if is_error {
3176            // TR-10: the reduction layer's success/failure boundary
3177            // (`ReductionKind::ToolInputElided` must never target an
3178            // errored call — TR-6's territory) has no other
3179            // structural signal on `ChatMessage`; stamp both the
3180            // recorded copy (so it survives a sidecar round-trip via
3181            // `NativeTurn`) and the live-history copy (so an
3182            // in-process `project_messages` sees it immediately).
3183            reduce::mark_tool_error(&mut full_result);
3184            reduce::mark_tool_error(&mut hist_result);
3185        }
3186        self.record(&full_result)?;
3187        self.history.push(hist_result);
3188        Ok(())
3189    }
3190
3191    /// Truncate an oversized tool result so a single runaway command can't blow
3192    /// up the context window. Cuts on a char boundary and appends a notice.
3193    fn cap_tool_output(&self, output: String) -> String {
3194        let Some(max) = self.config.max_tool_output_bytes else {
3195            return output;
3196        };
3197        if max == 0 || output.len() <= max {
3198            return output;
3199        }
3200        // Find the largest char boundary <= max.
3201        let mut end = max;
3202        while end > 0 && !output.is_char_boundary(end) {
3203            end -= 1;
3204        }
3205        let total = output.len();
3206        let mut s = output[..end].to_string();
3207        // Honest retention labeling (D6, B10-AC4): only claim the sidecar has
3208        // the full output when a recorder is actually installed.
3209        let retention = if self.recorder.is_some() {
3210            "full output in session sidecar"
3211        } else {
3212            "full output not retained"
3213        };
3214        s.push_str(&format!(
3215            "{CAP_NOTICE_MARKER}{total} bytes total, showing first {end}; {retention}]"
3216        ));
3217        s
3218    }
3219
3220    /// P5-3 note on the signature: written as a plain fn returning an
3221    /// explicitly boxed future (`Pin<Box<dyn Future + Send>>`) rather than
3222    /// as `async fn`. `spawn_subagent` makes this function genuinely
3223    /// recursive at the TYPE level: `run_tool` -> `run_spawn_subagent` ->
3224    /// (a child) `Agent::send` -> `run_loop` -> `run_tool` again — an
3225    /// `async fn`'s return type is an anonymous, compiler-inferred
3226    /// self-referential state machine, and inferring one that embeds
3227    /// itself (even indirectly, through several other functions) is a
3228    /// compile error (an infinitely-sized/cyclic opaque type). Declaring
3229    /// `run_tool`'s return type EXPLICITLY as a boxed trait object breaks
3230    /// the cycle: every other function on the call graph now embeds a
3231    /// concrete, already-known type here instead of one the compiler would
3232    /// otherwise need to (cyclically) infer. Callers are unaffected —
3233    /// `self.run_tool(call).await` reads identically either way.
3234    fn run_tool<'a>(
3235        &'a mut self,
3236        call: &'a crate::message::ToolCall,
3237    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = (String, bool)> + Send + 'a>> {
3238        Box::pin(async move {
3239            let translated_builtin = if self.config.claude_runtime_tools_enabled {
3240                match self.translate_claude_builtin_call(call) {
3241                    Ok(translated) => translated,
3242                    Err(error) => return (format!("Error: {error}"), true),
3243                }
3244            } else {
3245                None
3246            };
3247            let call = translated_builtin.as_ref().unwrap_or(call);
3248            if self.config.claude_runtime_tools_enabled
3249                && matches!(
3250                    call.function.name.as_str(),
3251                    CLAUDE_CRON_CREATE
3252                        | CLAUDE_CRON_DELETE
3253                        | CLAUDE_CRON_LIST
3254                        | CLAUDE_SCHEDULE_WAKEUP
3255                )
3256            {
3257                return self.run_claude_runtime_tool(call);
3258            }
3259            // P5-3: `spawn_subagent`/`subagent_status` need full async
3260            // `&mut self` access (running a child agent's loop, or
3261            // awaiting an already-finished background `JoinHandle`) —
3262            // `prepare_tool_call` is purely synchronous, so these are
3263            // intercepted HERE, one level above it, rather than inside it
3264            // like `TOOL_SEARCH`/`EXPAND_REDUCTION`/`SIDECAR_SEARCH`.
3265            if call.function.name == CLAUDE_AGENT && self.config.subagents_claude_agent_alias {
3266                return match self.translate_claude_agent_call(call) {
3267                    Ok(translated) => self.run_spawn_subagent(&translated).await,
3268                    Err(error) => (format!("Error: {error}"), true),
3269                };
3270            }
3271            if call.function.name == SPAWN_SUBAGENT {
3272                return self.run_spawn_subagent(call).await;
3273            }
3274            // P5-3 safety-hardening fix (Fable-5 review, LOW "wrong error
3275            // when disabled"): gated on `subagents_enabled`, matching
3276            // `run_spawn_subagent`'s own already-correct disabled behavior
3277            // (that one gates INTERNALLY, at its own top; this one gates
3278            // HERE, at the interception point, because unlike
3279            // `spawn_subagent` it has no other reason to run any logic at
3280            // all when subagents are off). When disabled, a hallucinated
3281            // `subagent_status` call must NOT be intercepted — it falls
3282            // through to `prepare_tool_call`'s normal unknown-tool path
3283            // below, which returns `Error::UnknownTool("subagent_status")`,
3284            // byte-identical to the pre-P5-3 (and disabled-spawn_subagent)
3285            // error text — never `Error::SubagentNotFound`'s "unknown
3286            // subagent id" text, which would wrongly imply subagents are on
3287            // but this particular id is bogus.
3288            if call.function.name == SUBAGENT_STATUS && self.config.subagents_enabled {
3289                return self.run_subagent_status(call).await;
3290            }
3291            match self.prepare_tool_call(call) {
3292                PreparedCall::Done(result) => result,
3293                PreparedCall::Ready { name, args } => {
3294                    // `prepare_tool_call` already confirmed the registry has
3295                    // this tool.
3296                    let tool = self.registry.get(&name).expect("prepared as Ready");
3297                    let (output, is_error) = match tool.execute(args, &self.ctx).await {
3298                        Ok(out) => (out, false),
3299                        Err(e) => (format!("Error: {e}"), true),
3300                    };
3301                    if let Some(hook) = &self.config.post_tool_hook {
3302                        hook(&name, &output, is_error);
3303                    }
3304                    (output, is_error)
3305                }
3306            }
3307        })
3308    }
3309
3310    /// P4e (§3.1 `core.parallel_tool_calls`, catalog:59): the SYNCHRONOUS
3311    /// half of dispatching one tool call — everything `Self::run_tool` did
3312    /// BEFORE its single `tool.execute(...).await`, factored out so
3313    /// [`Self::run_tools_concurrently`] can run these cheap, stateful,
3314    /// `&mut self` checks (agent intrinsics, unknown-tool, approval,
3315    /// doom-loop, pre-tool-hook) SEQUENTIALLY and in ORIGINAL call order —
3316    /// exactly as `run_tool` always has — before handing the remaining
3317    /// calls' `execute()` futures to `join_all`. `Self::run_tool` itself is
3318    /// now a thin wrapper over this (a pure refactor: byte-identical
3319    /// observable behavior, verified by the existing test suite).
3320    fn prepare_tool_call(&mut self, call: &crate::message::ToolCall) -> PreparedCall {
3321        let name = &call.function.name;
3322        if name == TOOL_SEARCH {
3323            // Agent intrinsic (B6): intercepted before registry lookup, since
3324            // `Tool::execute` has no access to the registry or `activated_tools`.
3325            return PreparedCall::Done(self.run_tool_search(call));
3326        }
3327        if name == EXPAND_REDUCTION {
3328            // Agent intrinsic (T12/TR-1): intercepted before registry lookup,
3329            // same reason — resolves against `self.reduction_log`/`self.history`,
3330            // which `Tool::execute` has no access to.
3331            return PreparedCall::Done(self.run_expand_reduction(call));
3332        }
3333        if name == SIDECAR_SEARCH {
3334            return PreparedCall::Done(self.run_sidecar_search(call));
3335        }
3336        // P5-6 (§2 module 4 `tools.background`): gated at the interception
3337        // point itself (not internally, at each method's own top) —
3338        // mirroring `SUBAGENT_STATUS`'s own fix (Fable-5 review, LOW "wrong
3339        // error when disabled"): a hallucinated call when the module is off
3340        // must fall through to the plain `Error::UnknownTool` path below,
3341        // never a background-specific error that would wrongly imply the
3342        // module is on. Unlike `SPAWN_SUBAGENT`/`SUBAGENT_STATUS`, none of
3343        // these four need async `&mut self` access (spawning a process,
3344        // `Child::try_wait`, and `Child::start_kill` are all synchronous),
3345        // so they're intercepted here in `prepare_tool_call` rather than in
3346        // `Self::run_tool`.
3347        if self.config.tools_background_enabled {
3348            if name == BACKGROUND_EXEC {
3349                return PreparedCall::Done(self.run_background_exec(call));
3350            }
3351            if name == BACKGROUND_STATUS {
3352                return PreparedCall::Done(self.run_background_status(call));
3353            }
3354            if name == BACKGROUND_LIST {
3355                return PreparedCall::Done(self.run_background_list(call));
3356            }
3357            if name == BACKGROUND_KILL {
3358                return PreparedCall::Done(self.run_background_kill(call));
3359            }
3360        }
3361        if self.registry.get(name).is_none() {
3362            let err = Error::UnknownTool(name.clone());
3363            return PreparedCall::Done((format!("Error: {err}"), true));
3364        }
3365
3366        // P5-1 (§2 modules 10-11, integration point named in
3367        // COMPOSABLE-HARNESS-DESIGN.md's activation set): the permissions
3368        // ENGINE governs the gate when `capabilities.permissions.enabled`
3369        // is on; every other config resolves this to `false`
3370        // (`Config::default`), which takes the `else` branch below —
3371        // the EXACT pre-P5-1 code, untouched, so the default posture
3372        // (approval=never/sandbox=none) and every existing test's observed
3373        // behavior is byte-for-byte unchanged.
3374        if self.config.permissions_enabled {
3375            // The engine needs the command/path TEXT the legacy tool-name-
3376            // only gate below never looked at, so args must be parsed
3377            // BEFORE the gate here (not after, like the legacy branch).
3378            let args = match call.function.parsed_arguments() {
3379                Ok(v) => v,
3380                Err(e) => {
3381                    let err = Error::InvalidArguments {
3382                        tool: name.clone(),
3383                        message: e.to_string(),
3384                    };
3385                    return PreparedCall::Done((format!("Error: {err}"), true));
3386                }
3387            };
3388            if let Some(reason) = self.permissions_gate_denial(name, &args) {
3389                return PreparedCall::Done((format!("Error: {reason}"), true));
3390            }
3391            self.finish_prepare(name.clone(), args)
3392        } else {
3393            // ---- pre-P5-1 gate, byte-for-byte unchanged ----
3394            // Approval gate: if the policy requires it, consult the handler
3395            // (absent handler denies, so an OnRequest/Untrusted policy is
3396            // fail-closed).
3397            if self.config.needs_approval(name) {
3398                let approved = self
3399                    .config
3400                    .approval_handler
3401                    .as_ref()
3402                    .map(|h| h(call))
3403                    .unwrap_or(false);
3404                if !approved {
3405                    return PreparedCall::Done((
3406                        format!("Error: tool `{name}` was not approved for execution"),
3407                        true,
3408                    ));
3409                }
3410            }
3411            let args = match call.function.parsed_arguments() {
3412                Ok(v) => v,
3413                Err(e) => {
3414                    let err = Error::InvalidArguments {
3415                        tool: name.clone(),
3416                        message: e.to_string(),
3417                    };
3418                    return PreparedCall::Done((format!("Error: {err}"), true));
3419                }
3420            };
3421            self.finish_prepare(name.clone(), args)
3422        }
3423    }
3424
3425    /// P5-1: the shared tail of [`Self::prepare_tool_call`] — doom-loop
3426    /// check, pre-tool hook, `Ready` construction — factored out so both
3427    /// the legacy gate and the new permissions-engine gate run the exact
3428    /// same downstream checks in the exact same order (§5.3 risk 1: the
3429    /// permissions engine changes WHO gets to run, never what happens once
3430    /// they're approved).
3431    fn finish_prepare(&mut self, name: String, args: serde_json::Value) -> PreparedCall {
3432        // P4c (§5.2 P4 "doom-loop breaker", oc UNIQUE `doom_loop` row,
3433        // catalog D3): a default, always-available veto point distinct from
3434        // `Config.pre_tool_hook` (a single user-installable slot — the
3435        // breaker must coexist with a caller's own hook, not compete for the
3436        // one slot). `None`/`Some(0|1)` is a no-op — byte-identical to
3437        // today (no repetition tracking, no call is ever refused on this
3438        // basis).
3439        if let Some(reason) = self.check_doom_loop(&name, &args) {
3440            return PreparedCall::Done((format!("Error: {reason}"), true));
3441        }
3442        // Pre-tool hook may block the call.
3443        if let Some(hook) = &self.config.pre_tool_hook {
3444            if let Some(reason) = hook(&name, &args) {
3445                return PreparedCall::Done((
3446                    format!("Error: blocked by pre-tool hook: {reason}"),
3447                    true,
3448                ));
3449            }
3450        }
3451        PreparedCall::Ready { name, args }
3452    }
3453
3454    /// D-2 (Fable-5 delta review — LOW-MEDIUM, "over-grant residual"): the
3455    /// built-in tools whose `command`/`path`/`patch` arg IS semantically
3456    /// the whole call — the ONLY tools [`Self::permissions_gate_denial`]
3457    /// is allowed to turn into an [`permissions::ApprovalRequest::subject`]
3458    /// (see that method's own doc comment on the `subject` line for the
3459    /// full story). Bash-family (`bash`, and `shell` —
3460    /// [`crate::tools::builtins::PersistentShellTool`]'s registered name,
3461    /// what the review's "persistent-shell" refers to), the file tools
3462    /// (`read_file`/`write_file`/`edit_file`/`view_image`, whose `path` IS
3463    /// the subject), and `apply_patch` (whose `patch` envelope is handled
3464    /// separately but is unconditionally this tool only, see the `patch`
3465    /// local a few lines below). Deliberately NOT `list_dir`/`glob`/
3466    /// `search` — this crate's F2 fix (`ApprovalCache::key_for_request`)
3467    /// already falls back to a full-args digest for anything not on this
3468    /// list, which is a strictly SAFER (if slightly less cache-granular)
3469    /// default than guessing at more built-ins that weren't part of this
3470    /// finding.
3471    const SUBJECT_BEARING_BUILTIN_TOOLS: &'static [&'static str] = &[
3472        "bash",
3473        "shell",
3474        "read_file",
3475        "write_file",
3476        "edit_file",
3477        "view_image",
3478        "apply_patch",
3479    ];
3480
3481    /// P5-1: evaluate `name`'s call (with parsed `args`) against the
3482    /// permissions engine (`crate::permissions`) — builds the
3483    /// [`crate::permissions::RuleSet`] from `Config`'s deny/ask/allow
3484    /// pattern lists (folding [`Config::permissions_protected_paths`] into
3485    /// the `deny` tier, module 13), picks a command-, path-, or name-only
3486    /// evaluation depending on what `args` carries, resolves an `Ask`
3487    /// decision via the session cache + THIS agent's own installed
3488    /// [`crate::permissions::PermissionsApprovalHandler`], and returns
3489    /// `Some(reason)` when the call is refused (`None` = proceed). Thin
3490    /// wrapper over [`Self::permissions_gate_denial_impl`] — see that
3491    /// method's doc comment for why the handler is a parameter there.
3492    fn permissions_gate_denial(&self, name: &str, args: &serde_json::Value) -> Option<String> {
3493        self.permissions_gate_denial_impl(name, args, self.permissions_approval_handler.as_deref())
3494    }
3495
3496    /// P5-6 (§2.2 C6, build brief "wire to the P5-1 engine's non-
3497    /// interactive fail-closed path"): the SAME rule-evaluation body as
3498    /// [`Self::permissions_gate_denial`], but the approval `handler` is a
3499    /// PARAMETER instead of always reading `self.permissions_approval_handler`
3500    /// — `Agent::background_permission_denial` calls this with `handler:
3501    /// None` (or a [`crate::subagents::ParentQueueApprovalHandler`]) so a
3502    /// `background_exec` call's `Ask`-tier decisions resolve exactly like a
3503    /// P5-3 background child's do (`crate::permissions::resolve_ask`'s
3504    /// pre-existing "no handler ⇒ deny" contract), REGARDLESS of whether
3505    /// this agent itself has an interactive handler installed for its own
3506    /// foreground calls — a background job must never block on a prompt it
3507    /// has no way to answer, even if the agent hosting it could otherwise
3508    /// answer one. The rule SET and default-policy baseline are otherwise
3509    /// identical to a foreground call's — only how an `Ask` decision
3510    /// resolves ever differs, and only in the strictly-narrower direction
3511    /// (never escalates past what a foreground call of the same command is
3512    /// allowed).
3513    fn permissions_gate_denial_impl(
3514        &self,
3515        name: &str,
3516        args: &serde_json::Value,
3517        handler: Option<&dyn crate::permissions::PermissionsApprovalHandler>,
3518    ) -> Option<String> {
3519        use crate::permissions::{self, Decision, PathKind};
3520
3521        // `Config.tool_deny_patterns`/`tool_allow_patterns` (P4a) ARE the
3522        // engine's deny/allow tiers — the same `capabilities.permissions.
3523        // rules.deny`/`.allow` keys, one source of truth, no duplication.
3524        // Protected paths (module 13) are an unconditional deny floor,
3525        // folded in here rather than checked separately, so they benefit
3526        // from the SAME first-match deny-wins priority every other deny
3527        // rule gets.
3528        let mut deny = self.config.tool_deny_patterns.clone();
3529        deny.extend(permissions::protected_path_deny_rules(
3530            &self.config.permissions_protected_paths,
3531        ));
3532        let rules = permissions::RuleSet {
3533            deny,
3534            ask: self.config.permissions_ask_patterns.clone(),
3535            allow: self.config.tool_allow_patterns.clone(),
3536        };
3537
3538        // The baseline decision when NO rule matches at all — derived from
3539        // `ApprovalPolicy`, the same per-policy shape
3540        // `Config::needs_approval` uses for the legacy gate (see
3541        // `ApprovalPolicy::ModelRequested`'s doc comment for why this
3542        // richer gate approximates Codex's real "mostly silent" posture
3543        // instead of that method's conservative OnRequest-alike treatment
3544        // — explicit deny/ask rules still apply on top regardless).
3545        let default = match self.config.approval {
3546            crate::config::ApprovalPolicy::Never => Decision::Allow,
3547            crate::config::ApprovalPolicy::OnRequest => {
3548                if self.config.auto_approved_tools.contains(name) {
3549                    Decision::Allow
3550                } else {
3551                    Decision::Ask
3552                }
3553            }
3554            crate::config::ApprovalPolicy::Untrusted => Decision::Ask,
3555            crate::config::ApprovalPolicy::ModelRequested => Decision::Allow,
3556        };
3557
3558        let command = args.get("command").and_then(|v| v.as_str());
3559        let path = args.get("path").and_then(|v| v.as_str());
3560        // F4 (Fable-5 adversarial review): `apply_patch`'s args carry a
3561        // patch ENVELOPE body (`args["patch"]`), not a `command` or a
3562        // `path` — the two branches above never fire for it, which is
3563        // exactly how a patch touching a protected path bypassed
3564        // `protected_paths` entirely. Only consulted for the `apply_patch`
3565        // tool specifically (a `patch`-shaped arg on some other tool is not
3566        // this envelope format and isn't given this treatment).
3567        let patch = (name == "apply_patch")
3568            .then(|| args.get("patch").and_then(|v| v.as_str()))
3569            .flatten();
3570        let decision = if let Some(command) = command {
3571            permissions::evaluate_command(&rules, name, command, default)
3572        } else if let Some(patch) = patch {
3573            // Same dual-check shape as the path branch below (pseudo-tool
3574            // `write(...)` rules from `protected_paths`, AND a rule
3575            // authored against the real `apply_patch` tool name), applied
3576            // to EVERY path the envelope's ops touch (`Add`/`Delete`/
3577            // `Update`'s `path`, plus `*** Move to:`). A patch that fails
3578            // to parse can't be proven to avoid a protected path — fail
3579            // closed to at least `Ask`, the same floor an unparseable bash
3580            // command gets in `permissions::evaluate_command`, rather than
3581            // silently let it through on `default`.
3582            let mut d = rules.evaluate(name, None).unwrap_or(default);
3583            match crate::tools::patch_target_paths(patch) {
3584                Ok(paths) => {
3585                    for p in &paths {
3586                        // SECURITY (CRITICAL fix): route both checks through
3587                        // the safe-path-resolving variants — a patch target
3588                        // like `x/../.git/config` must be caught exactly
3589                        // like a `write_file`/`edit_file` `path` argument
3590                        // would be (see `evaluate_path_safe`'s doc comment).
3591                        let pseudo = permissions::evaluate_path_safe(
3592                            &rules,
3593                            PathKind::Write,
3594                            &self.config.cwd,
3595                            p,
3596                            Decision::Allow,
3597                        );
3598                        let real_tool = permissions::evaluate_path_subject_safe(
3599                            &rules,
3600                            name,
3601                            &self.config.cwd,
3602                            p,
3603                            Decision::Allow,
3604                        );
3605                        d = d.stricter(pseudo).stricter(real_tool);
3606                    }
3607                }
3608                Err(_) => {
3609                    d = d.stricter(Decision::Ask);
3610                }
3611            }
3612            d
3613        } else if let Some(path) = path {
3614            let kind = if matches!(name, "write_file" | "edit_file") {
3615                PathKind::Write
3616            } else {
3617                PathKind::Read
3618            };
3619            // TWO independent sources of path-shaped rules can apply to the
3620            // same call, and BOTH must be checked:
3621            // (a) the `read(...)`/`write(...)` pseudo-tool (tool-agnostic —
3622            //     applies no matter WHICH tool touches the path; this is
3623            //     what `Config::permissions_protected_paths`/module 13
3624            //     expands into, via `protected_path_deny_rules`);
3625            // (b) a rule authored against the REAL tool name with the path
3626            //     as its subject — design §4.4's own oc-parity worked
3627            //     example writes exactly this shape (`"read_file(*.env)"`,
3628            //     not a pseudo-tool), matching how `bash(cmdglob)` rules
3629            //     are authored. `RuleSet::evaluate`'s bare-tool-name-glob
3630            //     branch (no parens) ALSO fires here regardless of
3631            //     `subject`, so this one call additionally covers a
3632            //     blanket "deny this tool entirely" rule — no separate
3633            //     `rules.evaluate(name, None)` call is needed.
3634            // SECURITY (CRITICAL fix, guarantor audit): both checks now
3635            // route through the safe-path-resolving variants (see
3636            // `evaluate_path_safe`'s doc comment) instead of glob-matching
3637            // the raw model-supplied `path` string directly — this is what
3638            // closes the traversal bypass (`write_file
3639            // path="x/../.git/config"`) and the analogous symlink escape.
3640            let pseudo_decision =
3641                permissions::evaluate_path_safe(&rules, kind, &self.config.cwd, path, default);
3642            let real_tool_decision = permissions::evaluate_path_subject_safe(
3643                &rules,
3644                name,
3645                &self.config.cwd,
3646                path,
3647                default,
3648            );
3649            pseudo_decision.stricter(real_tool_decision)
3650        } else {
3651            rules.evaluate(name, None).unwrap_or(default)
3652        };
3653
3654        // D-2 (Fable-5 delta review — LOW-MEDIUM): `command`/`path`/`patch`
3655        // above are extracted (and used to DRIVE the decision above) for
3656        // ANY tool that happens to carry one of those arg names — that
3657        // part is unchanged and correct (a rule authored against, say, an
3658        // MCP tool's own name legitimately wants to glob-match its
3659        // `command`-shaped arg too). But the narrower single-field
3660        // `subject` handed to the cache/handler below must NOT do the
3661        // same for a non-built-in tool: an MCP (or other) tool's
3662        // `command`/`path` is just one field among potentially several
3663        // that together define what the call actually does — collapsing
3664        // an `AllowForSession` grant down to that one field would silently
3665        // auto-allow a later call with the SAME `command` but different
3666        // OTHER args (e.g. `{"command":"sync","target":"staging"}`
3667        // auto-allowing `{"command":"sync","target":"production"}`).
3668        // Restricting this to the known built-ins whose `subject` really
3669        // IS the whole call leaves every other tool with `subject: None`,
3670        // which routes it through `ApprovalCache::key_for_request`'s
3671        // full-args-digest fallback (F2) instead.
3672        let subject = Self::SUBJECT_BEARING_BUILTIN_TOOLS
3673            .contains(&name)
3674            .then(|| command.or(path).or(patch))
3675            .flatten();
3676        let req = permissions::ApprovalRequest {
3677            tool: name,
3678            subject,
3679            raw_args: args,
3680        };
3681        let approved = permissions::decision_to_approved(decision, || {
3682            permissions::resolve_ask(&self.permissions_approval_cache, handler, &req)
3683        });
3684        if approved {
3685            None
3686        } else {
3687            Some(format!(
3688                "tool `{name}` was not approved for execution (permissions engine: {decision:?})"
3689            ))
3690        }
3691    }
3692
3693    /// P5-6 (§2.2 C6, build brief "a bg `rm -rf` subject to the same deny
3694    /// rules... must never escalate past what a foreground exec of the
3695    /// same command is allowed"): the permission gate `background_exec`
3696    /// runs BEFORE spawning anything. Evaluated against the tool name
3697    /// `"bash"` (not `"background_exec"`) deliberately — so any
3698    /// `bash(...)`-authored deny/ask/allow rule (or protected-path floor)
3699    /// applies to a background command byte-for-byte identically to a
3700    /// foreground `bash` call, the SAME rule set + default baseline
3701    /// [`Self::permissions_gate_denial`] would use for one.
3702    ///
3703    /// The one deliberate difference (C6 itself): an `Ask`-tier decision
3704    /// NEVER reaches an interactive handler here — a background job has no
3705    /// way to block on a prompt it can't answer. When
3706    /// [`Config::subagents_background_prompts`] is
3707    /// [`crate::subagents::BackgroundPromptsPolicy::Parent`], the denied
3708    /// request is additionally queued onto [`Self::pending_child_approvals`]
3709    /// (via [`crate::subagents::ParentQueueApprovalHandler`], reused
3710    /// verbatim — the SAME "parent-surfaced queue" §2.2 C6 names for
3711    /// `subagents.background`, with the job id standing in for a child
3712    /// agent id) for later inspection; any other configuration (including
3713    /// no `background_prompts` set at all) resolves via `handler: None` —
3714    /// [`crate::permissions::resolve_ask`]'s pre-existing "no handler ⇒
3715    /// deny" fail-closed default, identical to `subagents`'s own
3716    /// `AutoPolicy` reading. Either way, `Ask` always denies; only `Allow`
3717    /// (from the rule engine itself, or a PRIOR interactively-granted
3718    /// `AllowForSession` cache entry) ever lets a background command run —
3719    /// so this can only ever be as-or-more restrictive than a foreground
3720    /// call, never looser, regardless of configuration.
3721    ///
3722    /// Covers BOTH gate generations: when [`Config::permissions_enabled`]
3723    /// is on, the P5-1 engine (above) is used; otherwise the legacy
3724    /// [`Config::needs_approval`] gate is consulted but its
3725    /// `approval_handler` closure is NEVER invoked (that closure could
3726    /// itself block, e.g. a real interactive prompt) — an approval-required
3727    /// legacy policy simply denies a background command outright, the same
3728    /// never-hang guarantee under the older gate.
3729    fn background_permission_denial(&self, command: &str, job_id: &str) -> Option<String> {
3730        let args = serde_json::json!({ "command": command });
3731        if self.config.permissions_enabled {
3732            if let Some(crate::subagents::BackgroundPromptsPolicy::Parent) =
3733                self.config.subagents_background_prompts
3734            {
3735                let handler = crate::subagents::ParentQueueApprovalHandler {
3736                    child_agent_id: format!("bg:{job_id}"),
3737                    queue: self.pending_child_approvals.clone(),
3738                };
3739                self.permissions_gate_denial_impl("bash", &args, Some(&handler))
3740            } else {
3741                self.permissions_gate_denial_impl("bash", &args, None)
3742            }
3743        } else if self.config.needs_approval("bash") {
3744            Some(
3745                "tool `bash` requires approval, which a background job cannot request \
3746                 interactively (§2.2 C6: auto-policy denies)"
3747                    .to_string(),
3748            )
3749        } else {
3750            None
3751        }
3752    }
3753
3754    /// P4e (§3.1 `core.parallel_tool_calls`, catalog:59 "Independent
3755    /// sibling calls run concurrently"): runs `calls`' `Tool::execute()`
3756    /// futures CONCURRENTLY via `futures::future::join_all`, for whichever
3757    /// calls [`Self::prepare_tool_call`] resolves to [`PreparedCall::Ready`]
3758    /// — i.e. every plain (non-intrinsic) registry-tool call that passes
3759    /// its synchronous approval/doom-loop/pre-tool-hook checks. A call that
3760    /// resolves to [`PreparedCall::Done`] (an intrinsic, an unknown tool, a
3761    /// denied/blocked call) is NOT parallelized — its result is already in
3762    /// hand from the synchronous prepare pass. Every prepare check still
3763    /// runs sequentially, in original call order, before ANY `execute()`
3764    /// future starts (only the actual tool I/O overlaps) — so doom-loop
3765    /// bookkeeping and pre-tool-hook vetoes see the exact same call order
3766    /// they would under the sequential path. Returns results in the SAME
3767    /// order as `calls`, so callers can always `zip` the two. Post-tool
3768    /// hooks fire per call, in original order, once every result is in
3769    /// hand — a caller-visible timing difference from the sequential path
3770    /// ONLY when this method runs at all (i.e. only when
3771    /// `Config::parallel_tool_calls` is on): hooks see "this batch
3772    /// finished" ordering rather than "this one call finished" ordering.
3773    /// Documented, not a bug.
3774    async fn run_tools_concurrently(
3775        &mut self,
3776        calls: &[crate::message::ToolCall],
3777    ) -> Vec<(String, bool)> {
3778        // P5-3: `spawn_subagent`/`subagent_status` need sequential `&mut
3779        // self` access `prepare_tool_call`'s synchronous-only signature
3780        // can't give them (see `Self::run_tool`'s identical interception).
3781        // A batch that includes one falls back to dispatching the WHOLE
3782        // batch sequentially via `Self::run_tool` — a documented, narrow
3783        // simplification (not a partial-parallelization attempt) rather
3784        // than restructuring `PreparedCall` to carry a future; a batch with
3785        // no subagent intrinsic is completely unaffected and still
3786        // parallelizes exactly as before.
3787        if calls.iter().any(|c| {
3788            c.function.name == SPAWN_SUBAGENT
3789                || c.function.name == SUBAGENT_STATUS
3790                || (self.config.claude_runtime_tools_enabled
3791                    && matches!(
3792                        c.function.name.as_str(),
3793                        CLAUDE_CRON_CREATE
3794                            | CLAUDE_CRON_DELETE
3795                            | CLAUDE_CRON_LIST
3796                            | CLAUDE_SCHEDULE_WAKEUP
3797                    ))
3798        }) {
3799            let mut out = Vec::with_capacity(calls.len());
3800            for call in calls {
3801                out.push(self.run_tool(call).await);
3802            }
3803            return out;
3804        }
3805        let prepared: Vec<PreparedCall> = calls.iter().map(|c| self.prepare_tool_call(c)).collect();
3806        let mut slots: Vec<Option<(String, bool)>> = prepared
3807            .iter()
3808            .map(|p| match p {
3809                PreparedCall::Done(r) => Some(r.clone()),
3810                PreparedCall::Ready { .. } => None,
3811            })
3812            .collect();
3813
3814        let ready_idxs: Vec<usize> = prepared
3815            .iter()
3816            .enumerate()
3817            .filter(|(_, p)| matches!(p, PreparedCall::Ready { .. }))
3818            .map(|(i, _)| i)
3819            .collect();
3820
3821        if !ready_idxs.is_empty() {
3822            let futs = ready_idxs.iter().map(|&i| {
3823                let PreparedCall::Ready { name, args } = &prepared[i] else {
3824                    unreachable!("filtered to Ready above")
3825                };
3826                // `self.registry.get` borrows `self.registry` immutably;
3827                // `self.ctx` is `Clone` (P4c precedent) so each future owns
3828                // its own copy rather than borrowing `self` across the
3829                // `.await` inside `join_all`.
3830                let tool = self.registry.get(name).expect("prepared as Ready");
3831                let args = args.clone();
3832                let ctx = self.ctx.clone();
3833                async move {
3834                    match tool.execute(args, &ctx).await {
3835                        Ok(out) => (out, false),
3836                        Err(e) => (format!("Error: {e}"), true),
3837                    }
3838                }
3839            });
3840            let results = futures::future::join_all(futs).await;
3841            for (idx, result) in ready_idxs.iter().zip(results) {
3842                slots[*idx] = Some(result);
3843            }
3844        }
3845
3846        let out: Vec<(String, bool)> = slots
3847            .into_iter()
3848            .map(|s| s.expect("every call resolved to Some above"))
3849            .collect();
3850        // Post-tool hook, in original order — only for calls that actually
3851        // reached `execute()` (matches `run_tool`'s existing behavior: an
3852        // intrinsic/denied/blocked call never fires the post-tool hook).
3853        let ready_set: std::collections::HashSet<usize> = ready_idxs.into_iter().collect();
3854        for (i, call) in calls.iter().enumerate() {
3855            if !ready_set.contains(&i) {
3856                continue;
3857            }
3858            let (output, is_error) = &out[i];
3859            if let Some(hook) = &self.config.post_tool_hook {
3860                hook(&call.function.name, output, *is_error);
3861            }
3862        }
3863        out
3864    }
3865
3866    /// P4c (§5.2 P4 "doom-loop breaker", §3.1 `core.doom_loop_threshold`):
3867    /// update the consecutive-identical-call streak for `(name, args)` and
3868    /// return `Some(reason)` the moment the streak reaches
3869    /// `Config.doom_loop_threshold` (a call whose name AND JSON-canonical
3870    /// arguments are byte-identical to the immediately preceding call
3871    /// extends the streak; anything else resets it to 1). `None`
3872    /// (`Config.doom_loop_threshold` unset, or `Some(n)` with `n < 2` — a
3873    /// threshold below 2 can never fire since the FIRST call already
3874    /// "repeats zero times") never touches the streak fields at all.
3875    fn check_doom_loop(&mut self, name: &str, args: &serde_json::Value) -> Option<String> {
3876        let threshold = self.config.doom_loop_threshold?;
3877        if threshold < 2 {
3878            return None;
3879        }
3880        // `serde_json::Value::Object` is a `BTreeMap` in this workspace (no
3881        // `preserve_order` feature), so `to_string()` is already
3882        // key-order-canonical — two calls that differ only in argument key
3883        // order are still treated as identical.
3884        let key = (name.to_string(), args.to_string());
3885        if self.doom_loop_last_call.as_ref() == Some(&key) {
3886            self.doom_loop_streak += 1;
3887        } else {
3888            self.doom_loop_last_call = Some(key);
3889            self.doom_loop_streak = 1;
3890        }
3891        if self.doom_loop_streak >= threshold {
3892            Some(format!(
3893                "doom-loop breaker: `{name}` called with identical arguments {} times in a row \
3894                 — try a different approach instead of repeating the same call",
3895                self.doom_loop_streak
3896            ))
3897        } else {
3898            None
3899        }
3900    }
3901
3902    /// Whether `name` is in the eagerly-advertised "core" set for the current
3903    /// [`ToolAdvertising`] mode: every enabled tool under `Full`, or the
3904    /// explicit `core` allowlist under `Deferred`.
3905    fn is_core_tool(&self, name: &str) -> bool {
3906        match &self.config.tool_advertising {
3907            ToolAdvertising::Full => true,
3908            ToolAdvertising::Deferred { core } => core.iter().any(|c| c == name),
3909        }
3910    }
3911
3912    /// The schema advertised on the wire for `t`: the raw (as-shipped)
3913    /// schema with TR-8/T5's per-tool schema tier applied. This is what
3914    /// [`Self::tool_schemas`] sends every request.
3915    fn schema_for(&self, t: &dyn crate::tools::Tool) -> ToolSchema {
3916        let raw = self.raw_schema_for(t);
3917        let tier = self.config.schema_tier_for(t.name());
3918        let (description, parameters) =
3919            crate::tools::tiers::minify(&raw.description, &raw.parameters, tier);
3920        ToolSchema {
3921            name: raw.name,
3922            description,
3923            parameters,
3924        }
3925    }
3926
3927    /// The ORIGINAL, as-shipped schema for `t` — never tier-minified. This is
3928    /// the full contract [`Self::run_tool_search`] hands back on activation
3929    /// (TR-8/T5 dev/03: the B6 fetch path is the invert of tiering, so a
3930    /// model that fetched a tool via `tool_search` always sees the complete
3931    /// schema, byte-equal to `t.description()`/`t.parameters()` — modulo the
3932    /// pre-existing [`crate::Config::tool_description`] override, which is
3933    /// orthogonal to tiering).
3934    fn raw_schema_for(&self, t: &dyn crate::tools::Tool) -> ToolSchema {
3935        ToolSchema {
3936            name: t.name().to_string(),
3937            description: self
3938                .config
3939                .tool_description(t.name(), t.description())
3940                .to_string(),
3941            parameters: t.parameters(),
3942        }
3943    }
3944
3945    /// The synthetic `tool_search` schema advertised under `Deferred` (B6).
3946    fn tool_search_schema() -> ToolSchema {
3947        ToolSchema {
3948            name: TOOL_SEARCH.to_string(),
3949            description: "Search for additional tools not currently advertised (the deferred \
3950                MCP surface and any other non-core tools). Matches keywords case-insensitively \
3951                against each tool's name and description. Matched tools become callable starting \
3952                with your NEXT message, not this one."
3953                .to_string(),
3954            parameters: serde_json::json!({
3955                "type": "object",
3956                "properties": {
3957                    "query": {
3958                        "type": "string",
3959                        "description": "Keyword(s) to search for in tool names and descriptions."
3960                    },
3961                    "max_results": {
3962                        "type": "integer",
3963                        "description": "Maximum number of matching tools to return."
3964                    }
3965                },
3966                "required": ["query"],
3967                "additionalProperties": false
3968            }),
3969        }
3970    }
3971
3972    /// The tool-schema array this agent would advertise on its NEXT
3973    /// request, exactly as `Self::run_loop` computes it. Public
3974    /// (PARITY-18 D1) so a caller can measure the real request-token cost
3975    /// of an agent's tool surface — including the current
3976    /// [`crate::config::ToolAdvertising`] mode's core/deferred split and
3977    /// the synthetic `tool_search`/`expand_reduction`/`sidecar_search`
3978    /// schemas — BEFORE ever calling [`Self::send`], e.g. for a preflight
3979    /// context-guard check.
3980    pub fn tool_schemas(&self) -> Vec<ToolSchema> {
3981        let mut out = match &self.config.tool_advertising {
3982            ToolAdvertising::Full => self
3983                .registry
3984                .iter()
3985                .filter(|t| self.config.tool_enabled(t.name()))
3986                .map(|t| self.schema_for(t))
3987                .collect(),
3988            ToolAdvertising::Deferred { .. } => {
3989                let mut out: Vec<ToolSchema> = self
3990                    .registry
3991                    .iter()
3992                    .filter(|t| self.config.tool_enabled(t.name()))
3993                    .filter(|t| {
3994                        self.is_core_tool(t.name()) || self.activated_tools.contains(t.name())
3995                    })
3996                    .map(|t| self.schema_for(t))
3997                    .collect();
3998                out.push(Self::tool_search_schema());
3999                out
4000            }
4001        };
4002        // T12/TR-1: `expand_reduction`/`sidecar_search` are orthogonal to
4003        // `tool_advertising` (which governs the ordinary tool surface) —
4004        // advertised whenever a `ReductionPolicy` is installed, regardless of
4005        // Full/Deferred, since only a reduced session ever has anything to
4006        // expand or search (SPEC.md TR-1 dev/01).
4007        if self.reduction_policy.is_some() {
4008            out.push(Self::expand_reduction_schema());
4009            out.push(Self::sidecar_search_schema());
4010        }
4011        // P5-3 (§2 module 9): `spawn_subagent`/`subagent_status` are
4012        // orthogonal to `tool_advertising` too, same reasoning as
4013        // `expand_reduction`/`sidecar_search` above — advertised whenever
4014        // `Config::subagents_enabled` is on, Full or Deferred alike.
4015        // `false` (the default) never appends either, so a config that
4016        // never turns the module on gets byte-identical tool schemas to
4017        // today.
4018        if self.config.subagents_enabled {
4019            out.push(self.spawn_subagent_schema());
4020            if self.config.subagents_claude_agent_alias {
4021                out.push(self.claude_agent_schema());
4022            }
4023            if self.config.subagents_background {
4024                out.push(Self::subagent_status_schema());
4025            }
4026        }
4027        if self.config.claude_runtime_tools_enabled {
4028            out.extend(self.claude_builtin_tool_schemas());
4029            out.push(Self::claude_cron_create_schema());
4030            out.push(Self::claude_cron_delete_schema());
4031            out.push(Self::claude_cron_list_schema());
4032            out.push(Self::claude_schedule_wakeup_schema());
4033        }
4034        // P5-6 (§2 module 4 `tools.background`): same orthogonal-to-
4035        // `tool_advertising` treatment, advertised whenever
4036        // `Config::tools_background_enabled` is on. `false` (the default)
4037        // never appends any of the four, so a config that never turns the
4038        // module on gets byte-identical tool schemas to today.
4039        if self.config.tools_background_enabled {
4040            out.push(Self::background_exec_schema());
4041            out.push(Self::background_status_schema());
4042            out.push(Self::background_list_schema());
4043            out.push(Self::background_kill_schema());
4044        }
4045        out
4046    }
4047
4048    fn claude_builtin_tool_schemas(&self) -> Vec<ToolSchema> {
4049        let mut schemas = Vec::new();
4050        let mut push = |alias: &str, native: &str, description: &str, parameters| {
4051            if self.registry.get(native).is_some() && self.config.tool_enabled(native) {
4052                schemas.push(ToolSchema {
4053                    name: alias.to_string(),
4054                    description: description.to_string(),
4055                    parameters,
4056                });
4057            }
4058        };
4059        push(
4060            CLAUDE_BASH,
4061            "bash",
4062            "Claude Code-compatible shell command execution.",
4063            serde_json::json!({
4064                "type": "object",
4065                "properties": {
4066                    "command": {"type": "string"},
4067                    "timeout": {"type": "integer", "description": "Timeout in milliseconds."},
4068                    "description": {"type": "string"}
4069                },
4070                "required": ["command"],
4071                "additionalProperties": true
4072            }),
4073        );
4074        push(
4075            CLAUDE_READ,
4076            "read_file",
4077            "Claude Code-compatible file reader.",
4078            serde_json::json!({
4079                "type": "object",
4080                "properties": {
4081                    "file_path": {"type": "string"},
4082                    "offset": {"type": "integer"},
4083                    "limit": {"type": "integer"}
4084                },
4085                "required": ["file_path"],
4086                "additionalProperties": false
4087            }),
4088        );
4089        push(
4090            CLAUDE_WRITE,
4091            "write_file",
4092            "Claude Code-compatible file writer.",
4093            serde_json::json!({
4094                "type": "object",
4095                "properties": {"file_path": {"type": "string"}, "content": {"type": "string"}},
4096                "required": ["file_path", "content"],
4097                "additionalProperties": false
4098            }),
4099        );
4100        push(
4101            CLAUDE_EDIT,
4102            "edit_file",
4103            "Claude Code-compatible exact file edit.",
4104            serde_json::json!({
4105                "type": "object",
4106                "properties": {
4107                    "file_path": {"type": "string"},
4108                    "old_string": {"type": "string"},
4109                    "new_string": {"type": "string"},
4110                    "replace_all": {"type": "boolean"}
4111                },
4112                "required": ["file_path", "old_string", "new_string"],
4113                "additionalProperties": false
4114            }),
4115        );
4116        push(
4117            CLAUDE_GLOB,
4118            "glob",
4119            "Claude Code-compatible file glob.",
4120            serde_json::json!({
4121                "type": "object",
4122                "properties": {"pattern": {"type": "string"}, "path": {"type": "string"}},
4123                "required": ["pattern"],
4124                "additionalProperties": false
4125            }),
4126        );
4127        push(
4128            CLAUDE_GREP,
4129            "search",
4130            "Claude Code-compatible content search.",
4131            serde_json::json!({
4132                "type": "object",
4133                "properties": {"pattern": {"type": "string"}, "path": {"type": "string"}},
4134                "required": ["pattern"],
4135                "additionalProperties": true
4136            }),
4137        );
4138        schemas
4139    }
4140
4141    fn translate_claude_builtin_call(
4142        &self,
4143        call: &crate::message::ToolCall,
4144    ) -> Result<Option<crate::message::ToolCall>> {
4145        let native = match call.function.name.as_str() {
4146            CLAUDE_BASH => "bash",
4147            CLAUDE_READ => "read_file",
4148            CLAUDE_WRITE => "write_file",
4149            CLAUDE_EDIT => "edit_file",
4150            CLAUDE_GLOB => "glob",
4151            CLAUDE_GREP => "search",
4152            _ => return Ok(None),
4153        };
4154        let mut args = call.function.parsed_arguments()?;
4155        let object = args
4156            .as_object_mut()
4157            .ok_or_else(|| Error::InvalidArguments {
4158                tool: call.function.name.clone(),
4159                message: "expected a JSON object".to_string(),
4160            })?;
4161        if let Some(path) = object.remove("file_path") {
4162            object.entry("path".to_string()).or_insert(path);
4163        }
4164        if call.function.name == CLAUDE_BASH {
4165            if let Some(timeout) = object.remove("timeout") {
4166                object.entry("timeout_ms".to_string()).or_insert(timeout);
4167            }
4168        }
4169        if call.function.name == CLAUDE_GLOB {
4170            if let Some(path) = object
4171                .remove("path")
4172                .and_then(|value| value.as_str().map(str::to_owned))
4173            {
4174                if let Some(pattern) = object.get_mut("pattern") {
4175                    if let Some(value) = pattern.as_str() {
4176                        if !std::path::Path::new(value).is_absolute() {
4177                            *pattern = serde_json::Value::String(
4178                                std::path::Path::new(&path)
4179                                    .join(value)
4180                                    .to_string_lossy()
4181                                    .into_owned(),
4182                            );
4183                        }
4184                    }
4185                }
4186            }
4187        }
4188        let mut translated = call.clone();
4189        translated.function.name = native.to_string();
4190        translated.function.arguments = serde_json::to_string(&args)?;
4191        Ok(Some(translated))
4192    }
4193
4194    fn claude_cron_create_schema() -> ToolSchema {
4195        ToolSchema {
4196            name: CLAUDE_CRON_CREATE.to_string(),
4197            description: "Record a Claude-compatible cron job in the imported runtime manifest. \
4198                The job inherits the manifest's ACTIVE or PAUSED posture; an embedding scheduler, \
4199                not this agent loop, owns execution."
4200                .to_string(),
4201            parameters: serde_json::json!({
4202                "type": "object",
4203                "properties": {
4204                    "cron": {"type": "string", "description": "Cron expression to preserve."},
4205                    "prompt": {"type": "string", "description": "Prompt associated with the job."},
4206                    "recurring": {"type": "boolean", "default": false},
4207                    "durable": {"type": "boolean", "default": false}
4208                },
4209                "required": ["cron", "prompt"],
4210                "additionalProperties": false
4211            }),
4212        }
4213    }
4214
4215    fn claude_cron_delete_schema() -> ToolSchema {
4216        ToolSchema {
4217            name: CLAUDE_CRON_DELETE.to_string(),
4218            description: "Delete a Claude-compatible cron job from the imported manifest. \
4219                This updates state only; an embedding scheduler owns execution."
4220                .to_string(),
4221            parameters: serde_json::json!({
4222                "type": "object",
4223                "properties": {"id": {"type": "string"}},
4224                "required": ["id"],
4225                "additionalProperties": false
4226            }),
4227        }
4228    }
4229
4230    fn claude_cron_list_schema() -> ToolSchema {
4231        ToolSchema {
4232            name: CLAUDE_CRON_LIST.to_string(),
4233            description: "List imported Claude cron jobs and their explicit ACTIVE or PAUSED \
4234                manifest posture. This agent loop itself does not run a scheduler."
4235                .to_string(),
4236            parameters: serde_json::json!({
4237                "type": "object",
4238                "properties": {},
4239                "additionalProperties": false
4240            }),
4241        }
4242    }
4243
4244    fn claude_schedule_wakeup_schema() -> ToolSchema {
4245        ToolSchema {
4246            name: CLAUDE_SCHEDULE_WAKEUP.to_string(),
4247            description: "Replace the one-shot wakeup stored in the imported Claude manifest. \
4248                The wakeup inherits the manifest's ACTIVE or PAUSED posture; an embedding scheduler \
4249                owns timer execution."
4250                .to_string(),
4251            parameters: serde_json::json!({
4252                "type": "object",
4253                "properties": {
4254                    "delaySeconds": {"type": "integer", "minimum": 0},
4255                    "reason": {"type": "string"},
4256                    "prompt": {"type": "string"}
4257                },
4258                "required": ["delaySeconds"],
4259                "additionalProperties": false
4260            }),
4261        }
4262    }
4263
4264    /// The `background_exec` schema (P5-6, §2 module 4, D1 "background
4265    /// exec").
4266    fn background_exec_schema() -> ToolSchema {
4267        ToolSchema {
4268            name: BACKGROUND_EXEC.to_string(),
4269            description: "Run a shell command in the BACKGROUND: spawns it as a detached \
4270                process and returns a `job_id` IMMEDIATELY, before the command finishes — this \
4271                call never returns the command's output. Poll `background_status` with the \
4272                `job_id` to check progress and retrieve captured output; use `background_kill` \
4273                to cancel it early. The command goes through the exact same sandbox/permission \
4274                checks as a foreground `bash` call, and any check that would need an \
4275                interactive approval is denied automatically (a background job cannot wait for \
4276                one)."
4277                .to_string(),
4278            parameters: serde_json::json!({
4279                "type": "object",
4280                "properties": {
4281                    "command": {
4282                        "type": "string",
4283                        "description": "Shell command to run in the background via `sh -c`."
4284                    }
4285                },
4286                "required": ["command"],
4287                "additionalProperties": false
4288            }),
4289        }
4290    }
4291
4292    /// The `background_status` schema (P5-6, D1 "monitor/event feed").
4293    fn background_status_schema() -> ToolSchema {
4294        ToolSchema {
4295            name: BACKGROUND_STATUS.to_string(),
4296            description: "Check on a background job spawned via background_exec: its \
4297                running/exited/killed status, exit code (once known), and the command's \
4298                captured stdout/stderr so far (bounded — very large output is truncated with a \
4299                marker). Once the job has exited or been killed, this call also reaps it (it \
4300                will no longer appear in background_list or accept further status polls)."
4301                .to_string(),
4302            parameters: serde_json::json!({
4303                "type": "object",
4304                "properties": {
4305                    "job_id": {
4306                        "type": "string",
4307                        "description": "The id `background_exec` returned when this job was \
4308                            started."
4309                    }
4310                },
4311                "required": ["job_id"],
4312                "additionalProperties": false
4313            }),
4314        }
4315    }
4316
4317    /// The `background_list` schema (P5-6, D10 "bg-manager").
4318    fn background_list_schema() -> ToolSchema {
4319        ToolSchema {
4320            name: BACKGROUND_LIST.to_string(),
4321            description: "List every background job currently tracked (running, or finished \
4322                but not yet polled via background_status) — job id, command, status, pid, and \
4323                start time for each. Does not retrieve output or reap anything."
4324                .to_string(),
4325            parameters: serde_json::json!({
4326                "type": "object",
4327                "properties": {},
4328                "additionalProperties": false
4329            }),
4330        }
4331    }
4332
4333    /// The `background_kill` schema (P5-6, D10 "bg-manager").
4334    fn background_kill_schema() -> ToolSchema {
4335        ToolSchema {
4336            name: BACKGROUND_KILL.to_string(),
4337            description: "Kill a background job's real process immediately (a no-op, not an \
4338                error, if it already exited on its own) and reap it."
4339                .to_string(),
4340            parameters: serde_json::json!({
4341                "type": "object",
4342                "properties": {
4343                    "job_id": {
4344                        "type": "string",
4345                        "description": "The id `background_exec` returned when this job was \
4346                            started."
4347                    }
4348                },
4349                "required": ["job_id"],
4350                "additionalProperties": false
4351            }),
4352        }
4353    }
4354
4355    /// The `spawn_subagent` schema (P5-3, §2 module 9 D1 "spawn tool").
4356    /// Lists every configured `agent_type` name so the model knows what's
4357    /// available, but `agent_type` stays optional — an ad-hoc spawn with an
4358    /// inline `system_prompt` is always allowed too.
4359    fn spawn_subagent_schema(&self) -> ToolSchema {
4360        let mut names: Vec<&str> = self
4361            .config
4362            .subagents_definitions
4363            .keys()
4364            .map(String::as_str)
4365            .collect();
4366        names.sort_unstable();
4367        let agent_type_desc = if names.is_empty() {
4368            "Optional named subagent type to run (none configured — omit this and pass \
4369             `system_prompt` instead)."
4370                .to_string()
4371        } else {
4372            format!(
4373                "Optional named subagent type to run: {}. Omit to run an ad-hoc subagent with \
4374                 your own `system_prompt` instead.",
4375                names.join(", ")
4376            )
4377        };
4378        let background_desc = if self.config.subagents_background {
4379            "Run this subagent in the background instead of waiting for it — this call \
4380             returns immediately with a `subagent_id`; poll `subagent_status` with that id for \
4381             the result."
4382        } else {
4383            "Background subagents are disabled for this agent — this must be omitted or false."
4384        };
4385        ToolSchema {
4386            name: SPAWN_SUBAGENT.to_string(),
4387            description: "Spawn a subagent to work on a self-contained task and (by default) \
4388                wait for its final answer, which is returned as this call's result. The \
4389                subagent runs its own independent reasoning/tool loop; it does not see your \
4390                conversation except for the `task` text you give it here."
4391                .to_string(),
4392            parameters: serde_json::json!({
4393                "type": "object",
4394                "properties": {
4395                    "task": {
4396                        "type": "string",
4397                        "description": "The self-contained task/prompt for the subagent."
4398                    },
4399                    "agent_type": {
4400                        "type": "string",
4401                        "description": agent_type_desc
4402                    },
4403                    "system_prompt": {
4404                        "type": "string",
4405                        "description": "Inline system prompt for an ad-hoc subagent (ignored \
4406                            if `agent_type` is given — the named type's own prompt is used \
4407                            instead)."
4408                    },
4409                    "background": {
4410                        "type": "boolean",
4411                        "description": background_desc
4412                    }
4413                },
4414                "required": ["task"],
4415                "additionalProperties": false
4416            }),
4417        }
4418    }
4419
4420    /// Claude Code-compatible alias for [`Self::spawn_subagent_schema`].
4421    fn claude_agent_schema(&self) -> ToolSchema {
4422        let mut names: Vec<String> = self.config.subagents_definitions.keys().cloned().collect();
4423        names.push("general-purpose".into());
4424        names.sort_unstable();
4425        names.dedup();
4426        ToolSchema {
4427            name: CLAUDE_AGENT.to_string(),
4428            description: "Claude Code-compatible subagent dispatcher. Runs a named or ad-hoc \
4429                child agent; children default to background execution in this compatibility mode."
4430                .to_string(),
4431            parameters: serde_json::json!({
4432                "type": "object",
4433                "properties": {
4434                    "prompt": {"type": "string", "description": "Self-contained child task."},
4435                    "subagent_type": {
4436                        "type": "string",
4437                        "description": format!("Named agent type. Available: {}", names.join(", "))
4438                    },
4439                    "description": {
4440                        "type": "string",
4441                        "description": "Short human-facing task label; preserved as descriptive input."
4442                    },
4443                    "model": {
4444                        "type": "string",
4445                        "description": "Optional model alias or full provider slug for this child."
4446                    },
4447                    "run_in_background": {
4448                        "type": "boolean",
4449                        "description": "Whether to return immediately with a child id (default true)."
4450                    }
4451                },
4452                "required": ["prompt"],
4453                "additionalProperties": false
4454            }),
4455        }
4456    }
4457
4458    /// Translate Claude's `Agent` arguments to the native subagent intrinsic.
4459    fn translate_claude_agent_call(
4460        &self,
4461        call: &crate::message::ToolCall,
4462    ) -> Result<crate::message::ToolCall> {
4463        let args = call
4464            .function
4465            .parsed_arguments()
4466            .map_err(|error| Error::InvalidArguments {
4467                tool: CLAUDE_AGENT.to_string(),
4468                message: error.to_string(),
4469            })?;
4470        let object = args.as_object().ok_or_else(|| Error::InvalidArguments {
4471            tool: CLAUDE_AGENT.to_string(),
4472            message: "arguments must be an object".to_string(),
4473        })?;
4474        let mut translated = serde_json::Map::new();
4475        if let Some(value) = object.get("prompt") {
4476            translated.insert("task".to_string(), value.clone());
4477        }
4478        if let Some(value) = object.get("subagent_type") {
4479            // `general-purpose` is a built-in Claude agent, not a project
4480            // definition file. Supercode's equivalent is an ad-hoc child
4481            // using the inherited default system prompt, represented by an
4482            // omitted `agent_type`.
4483            if value.as_str() != Some("general-purpose") {
4484                translated.insert("agent_type".to_string(), value.clone());
4485            }
4486        }
4487        if let Some(value) = object.get("model") {
4488            translated.insert("model".to_string(), value.clone());
4489        }
4490        translated.insert(
4491            "background".to_string(),
4492            object
4493                .get("run_in_background")
4494                .cloned()
4495                .unwrap_or(serde_json::Value::Bool(true)),
4496        );
4497        Ok(crate::message::ToolCall {
4498            id: call.id.clone(),
4499            kind: call.kind.clone(),
4500            function: crate::message::FunctionCall {
4501                name: SPAWN_SUBAGENT.to_string(),
4502                arguments: serde_json::Value::Object(translated).to_string(),
4503            },
4504        })
4505    }
4506
4507    /// Execute Claude's scheduling vocabulary against the imported manifest.
4508    ///
4509    /// This is intentionally a state editor, not a scheduler: it owns no
4510    /// timer/task handle and every successful response names the manifest's
4511    /// active/paused posture explicitly. Active creates get real UTC
4512    /// timestamps so an embedding scheduler can consume the persisted state.
4513    fn run_claude_runtime_tool(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
4514        let args = match call.function.parsed_arguments() {
4515            Ok(value) if value.is_object() => value,
4516            Ok(_) => {
4517                return (
4518                    format!("Error: {} arguments must be an object", call.function.name),
4519                    true,
4520                )
4521            }
4522            Err(error) => return (format!("Error: {error}"), true),
4523        };
4524        let object = args.as_object().expect("checked object above");
4525
4526        let Some(manifest) = self.claude_runtime_manifest.as_mut() else {
4527            return (
4528                "Error: Claude runtime compatibility was enabled without an imported runtime \
4529                 manifest; refusing to invent scheduler state"
4530                    .to_string(),
4531                true,
4532            );
4533        };
4534        let active = matches!(
4535            manifest.execution_state,
4536            crate::claude_runtime_state::ClaudeRuntimeExecutionState::Active
4537        );
4538        let state = if active { "active" } else { "paused" };
4539        let active_now_ms = active.then(now_ms);
4540        let active_now_unix = active_now_ms.map(|ms| ms.div_euclid(1_000));
4541
4542        match call.function.name.as_str() {
4543            CLAUDE_CRON_LIST => {
4544                let jobs: Vec<serde_json::Value> = manifest
4545                    .active_crons
4546                    .iter()
4547                    .map(|job| {
4548                        serde_json::json!({
4549                            "id": job.id,
4550                            "cron": job.schedule,
4551                            "prompt": job.prompt,
4552                            "recurring": job.recurring,
4553                            "durable": job.durable_requested,
4554                            "state": state
4555                        })
4556                    })
4557                    .collect();
4558                let notice = if active {
4559                    "The manifest is active; an attached scheduler may claim due jobs."
4560                } else {
4561                    "Imported jobs are preserved but no scheduler is running."
4562                };
4563                (
4564                    serde_json::json!({
4565                        "execution_state": state,
4566                        "execution_notice": notice,
4567                        "jobs": jobs
4568                    })
4569                    .to_string(),
4570                    false,
4571                )
4572            }
4573            CLAUDE_CRON_CREATE => {
4574                let Some(schedule) = object.get("cron").and_then(serde_json::Value::as_str) else {
4575                    return ("Error: CronCreate requires string `cron`".to_string(), true);
4576                };
4577                let Some(prompt) = object.get("prompt").and_then(serde_json::Value::as_str) else {
4578                    return (
4579                        "Error: CronCreate requires string `prompt`".to_string(),
4580                        true,
4581                    );
4582                };
4583                let recurring = object
4584                    .get("recurring")
4585                    .and_then(serde_json::Value::as_bool)
4586                    .unwrap_or(false);
4587                let durable_requested = object
4588                    .get("durable")
4589                    .and_then(serde_json::Value::as_bool)
4590                    .unwrap_or(false);
4591                let mut sequence = 1_u64;
4592                let id = loop {
4593                    let candidate = format!("sc{sequence:06}");
4594                    if !manifest.active_crons.iter().any(|job| job.id == candidate) {
4595                        break candidate;
4596                    }
4597                    sequence += 1;
4598                };
4599                let kind = if recurring { "recurring " } else { "" };
4600                let created_at = active_now_ms.map(crate::sidecar::ms_to_rfc3339);
4601                let result = if active {
4602                    format!(
4603                        "Scheduled {kind}job {id} ({schedule}) in ACTIVE state. The job is \
4604                         eligible for execution by the attached scheduler."
4605                    )
4606                } else {
4607                    format!(
4608                        "Scheduled {kind}job {id} ({schedule}) in PAUSED state. The job is preserved \
4609                         in the continuation manifest but no scheduler is running and it will not execute."
4610                    )
4611                };
4612                manifest
4613                    .active_crons
4614                    .push(crate::claude_runtime_state::ClaudeCronJob {
4615                        id: id.clone(),
4616                        tool_use_id: call.id.clone(),
4617                        schedule: schedule.to_string(),
4618                        recurring,
4619                        durable_requested,
4620                        prompt: prompt.to_string(),
4621                        created_at,
4622                        expires_after_seconds: None,
4623                        creation_result: result.clone(),
4624                    });
4625                manifest
4626                    .active_crons
4627                    .sort_by(|left, right| left.id.cmp(&right.id));
4628                if let Some(now_unix) = active_now_unix {
4629                    let scheduler_before = manifest.scheduler.clone();
4630                    if let Err(error) = manifest.reconcile_scheduler(now_unix) {
4631                        manifest.active_crons.retain(|job| job.id != id);
4632                        manifest.scheduler = scheduler_before;
4633                        return (format!("Error: {error}"), true);
4634                    }
4635                }
4636                (result, false)
4637            }
4638            CLAUDE_CRON_DELETE => {
4639                let Some(id) = object.get("id").and_then(serde_json::Value::as_str) else {
4640                    return ("Error: CronDelete requires string `id`".to_string(), true);
4641                };
4642                let Some(index) = manifest.active_crons.iter().position(|job| job.id == id) else {
4643                    return (
4644                        format!("Error: unknown {state} Claude cron job `{id}`"),
4645                        true,
4646                    );
4647                };
4648                let removed = manifest.active_crons.remove(index);
4649                if let Some(now_unix) = active_now_unix {
4650                    let scheduler_before = manifest.scheduler.clone();
4651                    if let Err(error) = manifest.reconcile_scheduler(now_unix) {
4652                        manifest.active_crons.insert(index, removed);
4653                        manifest.scheduler = scheduler_before;
4654                        return (format!("Error: {error}"), true);
4655                    }
4656                }
4657                let result = if active {
4658                    format!("Cancelled job {id}. The job was removed from ACTIVE scheduler state.")
4659                } else {
4660                    format!("Cancelled job {id}. The job was PAUSED; no execution occurred.")
4661                };
4662                (result, false)
4663            }
4664            CLAUDE_SCHEDULE_WAKEUP => {
4665                let Some(delay_seconds) = object
4666                    .get("delaySeconds")
4667                    .and_then(serde_json::Value::as_u64)
4668                else {
4669                    return (
4670                        "Error: ScheduleWakeup requires integer `delaySeconds`".to_string(),
4671                        true,
4672                    );
4673                };
4674                let reason = object
4675                    .get("reason")
4676                    .and_then(serde_json::Value::as_str)
4677                    .map(str::to_string);
4678                let prompt = object
4679                    .get("prompt")
4680                    .and_then(serde_json::Value::as_str)
4681                    .map(str::to_string);
4682                let created_at = active_now_ms.map(crate::sidecar::ms_to_rfc3339);
4683                let scheduled_for = active_now_ms
4684                    .map(|now| {
4685                        let delay_ms = i64::try_from(delay_seconds)
4686                            .unwrap_or(i64::MAX)
4687                            .saturating_mul(1_000);
4688                        crate::sidecar::ms_to_rfc3339(now.saturating_add(delay_ms))
4689                    })
4690                    .unwrap_or_else(|| "PAUSED".to_string());
4691                let result = if active {
4692                    format!(
4693                        "Next wakeup scheduled for {scheduled_for} (in {delay_seconds}s). Runtime \
4694                         state is ACTIVE; the attached scheduler may execute it."
4695                    )
4696                } else {
4697                    format!(
4698                        "Next wakeup scheduled for PAUSED (in {delay_seconds}s). The request replaced \
4699                         the prior wakeup in the manifest, but no timer is running and it will not execute."
4700                    )
4701                };
4702                let previous_wakeups = if active {
4703                    Some(manifest.pending_wakeups.clone())
4704                } else {
4705                    None
4706                };
4707                manifest.pending_wakeups.clear();
4708                manifest
4709                    .pending_wakeups
4710                    .push(crate::claude_runtime_state::ClaudeWakeup {
4711                        tool_use_id: call.id.clone(),
4712                        delay_seconds,
4713                        reason,
4714                        prompt,
4715                        created_at,
4716                        scheduled_for: Some(scheduled_for),
4717                        creation_result: result.clone(),
4718                    });
4719                if let Some(now_unix) = active_now_unix {
4720                    let scheduler_before = manifest.scheduler.clone();
4721                    if let Err(error) = manifest.reconcile_scheduler(now_unix) {
4722                        manifest.pending_wakeups = previous_wakeups.unwrap_or_default();
4723                        manifest.scheduler = scheduler_before;
4724                        return (format!("Error: {error}"), true);
4725                    }
4726                }
4727                (result, false)
4728            }
4729            _ => unreachable!("runtime tool dispatch is name-gated"),
4730        }
4731    }
4732
4733    /// The `subagent_status` schema (P5-3, D3 "background+resume").
4734    fn subagent_status_schema() -> ToolSchema {
4735        ToolSchema {
4736            name: SUBAGENT_STATUS.to_string(),
4737            description: "Check on (and, once finished, retrieve the result of) a background \
4738                subagent spawned via spawn_subagent with background=true. Pass the \
4739                `subagent_id` that spawn returned."
4740                .to_string(),
4741            parameters: serde_json::json!({
4742                "type": "object",
4743                "properties": {
4744                    "subagent_id": {
4745                        "type": "string",
4746                        "description": "The id `spawn_subagent` returned when this subagent \
4747                            was spawned."
4748                    }
4749                },
4750                "required": ["subagent_id"],
4751                "additionalProperties": false
4752            }),
4753        }
4754    }
4755
4756    /// Build the CHILD `Config` a `spawn_subagent` call constructs its
4757    /// [`Agent`] from. The whole point of this method (§5.3-style
4758    /// "monotonic posture", build-brief "a subagent inherits or narrows —
4759    /// never widens — the parent's permission posture"): every field that
4760    /// governs what the child is ALLOWED to do (sandbox, approval,
4761    /// tool_overrides, deny/allow patterns, protected paths, the subagents
4762    /// caps themselves) is copied VERBATIM from `self.config` — never
4763    /// loosened — and the only NARROWING lever is `definition.tools`
4764    /// (intersected with whatever the parent already had enabled, never
4765    /// unioned in anything new).
4766    ///
4767    /// P5-3 safety hardening (Fable-5 review, LOW-MEDIUM "child safety-limit
4768    /// inheritance"): the monotonic-posture guarantee above was, before this
4769    /// fix, scoped to PERMISSION fields only — a child could still silently
4770    /// get a LOOSER safety BUDGET/BREAKER than its parent, because
4771    /// `max_total_output_tokens`/`max_tool_output_bytes`/`max_tokens`/
4772    /// `doom_loop_threshold`/`edit_file_require_read_before_edit` were never
4773    /// copied and so fell back to `Config::default()`'s (looser/uncapped)
4774    /// values on every spawn regardless of what the parent had configured.
4775    /// These are now copied verbatim alongside the permission-posture
4776    /// fields — a parent that capped its own output/tool-output/doom-loop
4777    /// exposure, or required read-before-edit, gets a child that is bound
4778    /// by the exact same ceiling, never a wider one.
4779    ///
4780    /// **Full field-by-field accounting** (every [`Config`] field, so this
4781    /// doc comment stays the single place that answers "did we forget
4782    /// one?"): fields already copied above/below this note (permission
4783    /// posture: `sandbox`/`approval`/`tool_overrides`/`auto_approved_tools`/
4784    /// `tool_deny_patterns`/`tool_allow_patterns`/`permissions_enabled`/
4785    /// `permissions_ask_patterns`/`permissions_protected_paths`/
4786    /// `network_policy`/`core_tools_enabled`/`module_registry`/
4787    /// `module_activation`/every `subagents_*` field; safety limits:
4788    /// `max_iterations`/`max_total_output_tokens`/`max_tool_output_bytes`/
4789    /// `max_tokens`/`doom_loop_threshold`/`edit_file_require_read_before_edit`;
4790    /// identity/transport: `model`/`system_prompt`/`cwd`/`base_url`/
4791    /// `api_key`/`api_key_env`/`api_key_cmd`) are the ones that gate
4792    /// harm/spend/hazard exposure. Every OTHER field is deliberately left at
4793    /// `Config::default()` because none of them is a safety ceiling the
4794    /// child could "loosen" by missing it:
4795    /// - `temperature`/`effort`/`response_format`/`extra_body`/`extra_headers`/
4796    ///   `tool_advertising`/`tool_schema_tier`/`cache_plan`/`cache_warnings`/
4797    ///   `reduction_policy`/
4798    ///   `session_*`/`small_model`/`model_fallback`/`env_context`/
4799    ///   `project_root_markers`/`project_doc_max_bytes`/`instruction_imports`/
4800    ///   `retry_*`/`compaction_*`/`auto_title`/`steering_mode`/
4801    ///   `follow_up_mode`/`read_file_multimodal`/`edit_file_notebook_aware`/
4802    ///   `shell_env_snapshot`/`nested_instructions`/`model_switch_allow_switch`/
4803    ///   `context_injections`/`context_injection_blocks`/`parallel_tool_calls`
4804    ///   are behavior/cost-shaping or presentation knobs, not hard guards —
4805    ///   a child defaulting on any of these can do LESS (e.g. no multimodal
4806    ///   read, no notebook-aware edits, no proactive compaction) or the same,
4807    ///   never something the parent hadn't already exposed it to. Several
4808    ///   default to their OFF/conservative state (`false`/`None`), which is
4809    ///   the tight direction, not the loose one.
4810    /// - `additional_dirs`: governs which extra roots are reachable at all
4811    ///   (`presets.rs`'s `[core] additional_dirs` note) — a child that
4812    ///   doesn't inherit it has FEWER reachable roots than its parent, i.e.
4813    ///   strictly tighter, never looser.
4814    /// - `load_project_context`: whether instruction files are auto-loaded
4815    ///   into the system prompt — a read-time convenience, not an access
4816    ///   grant (`sandbox`/`permissions_protected_paths` already gate actual
4817    ///   file access).
4818    /// - `prompts`: named `/slash` command templates for THIS agent's own
4819    ///   user-facing input surface, not something the model can invoke
4820    ///   against the child's tool surface.
4821    /// - `stop_gate`/`post_tool_hook`/`approval_handler`/`event_sink`:
4822    ///   code-only `Box<dyn Fn>` callbacks (see the `pre_tool_hook` note
4823    ///   immediately below — same non-`Clone` shape) that are observational
4824    ///   or terminate-only, not a call-time veto over what a tool is allowed
4825    ///   to do; `approval_handler` specifically is ALREADY documented at
4826    ///   this method's call site (`Self::run_spawn_subagent`) as
4827    ///   intentionally never set here — a foreground child gets no handler
4828    ///   by design, an embedder installs its own after spawn if it wants
4829    ///   one.
4830    ///
4831    /// **`pre_tool_hook` cannot propagate, and this is deliberate + named,
4832    /// not a silent gap**: `Config::pre_tool_hook` is a `Box<dyn Fn(&str,
4833    /// &serde_json::Value) -> Option<String> + Send + Sync>` — an
4834    /// embedder's own call-time veto over every tool call. `Box<dyn Fn>` is
4835    /// not `Clone` (there is no generic way to duplicate an opaque closure),
4836    /// so it genuinely CANNOT be copied into a child `Config` the way every
4837    /// `Clone`-able field above is — there is no fix that makes this one
4838    /// "verbatim copy" like the others. An embedder relying on a
4839    /// `pre_tool_hook` veto reaching spawned children as well as the parent
4840    /// MUST re-install one on the child explicitly (e.g. via a
4841    /// `spawn_subagent`-adjacent hook of their own, or by not relying on
4842    /// `pre_tool_hook` alone for anything safety-critical across a spawn
4843    /// boundary) — named here so this is a documented contract, not a gap
4844    /// an embedder discovers by a child silently misbehaving.
4845    fn build_child_config(
4846        &self,
4847        definition: Option<&crate::subagents::NamedAgentDefinition>,
4848        inline_system_prompt: Option<String>,
4849        model_override: Option<String>,
4850    ) -> Config {
4851        let system_prompt = definition
4852            .map(|d| d.system_prompt.clone())
4853            .filter(|s| !s.is_empty())
4854            .or(inline_system_prompt)
4855            .unwrap_or_else(|| self.config.system_prompt.clone());
4856        let model = model_override.unwrap_or_else(|| self.config.model.clone());
4857
4858        let mut child = Config::builder()
4859            .model(model)
4860            .system_prompt(system_prompt)
4861            .cwd(self.config.cwd.clone())
4862            // Monotonic: verbatim, never loosened.
4863            .sandbox(self.config.sandbox)
4864            .approval(self.config.approval)
4865            .max_iterations(self.config.max_iterations)
4866            .build();
4867        child.base_url = self.config.base_url.clone();
4868        child.api_key = self.config.api_key.clone();
4869        child.api_key_env = self.config.api_key_env.clone();
4870        child.api_key_cmd = self.config.api_key_cmd.clone();
4871        // P5-3 safety hardening (Fable-5 review, LOW-MEDIUM "child
4872        // safety-limit inheritance"): the monotonic-posture spirit extends
4873        // to safety BUDGETS/BREAKERS, not just permissions — a child must
4874        // not get a looser cap/breaker than its parent by simply falling
4875        // back to `Config::default()`'s (looser) values. See this method's
4876        // doc comment for the full field-by-field accounting.
4877        child.max_total_output_tokens = self.config.max_total_output_tokens;
4878        child.max_tool_output_bytes = self.config.max_tool_output_bytes;
4879        child.max_tokens = self.config.max_tokens;
4880        child.doom_loop_threshold = self.config.doom_loop_threshold;
4881        child.edit_file_require_read_before_edit = self.config.edit_file_require_read_before_edit;
4882        // Monotonic tool posture: start from the PARENT's own overrides
4883        // (so anything the parent already disabled stays disabled), then
4884        // narrow further if a named definition restricts the tool set.
4885        child.tool_overrides = self.config.tool_overrides.clone();
4886        child.auto_approved_tools = self.config.auto_approved_tools.clone();
4887        child.tool_deny_patterns = self.config.tool_deny_patterns.clone();
4888        child.tool_allow_patterns = self.config.tool_allow_patterns.clone();
4889        child.permissions_enabled = self.config.permissions_enabled;
4890        child.permissions_ask_patterns = self.config.permissions_ask_patterns.clone();
4891        child.permissions_protected_paths = self.config.permissions_protected_paths.clone();
4892        child.network_policy = self.config.network_policy.clone();
4893        // P5-10 (§2 module 12): same monotonic-posture treatment as
4894        // `sandbox`/`approval` above — a subagent must inherit its
4895        // parent's OS-sandbox posture verbatim, never a looser
4896        // `Config::default()` fallback (`sandbox_os_enabled: None`,
4897        // `escalation: Deny`, `env_policy: Inherit` would otherwise be
4898        // right back to "confine only when the tier itself says so" for a
4899        // child whose parent explicitly forced the backstop on/off).
4900        child.sandbox_os_enabled = self.config.sandbox_os_enabled;
4901        child.sandbox_escalation = self.config.sandbox_escalation;
4902        child.sandbox_env_policy = self.config.sandbox_env_policy;
4903        if let Some(def) = definition {
4904            if let Some(allowed) = &def.tools {
4905                for name in &self.config.core_tools_enabled {
4906                    if !allowed.iter().any(|t| t == name) {
4907                        child
4908                            .tool_overrides
4909                            .entry(name.clone())
4910                            .or_default()
4911                            .enabled = Some(false);
4912                    }
4913                }
4914            }
4915        }
4916        child.core_tools_enabled = self.config.core_tools_enabled.clone();
4917        child.module_registry = self.config.module_registry;
4918        child.module_activation = self.config.module_activation.clone();
4919        // The subagents module itself never widens either: a child spawned
4920        // at depth d+1 inherits the SAME caps (never a looser depth/
4921        // concurrency/background posture than its own parent).
4922        child.subagents_enabled = self.config.subagents_enabled;
4923        child.subagents_max_depth = self.config.subagents_max_depth;
4924        child.subagents_max_concurrent = self.config.subagents_max_concurrent;
4925        child.subagents_background = self.config.subagents_background;
4926        child.subagents_background_prompts = self.config.subagents_background_prompts;
4927        child.subagents_claude_agent_alias = self.config.subagents_claude_agent_alias;
4928        child.subagents_definitions = self.config.subagents_definitions.clone();
4929        child.subagent_depth = self.subagent_depth + 1;
4930        child
4931    }
4932
4933    /// Execute the `spawn_subagent` intrinsic (P5-3, §2 module 9). See
4934    /// `Self::build_child_config` for the monotonic-posture guarantee and
4935    /// `crate::subagents` for the depth/concurrency resource bounds and the
4936    /// §2.2 C6 background-policy enforcement.
4937    async fn run_spawn_subagent(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
4938        if !self.config.subagents_enabled {
4939            let err = Error::UnknownTool(SPAWN_SUBAGENT.to_string());
4940            return (format!("Error: {err}"), true);
4941        }
4942        let args = match call.function.parsed_arguments() {
4943            Ok(v) => v,
4944            Err(e) => {
4945                let err = Error::InvalidArguments {
4946                    tool: SPAWN_SUBAGENT.to_string(),
4947                    message: e.to_string(),
4948                };
4949                return (format!("Error: {err}"), true);
4950            }
4951        };
4952        let task = args
4953            .get("task")
4954            .and_then(serde_json::Value::as_str)
4955            .unwrap_or("")
4956            .to_string();
4957        if task.is_empty() {
4958            let err = Error::InvalidArguments {
4959                tool: SPAWN_SUBAGENT.to_string(),
4960                message: "`task` is required and must be non-empty".to_string(),
4961            };
4962            return (format!("Error: {err}"), true);
4963        }
4964        let agent_type = args
4965            .get("agent_type")
4966            .and_then(serde_json::Value::as_str)
4967            .map(String::from);
4968        let inline_system_prompt = args
4969            .get("system_prompt")
4970            .and_then(serde_json::Value::as_str)
4971            .map(String::from);
4972        let background = args
4973            .get("background")
4974            .and_then(serde_json::Value::as_bool)
4975            .unwrap_or(false);
4976        let requested_model = args
4977            .get("model")
4978            .and_then(serde_json::Value::as_str)
4979            .map(|model| crate::model_catalog::resolve_alias(model, &[]));
4980
4981        let definition = match &agent_type {
4982            Some(name) => match self.config.subagents_definitions.get(name) {
4983                Some(d) => Some(d.clone()),
4984                None => {
4985                    let err = Error::SubagentDefinitionNotFound(name.clone());
4986                    return (format!("Error: {err}"), true);
4987                }
4988            },
4989            None => None,
4990        };
4991
4992        if background {
4993            if !self.config.subagents_background {
4994                let err = Error::tool(
4995                    SPAWN_SUBAGENT,
4996                    "background=true requires capabilities.subagents.background = true",
4997                );
4998                return (format!("Error: {err}"), true);
4999            }
5000            // §2.2 C6, defensive re-check (belt-and-suspenders — see
5001            // `Error::SubagentBackgroundPolicyMissing`'s doc comment for why
5002            // this can't just trust the resolver already checked it).
5003            if self.config.subagents_background_prompts.is_none() {
5004                let err = Error::SubagentBackgroundPolicyMissing;
5005                return (format!("Error: {err}"), true);
5006            }
5007        }
5008
5009        // Resource bounds (fail-closed): depth first (cheap, no side
5010        // effect on failure), THEN concurrency (holds a slot — must be the
5011        // LAST check before actually spawning, so a refused spawn never
5012        // leaves a stray slot held).
5013        if let Err(e) =
5014            crate::subagents::check_depth(self.subagent_depth, self.config.subagents_max_depth)
5015        {
5016            return (format!("Error: {e}"), true);
5017        }
5018        let Some(guard) = crate::subagents::try_acquire(
5019            &self.subagent_concurrency_gauge,
5020            self.config.subagents_max_concurrent,
5021        ) else {
5022            let err = Error::SubagentConcurrencyExceeded {
5023                max_concurrent: self.config.subagents_max_concurrent,
5024            };
5025            return (format!("Error: {err}"), true);
5026        };
5027
5028        let child_id = next_subagent_id();
5029        let child_config = self.build_child_config(
5030            definition.as_ref(),
5031            inline_system_prompt,
5032            requested_model.or_else(|| definition.as_ref().and_then(|d| d.model.clone())),
5033        );
5034        let child_model = child_config.model.clone();
5035        let mut child = Agent::with_provider_arc(child_config, self.provider.clone());
5036        child.subagent_depth = self.subagent_depth + 1;
5037        child.subagent_concurrency_gauge = self.subagent_concurrency_gauge.clone();
5038
5039        // §2.2 C6: a background child NEVER gets a BLOCKING-BY-DEFAULT
5040        // interactive approval handler — either no handler at all
5041        // (`AutoPolicy`: the engine's pre-existing "no handler ⇒ deny"
5042        // fail-closed default), or (`Parent`) the never-blocking
5043        // `ParentQueueApprovalHandler`, UNLESS a `tui` embedder has
5044        // installed [`Self::child_approval_handler_factory`] (P5-4), in
5045        // which case THAT builds the handler instead — see
5046        // [`Self::set_child_approval_handler_factory`]'s doc comment for
5047        // why this can't escalate past what the rule engine already routed
5048        // to `Ask`. A foreground child also gets no handler here (today's
5049        // existing default posture; an embedder that wants an interactive
5050        // child installs its own via `set_permissions_approval_handler`
5051        // after this call returns, out of this method's scope).
5052        if background {
5053            if let Some(crate::subagents::BackgroundPromptsPolicy::Parent) =
5054                self.config.subagents_background_prompts
5055            {
5056                let handler: std::sync::Arc<dyn crate::permissions::PermissionsApprovalHandler> =
5057                    match &self.child_approval_handler_factory {
5058                        Some(factory) => {
5059                            factory(child_id.clone(), self.pending_child_approvals.clone())
5060                        }
5061                        None => std::sync::Arc::new(crate::subagents::ParentQueueApprovalHandler {
5062                            child_agent_id: child_id.clone(),
5063                            queue: self.pending_child_approvals.clone(),
5064                        }),
5065                    };
5066                child.ctx.sandbox_approval_handler =
5067                    Some(crate::sandbox::SandboxApprovalHandler(handler.clone()));
5068                child.permissions_approval_handler = Some(handler);
5069            }
5070        }
5071
5072        let lineage = crate::subagents::SubagentLineage {
5073            child_agent_id: child_id.clone(),
5074            parent_session_id: self.subagent_store.as_ref().map(|(_, name)| name.clone()),
5075            parent_tool_use_id: call.id.clone(),
5076            depth: self.subagent_depth + 1,
5077            agent_type: agent_type.clone(),
5078            task: task.clone(),
5079            background,
5080            spawned_at_ms: now_ms(),
5081            model: child_model,
5082        };
5083        if let Some((store, parent_name)) = &self.subagent_store {
5084            let _ = store.save_subagent_lineage(parent_name, &child_id, &lineage);
5085        }
5086
5087        if background {
5088            let spawned_task_text = task.clone();
5089            self.background_subagents.insert(
5090                child_id.clone(),
5091                BackgroundSubagent {
5092                    handle: tokio::spawn(async move {
5093                        // The concurrency slot lives for exactly as long as
5094                        // this future runs — moved in here, dropped when the
5095                        // child's `send` (and this future) finishes.
5096                        let _guard = guard;
5097                        let result = child.send(spawned_task_text).await;
5098                        let transcript = child.history()[1..].to_vec();
5099                        (child_id, result, transcript)
5100                    }),
5101                    task,
5102                    agent_type,
5103                    started_at_ms: lineage.spawned_at_ms,
5104                },
5105            );
5106            let out = serde_json::json!({
5107                "subagent_id": lineage.child_agent_id,
5108                "status": "spawned",
5109                "background": true,
5110            });
5111            return (out.to_string(), false);
5112        }
5113
5114        // Foreground: run to completion now, guard held until this
5115        // function returns (then drops, freeing the slot).
5116        let result = child.send(task).await;
5117        let transcript = child.history()[1..].to_vec();
5118        self.persist_subagent_transcript(&child_id, &lineage, &transcript);
5119        drop(guard);
5120        match result {
5121            Ok(text) => (text, false),
5122            Err(e) => (format!("Error: subagent `{child_id}` failed: {e}"), true),
5123        }
5124    }
5125
5126    /// Execute the `subagent_status` intrinsic (P5-3, D3
5127    /// "background+resume"): poll a background child; once its `JoinHandle`
5128    /// is finished, reap it (removing it from `Self::background_subagents`
5129    /// and persisting its transcript, same as the foreground path).
5130    async fn run_subagent_status(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
5131        let args = match call.function.parsed_arguments() {
5132            Ok(v) => v,
5133            Err(e) => {
5134                let err = Error::InvalidArguments {
5135                    tool: SUBAGENT_STATUS.to_string(),
5136                    message: e.to_string(),
5137                };
5138                return (format!("Error: {err}"), true);
5139            }
5140        };
5141        let Some(id) = args.get("subagent_id").and_then(serde_json::Value::as_str) else {
5142            let err = Error::InvalidArguments {
5143                tool: SUBAGENT_STATUS.to_string(),
5144                message: "`subagent_id` is required".to_string(),
5145            };
5146            return (format!("Error: {err}"), true);
5147        };
5148        let Some(entry) = self.background_subagents.get(id) else {
5149            let err = Error::SubagentNotFound(id.to_string());
5150            return (format!("Error: {err}"), true);
5151        };
5152        if !entry.handle.is_finished() {
5153            let out = serde_json::json!({
5154                "subagent_id": id,
5155                "status": "pending",
5156                "task": entry.task,
5157                "agent_type": entry.agent_type,
5158                "started_at_ms": entry.started_at_ms,
5159            });
5160            return (out.to_string(), false);
5161        }
5162        // Finished — reap it. `.await` on an already-finished handle
5163        // resolves immediately (never actually blocks).
5164        let entry = self
5165            .background_subagents
5166            .remove(id)
5167            .expect("checked Some above");
5168        let (child_id, result, transcript) = match entry.handle.await {
5169            Ok(v) => v,
5170            Err(join_err) => {
5171                let err = Error::tool(
5172                    SUBAGENT_STATUS,
5173                    format!("subagent `{id}` task panicked: {join_err}"),
5174                );
5175                return (format!("Error: {err}"), true);
5176            }
5177        };
5178        // Re-derive the lineage record for persistence (cheap; the fields
5179        // are all still in hand) — mirrors the foreground path's single
5180        // `persist_subagent_transcript` call site.
5181        if let Some((store, parent_name)) = self.subagent_store.clone() {
5182            if let Ok(Some(lineage)) = store.load_subagent_lineage(&parent_name, &child_id) {
5183                self.persist_subagent_transcript(&child_id, &lineage, &transcript);
5184            }
5185        }
5186        match result {
5187            Ok(text) => {
5188                let out = serde_json::json!({
5189                    "subagent_id": child_id,
5190                    "status": "done",
5191                    "result": text,
5192                });
5193                (out.to_string(), false)
5194            }
5195            Err(e) => {
5196                let out = serde_json::json!({
5197                    "subagent_id": child_id,
5198                    "status": "error",
5199                    "message": e.to_string(),
5200                });
5201                (out.to_string(), true)
5202            }
5203        }
5204    }
5205
5206    /// Execute the `background_exec` intrinsic (P5-6, §2 module 4, D1
5207    /// "background exec"): spawn `args.command` as a detached OS process
5208    /// via `crate::tools::build_sandboxed_sh` — the SAME sandboxed-spawn
5209    /// path [`crate::tools::BashTool::execute`] uses — and return its job
5210    /// id IMMEDIATELY, never the command's output. Gated by the same
5211    /// permission check a foreground `bash` call gets
5212    /// ([`Self::background_permission_denial`]), then a fail-closed
5213    /// concurrency cap ([`Config::tools_background_max_concurrent`]), THEN
5214    /// the actual spawn — in that order, so a refused call never holds a
5215    /// concurrency slot and never touches the process table.
5216    fn run_background_exec(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
5217        let args = match call.function.parsed_arguments() {
5218            Ok(v) => v,
5219            Err(e) => {
5220                let err = Error::InvalidArguments {
5221                    tool: BACKGROUND_EXEC.to_string(),
5222                    message: e.to_string(),
5223                };
5224                return (format!("Error: {err}"), true);
5225            }
5226        };
5227        let command = args
5228            .get("command")
5229            .and_then(serde_json::Value::as_str)
5230            .unwrap_or("")
5231            .to_string();
5232        if command.is_empty() {
5233            let err = Error::InvalidArguments {
5234                tool: BACKGROUND_EXEC.to_string(),
5235                message: "`command` is required and must be non-empty".to_string(),
5236            };
5237            return (format!("Error: {err}"), true);
5238        }
5239
5240        // A job id up front (before spawning) — used both as the audit
5241        // handle for a §2.2 C6 `Parent`-policy queued denial (this call may
5242        // never actually reach the spawn below) and, if the call proceeds,
5243        // as `Self::background_jobs`'s real key.
5244        let job_id = crate::background::next_job_id(now_ms());
5245
5246        if let Some(reason) = self.background_permission_denial(&command, &job_id) {
5247            return (format!("Error: {reason}"), true);
5248        }
5249
5250        // Fable-5 review (LOW, "pre_tool_hook + doom-loop don't cover
5251        // background_exec"): this intrinsic is intercepted in
5252        // `Self::prepare_tool_call` and returns before `Self::finish_prepare`
5253        // ever runs, so — unlike a foreground `bash` call — it was reaching
5254        // this real spawn below WITHOUT ever offering `Config.pre_tool_hook`
5255        // a chance to veto it. `background_exec` runs a REAL command (unlike
5256        // the purely in-process meta-intrinsics `tool_search`/
5257        // `expand_reduction`/`sidecar_search`, which have no such gap to
5258        // close), so it belongs behind the same security-relevant veto a
5259        // foreground call gets. Scoped to this one call site — the other
5260        // meta-intrinsics are unchanged. The doom-loop counter
5261        // (`Self::check_doom_loop`) is deliberately NOT wired here: it is a
5262        // foreground repetition breaker keyed on `(self.doom_loop_last_call,
5263        // self.doom_loop_streak)`, a single piece of state shared with the
5264        // ordinary tool-call loop — folding background jobs into that same
5265        // streak would make an interleaved foreground/background pattern
5266        // trip (or fail to trip) the breaker in ways that have nothing to
5267        // do with the foreground loop actually repeating itself; the
5268        // pre_tool_hook veto below is the security-relevant half of this
5269        // fix, the doom-loop breaker is not.
5270        if let Some(hook) = &self.config.pre_tool_hook {
5271            if let Some(reason) = hook(BACKGROUND_EXEC, &args) {
5272                return (format!("Error: blocked by pre-tool hook: {reason}"), true);
5273            }
5274        }
5275
5276        let Some(guard) = crate::subagents::try_acquire(
5277            &self.background_concurrency_gauge,
5278            self.config.tools_background_max_concurrent,
5279        ) else {
5280            let err = Error::BackgroundJobConcurrencyExceeded {
5281                max_concurrent: self.config.tools_background_max_concurrent,
5282            };
5283            return (format!("Error: {err}"), true);
5284        };
5285
5286        let mut cmd = match crate::tools::build_sandboxed_sh(&command, &self.ctx) {
5287            Ok(cmd) => cmd,
5288            Err(e) => return (format!("Error: {e}"), true),
5289        };
5290        cmd.current_dir(&self.ctx.cwd)
5291            .stdin(std::process::Stdio::null())
5292            .stdout(std::process::Stdio::piped())
5293            .stderr(std::process::Stdio::piped())
5294            // Defense-in-depth for the "must be killed on drop" guarantee —
5295            // see `impl Drop for Agent`'s doc comment; the EXPLICIT
5296            // `start_kill()` loop there is what makes the guarantee
5297            // provable, this is a second, independent line of defense for
5298            // the same outcome.
5299            .kill_on_drop(true);
5300        // Fable-5 review (HIGH, "grandchildren orphaned on kill AND
5301        // agent-drop"): `Child::start_kill` only signals the DIRECT child.
5302        // A background command that spawns a surviving subprocess (a `&`
5303        // job, a pipeline, a double-forking daemon — or, on macOS, the
5304        // `sandbox-exec` wrapper itself in `build_sandboxed_sh`, whose real
5305        // `sh` and ITS children are all grandchildren of the tracked pid)
5306        // leaves those processes running, reparented to init, after the
5307        // tracked job is "killed". Putting this job in its OWN new process
5308        // group (`pgid == its own pid`, since every descendant inherits the
5309        // group unless it explicitly opts out) lets `kill_job_process_group`
5310        // below signal the WHOLE tree at kill/drop time, not just the one
5311        // pid we happen to be tracking. No portable equivalent on Windows —
5312        // see `kill_job_process_group`'s `#[cfg(not(unix))]` fallback.
5313        #[cfg(unix)]
5314        cmd.process_group(0);
5315        // P4c (`core.shell_env_snapshot`)/P5-10 (`env_policy`):
5316        // `build_sandboxed_sh` (above) already applied both via its own
5317        // `apply_sandbox_env_policy` last step — no separate `ctx.shell_env`
5318        // application here (that would re-add a secret `Filtered`/`None`
5319        // just stripped, on top of the already-`env_clear`'d command).
5320
5321        let mut child = match cmd.spawn() {
5322            Ok(c) => c,
5323            Err(e) => {
5324                drop(guard);
5325                let err = Error::tool(
5326                    BACKGROUND_EXEC,
5327                    format!("failed to spawn background command: {e}"),
5328                );
5329                return (format!("Error: {err}"), true);
5330            }
5331        };
5332        let pid = child.id();
5333        let output = std::sync::Arc::new(crate::background::CapturedOutput::new());
5334        let cap = self.config.tools_background_max_output_bytes;
5335        // Fire-and-forget: the reader tasks outlive this method call and
5336        // exit on their own at pipe EOF — see `spawn_output_reader`'s doc
5337        // comment. Bound to named (not `_`) locals only to keep clippy's
5338        // `let_underscore_future` lint quiet; neither handle is awaited or
5339        // aborted anywhere.
5340        if let Some(stdout) = child.stdout.take() {
5341            let _stdout_reader = spawn_output_reader(stdout, output.clone(), cap);
5342        }
5343        if let Some(stderr) = child.stderr.take() {
5344            let _stderr_reader = spawn_output_reader(stderr, output.clone(), cap);
5345        }
5346
5347        let started_at_ms = now_ms();
5348        self.background_jobs.insert(
5349            job_id.clone(),
5350            BackgroundJob {
5351                child,
5352                command: command.clone(),
5353                pid,
5354                output,
5355                started_at_ms,
5356                killed: false,
5357                _guard: guard,
5358            },
5359        );
5360
5361        let out = serde_json::json!({
5362            "job_id": job_id,
5363            "status": "running",
5364            "pid": pid,
5365            "command": command,
5366        });
5367        (out.to_string(), false)
5368    }
5369
5370    /// Execute the `background_status` intrinsic (P5-6, D1 "monitor/event
5371    /// feed"): non-blocking poll of one job's run status (via
5372    /// `Child::try_wait`), drain its output captured since the LAST poll
5373    /// and emit it as an [`AgentEvent::BackgroundOutput`] event (the
5374    /// "event feed" — a real `EventSink` consumer sees each poll's new
5375    /// output live), and return the full captured output (bounded, per
5376    /// [`Config::tools_background_max_output_bytes`]) so far either way.
5377    /// Once the job is terminal (exited or killed), this reaps it — removes
5378    /// it from [`Self::background_jobs`], freeing its concurrency slot —
5379    /// same "poll once more to reap" contract [`Self::run_subagent_status`]
5380    /// already established for background subagents.
5381    fn run_background_status(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
5382        let args = match call.function.parsed_arguments() {
5383            Ok(v) => v,
5384            Err(e) => {
5385                let err = Error::InvalidArguments {
5386                    tool: BACKGROUND_STATUS.to_string(),
5387                    message: e.to_string(),
5388                };
5389                return (format!("Error: {err}"), true);
5390            }
5391        };
5392        let Some(job_id) = args.get("job_id").and_then(serde_json::Value::as_str) else {
5393            let err = Error::InvalidArguments {
5394                tool: BACKGROUND_STATUS.to_string(),
5395                message: "`job_id` is required".to_string(),
5396            };
5397            return (format!("Error: {err}"), true);
5398        };
5399        let job_id = job_id.to_string();
5400
5401        // Scoped so the mutable borrow of `self.background_jobs` ends
5402        // before `self.emit(...)`/`self.background_jobs.remove(...)` below
5403        // need their own (mutable) access to `self`.
5404        let (command, pid, started_at_ms, status, output_so_far, truncated, delta) = {
5405            let Some(job) = self.background_jobs.get_mut(&job_id) else {
5406                let err = Error::BackgroundJobNotFound(job_id);
5407                return (format!("Error: {err}"), true);
5408            };
5409            let status = background_job_status(job);
5410            let (output_so_far, truncated) = job.output.snapshot();
5411            let delta = job.output.drain_new();
5412            (
5413                job.command.clone(),
5414                job.pid,
5415                job.started_at_ms,
5416                status,
5417                output_so_far,
5418                truncated,
5419                delta,
5420            )
5421        };
5422
5423        if !delta.is_empty() {
5424            self.emit(AgentEvent::BackgroundOutput {
5425                job_id: job_id.clone(),
5426                chunk: delta,
5427                truncated,
5428            });
5429        }
5430
5431        let exit_code = match status {
5432            crate::background::JobStatus::Exited(code) => code,
5433            _ => None,
5434        };
5435        let out = serde_json::json!({
5436            "job_id": job_id,
5437            "command": command,
5438            "status": status.as_str(),
5439            "exit_code": exit_code,
5440            "pid": pid,
5441            "started_at_ms": started_at_ms,
5442            "output": output_so_far,
5443            "output_truncated": truncated,
5444        });
5445        if !matches!(status, crate::background::JobStatus::Running) {
5446            self.background_jobs.remove(&job_id);
5447        }
5448        (out.to_string(), false)
5449    }
5450
5451    /// Execute the `background_list` intrinsic (P5-6, D10 "bg-manager"):
5452    /// list every background job this agent is currently tracking, without
5453    /// draining output or reaping anything (a read-only listing —
5454    /// `background_status` is the reaping poll).
5455    fn run_background_list(&mut self, _call: &crate::message::ToolCall) -> (String, bool) {
5456        let mut jobs = Vec::new();
5457        for (job_id, job) in self.background_jobs.iter_mut() {
5458            let status = background_job_status(job);
5459            jobs.push(serde_json::json!({
5460                "job_id": job_id,
5461                "command": job.command,
5462                "status": status.as_str(),
5463                "pid": job.pid,
5464                "started_at_ms": job.started_at_ms,
5465            }));
5466        }
5467        let out = serde_json::json!({ "jobs": jobs });
5468        (out.to_string(), false)
5469    }
5470
5471    /// Execute the `background_kill` intrinsic (P5-6, D10 "bg-manager",
5472    /// build brief "kill/cancel a job"): request REAL termination of a
5473    /// background job's OS process AND its whole process group (see
5474    /// [`kill_job_process_group`] — Fable-5 review, HIGH, "grandchildren
5475    /// orphaned on kill"; a documented no-op if the process already
5476    /// exited) and reap it immediately, freeing its concurrency slot.
5477    fn run_background_kill(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
5478        let args = match call.function.parsed_arguments() {
5479            Ok(v) => v,
5480            Err(e) => {
5481                let err = Error::InvalidArguments {
5482                    tool: BACKGROUND_KILL.to_string(),
5483                    message: e.to_string(),
5484                };
5485                return (format!("Error: {err}"), true);
5486            }
5487        };
5488        let Some(job_id) = args.get("job_id").and_then(serde_json::Value::as_str) else {
5489            let err = Error::InvalidArguments {
5490                tool: BACKGROUND_KILL.to_string(),
5491                message: "`job_id` is required".to_string(),
5492            };
5493            return (format!("Error: {err}"), true);
5494        };
5495        let job_id = job_id.to_string();
5496        let Some(mut job) = self.background_jobs.remove(&job_id) else {
5497            let err = Error::BackgroundJobNotFound(job_id);
5498            return (format!("Error: {err}"), true);
5499        };
5500        kill_job_process_group(&mut job);
5501        job.killed = true;
5502        let out = serde_json::json!({
5503            "job_id": job_id,
5504            "status": "killed",
5505            "pid": job.pid,
5506        });
5507        // `job` (and its `ConcurrencyGuard`) drops here, freeing the slot.
5508        (out.to_string(), false)
5509    }
5510
5511    /// P5-3 (D5 "subagent transcripts… persisted + linked"): write a
5512    /// finished child's transcript to `Self::subagent_store`, if one is
5513    /// installed — a no-op otherwise (see that field's doc comment). Builds
5514    /// the child's `Session` the same way `to_native_jsonl_v2`'s doc
5515    /// comment describes (an empty imported prefix + `transcript` as
5516    /// `appended` `NativeTurn`s), with `meta.agent_id`/`parent_tool_use_id`/
5517    /// `lineage` populated from `lineage` so the native-v2 header carries
5518    /// the full lineage record on disk (see `Session::to_native_jsonl_v2`'s
5519    /// P5-3 doc note).
5520    ///
5521    /// P5-3 safety-hardening fix (Fable-5 review, LOW "translation-fidelity
5522    /// cosmetic"): `Session::from_claude_code_str("")` is used ONLY to get
5523    /// a blank `raw`/`messages` skeleton cheaply (an empty string parses
5524    /// identically under any loader) — it is NOT claiming this child's
5525    /// session actually came from Claude Code. Before this fix, that
5526    /// borrowed constructor's `meta.source` (`SessionSource::ClaudeCode`)
5527    /// leaked straight through to the persisted sidecar's `source` header,
5528    /// mislabeling a native `spawn_subagent` child as an imported CC
5529    /// session. Corrected to `SessionSource::Native` immediately after —
5530    /// see that variant's doc comment.
5531    fn persist_subagent_transcript(
5532        &self,
5533        child_id: &str,
5534        lineage: &crate::subagents::SubagentLineage,
5535        transcript: &[ChatMessage],
5536    ) {
5537        let Some((store, parent_name)) = &self.subagent_store else {
5538            return;
5539        };
5540        let mut session = match Session::from_claude_code_str("") {
5541            Ok(s) => s,
5542            Err(_) => return,
5543        };
5544        session.meta.source = crate::session::SessionSource::Native;
5545        session.meta.agent_id = Some(lineage.child_agent_id.clone());
5546        session.meta.parent_tool_use_id = Some(lineage.parent_tool_use_id.clone());
5547        session.meta.lineage = lineage.to_lineage_map();
5548        let sidecar_jsonl = session.to_native_jsonl_v2(transcript);
5549        let _ = store.save_subagent_transcript(parent_name, child_id, &sidecar_jsonl);
5550        let _ = store.save_subagent_lineage(parent_name, child_id, lineage);
5551    }
5552
5553    /// Execute the `tool_search` intrinsic (B6): case-insensitive keyword
5554    /// match over `name` + `description` of every registered, enabled,
5555    /// non-core, not-yet-activated tool (builtin and `mcp__*` alike). Matches
5556    /// are activated (advertised starting with the next request) and
5557    /// returned as a JSON array of their full [`ToolSchema`]s.
5558    fn run_tool_search(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
5559        let args = match call.function.parsed_arguments() {
5560            Ok(v) => v,
5561            Err(e) => {
5562                let err = Error::InvalidArguments {
5563                    tool: TOOL_SEARCH.to_string(),
5564                    message: e.to_string(),
5565                };
5566                return (format!("Error: {err}"), true);
5567            }
5568        };
5569        let query = args
5570            .get("query")
5571            .and_then(serde_json::Value::as_str)
5572            .unwrap_or("")
5573            .to_lowercase();
5574        let max_results = args
5575            .get("max_results")
5576            .and_then(serde_json::Value::as_u64)
5577            .map(|n| n as usize);
5578
5579        let mut matches: Vec<ToolSchema> = self
5580            .registry
5581            .iter()
5582            .filter(|t| self.config.tool_enabled(t.name()))
5583            .filter(|t| !self.is_core_tool(t.name()))
5584            .filter(|t| !self.activated_tools.contains(t.name()))
5585            .filter(|t| {
5586                query.is_empty()
5587                    || t.name().to_lowercase().contains(&query)
5588                    || self
5589                        .config
5590                        .tool_description(t.name(), t.description())
5591                        .to_lowercase()
5592                        .contains(&query)
5593            })
5594            // TR-8/T5 dev/03: the on-demand fetch always returns the ORIGINAL
5595            // full schema, never the tier-minified one — that's the invert.
5596            .map(|t| self.raw_schema_for(t))
5597            .collect();
5598
5599        if let Some(max) = max_results {
5600            matches.truncate(max);
5601        }
5602
5603        for m in &matches {
5604            self.activated_tools.insert(m.name.clone());
5605        }
5606
5607        let result = serde_json::to_string(&matches).unwrap_or_else(|_| "[]".to_string());
5608        (result, false)
5609    }
5610
5611    /// The `expand_reduction` schema (T12/TR-1), advertised whenever a
5612    /// [`ReductionPolicy`] is installed.
5613    ///
5614    /// The description deliberately never spells the literal stub sentinel
5615    /// prefix: A11's export leak guard is unconditional, so an assistant
5616    /// turn that quoted a stub line verbatim (which teaching the syntax
5617    /// invites) would permanently fail export for that session. Stubs are
5618    /// described abstractly and the model is told to pass ids only.
5619    fn expand_reduction_schema() -> ToolSchema {
5620        ToolSchema {
5621            name: EXPAND_REDUCTION.to_string(),
5622            description: "Fetch back the original content hidden behind a reduction stub in \
5623                your current view — a truncated tool output, cleared old turns, or an elided \
5624                file read that was hidden to save context. Each stub line names a reduction id \
5625                like r0042-9f3c: pass ONLY that id here, and never quote or repeat a stub line \
5626                itself in your replies. The original is durably kept in the session sidecar. \
5627                Pass `byte_range` to fetch a slice of a large one at a time instead of all of \
5628                it at once; ranged results are prefixed with a `bytes start..end of total` \
5629                header so you can plan the next slice."
5630                .to_string(),
5631            parameters: serde_json::json!({
5632                "type": "object",
5633                "properties": {
5634                    "reduction_id": {
5635                        "type": "string",
5636                        "description": "The reduction id named in the stub line, e.g. \
5637                            \"r0042-9f3c\". Pass the id alone."
5638                    },
5639                    "byte_range": {
5640                        "type": "array",
5641                        "items": {"type": "integer"},
5642                        "minItems": 2,
5643                        "maxItems": 2,
5644                        "description": "Optional [start, end) byte offsets within the original \
5645                            content to fetch instead of all of it. Exactly two non-negative \
5646                            integers with start <= end."
5647                    }
5648                },
5649                "required": ["reduction_id"],
5650                "additionalProperties": false
5651            }),
5652        }
5653    }
5654
5655    /// The `sidecar_search` schema (T12/TR-1), advertised whenever a
5656    /// [`ReductionPolicy`] is installed. Same no-literal-sentinel rule as
5657    /// [`Self::expand_reduction_schema`].
5658    fn sidecar_search_schema() -> ToolSchema {
5659        ToolSchema {
5660            name: SIDECAR_SEARCH.to_string(),
5661            description: "Search content currently hidden from your view by reduction stubs \
5662                (large tool outputs, cleared old turns, elided file reads) for a substring or \
5663                regex. Only hidden content is searched, never what you can already see. \
5664                Returns match snippets with each match's reduction_id for use with \
5665                expand_reduction; refer to results by their reduction id rather than quoting \
5666                stub lines. Results are capped — if `truncated` is true, narrow the query."
5667                .to_string(),
5668            parameters: serde_json::json!({
5669                "type": "object",
5670                "properties": {
5671                    "query": {
5672                        "type": "string",
5673                        "description": "Non-empty substring or regex to search for \
5674                            (case-insensitive)."
5675                    }
5676                },
5677                "required": ["query"],
5678                "additionalProperties": false
5679            }),
5680        }
5681    }
5682
5683    /// Reload the recorder's full recorded messages from disk (TR-1's
5684    /// `recorded` resolution source). Since TR-12's D6/A7 supersession gate
5685    /// (`Self::run_loop`), a `expand_reduction`/`sidecar_search` call only
5686    /// ever exists alongside an active [`ReductionPolicy`] (see
5687    /// [`EXPAND_REDUCTION`]'s doc), and pairing one with a recorder — as the
5688    /// CLI's reduced mode always does — means the gate is already on and
5689    /// `history[1..]` holds the same full bytes as this reload: this upgrade
5690    /// is then a dormant no-op (`reduce::rehydrate::prefer_recorded` sees
5691    /// `recorded == minted` and keeps `minted`). It stops being a no-op —
5692    /// defense in depth, not the common path — for a **legacy** sidecar
5693    /// recorded before this gate existed, or for a policy-without-recorder
5694    /// agent (gate off, so `history[1..]` still carries
5695    /// [`Self::cap_tool_output`]-capped copies): only there can `history[1..]`
5696    /// diverge from the sidecar, and only there does consulting this reload
5697    /// actually recover bytes `history[1..]` alone couldn't. `Ok(None)` when
5698    /// no recorder is attached (rehydration then resolves from history alone,
5699    /// whose capped copies — if any — carry their own honest cap notice). A
5700    /// disk-level reload is fine here regardless: these intrinsic calls are
5701    /// rare, model-initiated events, not per-request work.
5702    fn recorded_messages(&self) -> std::result::Result<Option<Vec<ChatMessage>>, String> {
5703        let Some(recorder) = &self.recorder else {
5704            return Ok(None);
5705        };
5706        let raw = std::fs::read_to_string(recorder.path())
5707            .map_err(|e| format!("failed to read the session sidecar: {e}"))?;
5708        let session = Session::from_sidecar_str(&raw)
5709            .map_err(|e| format!("failed to parse the session sidecar: {e}"))?;
5710        Ok(Some(session.messages))
5711    }
5712
5713    /// Execute the `expand_reduction` intrinsic (T12/TR-1): resolves against
5714    /// `self.reduction_log` + `self.history[1..]` (the hash-minting source),
5715    /// upgraded to the recorder's full recorded bytes for cap-diverged
5716    /// content ([`Self::recorded_messages`]; the two-source contract is
5717    /// documented on `reduce::rehydrate`). `byte_range` is validated
5718    /// strictly — any malformed shape is a model-recoverable error naming
5719    /// the expected form and the original's true size, never a silent
5720    /// whole-content (or empty) return.
5721    fn run_expand_reduction(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
5722        let args = match call.function.parsed_arguments() {
5723            Ok(v) => v,
5724            Err(e) => {
5725                let err = Error::InvalidArguments {
5726                    tool: EXPAND_REDUCTION.to_string(),
5727                    message: e.to_string(),
5728                };
5729                return (format!("Error: {err}"), true);
5730            }
5731        };
5732        let Some(id) = args.get("reduction_id").and_then(serde_json::Value::as_str) else {
5733            return (
5734                "Error: expand_reduction requires a `reduction_id` string argument".to_string(),
5735                true,
5736            );
5737        };
5738        let recorded = match self.recorded_messages() {
5739            Ok(r) => r,
5740            Err(e) => return (format!("Error: expand_reduction: {e}"), true),
5741        };
5742        let recorded = recorded.as_deref();
5743
5744        // B3: strict shape validation — exactly two non-negative integers.
5745        // Anything else errors (with the true total when resolvable) rather
5746        // than silently degrading to a whole-content expand.
5747        let byte_range = match args.get("byte_range") {
5748            None | Some(serde_json::Value::Null) => None,
5749            Some(v) => {
5750                let parsed = v
5751                    .as_array()
5752                    .filter(|a| a.len() == 2)
5753                    .and_then(|a| Some((a[0].as_u64()? as usize, a[1].as_u64()? as usize)));
5754                match parsed {
5755                    Some(range) => Some(range),
5756                    None => {
5757                        let total = reduce::rehydrate::reduction_total_bytes(
5758                            &self.reduction_log,
5759                            &self.history[1..],
5760                            recorded,
5761                            id,
5762                        )
5763                        .map(|n| format!("; the original is {n} bytes"))
5764                        .unwrap_or_default();
5765                        return (
5766                            format!(
5767                                "Error: expand_reduction: malformed byte_range {v} — expected \
5768                                 [start, end): exactly two non-negative integers with \
5769                                 start <= end{total}"
5770                            ),
5771                            true,
5772                        );
5773                    }
5774                }
5775            }
5776        };
5777        match reduce::rehydrate::expand_reduction(
5778            &self.reduction_log,
5779            &self.history[1..],
5780            recorded,
5781            id,
5782            byte_range,
5783        ) {
5784            // A ranged result carries a provenance header naming the slice
5785            // and the true total, so the model can plan its next slice; a
5786            // whole-content expand stays byte-exact (TR-1 dev/01).
5787            Ok(outcome) => match outcome.range {
5788                Some((start, end)) => (
5789                    format!(
5790                        "[{id}: bytes {start}..{end} of {total}]\n{content}",
5791                        total = outcome.total_bytes,
5792                        content = outcome.content
5793                    ),
5794                    false,
5795                ),
5796                None => (outcome.content, false),
5797            },
5798            Err(e) => (format!("Error: {e}"), true),
5799        }
5800    }
5801
5802    /// Execute the `sidecar_search` intrinsic (T12/TR-1); same two-source
5803    /// resolution as [`Self::run_expand_reduction`]. The result is bounded
5804    /// by construction (`reduce::rehydrate::SidecarSearchResult`'s caps), so
5805    /// a broad query can never re-inflate the context or bloat the sidecar
5806    /// the recorder appends this result to.
5807    fn run_sidecar_search(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
5808        let args = match call.function.parsed_arguments() {
5809            Ok(v) => v,
5810            Err(e) => {
5811                let err = Error::InvalidArguments {
5812                    tool: SIDECAR_SEARCH.to_string(),
5813                    message: e.to_string(),
5814                };
5815                return (format!("Error: {err}"), true);
5816            }
5817        };
5818        let query = args
5819            .get("query")
5820            .and_then(serde_json::Value::as_str)
5821            .unwrap_or("");
5822        if query.trim().is_empty() {
5823            return (
5824                "Error: sidecar_search requires a non-empty `query` string argument".to_string(),
5825                true,
5826            );
5827        }
5828        let recorded = match self.recorded_messages() {
5829            Ok(r) => r,
5830            Err(e) => return (format!("Error: sidecar_search: {e}"), true),
5831        };
5832        match reduce::rehydrate::sidecar_search(
5833            &self.reduction_log,
5834            &self.history[1..],
5835            recorded.as_deref(),
5836            query,
5837        ) {
5838            Ok(result) => (
5839                serde_json::to_string(&result).unwrap_or_else(|_| "{}".to_string()),
5840                false,
5841            ),
5842            Err(e) => (format!("Error: {e}"), true),
5843        }
5844    }
5845
5846    fn emit(&self, event: AgentEvent) {
5847        if let Some(sink) = &self.config.event_sink {
5848            sink(event);
5849        }
5850    }
5851
5852    /// Number of non-system messages exchanged so far.
5853    pub fn turn_count(&self) -> usize {
5854        self.history
5855            .iter()
5856            .filter(|m| m.role != Role::System)
5857            .count()
5858    }
5859
5860    /// Cumulative output (completion) tokens reported by the provider across
5861    /// every `send` on this agent. Zero if the provider reports no usage.
5862    pub fn total_output_tokens(&self) -> u64 {
5863        self.total_output_tokens
5864    }
5865}
5866
5867#[cfg(test)]
5868mod api_key_cmd_tests {
5869    //! P4 (design §5.2, §1.8 D6 row): `api_key_cmd` credential-helper
5870    //! resolution. `Agent::new` never makes a network call, so these tests
5871    //! exercise the real resolution chain end-to-end without mocking.
5872
5873    use super::*;
5874
5875    /// Default-off: with no `api_key`/`api_key_cmd` set and an env var that
5876    /// isn't set either, resolution fails exactly as it always has —
5877    /// `api_key_cmd` being a brand-new field changes nothing when unset.
5878    #[test]
5879    fn default_none_falls_through_to_missing_api_key_error() {
5880        let config = Config::builder()
5881            .api_key_env("SUPERCODE_TEST_UNSET_VAR_API_KEY_CMD")
5882            .build();
5883        assert!(config.api_key.is_none());
5884        assert!(config.api_key_cmd.is_none());
5885        let err = Agent::new(config).err().expect("no key source configured");
5886        assert!(matches!(err, Error::MissingApiKey(_)));
5887    }
5888
5889    /// Happy path: `api_key_cmd` alone (no `api_key`, no matching env var)
5890    /// is enough for `Agent::new` to succeed — the helper's stdout is
5891    /// resolved and used.
5892    #[test]
5893    fn api_key_cmd_alone_resolves_successfully() {
5894        let config = Config::builder()
5895            .api_key_cmd("echo sk-test-from-helper")
5896            .api_key_env("SUPERCODE_TEST_UNSET_VAR_API_KEY_CMD_2")
5897            .build();
5898        assert!(Agent::new(config).is_ok());
5899    }
5900
5901    /// A failing helper command (non-zero exit, or empty stdout) falls
5902    /// through to `api_key_env` rather than propagating the helper's own
5903    /// failure — same "try the next source" posture as every other layer.
5904    #[test]
5905    fn api_key_cmd_failure_falls_through_to_env() {
5906        std::env::set_var(
5907            "SUPERCODE_TEST_API_KEY_CMD_FALLBACK",
5908            "sk-from-env-fallback",
5909        );
5910        let config = Config::builder()
5911            .api_key_cmd("exit 1")
5912            .api_key_env("SUPERCODE_TEST_API_KEY_CMD_FALLBACK")
5913            .build();
5914        assert!(Agent::new(config).is_ok());
5915        std::env::remove_var("SUPERCODE_TEST_API_KEY_CMD_FALLBACK");
5916    }
5917
5918    /// A failing helper AND no fallback env var still produces the same
5919    /// `MissingApiKey` error today's no-key path always produced — the new
5920    /// source never turns a hard failure into a silent empty key.
5921    #[test]
5922    fn api_key_cmd_failure_with_no_fallback_still_errors() {
5923        let config = Config::builder()
5924            .api_key_cmd("exit 1")
5925            .api_key_env("SUPERCODE_TEST_UNSET_VAR_API_KEY_CMD_3")
5926            .build();
5927        let err = Agent::new(config)
5928            .err()
5929            .expect("helper failed, no env fallback");
5930        assert!(matches!(err, Error::MissingApiKey(_)));
5931    }
5932
5933    /// `run_api_key_cmd` directly: happy path trims trailing whitespace/
5934    /// newline from the command's stdout.
5935    #[test]
5936    fn run_api_key_cmd_trims_output() {
5937        assert_eq!(run_api_key_cmd("echo '  sk-abc123  '"), "sk-abc123");
5938    }
5939
5940    /// `run_api_key_cmd` directly: a nonexistent binary fails to spawn and
5941    /// returns an empty string rather than panicking.
5942    #[test]
5943    fn run_api_key_cmd_spawn_failure_returns_empty() {
5944        // `sh -c` itself always spawns; feed it a command that can't run.
5945        assert_eq!(
5946            run_api_key_cmd("/no/such/binary/at/all --flag"),
5947            String::new()
5948        );
5949    }
5950}