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