Skip to main content

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