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