supercode_harness/agent.rs
1//! The agent loop.
2
3use std::collections::HashSet;
4
5use crate::config::{CachePlan, Config, SteeringMode, ToolAdvertising};
6use crate::error::{Error, Result};
7use crate::event::AgentEvent;
8use crate::message::{ChatMessage, Role};
9use crate::provider::{self, ChatRequest, OpenAiProvider, Provider, ToolSchema};
10use crate::reduce::rehydrate::CAP_NOTICE_MARKER;
11use crate::reduce::{self, ReductionLog, ReductionPolicy};
12use crate::session::Session;
13use crate::sidecar::SidecarWriter;
14use crate::tools::{ToolContext, ToolRegistry};
15
16/// BP-6: how many skill bodies one user message may pull in through `$slug`
17/// mentions — Claude Code caps skill chaining at six per message (cc§7
18/// "Skill chaining"); the same ceiling bounds the mention path here.
19const MAX_SKILL_LOADS_PER_MESSAGE: usize = 6;
20
21/// BP-5: how many `@path` mentions one message may attach. A prompt is not
22/// a bulk loader; past this the user means `--file`.
23const MAX_FILE_MENTIONS_PER_MESSAGE: usize = 10;
24
25/// BP-5: ceiling on the bytes one `@path` mention contributes.
26const MAX_FILE_MENTION_BYTES: usize = 64 * 1024;
27
28/// Tool name of the `tool_search` agent intrinsic (B6). Never a registered
29/// [`crate::tools::Tool`] — intercepted in [`Agent::run_tool`] before registry
30/// lookup, so it works under any [`ToolAdvertising`] mode.
31const TOOL_SEARCH: &str = "tool_search";
32
33/// Tool name of the `expand_reduction` agent intrinsic (T12/TR-1) — the
34/// model-invocable rehydration counterpart to `tool_search`, same
35/// interception pattern. Advertised whenever a [`ReductionPolicy`] is
36/// installed, regardless of [`ToolAdvertising`] mode (see [`Self::tool_schemas`]).
37const EXPAND_REDUCTION: &str = "expand_reduction";
38
39/// Tool name of the `sidecar_search` agent intrinsic (T12/TR-1).
40const SIDECAR_SEARCH: &str = "sidecar_search";
41
42/// Tool name of the `spawn_subagent` agent intrinsic (P5-3, §2 module 9 D1
43/// "spawn tool"). Same interception pattern as [`TOOL_SEARCH`] — never a
44/// registered [`crate::tools::Tool`], intercepted in [`Agent::run_tool`]
45/// before registry lookup — but ALSO needs full `&mut self` async access
46/// (running a whole child agent loop, or `tokio::spawn`-ing one), which
47/// [`Agent::prepare_tool_call`]'s purely-synchronous intrinsics don't, so
48/// the interception point is `Self::run_tool`'s top, not
49/// `prepare_tool_call`.
50const SPAWN_SUBAGENT: &str = "spawn_subagent";
51
52/// BP-7 (catalog §4a "Review mode"): the `[core.prompts]` key the review
53/// turn's template lives under. One name for both presets — cc spells the
54/// command `/code-review`, cx spells it `/review`, and both resolve to this
55/// template, so the row's evidence is one config key, not two.
56pub const REVIEW_PROMPT_NAME: &str = "code-review";
57
58/// BP-7 (catalog §4a "Side/ephemeral Q&A"): the instruction prefixed to a
59/// side question, so the model knows it is answering ABOUT the session
60/// rather than continuing it. The exchange never enters history either way;
61/// this keeps the answer from reading like the next assistant turn.
62const SIDE_QUESTION_PREAMBLE: &str = "[side question — answer from the conversation above; this exchange is not part of the conversation and you have no tools for it]";
63
64/// Claude Code's native name for [`SPAWN_SUBAGENT`]. It is exposed only when
65/// `Config::subagents_claude_agent_alias` is enabled for a Claude import.
66const CLAUDE_AGENT: &str = "Agent";
67
68/// Claude Code spellings for core filesystem/shell tools. Imported Claude
69/// context frequently continues to call these names even when another model
70/// is driving the turn, so emulation must translate execution as well as
71/// preserve the original call/result names in the transcript.
72const CLAUDE_BASH: &str = "Bash";
73const CLAUDE_READ: &str = "Read";
74const CLAUDE_WRITE: &str = "Write";
75const CLAUDE_EDIT: &str = "Edit";
76const CLAUDE_GLOB: &str = "Glob";
77const CLAUDE_GREP: &str = "Grep";
78
79/// Claude Code scheduler compatibility intrinsics. They edit an imported
80/// [`crate::ClaudeRuntimeManifest`]; actual timer execution belongs to an
81/// embedding scheduler driver, never this agent loop.
82const CLAUDE_CRON_CREATE: &str = "CronCreate";
83const CLAUDE_CRON_DELETE: &str = "CronDelete";
84const CLAUDE_CRON_LIST: &str = "CronList";
85const CLAUDE_SCHEDULE_WAKEUP: &str = "ScheduleWakeup";
86
87/// Shared SDK steering mailbox. `accepting` and `queue` share one lock so a
88/// turn's final boundary can close acceptance atomically with its last drain;
89/// a steer can therefore never be acknowledged into the following turn.
90#[derive(Default)]
91pub(crate) struct SteerInbox {
92 queue: std::collections::VecDeque<QueuedSteer>,
93 accepting: bool,
94}
95
96struct QueuedSteer {
97 message: String,
98 sdk_bound: bool,
99}
100
101impl SteerInbox {
102 pub(crate) fn open(&mut self) {
103 self.queue.clear();
104 self.accepting = true;
105 }
106
107 pub(crate) fn enqueue(&mut self, message: String) -> bool {
108 if !self.accepting {
109 return false;
110 }
111 self.queue.push_back(QueuedSteer {
112 message,
113 sdk_bound: true,
114 });
115 true
116 }
117
118 pub(crate) fn close(&mut self) {
119 self.accepting = false;
120 self.queue.retain(|queued| !queued.sdk_bound);
121 }
122
123 fn drain(&mut self, mode: SteeringMode) -> Option<String> {
124 if self.queue.is_empty() {
125 return None;
126 }
127 match mode {
128 SteeringMode::All => Some(
129 self.queue
130 .drain(..)
131 .map(|queued| queued.message)
132 .collect::<Vec<_>>()
133 .join("\n\n"),
134 ),
135 SteeringMode::OneAtATime => self.queue.pop_front().map(|queued| queued.message),
136 }
137 }
138
139 fn drain_or_close(&mut self, mode: SteeringMode) -> Option<String> {
140 if !self.queue.iter().any(|queued| queued.sdk_bound) {
141 self.accepting = false;
142 return None;
143 }
144 self.drain(mode)
145 }
146
147 fn queue_unchecked(&mut self, message: String) {
148 self.queue.push_back(QueuedSteer {
149 message,
150 sdk_bound: false,
151 });
152 }
153
154 fn len(&self) -> usize {
155 self.queue.len()
156 }
157}
158
159struct SteerTurnGuard {
160 inbox: std::sync::Arc<std::sync::Mutex<SteerInbox>>,
161}
162
163impl SteerTurnGuard {
164 fn new(inbox: std::sync::Arc<std::sync::Mutex<SteerInbox>>) -> Self {
165 inbox
166 .lock()
167 .unwrap_or_else(std::sync::PoisonError::into_inner)
168 .accepting = true;
169 Self { inbox }
170 }
171}
172
173impl Drop for SteerTurnGuard {
174 fn drop(&mut self) {
175 self.inbox
176 .lock()
177 .unwrap_or_else(std::sync::PoisonError::into_inner)
178 .close();
179 }
180}
181
182/// Tool name of the `subagent_status` agent intrinsic (P5-3, D3
183/// "background+resume"): poll (and reap, once finished) a background child
184/// spawned via [`SPAWN_SUBAGENT`]. Only advertised when
185/// `Config::subagents_background` is on (see [`Agent::tool_schemas`]).
186const SUBAGENT_STATUS: &str = "subagent_status";
187
188/// BP-7 (catalog §4a "Background subagents + resume": cc's `SendMessage`,
189/// cx's v2 mailbox `send_message`): deliver a message to a STILL-RUNNING
190/// background child. Only advertised when `Config::subagents_background`
191/// is on.
192const SUBAGENT_MESSAGE: &str = "subagent_message";
193
194/// BP-7 (catalog §4a "Background subagents + resume": "resumable with
195/// context intact"): continue a FINISHED child with its own transcript
196/// restored, rather than starting a fresh one that has to be re-briefed.
197const SUBAGENT_RESUME: &str = "subagent_resume";
198
199/// Tool name of the `background_exec` agent intrinsic (P5-6, §2 module 4
200/// `tools.background` D1 "background exec"). Unlike [`SPAWN_SUBAGENT`], this
201/// needs no async child-agent loop — spawning a process
202/// (`tokio::process::Command::spawn`) is itself synchronous — so, like
203/// [`TOOL_SEARCH`], it is intercepted in [`Agent::prepare_tool_call`], not
204/// [`Agent::run_tool`].
205const BACKGROUND_EXEC: &str = "background_exec";
206
207/// Tool name of the `background_status` agent intrinsic (P5-6, D1 "monitor/
208/// event feed"): poll a background job's run status, drain its newly
209/// captured output as an [`AgentEvent::BackgroundOutput`] event, and reap it
210/// (remove it from [`Agent::background_jobs`]) once it has exited or been
211/// killed.
212const BACKGROUND_STATUS: &str = "background_status";
213
214/// Tool name of the `background_list` agent intrinsic (P5-6, D10
215/// "bg-manager"): list every background job this agent is currently
216/// tracking (running or finished-but-unreaped), without draining output or
217/// reaping anything.
218const BACKGROUND_LIST: &str = "background_list";
219
220/// Tool name of the `background_kill` agent intrinsic (P5-6, D10
221/// "bg-manager"): kill a background job's real OS process
222/// (`tokio::process::Child::start_kill`) and reap it immediately.
223const BACKGROUND_KILL: &str = "background_kill";
224
225/// P4e (§3.1 `core.parallel_tool_calls`): the synchronous outcome of
226/// [`Agent::prepare_tool_call`] — either a result already in hand (an
227/// intrinsic, or a call refused before it ever reached `Tool::execute`), or
228/// a plain registry-tool call ready for the (possibly concurrent) async
229/// `execute()` step.
230enum PreparedCall {
231 /// A final `(output, is_error)` result — no `Tool::execute` call is
232 /// coming for this one.
233 Done((String, bool)),
234 /// Passed every synchronous check; `execute(args, &ctx)` on the named
235 /// registry tool is the only remaining step.
236 Ready {
237 name: String,
238 args: serde_json::Value,
239 },
240}
241
242/// Marker prefix of the notice [`Agent::cap_tool_output`] appends to an
243/// oversized tool result kept in `history` (the recorder receives the full
244/// UX-26 (B7-warn): current wall-clock time as unix milliseconds, the same
245/// unit [`crate::sidecar::rfc3339_to_ms`] parses session timestamps into —
246/// lets [`Agent::build_request_messages`] compare "now" against a
247/// cross-process signal (a loaded session's last message timestamp) on
248/// equal footing with an in-process one (this agent's own last annotated
249/// send). Saturates to 0 on a pre-epoch clock rather than panicking (never
250/// happens on real hardware, but `duration_since` can theoretically error).
251fn now_ms() -> i64 {
252 std::time::SystemTime::now()
253 .duration_since(std::time::UNIX_EPOCH)
254 .map(|d| d.as_millis() as i64)
255 .unwrap_or(0)
256}
257
258/// P5-3: process-wide sequence number backing [`next_subagent_id`] —
259/// disambiguates two spawns landing in the same millisecond (which
260/// `now_ms()` alone cannot).
261static SUBAGENT_ID_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
262
263/// P5-3: a fresh, process-unique child agent id (`"agent-<hex-ts>-<hex-seq>"`
264/// — the native analog of Claude Code's `agent-<id>` naming, see
265/// `crate::session::SessionMeta::agent_id`'s doc comment).
266fn next_subagent_id() -> String {
267 let seq = SUBAGENT_ID_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
268 format!("agent-{:x}-{:x}", now_ms(), seq)
269}
270
271/// P5-4: the shape [`Agent::child_approval_handler_factory`]/
272/// [`Agent::set_child_approval_handler_factory`] share — factored into its
273/// own alias (clippy `type_complexity`) rather than spelled out inline at
274/// both use sites.
275type ChildApprovalHandlerFactory = dyn Fn(
276 String,
277 std::sync::Arc<std::sync::Mutex<Vec<crate::subagents::QueuedApproval>>>,
278 ) -> std::sync::Arc<dyn crate::permissions::PermissionsApprovalHandler>
279 + Send
280 + Sync;
281
282/// BP-4 (catalog:109 "Context-usage introspection"): the live
283/// context-window accounting [`Agent::context_usage`] reports — cc's
284/// `/context` grid and cx's `/status` + `get_context_remaining` in one
285/// shape, over the numbers `resume --dry-run`'s preflight already computes.
286///
287/// Every token figure is the SAME estimate the context guard enforces
288/// (`crate::tokens`), so what this reports and what refuses an oversized
289/// turn can never disagree.
290#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
291pub struct ContextUsage {
292 /// The model the accounting is against.
293 pub model: String,
294 /// Messages in the projected request view (reduction stubs included).
295 pub messages: usize,
296 /// Estimated tokens for those messages.
297 pub message_tokens: u64,
298 /// Tools advertised on the next request.
299 pub tool_count: usize,
300 /// Estimated tokens for the serialized tool-schema array — a real part
301 /// of the wire request, and the half a message-only count misses.
302 pub tool_schema_tokens: u64,
303 /// `message_tokens + tool_schema_tokens`.
304 pub request_tokens: u64,
305 /// `request_tokens` with the guard's safety margin applied — the figure
306 /// the context guard actually compares.
307 pub projected_tokens: u64,
308 /// Headroom the guard reserves for the model's own reply.
309 pub response_reserve_tokens: u64,
310 /// The model's context window, when known.
311 pub context_limit: Option<u64>,
312 /// Tokens still available after the reply reserve, `0` when unknown.
313 pub remaining_tokens: u64,
314 /// `projected_tokens` as a whole percentage of the window (rounded),
315 /// `0` when the window is unknown. An integer so this whole struct
316 /// stays `Eq`-comparable on the frontend wire.
317 pub used_pct: u32,
318 /// Whether the next request would pass the context guard.
319 pub fits: bool,
320}
321
322impl ContextUsage {
323 /// One human line, the shape a `/context` command prints.
324 pub fn summary_line(&self) -> String {
325 match self.context_limit {
326 Some(limit) => format!(
327 "{} · {}% of {} used ({} projected, {} left) · {} messages {} · {} tool schemas {}",
328 self.model,
329 self.used_pct,
330 crate::tokens::fmt_approx_tokens(limit),
331 crate::tokens::fmt_approx_tokens(self.projected_tokens),
332 crate::tokens::fmt_approx_tokens(self.remaining_tokens),
333 self.messages,
334 crate::tokens::fmt_approx_tokens(self.message_tokens),
335 self.tool_count,
336 crate::tokens::fmt_approx_tokens(self.tool_schema_tokens),
337 ),
338 None => format!(
339 "{} · context window unknown · {} projected · {} messages {} · {} tool schemas {}",
340 self.model,
341 crate::tokens::fmt_approx_tokens(self.projected_tokens),
342 self.messages,
343 crate::tokens::fmt_approx_tokens(self.message_tokens),
344 self.tool_count,
345 crate::tokens::fmt_approx_tokens(self.tool_schema_tokens),
346 ),
347 }
348 }
349}
350
351/// A stateful agent: configuration, a model transport, a tool set, and the
352/// running conversation. Drive it with [`Agent::send`].
353/// BP-13 — one hop the run loop's failure-fallback pass performed: the
354/// model it was on, the model it moved to, and the provider failure that
355/// made it move.
356#[derive(Debug, Clone, PartialEq, Eq)]
357pub struct FallbackHop {
358 /// The model that failed.
359 pub from: String,
360 /// The next chain entry, which the request was re-sent against.
361 pub to: String,
362 /// The failure, rendered — the record's `reason`.
363 pub reason: String,
364}
365
366/// BP-13 — whether `error` is the kind of failure ANOTHER MODEL could
367/// plausibly answer, i.e. one the fallback chain exists for.
368///
369/// Deliberately narrow: rate limiting (429) and server-side failures (5xx,
370/// which is where "overloaded" lives) are properties of the model/endpoint
371/// that was asked, so asking a different one is a real remedy. Everything
372/// else — a bad request, a refused key, a decode failure, a tool error —
373/// is the CALLER's problem and would fail identically against every entry
374/// in the chain, so walking it would only multiply the same error by three.
375/// The transport's own retry (`OpenAiProvider::send_with_retry`) has
376/// already run and given up by the time this is consulted.
377pub fn is_failover_worthy(error: &Error) -> bool {
378 matches!(error, Error::Provider { status, .. } if *status == 429 || *status >= 500)
379}
380
381pub struct Agent {
382 config: Config,
383 provider: std::sync::Arc<dyn Provider>,
384 registry: ToolRegistry,
385 history: Vec<ChatMessage>,
386 ctx: ToolContext,
387 /// Cumulative output (completion) tokens across every `send` on this agent.
388 total_output_tokens: u64,
389 /// Names of non-core tools discovered via `tool_search` (B6): advertised
390 /// starting with the *next* request once populated.
391 activated_tools: HashSet<String>,
392 /// The live sidecar writer (A3), if this agent is recording. `None` is
393 /// today's behavior, at zero cost: every append point becomes a no-op.
394 recorder: Option<SidecarWriter>,
395 /// BP-8 (catalog:150 "Append-only durable transcript"): the live
396 /// append-only journal, if one is installed
397 /// ([`Self::set_journal`], armed by the caller when
398 /// [`Config::session_append_only`] is on). Behind an `Arc<Mutex<_>>`
399 /// rather than owned outright because the queue doors
400 /// ([`Self::queue_steer`]) take `&self` — a pending input has to be
401 /// recorded from a shared handle while a turn holds `&mut Agent`.
402 /// `None` (the default) is a no-op at every append point: today's
403 /// behavior, no file created.
404 journal: Option<std::sync::Arc<std::sync::Mutex<crate::session_journal::SessionJournal>>>,
405 /// BP-8 (catalog:151 "In-place conversation tree"): the live
406 /// `SessionTree` for this session, materialized when
407 /// [`Config::session_tree_enabled`] is on. Every recorded message
408 /// becomes a node, and [`Self::rewind_conversation`] moves the active
409 /// branch's leaf — the tree is what makes a rewind lossless (the old
410 /// leaf is preserved under a sibling branch) rather than a truncation.
411 /// `None` (the default, and every preset that leaves the module off) is
412 /// zero cost: nothing is built and nothing is persisted.
413 session_tree: Option<crate::session_tree::SessionTree>,
414 /// BP-8 (catalog:152 "Rewind/rollback conversation"): tails removed by
415 /// rewinds that have not been undone, newest last. Restored from the
416 /// journal on resume, so "undo the rewind" survives a restart.
417 rewind_undo: Vec<Vec<ChatMessage>>,
418 /// BP-8 (catalog:156): the plan as last written to the journal —
419 /// compared against `ctx.plan` so an unchanged plan is not re-journaled
420 /// on every loop iteration.
421 journaled_plan: Vec<crate::session_journal::PlanEntry>,
422 /// Reversible reduction policy (A5/A7/A10). `None` is today's behavior,
423 /// at zero cost: every provider request is built from `self.history`
424 /// verbatim, exactly as before this landed.
425 reduction_policy: Option<ReductionPolicy>,
426 /// The accumulating reduction log (A5): fed back into
427 /// [`reduce::project_messages`] on every request-build so already-applied
428 /// reductions reproduce verbatim across turns and `send` calls (prefix
429 /// stability). `history` itself is never touched by this — see
430 /// `Self::run_loop`.
431 reduction_log: ReductionLog,
432 /// B7: length of the stable, byte-identical-across-turns prefix at the
433 /// front of [`Self::history`] — this agent's own system message plus
434 /// every message of a previously-imported session — set by
435 /// [`Self::load_session`]. `None` (the default) means no session has been
436 /// loaded, so [`crate::provider::apply_cache_plan`] has nothing to
437 /// annotate even under [`CachePlan::ImportedPrefix`].
438 imported_prefix_len: Option<usize>,
439 /// BP-11: set for the duration of a `/compact` so the `pre_compact`
440 /// observer can tell a manual compaction from an automatic trigger.
441 compacting_manually: bool,
442 /// BP-4 (catalog:90 "Environment context block", cx§2 "re-emitted on
443 /// change"): the `# Environment` block currently spliced into
444 /// `history[0]`, verbatim — `None` when `core.env_context` is off (or
445 /// on a construction path that assembles no prompt). Kept so
446 /// [`Self::refresh_env_context`] can locate and replace exactly this
447 /// text when cwd, approval/sandbox policy or the git branch moves
448 /// mid-session, instead of leaving the model reading a block that
449 /// stopped being true.
450 env_context_live: Option<String>,
451 /// BP-5 (catalog D2 "Per-model-family base-prompt selection"): the base
452 /// system prompt currently spliced at the head of `history[0]`,
453 /// verbatim. Kept for the same reason [`Self::env_context_live`] is:
454 /// when the model changes ([`Self::set_model`]) the family's prompt
455 /// changes with it, and the stale text has to be located and REPLACED
456 /// rather than left in front of the new model.
457 base_prompt_live: String,
458 /// BP-5 (catalog D2 "Shell-output injection in templates/skills"): the
459 /// permission-engine authorization every `` !`cmd` `` in a skill or
460 /// command body runs under. Inert (executes nothing) unless
461 /// `[core.skills] shell_injection` is on.
462 shell_injection: crate::skills::ShellInjection,
463 /// BP-4 (catalog:91): blocks spliced into the context AFTER
464 /// construction — see [`Self::inject_context_block`] and
465 /// [`crate::context_injection`]. Empty by default, at zero cost.
466 spliced_context_blocks: Vec<crate::config::ContextInjectionBlock>,
467 /// TR-7 (T20): the injectable side-call ([`reduce::summarize::SpanSummarizer`])
468 /// used to summarize an A10 `TurnsCleared` span, if one is installed
469 /// ([`Self::set_span_summarizer`]). `None` is today's behavior, at zero
470 /// cost: `Self::build_request_messages` never calls
471 /// [`reduce::prepare_cleared_turns_summary`] without one, so
472 /// `policy.summarize_cleared_turns` being on with no summarizer
473 /// installed behaves exactly like it being off (deterministic stub only)
474 /// — never a panic, never a blocked request.
475 span_summarizer: Option<std::sync::Arc<dyn reduce::summarize::SpanSummarizer + Send + Sync>>,
476 /// TR-8 (T5): the tool-schema tier signature (global knob + per-tool
477 /// overrides) as of the last request this agent built, or `None` before
478 /// the first request. Compared against the CURRENT signature at the top
479 /// of every `Self::build_request_messages` call so a tier change made
480 /// mid-session (via [`Self::set_schema_tier`] /
481 /// [`Self::set_tool_schema_tier`]) is detected and flagged to the B7
482 /// cache planner as a cache-bust event (`provider::tier_change_is_cache_bust`).
483 last_tool_schema_tier_signature: Option<u64>,
484 /// PARITY-18 D4 — the target model's context-window size, if the caller
485 /// has armed the guard via [`Self::set_context_limit`]. `None` (the
486 /// default) means no guard: every request is sent unconditionally.
487 /// CLI entry points arm it for their resolved model; direct SDK callers
488 /// retain explicit control through [`Self::set_context_limit`].
489 /// Once set, `Self::run_loop` re-checks
490 /// [`crate::tokens::context_guard`] before EVERY request it builds —
491 /// not just the first — so "never sends an over-context request" holds
492 /// for the whole session, not only a one-shot preflight.
493 context_limit: Option<u64>,
494 /// PARITY-18 D3 — becomes `true` the first time `Self::run_loop`
495 /// actually reaches its real send site (immediately before
496 /// [`Provider::complete`]). Exposed via [`Self::request_issued`] so a
497 /// caller can report "request sent" truthfully — never asserted ahead
498 /// of time, so a pre-delivery failure (guard refusal, a build error) or
499 /// an interactive session that quits before any turn completes is
500 /// reported honestly as "not sent".
501 requests_issued: bool,
502 /// UX-26 (B7-warn): unix-ms wall-clock time this agent last knew the
503 /// active [`CachePlan::ImportedPrefix`] breakpoint to be warm. Seeded by
504 /// [`Self::load_session`] from the just-loaded session's OWN last
505 /// message timestamp (`metadata["timestamp"]`, parsed via
506 /// [`crate::sidecar::rfc3339_to_ms`]) — a cross-process signal: how long
507 /// the resumed conversation has sat idle since ANY tool last touched it,
508 /// which is exactly when Anthropic's server-side cache entry (if one
509 /// ever existed) was last capable of being warm. Refreshed to "now"
510 /// every time `Self::run_loop` actually sends a cache-annotated
511 /// request (an in-process signal: idle time between this agent's own
512 /// turns). `None` when no imported prefix exists yet, or the loaded
513 /// session's last message carries no parseable timestamp — never
514 /// guessed, so the TTL check in [`provider::cache_cold_reason`] simply
515 /// doesn't fire rather than risk a false positive.
516 last_cache_activity_ms: Option<i64>,
517 /// UX-26: whether a PRIOR request already carried a cache_control
518 /// annotation for the current [`Self::imported_prefix_len`] — i.e.
519 /// whether reuse is genuinely "expected" on the NEXT annotated request.
520 /// `false` until the first annotated request goes out (that one is
521 /// establishing the cache entry, a legitimate write, never a "miss") and
522 /// reset to `false` by [`Self::load_session`] whenever the imported
523 /// prefix itself changes.
524 cache_established: bool,
525 /// UX-26 scratch: this turn's cache-warmth context, computed once at the
526 /// top of `Self::build_request_messages` (before the request is sent,
527 /// while `effective_cache_plan`/`busted` are in scope) and consumed once
528 /// in `Self::run_loop` right after `usage` comes back — never read
529 /// across turns, so a stale value can't leak. `(will_annotate,
530 /// cache_established, idle_secs)` — see [`provider::cache_cold_reason`]
531 /// for what each of the first two independently gates.
532 pending_cache_turn: (bool, bool, Option<i64>),
533 /// P4b: the injectable auto-title side-call ([`Self::set_session_titler`]),
534 /// mirroring `Self::span_summarizer`'s "installing one alone changes
535 /// nothing" contract — `Config::auto_title` is the actual gate a caller
536 /// consults before invoking [`Self::auto_title`].
537 session_titler: Option<std::sync::Arc<dyn crate::session_title::SessionTitler + Send + Sync>>,
538 /// P4b (§1.6, catalog §4a "persisted per-turn usage records"): every
539 /// [`crate::usage_log::UsageRecord`] recorded so far this agent's
540 /// lifetime. Always accumulated (cheap, small) regardless of whether a
541 /// caller ever persists it — see [`Self::usage_records`]/
542 /// [`Self::save_usage_log`].
543 usage_log: Vec<crate::usage_log::UsageRecord>,
544 /// P4b: 0-based index of the NEXT model round-trip, for
545 /// [`crate::usage_log::UsageRecord::turn`].
546 turn_index: usize,
547 /// BP-7 (catalog §4a "Turn/step bracketing records"): the per-round-trip
548 /// marker log — context/usage/finish brackets plus the retry, abort,
549 /// effort and goal markers. Persisted beside the session as
550 /// `<name>.events.jsonl` (see [`Self::save_turn_records`]).
551 turn_records: Vec<crate::turn_record::TurnRecord>,
552 /// BP-7: retries the transport reported, drained after every
553 /// `complete()` so each notice attaches to the round-trip that produced
554 /// it. Only the HTTP provider built by [`Self::new`] writes into this;
555 /// an injected provider simply never records anything.
556 retry_log: std::sync::Arc<crate::provider::RetryLog>,
557 /// BP-7 (catalog §4a "Per-turn cost/usage accounting", "Turn/budget
558 /// caps"): the price to bill this agent's model at, resolved at
559 /// construction from [`Config::price_input_per_mtok`]/
560 /// [`Config::price_output_per_mtok`] or [`crate::pricing`]'s table, and
561 /// re-resolved by [`Self::set_model`]. `None` = unpriceable, so no cost
562 /// is recorded (never a guess).
563 model_price: Option<crate::pricing::ModelPrice>,
564 /// BP-7: dollars this agent has spent across its whole lifetime — the
565 /// counter [`Config::max_budget_usd`] is measured against.
566 total_cost_usd: f64,
567 /// BP-7: tool calls this agent has executed across its whole lifetime —
568 /// the counter [`Config::max_steps`] is measured against.
569 total_steps: usize,
570 /// BP-7 (catalog §4a "Background subagents + resume"): a finished
571 /// child's post-system-prompt transcript, kept after the reap so
572 /// `subagent_resume` can restore its context in-process. A session
573 /// with a subagent store attached also has it on disk; this makes
574 /// resume work for an embedder that never attached one.
575 reaped_subagents: std::collections::HashMap<String, Vec<ChatMessage>>,
576 /// BP-7 (catalog §4a "Goals"): the session's standing objective, when
577 /// `capabilities.todos.goals` is on and one has been set. Restated at
578 /// the TAIL of every request while it stands (see
579 /// [`crate::goals::GoalRecord::reminder`]) and persisted as
580 /// `<session>.goal.json` — never written into `history`, so the
581 /// transcript stays exactly what the conversation was.
582 goal: Option<crate::goals::GoalRecord>,
583 /// P4b (§1.7/§3.1 `core.steering`, pi§3 semantics): queued mid-turn
584 /// steering messages — drained at the top of `Self::run_loop`'s next
585 /// iteration (pi's "steer = after current tool calls"). Empty by
586 /// default, at zero cost: `Self::run_loop` skips the drain entirely
587 /// when empty.
588 steer_queue: std::sync::Arc<std::sync::Mutex<SteerInbox>>,
589 /// P4b: queued follow-up messages — drained only once the loop is
590 /// otherwise idle (pi's "follow-up = at idle"), i.e. exactly the point
591 /// `Self::run_loop` would otherwise return a final answer.
592 follow_up_queue: std::collections::VecDeque<String>,
593 /// P4c (§5.2 P4 "doom-loop breaker", §3.1 `core.doom_loop_threshold`):
594 /// `(tool name, canonical JSON args)` of the most recent tool call, if
595 /// [`Config::doom_loop_threshold`] is armed — `None` before the first
596 /// call this agent has run. See [`Self::check_doom_loop`].
597 doom_loop_last_call: Option<(String, String)>,
598 /// P4c: how many times [`Self::doom_loop_last_call`] has repeated
599 /// consecutively so far (starts at 1 on the call that SET it).
600 doom_loop_streak: u32,
601 /// P4c (§1.10/§3.1 `core.model_switch.allow_switch`): every
602 /// [`crate::model_change::ModelChangeRecord`] [`Self::switch_model`] has
603 /// created so far this agent's lifetime. Always empty when
604 /// `Config::model_switch_allow_switch` is off (the default) or no
605 /// switch has happened yet.
606 model_change_log: Vec<crate::model_change::ModelChangeRecord>,
607 /// P4e (§1.6/§3.1 `core.session.git_metadata`, catalog:331): captured
608 /// once at construction when [`Config::session_git_metadata`] is on;
609 /// `None` when the gate is off (the default) or the best-effort git
610 /// probe found nothing (not a repo, `git` missing). See
611 /// [`Self::git_metadata`]/[`Self::save_git_metadata`].
612 git_metadata: Option<crate::git_metadata::GitMetadataRecord>,
613 /// P5-1 (§2.10, session-scoped "approve for session" cache): populated
614 /// only when a [`crate::permissions::PermissionsApprovalHandler`]
615 /// returns [`crate::permissions::ApprovalOutcome::AllowForSession`] —
616 /// see [`Self::prepare_tool_call`]'s `Config::permissions_enabled`
617 /// branch. Always constructed (cheap, empty) regardless of whether the
618 /// engine is ever active — the same "zero cost when off" posture as
619 /// [`Self::doom_loop_last_call`].
620 permissions_approval_cache: crate::permissions::ApprovalCache,
621 /// P5-1: the non-interactive decision seam a caller installs via
622 /// [`Self::set_permissions_approval_handler`] — mirrors
623 /// `Self::span_summarizer`/[`Self::session_titler`]'s "installing one
624 /// alone changes nothing, `Config::permissions_enabled` is the actual
625 /// gate" pattern. `None` (the default) means every `Ask`-tier decision
626 /// is denied (fail-closed — see
627 /// `crate::permissions::approval::PermissionsApprovalHandler`'s doc
628 /// comment).
629 permissions_approval_handler:
630 Option<std::sync::Arc<dyn crate::permissions::PermissionsApprovalHandler>>,
631 /// P5-2 (§2 module 15 D7 row 4 "prompts-as-commands"): MCP server
632 /// prompts registered via [`Self::register_mcp_prompt`], keyed by their
633 /// ALREADY-NAMESPACED command name (`mcp__<server>__<prompt>` — see
634 /// [`crate::mcp::McpPromptSource`]'s doc comment for why that namespace
635 /// is what keeps an untrusted server's prompt from ever colliding with
636 /// a trusted `Config::prompts` entry). Empty by default, at zero cost:
637 /// [`Self::expand_prompt_async`] only consults this after
638 /// `Config::prompts` finds no match.
639 mcp_prompts: std::collections::HashMap<String, Box<dyn crate::sdk::SdkPromptSource>>,
640 /// BP-6 (catalog D2 "Skills (progressive-disclosure packages)", D7
641 /// "Skill discovery from multiple roots"): the SKILL.md packages
642 /// discovered for this config, frontmatter only — name, description,
643 /// version and the manifest path. Never a body: a body is read from
644 /// disk on invocation (`/name`, `/skill:name`, a `$slug` mention, or
645 /// the `skill` tool) and nowhere else. Empty unless `[core.skills]` is
646 /// on AND names a harness whose roots to read.
647 skills: Vec<crate::skills::LoopSkill>,
648 /// P5-3 (§2 module 9): how deep in the spawn tree THIS agent is — `0`
649 /// for a top-level agent. Set from [`Config::subagent_depth`] at
650 /// construction; `Self::run_spawn_subagent` builds a child `Config`
651 /// with `subagent_depth = self.subagent_depth + 1` and ALSO overwrites
652 /// the freshly-built child `Agent`'s own field to match (belt-and-
653 /// suspenders — the child never has to trust its own `Config` alone).
654 subagent_depth: usize,
655 /// P5-3 (resource bound, "must not fork-bomb"): the shared, tree-wide
656 /// concurrency gauge every spawn (this agent's own, and every
657 /// descendant's) increments/decrements against
658 /// (`crate::subagents::try_acquire`/`ConcurrencyGuard`). A TOP-level
659 /// agent gets a fresh `Arc::new(AtomicUsize::new(0))` at construction;
660 /// `Self::run_spawn_subagent` clones this SAME `Arc` into every child it
661 /// spawns (never a fresh one), so a cap of N holds across the WHOLE
662 /// tree regardless of its branching shape — a parent with 3 children
663 /// each spawning 3 more shares one counter, not nine independent ones.
664 subagent_concurrency_gauge: std::sync::Arc<std::sync::atomic::AtomicUsize>,
665 /// P5-3 (D3 "background+resume"): background subagents this agent has
666 /// spawned and not yet reaped via `subagent_status`, keyed by their
667 /// `child_agent_id`. Each entry's `JoinHandle` moves its own
668 /// [`crate::subagents::ConcurrencyGuard`] into the spawned task, so the
669 /// concurrency slot is held for exactly as long as the child is
670 /// actually running, independent of whether/when the parent polls.
671 background_subagents: std::collections::HashMap<String, BackgroundSubagent>,
672 /// P5-3 (§2.2 C6 "parent-surfaced queue"): approval requests a
673 /// `background_prompts = "parent"` child raised, queued here rather
674 /// than blocking (see [`crate::subagents::QueuedApproval`]'s doc
675 /// comment — each is already resolved `Deny` by the time it lands
676 /// here). Exposed read-only via [`Self::pending_child_approvals`].
677 /// Always constructed (cheap, empty) regardless of whether background
678 /// spawning is ever used, same "zero cost when off" posture as
679 /// [`Self::permissions_approval_cache`].
680 pending_child_approvals:
681 std::sync::Arc<std::sync::Mutex<Vec<crate::subagents::QueuedApproval>>>,
682 /// P5-4 (tui, closes the P5-3 §2.2 C6 deferred chain — see
683 /// [`crate::subagents::ParentQueueApprovalHandler`]'s doc comment for
684 /// the "never blocks" contract this OVERRIDES only when a factory is
685 /// installed): when `Some`, `Self::run_spawn_subagent` uses THIS
686 /// factory — instead of constructing the default never-blocking
687 /// [`crate::subagents::ParentQueueApprovalHandler`] — to build the
688 /// `PermissionsApprovalHandler` a `background_prompts = "parent"`
689 /// child gets. Installed via
690 /// [`Self::set_child_approval_handler_factory`] by a `tui` embedder
691 /// that wants queued child approvals to be genuinely ANSWERABLE
692 /// (blocks the child's tool call until the parent resolves it, or
693 /// denies if the factory's handler's channel is ever dropped/closed —
694 /// still fail-closed, never a hang past process lifetime). `None` (the
695 /// default) preserves P5-3's shipped behavior byte-for-byte: every
696 /// `background_prompts = "parent"` child still gets the immediate-deny
697 /// `ParentQueueApprovalHandler`, and [`Self::pending_child_approvals`]
698 /// stays exactly the read-only audit view it already is.
699 child_approval_handler_factory: Option<std::sync::Arc<ChildApprovalHandlerFactory>>,
700 /// P5-3 (D5 "subagent transcripts… persisted + linked"): an optional
701 /// `(store, this agent's own session name)` pair installed via
702 /// [`Self::set_subagent_store`] — mirrors [`Self::set_recorder`]/
703 /// [`Self::set_span_summarizer`]'s "installing one alone changes
704 /// nothing" pattern. `None` (the default) means a spawned child's
705 /// transcript/lineage is still joined back into THIS agent's context
706 /// (the foreground/background mechanics work either way) but nothing
707 /// is written to a [`crate::store::SessionStore`] — no behavior change
708 /// for any caller that never installs one (e.g. every pre-P5-3 caller).
709 subagent_store: Option<(std::sync::Arc<crate::store::SessionStore>, String)>,
710 /// Imported Claude runtime state. The manifest can be paused or active,
711 /// but this Agent contains no scheduler or timer handle; an embedding
712 /// driver owns execution and persistence.
713 claude_runtime_manifest: Option<crate::claude_runtime_state::ClaudeRuntimeManifest>,
714 /// P5-6 (§2 module 4 `tools.background`, D10 "bg-manager"): background
715 /// OS processes spawned via `background_exec`, keyed by job id, tracked
716 /// until reaped (a terminal `background_status` poll, or an explicit
717 /// `background_kill`) — see [`BackgroundJob`]'s doc comment. Always
718 /// constructed (cheap, empty), same "zero cost when off" posture as
719 /// [`Self::background_subagents`].
720 background_jobs: std::collections::HashMap<String, BackgroundJob>,
721 /// P5-6 (resource bound, mirroring [`Self::subagent_concurrency_gauge`]'s
722 /// own precedent): the shared concurrency gauge every `background_exec`
723 /// call on this agent increments/decrements against
724 /// (`crate::subagents::try_acquire`/`ConcurrencyGuard` — reused
725 /// verbatim, a second independent gauge instance scoped to background
726 /// JOBS rather than subagent SPAWNS).
727 background_concurrency_gauge: std::sync::Arc<std::sync::atomic::AtomicUsize>,
728 /// P5-9 (§2 module 20 `checkpoint`): the write-path-interception
729 /// observer installed on [`Self::ctx`]'s `write_observer` (as a
730 /// `dyn WriteObserver`), held here ADDITIONALLY as its concrete type so
731 /// `Self::run_loop` can call
732 /// [`crate::checkpoint::CheckpointObserver::begin_turn`] once per turn.
733 /// `None` when `Config::checkpoint_enabled` is `false` (the default) or
734 /// the shadow store failed to open — see
735 /// [`crate::checkpoint::observer_for_config`].
736 checkpoint_observer: Option<std::sync::Arc<crate::checkpoint::CheckpointObserver>>,
737 /// P5-11 (§2 module 28 `lsp`): the LSP server registry installed (via
738 /// `crate::lsp::LspDiagnosticsObserver`) on `Self::ctx`'s
739 /// `write_observer` chain, held here ADDITIONALLY as its concrete type
740 /// so `impl Drop for Agent` can reach
741 /// [`crate::lsp::LspManager::kill_all_sync`] (no orphaned language-
742 /// server processes) and a clean-exit caller can reach
743 /// [`crate::lsp::LspManager::shutdown_all`] for a graceful handshake.
744 /// `None` when `Config::lsp_enabled` is `false` (the default).
745 lsp_manager: Option<std::sync::Arc<crate::lsp::LspManager>>,
746}
747
748/// P5-6 (§2 module 4 `tools.background`): one background-spawned OS process
749/// this agent is tracking, awaiting a `background_status`/`background_list`
750/// poll (or `background_kill`/agent drop) to reap or terminate it.
751///
752/// **Real process, not a child agent.** Unlike [`BackgroundSubagent`] (which
753/// wraps a whole recursive child [`Agent`] loop against the SAME mock/real
754/// provider), this wraps a plain OS subprocess spawned via
755/// `crate::tools::build_sandboxed_sh` — the exact function
756/// [`crate::tools::BashTool::execute`] itself calls, so a background
757/// command gets byte-identical sandboxing/cwd/env handling to a foreground
758/// `bash` call (build brief: "reuse the bash tool's execution + sandbox
759/// path").
760struct BackgroundJob {
761 /// The live process handle — kept directly on the job (not moved into a
762 /// spawned task) so [`Agent::run_background_status`]/
763 /// [`Agent::run_background_list`] can call the SYNCHRONOUS,
764 /// non-blocking `Child::try_wait` to observe exit status, and
765 /// [`Agent::run_background_kill`]/[`impl Drop for Agent`] can call the
766 /// SYNCHRONOUS `Child::start_kill` for a REAL process kill — never just
767 /// a `tokio::task::JoinHandle::abort` (which would only cancel a Rust
768 /// future, not the OS process it spawned). `kill_on_drop(true)` was set
769 /// at spawn time as defense-in-depth: even a `BackgroundJob` dropped
770 /// through some path OTHER than the explicit kill call sites below
771 /// still kills its child (a documented tokio behavior; a no-op if the
772 /// process already exited).
773 child: tokio::process::Child,
774 /// The exact command text this job is running — the SAME text that was
775 /// already checked against the permissions engine at spawn time (see
776 /// [`Agent::background_permission_denial`]).
777 command: String,
778 /// The OS process id, captured once at spawn time (before `child` is
779 /// ever mutated) — surfaced in every status/list/kill result, and the
780 /// only thing an OUTSIDE observer (e.g. a test proving real
781 /// termination) needs to check liveness independent of this process's
782 /// own bookkeeping.
783 pid: Option<u32>,
784 /// Bounded, incrementally-appended combined stdout+stderr capture —
785 /// written to by the reader tasks [`Agent::run_background_exec`] spawns
786 /// right after `child.stdout`/`child.stderr` are taken, read by every
787 /// status/list poll. Shared via `Arc` since the reader tasks outlive
788 /// this method call.
789 output: std::sync::Arc<crate::background::CapturedOutput>,
790 /// Unix-ms wall-clock time the spawn happened.
791 started_at_ms: i64,
792 /// Set by [`Agent::run_background_kill`] — [`Agent::run_background_status`]/
793 /// [`Agent::run_background_list`] report [`crate::background::JobStatus::Killed`]
794 /// unconditionally once this is `true`, rather than racing
795 /// `Child::try_wait` to see whether the kill signal has landed yet.
796 killed: bool,
797 /// The concurrency-gauge slot this job holds for as long as it remains
798 /// in [`Agent::background_jobs`] — dropped (freeing the slot) when this
799 /// `BackgroundJob` is removed from the map (a terminal reap, or an
800 /// explicit kill), exactly mirroring [`BackgroundSubagent`]'s own
801 /// "guard held for as long as it's tracked, not just while the process
802 /// is alive" posture (§2 module 9 precedent, kept consistent here).
803 _guard: crate::subagents::ConcurrencyGuard,
804}
805
806/// P5-6: the non-blocking status read [`Agent::run_background_status`]/
807/// [`Agent::run_background_list`] share — `job.killed` (set by
808/// [`Agent::run_background_kill`]) always wins over a fresh `try_wait`,
809/// since a kill signal racing the OS reaping the process is otherwise
810/// indistinguishable from "still running" for one poll cycle; reporting
811/// `Killed` unconditionally once requested avoids that race entirely. A
812/// `try_wait` error (would only happen if this job's id were somehow
813/// double-reaped, which the map ownership below already prevents) is
814/// treated as "no news yet" — `Running` — rather than inventing a made-up
815/// exit code.
816fn background_job_status(job: &mut BackgroundJob) -> crate::background::JobStatus {
817 if job.killed {
818 return crate::background::JobStatus::Killed;
819 }
820 match job.child.try_wait() {
821 Ok(Some(status)) => crate::background::JobStatus::Exited(status.code()),
822 Ok(None) | Err(_) => crate::background::JobStatus::Running,
823 }
824}
825
826/// Fable-5 review (HIGH, "grandchildren orphaned on kill AND agent-drop"):
827/// the shared real-kill body for both [`Agent::run_background_kill`] and
828/// `impl Drop for Agent` — sends `SIGKILL` to `job`'s ENTIRE process group,
829/// not just the one directly-tracked pid, so a surviving `&` job, pipeline
830/// stage, or double-forking daemon spawned by the job is killed too, then
831/// reaps the group leader so it doesn't linger as a zombie.
832///
833/// Relies on the spawn site (`Agent::run_background_exec`) having put the
834/// job in its OWN new process group via `Command::process_group(0)` — which
835/// makes the leader's pgid equal to its own pid, so `job.pid` doubles as the
836/// group id here.
837#[cfg(unix)]
838fn kill_job_process_group(job: &mut BackgroundJob) {
839 if let Some(pid) = job.pid {
840 // SAFETY: `libc::kill` with a negative pid is `killpg` — it only
841 // ever sends a signal (never dereferences memory), so this is safe
842 // regardless of whether the group is still alive. A `-1`/`ESRCH`
843 // return means the leader (and thus the whole group, since a group
844 // can't outlive its leader) already exited — not an error, just
845 // "already dead", exactly like `Child::start_kill`'s own documented
846 // no-op-on-already-exited contract.
847 unsafe {
848 libc::kill(-(pid as libc::pid_t), libc::SIGKILL);
849 }
850 }
851 // Belt-and-suspenders for the leader itself — `kill_on_drop(true)` set
852 // at spawn time is the same outcome via a different (implicit) path —
853 // then reap it so the SIGKILL we just delivered doesn't leave a zombie
854 // behind.
855 let _ = job.child.start_kill();
856 let _ = job.child.try_wait();
857}
858
859/// Non-unix fallback: no portable process-group primitive is wired up here
860/// (same posture as `crate::tools::build_sandboxed_sh`'s own platform
861/// split) — falls back to the pre-fix per-child kill. A background job that
862/// spawns a surviving grandchild process on a non-Unix target is a
863/// documented residual, not silently claimed fixed by this cfg arm.
864#[cfg(not(unix))]
865fn kill_job_process_group(job: &mut BackgroundJob) {
866 let _ = job.child.start_kill();
867}
868
869/// P5-6 (D1 "monitor/event feed", "output captured incrementally +
870/// BOUNDED"): spawn a fire-and-forget reader task that continuously drains
871/// `reader` (a piped `ChildStdout`/`ChildStderr`) into `output`, bounded at
872/// `cap` bytes. Reading NEVER stops at the cap — only what's RETAINED is
873/// bounded ([`crate::background::CapturedOutput::append`]'s own contract)
874/// — because a background job's child process would otherwise block
875/// forever writing to a full, undrained OS pipe once this stopped reading
876/// it, silently hanging real work behind an apparently-"running" job. The
877/// task exits on its own once the pipe reaches EOF (the process closed the
878/// descriptor, whether by exiting or being killed) — no explicit
879/// abort/cleanup call site is needed; a detached `tokio::spawn` this short-
880/// lived is not the kind of orphaned-task risk `impl Drop for Agent`'s own
881/// doc comment is about (that one concerns a whole recursive provider-
882/// calling child AGENT loop, not a bounded byte-copy loop that ends the
883/// instant its source pipe closes).
884fn spawn_output_reader<R>(
885 reader: R,
886 output: std::sync::Arc<crate::background::CapturedOutput>,
887 cap: usize,
888) -> tokio::task::JoinHandle<()>
889where
890 R: tokio::io::AsyncRead + Unpin + Send + 'static,
891{
892 tokio::spawn(async move {
893 use tokio::io::AsyncReadExt;
894 let mut reader = reader;
895 let mut buf = [0u8; 8192];
896 loop {
897 match reader.read(&mut buf).await {
898 Ok(0) => break,
899 Ok(n) => {
900 let chunk = String::from_utf8_lossy(&buf[..n]);
901 output.append(&chunk, cap);
902 }
903 Err(_) => break,
904 }
905 }
906 })
907}
908
909/// BP-7 (catalog §4a "Named agent definitions as data"): merge
910/// `<cwd>/.claude/agents/*.md` into `config.subagents_definitions`.
911///
912/// Runs for every `Agent` whose `subagents` module is on, whatever preset
913/// it came from — before BP-7 the `.md` loader was reachable only from the
914/// Claude emulate/resume path, so a cc-parity or cx-parity session ignored
915/// definitions sitting right there in the repo.
916///
917/// * A no-op when the module is off (the default), and for every spawned
918/// CHILD (`subagent_depth > 0`), which already inherits its parent's
919/// resolved definitions verbatim.
920/// * A config-table entry WINS over a discovered file of the same name:
921/// `[capabilities.subagents.agents.<name>]` is explicit configuration,
922/// the file is discovery.
923/// * A malformed file is skipped with a warning, never a failed
924/// construction: `Agent::with_provider` has no error channel, and a
925/// broken agent file in some repo must not make the harness unusable
926/// there. (The emulate/resume path keeps its own strict behavior, where
927/// a definition the resumed session may depend on going missing IS worth
928/// failing over.)
929fn merge_project_agent_definitions(config: &mut Config) {
930 if !config.subagents_enabled || config.subagent_depth > 0 {
931 return;
932 }
933 match crate::claude_compat::load_project_agents(&config.cwd) {
934 Ok(agents) => {
935 for agent in agents {
936 config
937 .subagents_definitions
938 .entry(agent.definition.name.clone())
939 .or_insert(agent.definition);
940 }
941 }
942 Err(e) => {
943 tracing::debug!(
944 error = %e,
945 "skipping .claude/agents discovery: a definition file could not be parsed"
946 );
947 }
948 }
949}
950
951/// P5-3: one background-spawned child this agent is tracking, awaiting a
952/// `subagent_status` poll (or agent drop) to reap it.
953struct BackgroundSubagent {
954 /// Resolves to `(child_agent_id, child's final result, the child's own
955 /// post-system-prompt history — for D5 transcript persistence once
956 /// reaped)` — the concurrency-guard slot for this child is held INSIDE
957 /// the spawned future (moved in at spawn time), so it releases the
958 /// instant the child's own run loop finishes, not when the parent gets
959 /// around to polling.
960 handle: tokio::task::JoinHandle<(String, Result<String>, Vec<ChatMessage>)>,
961 /// The task/prompt text the child was spawned with (surfaced by a
962 /// `"pending"` status poll, since the handle alone can't answer "what
963 /// is it doing").
964 task: String,
965 /// The named `agent_type` spawned, if any.
966 agent_type: Option<String>,
967 /// Unix-ms wall-clock time the spawn happened.
968 started_at_ms: i64,
969 /// BP-7 (catalog §4a "Background subagents + resume"): the child's own
970 /// steering inbox, captured before the child moved into its task.
971 ///
972 /// This IS the mailbox. `SteerInbox` was built (P4b) to be writable
973 /// while an active turn holds `&mut Agent` — exactly the property a
974 /// message-to-a-running-child needs — so the mailbox is that existing
975 /// seam reached from outside, not a second delivery channel with its
976 /// own ordering rules. A message lands at the top of the child's next
977 /// loop iteration, per `Config::steering_mode`.
978 mailbox: std::sync::Arc<std::sync::Mutex<SteerInbox>>,
979}
980
981/// P5-3 safety hardening (Fable-5 review, MEDIUM-LOW "orphaned billed
982/// spend"): a dropped parent must not leave a detached background child
983/// running against a REAL provider. Without this, a parent dropped
984/// mid-run (the caller's own process exits the scope, panics, or simply
985/// stops polling) leaves every still-running `BackgroundSubagent::handle`
986/// as an orphaned `tokio::spawn` task: nothing had ever awaited or
987/// aborted it, so it runs to its own (`max_iterations`-bounded)
988/// completion regardless — bounded but real provider spend nobody is
989/// paying attention to.
990///
991/// `.abort()` on a [`tokio::task::JoinHandle`] is safe to call
992/// unconditionally, including on an ALREADY-finished task (a documented
993/// no-op there — see tokio's `JoinHandle::abort` docs) — so this never
994/// needs to distinguish "still running" from "already done"; a background
995/// child that already finished and is merely awaiting a `subagent_status`
996/// reap is untouched in practice (aborting a finished task changes
997/// nothing observable). For a task still mid-flight, tokio cancels it at
998/// its next `.await` point, which drops that future in place — including
999/// the `_guard: ConcurrencyGuard` moved into it at spawn time (see
1000/// `Self::run_spawn_subagent`'s `tokio::spawn` body) — so the
1001/// concurrency-gauge slot is released exactly the same way a normal
1002/// completion releases it (`ConcurrencyGuard`'s own `Drop`, in
1003/// `crate::subagents`). No separate cleanup call site to forget.
1004///
1005/// Deliberately does NOT touch [`Self::pending_child_approvals]` or
1006/// `Self::subagent_store` — this is purely "stop burning provider
1007/// calls on behalf of a caller who's gone", not a transcript-persistence
1008/// path (a child aborted mid-flight has no finished result to persist;
1009/// see this build's named residual on abort-time transcript loss).
1010impl Drop for Agent {
1011 fn drop(&mut self) {
1012 for (child_id, bg) in self.background_subagents.drain() {
1013 // Named, not silent: a child that was still running gets its
1014 // provider calls cut off here — worth a trace even though
1015 // there's no transcript left to persist (the future is
1016 // dropped mid-flight, before it ever returns a result).
1017 if !bg.handle.is_finished() {
1018 tracing::debug!(
1019 child_id = %child_id,
1020 "parent Agent dropped: aborting still-running background subagent \
1021 to stop further provider spend"
1022 );
1023 }
1024 bg.handle.abort();
1025 }
1026 // P5-6 (§2 module 4 `tools.background`, build brief "on agent drop
1027 // / session end, jobs MUST be killed... real process kill via the
1028 // child handle's kill(), not just tokio task abort"): a REAL OS
1029 // process, not a Rust task — `Child::start_kill` (synchronous, no
1030 // `.await` needed, so callable from this non-async `Drop::drop`)
1031 // sends the actual kill signal; a no-op, per its own docs, on a
1032 // job that already exited. `kill_on_drop(true)` (set at spawn
1033 // time) is a second, independent line of defense for the same
1034 // outcome, but this explicit loop is what makes the guarantee
1035 // provable/traceable rather than relying solely on an implicit
1036 // tokio runtime behavior.
1037 for (job_id, mut job) in self.background_jobs.drain() {
1038 if !job.killed {
1039 tracing::debug!(
1040 job_id = %job_id,
1041 command = %job.command,
1042 "parent Agent dropped: killing still-tracked background job's real \
1043 OS process (and its whole process group — see \
1044 `kill_job_process_group`)"
1045 );
1046 }
1047 kill_job_process_group(&mut job);
1048 }
1049 // P5-11 (§2 module 28 `lsp`, build brief "no orphaned language-
1050 // server processes"): a REAL OS process, same rationale as the
1051 // background-job loop just above — `kill_all_sync` is
1052 // synchronous (`Child::start_kill`, no `.await` needed, so
1053 // callable from this non-async `Drop::drop`), SIGKILLs each
1054 // server's WHOLE process group (unix — same `kill_job_process_group`
1055 // mechanism as the background-job loop above, so worker
1056 // grandchildren like rust-analyzer's proc-macro server or
1057 // typescript-language-server's `tsserver` are killed too, not just
1058 // the one directly-tracked pid), and is provable/traceable rather
1059 // than relying solely on `kill_on_drop(true)`'s implicit tokio
1060 // runtime behavior (which remains a second, independent line of
1061 // defense on every spawned `LspClient`).
1062 if let Some(lsp) = &self.lsp_manager {
1063 lsp.kill_all_sync();
1064 }
1065 }
1066}
1067
1068/// P4 (§1.8 credential-helper indirection, D6 row): run an `api_key_cmd`
1069/// through the shell and return its trimmed stdout. Runs via `sh -c` (POSIX
1070/// shell, matching pi's `!command` precedent) so the configured string can
1071/// use pipes/substitution, e.g. `pass show api-key`. Never panics or
1072/// propagates an error: a spawn failure or non-zero exit is reported via
1073/// `tracing::warn!` and returns an empty `String`, which
1074/// `Agent::new`'s resolution chain treats exactly like an unset helper —
1075/// falling through to `Config::api_key_env`.
1076fn run_api_key_cmd(cmd: &str) -> String {
1077 match std::process::Command::new("sh").arg("-c").arg(cmd).output() {
1078 Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_string(),
1079 Ok(out) => {
1080 tracing::warn!(
1081 "api_key_cmd exited with status {:?}; falling back to api_key_env",
1082 out.status.code()
1083 );
1084 String::new()
1085 }
1086 Err(e) => {
1087 tracing::warn!("api_key_cmd failed to run ({e}); falling back to api_key_env");
1088 String::new()
1089 }
1090 }
1091}
1092
1093/// BP-9 (§3.1 `core.api_key_command`, D6 row "Credential helpers /
1094/// keyring"): run an ARGV credential helper and return its trimmed stdout.
1095/// No shell is involved — `argv[0]` is exec'd with the rest as arguments —
1096/// so a helper path with spaces, or an argument containing `$`/`;`, means
1097/// what it says. Same never-panics, fall-through-on-failure contract as
1098/// [`run_api_key_cmd`]: an empty result is treated as "no helper".
1099pub(crate) fn run_api_key_command(argv: &[String]) -> String {
1100 let Some((program, args)) = argv.split_first() else {
1101 return String::new();
1102 };
1103 match std::process::Command::new(program).args(args).output() {
1104 Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_string(),
1105 Ok(out) => {
1106 tracing::warn!(
1107 "api_key_command exited with status {:?}; trying the next credential source",
1108 out.status.code()
1109 );
1110 String::new()
1111 }
1112 Err(e) => {
1113 tracing::warn!(
1114 "api_key_command failed to run ({e}); trying the next credential source"
1115 );
1116 String::new()
1117 }
1118 }
1119}
1120
1121/// P4c (§1.2/§3.1 `core.shell_env_snapshot`, SPLIT CC+CX row, catalog:338):
1122/// capture the user's interactive login-shell environment ONCE, best-effort.
1123/// Runs `$SHELL -lc env` (falling back to `sh -lc env` when `$SHELL` is
1124/// unset) — a LOGIN shell (`-l`) sources the user's rc files, which is
1125/// exactly the sourcing `bash` calls should no longer need to repeat once
1126/// this snapshot is in hand. Never panics: any failure (spawn error,
1127/// non-zero exit, unparseable output) returns an empty map, which
1128/// `ToolContext::shell_env`'s "no-op when `None`/empty" contract already
1129/// treats as harmless.
1130fn capture_shell_env() -> std::collections::HashMap<String, String> {
1131 let shell = std::env::var("SHELL").unwrap_or_else(|_| "sh".to_string());
1132 let out = match std::process::Command::new(&shell)
1133 .arg("-lc")
1134 .arg("env")
1135 .output()
1136 {
1137 Ok(o) if o.status.success() => o.stdout,
1138 Ok(o) => {
1139 tracing::warn!(
1140 "shell_env_snapshot: `{shell} -lc env` exited with status {:?}; snapshot is empty",
1141 o.status.code()
1142 );
1143 return std::collections::HashMap::new();
1144 }
1145 Err(e) => {
1146 tracing::warn!(
1147 "shell_env_snapshot: failed to run `{shell} -lc env` ({e}); snapshot is empty"
1148 );
1149 return std::collections::HashMap::new();
1150 }
1151 };
1152 let text = String::from_utf8_lossy(&out);
1153 let mut map = std::collections::HashMap::new();
1154 for line in text.lines() {
1155 if let Some((k, v)) = line.split_once('=') {
1156 if !k.is_empty() {
1157 map.insert(k.to_string(), v.to_string());
1158 }
1159 }
1160 }
1161 map
1162}
1163
1164/// Build the [`ToolContext`] an [`Agent`] hands to every tool call, folding
1165/// in every P4c per-tool config knob (§1.2) alongside the pre-existing
1166/// `cwd`/`sandbox` — shared by [`Agent::with_parts`]/[`Agent::with_provider_arc`]
1167/// so the two construction paths can never drift apart on which config
1168/// fields reach the context. Also builds (P5-9) the
1169/// [`crate::checkpoint::CheckpointObserver`], if `config.checkpoint_enabled`
1170/// — installed on the returned context's `write_observer` AND returned
1171/// separately (as the concrete type) so `Agent::run_loop` can call
1172/// [`crate::checkpoint::CheckpointObserver::begin_turn`] once per turn.
1173/// `None`/no-op end to end when the module is off — see
1174/// [`crate::checkpoint::observer_for_config`]'s own doc comment for the
1175/// default-off byte-identity guarantee.
1176///
1177/// P5-11 (§2 modules 28/29, D-5 "shared write-path interception seam"):
1178/// `crate::formatters::observer_for_config`/`crate::lsp::manager_for_config`
1179/// are folded into the SAME `write_observer` slot via
1180/// [`crate::tools::WriteObserverChain`], in the design's required order —
1181/// `checkpoint -> formatters -> lsp` (checkpoint's pre-image capture must
1182/// see the file before ANY mutation; lsp's diagnostics must see the file
1183/// AFTER formatting, never before). When 0 or 1 of the three modules is
1184/// active, this degrades to exactly what P5-9 shipped (`None`, or the
1185/// single concrete observer installed directly) — no chain wrapper is
1186/// introduced unless there is actually more than one observer to order,
1187/// keeping every single-module (or all-off) configuration byte-identical
1188/// to before this function grew multi-observer support. The `lsp` manager
1189/// is ALSO returned separately (like `checkpoint_observer`), so
1190/// `Agent`'s `Drop` impl can reach `crate::lsp::LspManager::kill_all_sync`
1191/// regardless of how the chain is shaped.
1192/// BP-2: `pub(crate)` so a parity test can build the SAME `ToolContext` an
1193/// `Agent` would from a resolved preset's `Config` and drive a registry
1194/// tool through it — a tool's behavior under a preset is exactly the
1195/// composition of the two, and a test that hand-assembled a context would
1196/// be proving an unwired function.
1197pub(crate) fn build_tool_context(
1198 config: &Config,
1199) -> (
1200 ToolContext,
1201 Option<std::sync::Arc<crate::checkpoint::CheckpointObserver>>,
1202 Option<std::sync::Arc<crate::lsp::LspManager>>,
1203) {
1204 let shell_env = if config.shell_env_snapshot {
1205 Some(std::sync::Arc::new(capture_shell_env()))
1206 } else {
1207 None
1208 };
1209 let checkpoint_observer = crate::checkpoint::observer_for_config(config);
1210 let format_observer = crate::formatters::observer_for_config(config);
1211 let lsp_manager = crate::lsp::manager_for_config(config);
1212 let lsp_observer = lsp_manager
1213 .clone()
1214 .map(|m| std::sync::Arc::new(crate::lsp::LspDiagnosticsObserver::new(m)));
1215 let mut observers: Vec<std::sync::Arc<dyn crate::tools::WriteObserver>> = Vec::new();
1216 if let Some(cp) = &checkpoint_observer {
1217 observers.push(cp.clone() as std::sync::Arc<dyn crate::tools::WriteObserver>);
1218 }
1219 if let Some(f) = &format_observer {
1220 observers.push(f.clone() as std::sync::Arc<dyn crate::tools::WriteObserver>);
1221 }
1222 if let Some(l) = &lsp_observer {
1223 observers.push(l.clone() as std::sync::Arc<dyn crate::tools::WriteObserver>);
1224 }
1225 let write_observer: Option<std::sync::Arc<dyn crate::tools::WriteObserver>> =
1226 match observers.len() {
1227 0 => None,
1228 1 => observers.into_iter().next(),
1229 _ => Some(std::sync::Arc::new(crate::tools::WriteObserverChain::new(
1230 observers,
1231 ))),
1232 };
1233 let ctx = ToolContext {
1234 cwd: config.cwd.clone(),
1235 // BP-10 (catalog row "Additional working directories"): the
1236 // `--add-dir`/`core.additional_dirs` roots reach the TOOLS now,
1237 // not just the project-context walk — `ToolContext::check_write`,
1238 // the OS backstop's writable set, and the permissions engine's
1239 // path rules all read them. Empty (the default) is byte-identical
1240 // to confining everything to `cwd`.
1241 extra_roots: config.additional_dirs.clone(),
1242 sandbox: config.sandbox,
1243 multimodal_read: config.read_file_multimodal,
1244 // BP-2 (§1.2 `core.tools.read_file.line_numbers`, catalog:26).
1245 read_line_numbers: config.read_file_line_numbers,
1246 require_read_before_edit: config.edit_file_require_read_before_edit,
1247 // BP-2: path → content hash at read time (`ToolContext::read_state`).
1248 read_paths: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
1249 notebook_aware: config.edit_file_notebook_aware,
1250 shell_env,
1251 nested_instructions: config.nested_instructions,
1252 injected_instruction_dirs: std::sync::Arc::new(std::sync::Mutex::new(HashSet::new())),
1253 // BP-5: filled in by `Agent::with_parts` (the one construction path
1254 // that assembles a prompt, and therefore the one that knows which
1255 // rules were held back); empty everywhere else.
1256 path_rules: std::sync::Arc::new(Vec::new()),
1257 injected_rule_files: std::sync::Arc::new(std::sync::Mutex::new(HashSet::new())),
1258 // P5-1 (§2 module 12 carry-forward): now sourced from real config
1259 // (`capabilities.permissions.sandbox.network.*`, wired by
1260 // `configfile::materialize_config`) instead of always `None`. `None`
1261 // (the default, unchanged when the config never sets it) is still
1262 // byte-identical to today's behavior.
1263 network_policy: config.network_policy.clone(),
1264 // BP-10 (catalog row "Allow/ask/deny rule language", the DOMAIN
1265 // subject): the config's own rule arrays reach the network surface
1266 // too, so a `domain(...)` rule is evaluated by the SAME engine that
1267 // evaluates `bash(...)`/`write(...)` at the dispatch gate — not by
1268 // a second matcher over a second list. `None` when the permissions
1269 // module is off, which is byte-identical to before.
1270 permission_rules: config.permissions_enabled.then(|| {
1271 std::sync::Arc::new(crate::permissions::RuleSet {
1272 deny: config.tool_deny_patterns.clone(),
1273 ask: config.permissions_ask_patterns.clone(),
1274 allow: config.tool_allow_patterns.clone(),
1275 })
1276 }),
1277 // P4e (S3.1 `core.tools.bash.timeout_secs`, S14): folds the `bash`
1278 // `ToolOverride`'s `timeout_secs`, if set, into the context every
1279 // `BashTool::execute` call receives -- `None` (no override
1280 // configured) is byte-identical to today's behavior.
1281 bash_timeout_secs: config
1282 .tool_overrides
1283 .get("bash")
1284 .and_then(|o| o.timeout_secs),
1285 write_observer,
1286 // P5-10 (§2 module 12): sourced from real config
1287 // (`capabilities.permissions.sandbox.{enabled,escalation,env_policy}`,
1288 // wired by `configfile::materialize_config`). `sandbox_approval_handler`
1289 // starts `None` here (no handler is installed yet at `Agent`
1290 // construction time) and is kept in sync by
1291 // `Agent::set_permissions_approval_handler` — see that method's doc
1292 // comment.
1293 sandbox_os_enabled: config.sandbox_os_enabled,
1294 sandbox_escalation: config.sandbox_escalation,
1295 sandbox_env_policy: config.sandbox_env_policy,
1296 sandbox_approval_handler: None,
1297 // BP-3: both handler seams start `None` (nothing is installed at
1298 // construction time) and are filled by
1299 // `Agent::set_permissions_approval_handler` /
1300 // `Agent::set_user_question_handler`, exactly like
1301 // `sandbox_approval_handler` above. The two shared states are
1302 // always present but inert: plan mode starts off (contributing no
1303 // rules), and the budget starts unpublished.
1304 question_handler: None,
1305 approval_handler: None,
1306 plan_mode: std::sync::Arc::new(crate::tools::PlanModeState::new()),
1307 context_budget: std::sync::Arc::new(crate::tools::ContextBudget::new()),
1308 // BP-8 (catalog:156): the shared plan the agent journals and
1309 // persists. Always present, empty and inert until `update_plan`
1310 // writes one.
1311 plan: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
1312 };
1313 (ctx, checkpoint_observer, lsp_manager)
1314}
1315
1316/// P4b (§1.4/§3.1, catalog §4a "Global/user-level instruction file tier"):
1317/// where the user/global instruction tier lives — `$SUPERCODE_HOME`, else
1318/// `$XDG_CONFIG_HOME/supercode`, else `~/.config/supercode`. Deliberately
1319/// duplicates `crates/cli/src/userconfig.rs::config_home`'s exact precedence
1320/// rather than depending on the `cli` crate from `core` (wrong dependency
1321/// direction — `cli` depends on `core`, never the reverse). `pub(crate)`:
1322/// also the DEFAULT shadow-store root `crate::checkpoint::observer_for_config`
1323/// (P5-9) derives from when `Config::checkpoint_dir` is unset — one
1324/// `$SUPERCODE_HOME` resolver, not a second hand-rolled one.
1325pub(crate) fn global_instructions_dir() -> std::path::PathBuf {
1326 if let Ok(h) = std::env::var("SUPERCODE_HOME") {
1327 if !h.is_empty() {
1328 return std::path::PathBuf::from(h);
1329 }
1330 }
1331 if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
1332 if !xdg.is_empty() {
1333 return std::path::PathBuf::from(xdg).join("supercode");
1334 }
1335 }
1336 let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
1337 std::path::PathBuf::from(home)
1338 .join(".config")
1339 .join("supercode")
1340}
1341
1342/// P4b (§1.4, catalog §4a "Instruction imports"): is `rel` (an `@`-import
1343/// target found inside a PROJECT-sourced instruction file) LEXICALLY safe to
1344/// resolve? Mirrors `configfile::is_safe_project_dir`'s posture (LOW-1
1345/// precedent): rejects absolute paths, `~`-relative paths, and any `..`
1346/// component — an untrusted repo's own CLAUDE.md/AGENTS.md must not be able
1347/// to `@import` its way to an arbitrary file on disk (e.g. `@/etc/passwd`,
1348/// `@../../.ssh/id_rsa`). Global-tier files (the user's own machine, same
1349/// trust level as the user's shell) are NOT run through this check.
1350///
1351/// This is a cheap PRE-FILTER only — it operates on the literal token text
1352/// and cannot see through a symlink committed in the repo whose *target*
1353/// escapes the root while the *link itself* has a clean, traversal-free
1354/// relative name (e.g. `@link.md` where `link.md -> /etc/passwd`). See
1355/// [`import_target_is_contained`] for the canonicalizing check that closes
1356/// that gap; the project-scoped resolution path runs both.
1357fn import_path_is_safe(rel: &str) -> bool {
1358 if rel.is_empty() || rel.contains('\0') {
1359 return false;
1360 }
1361 let path = std::path::Path::new(rel);
1362 if path.is_absolute() || rel.starts_with('~') {
1363 return false;
1364 }
1365 !path
1366 .components()
1367 .any(|c| matches!(c, std::path::Component::ParentDir))
1368}
1369
1370/// P4b security fix (Fable-5 review, MEDIUM: symlink bypass of the
1371/// project-scoped `@`-import boundary): does `candidate` — after resolving
1372/// symlinks — stay inside `root` — also after resolving symlinks? This is
1373/// what actually enforces [`import_path_is_safe`]'s doc-comment guarantee
1374/// ("must not be able to `@import` its way to an arbitrary file on disk"):
1375/// the lexical check alone rejects `@/etc/passwd` and `@../../secret`, but a
1376/// repo can commit a symlink (e.g. `link.md -> /etc/passwd`) whose own
1377/// relative name is perfectly clean, defeating a purely lexical check.
1378///
1379/// Both sides are canonicalized before the comparison — not just
1380/// `candidate` — because `root` itself can legitimately be a symlink (a
1381/// tempdir under macOS's `/tmp` -> `/private/tmp`, or any other symlinked
1382/// project checkout); comparing a canonicalized candidate against a
1383/// non-canonicalized root would falsely reject genuinely-in-root files.
1384///
1385/// Fails CLOSED: a `canonicalize()` failure (broken symlink, a target that
1386/// doesn't exist, a permission error) returns `false` — never inlined,
1387/// mirroring [`expand_instruction_imports`]'s existing "unreadable file ⇒
1388/// left as literal text" posture rather than panicking or defaulting open.
1389pub(crate) fn import_target_is_contained(
1390 candidate: &std::path::Path,
1391 root: &std::path::Path,
1392) -> bool {
1393 let (Ok(real_root), Ok(real_candidate)) = (
1394 std::fs::canonicalize(root),
1395 std::fs::canonicalize(candidate),
1396 ) else {
1397 return false;
1398 };
1399 real_candidate.starts_with(&real_root)
1400}
1401
1402/// P4b (§1.4/§3.1 `core.instruction_imports`, catalog:85): inline `@path`
1403/// import tokens found in `text` with the referenced file's own (trimmed)
1404/// content, resolved relative to `dir` (the directory the CONTAINING file
1405/// lives in — so a chain of imports each resolves relative to its own
1406/// location, not the original file's). `depth` bounds recursion (CC's own
1407/// default of 4, cited in the cc-parity preset) so a cyclical or
1408/// deeply-nested import chain can't blow the stack or loop forever.
1409/// `project_scoped` gates [`import_path_is_safe`] AND
1410/// [`import_target_is_contained`] — see their doc comments; `root` is the
1411/// containment boundary those checks canonicalize against (the SAME root
1412/// for every level of a nested import chain, even though `dir` itself walks
1413/// deeper with each level — an import three levels deep must still resolve
1414/// under the original project root, not merely under its own immediate
1415/// parent). Ignored when `!project_scoped` (the global/user tier, trusted,
1416/// unrestricted — see [`append_instruction_file`]'s doc comment).
1417/// Any token that isn't `@`-prefixed, doesn't resolve to a readable file, or
1418/// (project-scoped) fails the safety/containment check is left as literal
1419/// text — an import is best-effort, never a hard error that could make
1420/// instruction loading fail outright.
1421fn expand_instruction_imports(
1422 text: &str,
1423 dir: &std::path::Path,
1424 root: &std::path::Path,
1425 project_scoped: bool,
1426 depth: u8,
1427) -> String {
1428 if depth >= 4 {
1429 return text.to_string();
1430 }
1431 let mut out = String::with_capacity(text.len());
1432 for token in split_preserving_whitespace(text) {
1433 if let Some(rel) = token.strip_prefix('@') {
1434 if !rel.is_empty()
1435 && !rel.contains(char::is_whitespace)
1436 && (!project_scoped || import_path_is_safe(rel))
1437 {
1438 let candidate = dir.join(rel);
1439 if !project_scoped || import_target_is_contained(&candidate, root) {
1440 if let Ok(imported) = std::fs::read_to_string(&candidate) {
1441 let imported = imported.trim();
1442 if !imported.is_empty() {
1443 let imported_dir = candidate.parent().unwrap_or(dir);
1444 out.push_str(&expand_instruction_imports(
1445 imported,
1446 imported_dir,
1447 root,
1448 project_scoped,
1449 depth + 1,
1450 ));
1451 continue;
1452 }
1453 }
1454 }
1455 }
1456 }
1457 out.push_str(token);
1458 }
1459 out
1460}
1461
1462/// Split `text` into tokens that, concatenated, reproduce it exactly —
1463/// alternating runs of non-whitespace and whitespace. Used by
1464/// [`expand_instruction_imports`] so `@import` tokens can be located and
1465/// replaced without disturbing surrounding formatting/whitespace.
1466fn split_preserving_whitespace(text: &str) -> Vec<&str> {
1467 let mut out = Vec::new();
1468 let mut start = 0;
1469 let mut in_ws = None;
1470 for (i, c) in text.char_indices() {
1471 let ws = c.is_whitespace();
1472 match in_ws {
1473 None => in_ws = Some(ws),
1474 Some(prev) if prev != ws => {
1475 out.push(&text[start..i]);
1476 start = i;
1477 in_ws = Some(ws);
1478 }
1479 _ => {}
1480 }
1481 }
1482 if start < text.len() {
1483 out.push(&text[start..]);
1484 }
1485 out
1486}
1487
1488/// BP-4 (catalog:87 "Instruction-file hygiene controls", cc§2
1489/// `claudeMdExcludes`): whether `path` is excluded from instruction loading
1490/// by [`Config::project_doc_excludes`]. A pattern matches when it
1491/// [`crate::config::glob_match`]es the file's NAME (`CLAUDE.md`), its full
1492/// path, or its path relative to `root` — the three spellings cc's own
1493/// "glob/absolute-path list" accepts. Empty (the default) excludes nothing.
1494fn instruction_file_excluded(
1495 config: &Config,
1496 path: &std::path::Path,
1497 root: &std::path::Path,
1498) -> bool {
1499 if config.project_doc_excludes.is_empty() {
1500 return false;
1501 }
1502 let full = path.to_string_lossy().to_string();
1503 let name = path
1504 .file_name()
1505 .map(|n| n.to_string_lossy().to_string())
1506 .unwrap_or_default();
1507 let rel = path
1508 .strip_prefix(root)
1509 .ok()
1510 .map(|p| p.to_string_lossy().to_string());
1511 config.project_doc_excludes.iter().any(|pat| {
1512 crate::config::glob_match(pat, &full)
1513 || crate::config::glob_match(pat, &name)
1514 || rel
1515 .as_deref()
1516 .is_some_and(|r| crate::config::glob_match(pat, r))
1517 })
1518}
1519
1520/// BP-4 (catalog:87, cc§2 "HTML comment stripping"): drop block-level
1521/// `<!-- … -->` spans from an instruction file's text so maintainer notes
1522/// cost no tokens, exactly as cc does before injection. Unterminated
1523/// openers drop the remainder (the same reading a markdown renderer takes).
1524/// Off by default ([`Config::project_doc_strip_comments`]) — cx does NOT
1525/// strip, so this is a per-preset hygiene lever, not a universal one.
1526fn strip_html_comments(text: &str) -> String {
1527 let mut out = String::with_capacity(text.len());
1528 let mut rest = text;
1529 while let Some(open) = rest.find("<!--") {
1530 out.push_str(&rest[..open]);
1531 match rest[open..].find("-->") {
1532 Some(close) => rest = &rest[open + close + 3..],
1533 None => return out,
1534 }
1535 }
1536 out.push_str(rest);
1537 out
1538}
1539
1540/// P4b (§1.4): append one instruction file's (trimmed, import-expanded)
1541/// content to `blob` as a labeled section, exactly like the pre-P4b inline
1542/// loop did — a no-op when `path` doesn't exist or is empty (the common
1543/// case). `project_scoped` distinguishes the project tier (imports bounded
1544/// to `root`, canonicalized-and-contained — see
1545/// [`import_target_is_contained`]) from the global tier (imports
1546/// unrestricted, same trust level as the user's own machine — `root` is
1547/// unused in that case). `root` is normally `path`'s own parent (the tier
1548/// root `path` was discovered under, e.g. an ancestor of `cwd` or an
1549/// `additional_dirs` entry) — see [`assemble_project_instructions`]'s call
1550/// sites.
1551///
1552/// BP-4 adds the hygiene controls (catalog:87): the exclude list
1553/// ([`instruction_file_excluded`]), HTML-comment stripping
1554/// ([`strip_html_comments`]) and the [`InstructionBudget`] —
1555/// [`Config::project_doc_max_bytes`] spent INCREMENTALLY as files are
1556/// concatenated root→cwd, which is how cx's own cap works on its root-down
1557/// concat, rather than one chop at the end (that chop would silently eat
1558/// the trailing per-file notices it had just written).
1559fn append_instruction_file(
1560 blob: &mut String,
1561 config: &Config,
1562 path: &std::path::Path,
1563 root: &std::path::Path,
1564 label: &str,
1565 project_scoped: bool,
1566 budget: &mut InstructionBudget,
1567) {
1568 if budget.exhausted() || instruction_file_excluded(config, path, root) {
1569 return;
1570 }
1571 let Ok(text) = std::fs::read_to_string(path) else {
1572 return;
1573 };
1574 let stripped;
1575 let text = if config.project_doc_strip_comments {
1576 stripped = strip_html_comments(&text);
1577 stripped.trim()
1578 } else {
1579 text.trim()
1580 };
1581 if text.is_empty() {
1582 return;
1583 }
1584 let dir = path.parent().unwrap_or(std::path::Path::new("."));
1585 let mut content = if config.instruction_imports {
1586 expand_instruction_imports(text, dir, root, project_scoped, 0)
1587 } else {
1588 text.to_string()
1589 };
1590 if !budget.take(&mut content) {
1591 return;
1592 }
1593 blob.push_str(&format!("\n\n# {label}\n{content}"));
1594}
1595
1596/// Truncate `s` to at most `max` BYTES, backing off to the nearest char
1597/// boundary — shared by the per-file and aggregate instruction caps.
1598fn truncate_at_char_boundary(s: &mut String, max: usize) {
1599 let mut end = max;
1600 while end > 0 && !s.is_char_boundary(end) {
1601 end -= 1;
1602 }
1603 s.truncate(end);
1604}
1605
1606/// BP-4 (catalog:87 "Instruction-file hygiene controls", cx§2
1607/// `project_doc_max_bytes`): the instruction-content byte budget, spent as
1608/// files are concatenated root→cwd.
1609///
1610/// `None` (the default, and cc-parity's explicit `= 0`) is uncapped, so
1611/// [`Self::take`] is a no-op and assembly is byte-identical to a config
1612/// that never heard of the cap. With a cap set, each file is truncated to
1613/// whatever budget REMAINS (per-file notice), and once the budget is gone
1614/// the remaining files are skipped entirely (aggregate notice, emitted once
1615/// by [`Self::aggregate_notice`]) — the total instruction CONTENT can
1616/// therefore never exceed the cap, and the notices survive because nothing
1617/// chops the assembled blob afterwards.
1618struct InstructionBudget {
1619 remaining: Option<usize>,
1620 hit: bool,
1621}
1622
1623impl InstructionBudget {
1624 fn new(config: &Config) -> Self {
1625 InstructionBudget {
1626 remaining: config.project_doc_max_bytes,
1627 hit: false,
1628 }
1629 }
1630
1631 /// True once the cap has consumed the whole budget — later files are
1632 /// skipped rather than partially appended.
1633 fn exhausted(&self) -> bool {
1634 self.remaining == Some(0)
1635 }
1636
1637 /// Charge `content` against the budget, truncating it (and appending a
1638 /// per-file notice) when it doesn't fit. Returns whether anything is
1639 /// left to append.
1640 fn take(&mut self, content: &mut String) -> bool {
1641 let Some(remaining) = self.remaining else {
1642 return true;
1643 };
1644 if content.len() <= remaining {
1645 self.remaining = Some(remaining - content.len());
1646 return true;
1647 }
1648 self.hit = true;
1649 self.remaining = Some(0);
1650 if remaining == 0 {
1651 return false;
1652 }
1653 truncate_at_char_boundary(content, remaining);
1654 content.push_str("\n[supercode: file truncated at core.project_doc_max_bytes]");
1655 true
1656 }
1657
1658 /// The one aggregate notice, appended after assembly when the cap bound
1659 /// anywhere — the statement that the assembled block is not the whole
1660 /// instruction set.
1661 fn aggregate_notice(&self) -> &'static str {
1662 if self.hit {
1663 "\n\n[supercode: instruction content truncated at core.project_doc_max_bytes]"
1664 } else {
1665 ""
1666 }
1667 }
1668}
1669
1670/// BP-4 (catalog:81 "Project instruction files w/ directory walk"; cc§2
1671/// "Directory-walk loading", cx§2 "walk project root (git root) down to
1672/// cwd"): the ancestor chain instruction files are discovered on, ordered
1673/// OUTERMOST FIRST so the nearest directory wins precedence by appearing
1674/// last in the concatenated blob (the root→cwd ordering both inventories
1675/// document).
1676///
1677/// The walk starts at [`Config::cwd`] and climbs until it has included the
1678/// project root [`crate::config::project_root_for`] identifies (`.git` by
1679/// default — cx's `project_root_markers`, §3.1), or until the filesystem
1680/// root, whichever comes first. [`MAX_INSTRUCTION_WALK_DEPTH`] bounds it
1681/// unconditionally, so a marker-less path deep under `/` can never turn
1682/// prompt assembly into an unbounded stat storm.
1683pub(crate) fn instruction_walk_roots(config: &Config) -> Vec<std::path::PathBuf> {
1684 // BP-9's shared answer to "where does the project stop?" — the same
1685 // walk the `env_context` git probe and the CLI's `.supercode.toml`
1686 // discovery use, so one `project_root_markers` value cannot mean three
1687 // different things. `None` (no marker anywhere, or an empty list) means
1688 // no root was found, and the climb below then stops at the filesystem
1689 // root under `MAX_INSTRUCTION_WALK_DEPTH`.
1690 let root = crate::config::project_root_for(&config.cwd, &config.project_root_markers);
1691 let mut chain: Vec<std::path::PathBuf> = Vec::new();
1692 let mut dir = config.cwd.clone();
1693 loop {
1694 let at_root = root.as_deref() == Some(dir.as_path());
1695 chain.push(dir.clone());
1696 if at_root || chain.len() >= MAX_INSTRUCTION_WALK_DEPTH {
1697 break;
1698 }
1699 match dir.parent() {
1700 Some(parent) if parent != dir => dir = parent.to_path_buf(),
1701 _ => break,
1702 }
1703 }
1704 chain.reverse();
1705 chain
1706}
1707
1708/// Hard bound on [`instruction_walk_roots`]'s ancestor climb.
1709const MAX_INSTRUCTION_WALK_DEPTH: usize = 64;
1710
1711/// P4b (§1.4, obligation 4 assembly site): the full instruction-file blob —
1712/// global/user tier (catalog §4a "Global/user-level instruction file tier")
1713/// FIRST, then the project tier — capped by
1714/// [`Config::project_doc_max_bytes`] if set (catalog §4a "hygiene caps
1715/// (`project_doc_max_bytes` analog)").
1716///
1717/// BP-4 (catalog:81): the project tier is no longer `cwd` alone. It is the
1718/// ANCESTOR WALK [`instruction_walk_roots`] returns (cwd's chain up to the
1719/// git root, outermost first) followed by `additional_dirs` — root-first
1720/// ordering throughout, so the nearest directory wins by appearing later,
1721/// which is exactly how both cc§2 ("concatenated root→cwd, closest read
1722/// last") and cx§2 ("nearer-to-cwd wins by appearing later") describe their
1723/// own walks. `cwd` is the last element of the walk chain, so a config
1724/// whose cwd IS the project root assembles byte-identically to the pre-BP-4
1725/// loop.
1726/// BP-5 (catalog D2 "Per-model-family base-prompt selection", cx§2
1727/// "Per-model base instructions": "the system prompt is selected per model
1728/// family from bundled markdown … the active `base_instructions` are
1729/// persisted verbatim into the rollout `session_meta`"): the base system
1730/// prompt for the model this config runs.
1731///
1732/// `[capabilities.model_catalog] base_prompts` maps a model-id glob to that
1733/// family's prompt; the most specific match wins
1734/// ([`crate::model_catalog::base_prompt_for`]). No table and no match both
1735/// give [`Config::system_prompt`] verbatim, so this is a no-op for every
1736/// config that does not set the table.
1737fn base_prompt_for_config(config: &Config) -> String {
1738 crate::model_catalog::base_prompt_for(&config.model_family_prompts, &config.model)
1739 .map(str::to_string)
1740 .unwrap_or_else(|| config.system_prompt.clone())
1741}
1742
1743fn assemble_project_instructions(config: &Config) -> String {
1744 let mut blob = String::new();
1745 let mut budget = InstructionBudget::new(config);
1746 let global_dir = global_instructions_dir();
1747 for name in ["CLAUDE.md", "AGENTS.md"] {
1748 append_instruction_file(
1749 &mut blob,
1750 config,
1751 &global_dir.join(name),
1752 // Global tier is trusted/unrestricted (project_scoped=false
1753 // below) — `root` is never consulted, but pass `global_dir`
1754 // rather than a bogus value for clarity.
1755 &global_dir,
1756 name,
1757 false,
1758 &mut budget,
1759 );
1760 }
1761 // BP-10 (catalog row "Project/workspace trust gate", cc§4/cx§4:
1762 // "Prompt before loading project-local config/code"): the PROJECT tier
1763 // is trust-gated. The global/user tier above is not — it is the user's
1764 // own machine, the same trust level as their shell, exactly as
1765 // `append_instruction_file`'s `project_scoped = false` argument
1766 // already says.
1767 //
1768 // `crate::trust::is_trusted` asks the `Config::trust_handler` door
1769 // once per project and records the answer; with no door installed
1770 // `TrustSurface::Instructions` resolves to LOADED, which is both the
1771 // pre-BP-10 behavior and what a headless run of either upstream
1772 // harness does — see `crate::trust`'s doc comment for why the
1773 // undecided answer differs between text and code.
1774 if !crate::trust::is_trusted(config, crate::trust::TrustSurface::Instructions) {
1775 blob.push_str(
1776 "\n[project instruction files were not loaded: this workspace is not trusted (capabilities.trust)]\n",
1777 );
1778 blob.push_str(budget.aggregate_notice());
1779 return blob;
1780 }
1781 let walk = instruction_walk_roots(config);
1782 for root in walk.iter().chain(config.additional_dirs.iter()) {
1783 for name in ["CLAUDE.md", "AGENTS.md"] {
1784 append_instruction_file(
1785 &mut blob,
1786 config,
1787 &root.join(name),
1788 // Project tier: `@`-imports from THIS file must stay under
1789 // THIS root (canonicalized) — see
1790 // `import_target_is_contained`.
1791 root,
1792 name,
1793 true,
1794 &mut budget,
1795 );
1796 }
1797 // A repository-native agent package is an additional project
1798 // instruction tier. It is subject to the same `project_context`
1799 // switch, import containment, and aggregate byte cap as root
1800 // AGENTS.md/CLAUDE.md; loading it never executes package code.
1801 for path in crate::agent_package::workspace_package_instruction_files(root) {
1802 append_instruction_file(
1803 &mut blob,
1804 config,
1805 &path,
1806 root,
1807 "Supercode agent package instructions",
1808 true,
1809 &mut budget,
1810 );
1811 }
1812 }
1813 blob.push_str(budget.aggregate_notice());
1814 blob
1815}
1816
1817/// P4b (§1.4/§3.1 `core.env_context`, catalog §4a "Environment context block
1818/// injection"): cwd, platform, date, and a best-effort git branch/dirty
1819/// status (silently absent when `cwd` isn't a git repo or `git` isn't on
1820/// `PATH` — never blocks agent construction).
1821///
1822/// BP-4 (catalog:90): plus the APPROVAL/SANDBOX POLICY line the row's own
1823/// semantics name ("cwd/git/platform/date/**policy**") and cx's
1824/// `<environment_context>` supplies — the model is told which approval mode
1825/// and which filesystem confinement it is operating under, which is what
1826/// makes "ask before you do X" instructions legible to it. The block is
1827/// re-derivable at any moment from `config` alone, which is what lets
1828/// [`Agent::refresh_env_context`] re-emit it mid-session on change.
1829/// BP-6 (catalog D2 "Skills (progressive-disclosure packages)", §1.4
1830/// obligation 4): the `# Skills` prompt section — the discovered SKILL.md
1831/// packages' names and descriptions, plus the `[core.prompts]` template
1832/// names, and NOTHING else. A skill's body is deliberately absent: it costs
1833/// its tokens only when something actually invokes it (`docs:skills`
1834/// "body loads only when used"; cx§7; pi§2 "progressive disclosure").
1835///
1836/// A skill whose frontmatter hides it from the model (`enabled: false`,
1837/// `disable-model-invocation: true`) is left OUT of the index while staying
1838/// user-invocable — cc§7 "Invocation control", pi§2.
1839///
1840/// Empty string when there is nothing to list, so an agent with neither
1841/// skills nor templates keeps the prompt it had before this existed.
1842fn skills_prompt_section(config: &Config, skills: &[crate::skills::LoopSkill]) -> String {
1843 let listed: Vec<&crate::skills::LoopSkill> = skills
1844 .iter()
1845 .filter(|skill| skill.model_invocable)
1846 .collect();
1847 let mut templates: Vec<&str> = config.prompts.keys().map(String::as_str).collect();
1848 templates.sort_unstable();
1849 if listed.is_empty() && templates.is_empty() {
1850 return String::new();
1851 }
1852 let mut out = String::from("\n\n# Skills\n");
1853 if !listed.is_empty() {
1854 out.push_str(
1855 "Installed skill packages. Only each skill's name and description are listed \
1856 here; call the `skill` tool with a name below to load that skill's full \
1857 instructions when it applies, then follow them.\n",
1858 );
1859 for skill in listed {
1860 out.push_str(&skill.index_line());
1861 out.push('\n');
1862 }
1863 }
1864 if !templates.is_empty() {
1865 if !out.ends_with("# Skills\n") {
1866 out.push('\n');
1867 }
1868 out.push_str("Prompt templates (invoke via `/name args`):\n");
1869 for name in templates {
1870 out.push_str(&format!("- {name}\n"));
1871 }
1872 }
1873 out
1874}
1875
1876fn env_context_block(config: &Config) -> String {
1877 let mut lines = vec![
1878 format!("cwd: {}", config.cwd.display()),
1879 format!("platform: {}", std::env::consts::OS),
1880 format!(
1881 "date: {}",
1882 crate::sidecar::now_rfc3339().get(..10).unwrap_or("")
1883 ),
1884 format!(
1885 "approval policy: {} · sandbox: {}",
1886 approval_policy_label(config.approval),
1887 sandbox_policy_label(config.sandbox),
1888 ),
1889 ];
1890 // BP-9 (§3.1 `core.project_root_markers`, catalog:232): the git probe
1891 // runs at the PROJECT ROOT the markers define, not at whatever
1892 // subdirectory the process happens to sit in — the marker knob's whole
1893 // job is deciding where "the project" starts. Falls back to `cwd` when
1894 // no ancestor carries a marker (or the list is empty), which is
1895 // byte-identical to the pre-BP-9 behavior.
1896 let root = crate::config::project_root_for(&config.cwd, &config.project_root_markers)
1897 .unwrap_or_else(|| config.cwd.clone());
1898 if let Some(status) = env_context_git_status(&root) {
1899 lines.push(status);
1900 }
1901 format!("\n\n# Environment\n{}", lines.join("\n"))
1902}
1903
1904/// The `[capabilities.permissions] approval` spelling of a policy — the same
1905/// token the config schema accepts (`configfile::parse_approval_str`), so
1906/// the block reports the policy in the vocabulary the user configured it in.
1907fn approval_policy_label(policy: crate::config::ApprovalPolicy) -> &'static str {
1908 match policy {
1909 crate::config::ApprovalPolicy::Never => "never",
1910 crate::config::ApprovalPolicy::OnRequest => "on-request",
1911 crate::config::ApprovalPolicy::Untrusted => "untrusted",
1912 crate::config::ApprovalPolicy::ModelRequested => "model-requested",
1913 }
1914}
1915
1916/// The `[capabilities.permissions] sandbox` spelling of a tier — see
1917/// [`approval_policy_label`].
1918fn sandbox_policy_label(policy: crate::tools::SandboxPolicy) -> &'static str {
1919 match policy {
1920 crate::tools::SandboxPolicy::ReadOnly => "read-only",
1921 crate::tools::SandboxPolicy::WorkspaceWrite => "workspace-write",
1922 crate::tools::SandboxPolicy::DangerFullAccess => "danger-full-access",
1923 }
1924}
1925
1926/// Best-effort `git branch (dirty|clean)` for [`env_context_block`]. `None`
1927/// on anything short of a clean success (not a repo, `git` missing, a
1928/// detached/errored state) — this is informational context, never worth
1929/// failing agent construction over.
1930fn env_context_git_status(cwd: &std::path::Path) -> Option<String> {
1931 let branch_out = std::process::Command::new("git")
1932 .args(["rev-parse", "--abbrev-ref", "HEAD"])
1933 .current_dir(cwd)
1934 .output()
1935 .ok()?;
1936 if !branch_out.status.success() {
1937 return None;
1938 }
1939 let branch = String::from_utf8_lossy(&branch_out.stdout)
1940 .trim()
1941 .to_string();
1942 if branch.is_empty() {
1943 return None;
1944 }
1945 let dirty = std::process::Command::new("git")
1946 .args(["status", "--porcelain"])
1947 .current_dir(cwd)
1948 .output()
1949 .ok()
1950 .map(|o| !o.stdout.is_empty())
1951 .unwrap_or(false);
1952 Some(format!(
1953 "git branch: {branch} ({})",
1954 if dirty { "dirty" } else { "clean" }
1955 ))
1956}
1957
1958impl Agent {
1959 /// Build an agent backed by an OpenAI-compatible endpoint (OpenRouter by
1960 /// default). The API key is taken from [`Config::api_key`], then
1961 /// [`Config::api_key_cmd`] (P4: a credential-helper command, run via the
1962 /// shell — see `run_api_key_cmd`), then the configured environment
1963 /// variable ([`Config::api_key_env`]).
1964 pub fn new(config: Config) -> Result<Self> {
1965 let api_key = match &config.api_key {
1966 Some(k) if !k.is_empty() => k.clone(),
1967 // BP-9: the ARGV helper (`core.api_key_command`) is consulted
1968 // first — it is the form with no shell in the path, so a config
1969 // that sets both gets the one with fewer ways to surprise its
1970 // author. Empty/failed → fall through, same as `api_key_cmd`.
1971 _ => match config
1972 .api_key_command
1973 .as_deref()
1974 .filter(|argv| !argv.is_empty())
1975 .map(run_api_key_command)
1976 .filter(|k| !k.is_empty())
1977 .or_else(|| {
1978 config
1979 .api_key_cmd
1980 .as_deref()
1981 .filter(|c| !c.is_empty())
1982 .map(run_api_key_cmd)
1983 }) {
1984 // P4 (§1.8 credential-helper indirection, D6 row): the
1985 // helper ran and produced a non-empty key — use it. A
1986 // failed/empty helper falls through to `api_key_env` rather
1987 // than erroring outright, same "try the next source"
1988 // posture as every other layer in this resolution chain.
1989 Some(k) if !k.is_empty() => k,
1990 _ => std::env::var(&config.api_key_env)
1991 .ok()
1992 .filter(|k| !k.is_empty())
1993 .ok_or_else(|| Error::MissingApiKey(config.api_key_env.clone()))?,
1994 },
1995 };
1996 // P4b (§1.1/§3.1 `core.retry`, pi§3 shape): `Config.retry_*` now
1997 // reaches the pre-existing transport-layer retry mechanism (see
1998 // `provider::HttpOptions::from_retry_config`'s doc comment for the
1999 // exact "byte-identical when unset" contract).
2000 let http_options = provider::HttpOptions::from_retry_config(
2001 config.retry_enabled,
2002 config.retry_max_retries,
2003 config.retry_base_delay_ms,
2004 );
2005 // BP-7 (catalog §4a "Turn/budget caps"): a spend cap armed against
2006 // a model this build cannot price is refused HERE rather than
2007 // accepted and silently never enforced. See
2008 // `Config::max_budget_usd`.
2009 if config.max_budget_usd.is_some_and(|b| b > 0.0)
2010 && crate::pricing::resolve(
2011 &config.model,
2012 config.price_input_per_mtok,
2013 config.price_output_per_mtok,
2014 )
2015 .is_none()
2016 {
2017 return Err(Error::UnpriceableBudget {
2018 model: config.model.clone(),
2019 });
2020 }
2021 // BP-7 (catalog §4a "Auto-retry on transient provider errors"): the
2022 // shared log the transport's retry loop reports into and
2023 // `Self::run_loop` drains after every completion.
2024 let retry_log = std::sync::Arc::new(crate::provider::RetryLog::default());
2025 let provider = OpenAiProvider::new_with_options(
2026 config.base_url.clone(),
2027 api_key,
2028 config.extra_headers.clone(),
2029 http_options,
2030 )
2031 .with_retry_log(retry_log.clone());
2032 // P3 (design §5.2): `ToolRegistry::from_config` replaces the
2033 // unconditional `with_builtins()` call — a no-op when
2034 // `config.module_registry` is off (the default, §5.3 risk 2).
2035 let registry = ToolRegistry::from_config(&config);
2036 let mut agent = Self::with_parts(config, Box::new(provider), registry);
2037 agent.retry_log = retry_log;
2038 Ok(agent)
2039 }
2040
2041 /// Build an agent with an explicit provider and the built-in tools. Handy
2042 /// for tests (inject a mock provider) or custom transports.
2043 pub fn with_provider(config: Config, provider: Box<dyn Provider>) -> Self {
2044 let registry = ToolRegistry::from_config(&config);
2045 Self::with_parts(config, provider, registry)
2046 }
2047
2048 /// Build an agent from all three parts.
2049 pub fn with_parts(
2050 mut config: Config,
2051 provider: Box<dyn Provider>,
2052 mut registry: ToolRegistry,
2053 ) -> Self {
2054 // P5-12 (§2 module 18 `plugins`, D-10): register every trusted,
2055 // loaded plugin's declared tools — the same "unconditional, config-
2056 // gated" wiring `build_tool_context` just below gives
2057 // checkpoint/formatters/lsp. `crate::plugins::register_into` is a
2058 // true no-op (no filesystem read, no subprocess) whenever
2059 // `config.plugins_enabled` is `false` (the default) — byte-identical
2060 // to before this module existed. Runs here (the one tail every
2061 // `Agent` construction path funnels through — `new`/`with_provider`
2062 // both call this) rather than in `ToolRegistry::from_config`, so it
2063 // is NOT entangled with that function's unrelated `module_registry`
2064 // experimental gate.
2065 crate::plugins::register_into(&config, &mut registry);
2066 let (mut ctx, checkpoint_observer, lsp_manager) = build_tool_context(&config);
2067 // Auto-load project context files (CLAUDE.md / AGENTS.md) from the
2068 // working directory (and any extra roots), appending them to the system
2069 // prompt — the analog of how Claude Code / Codex discover them.
2070 // P4b (§1.4): also the global/user tier + instruction imports + the
2071 // `project_doc_max_bytes` hygiene cap — see `assemble_project_instructions`.
2072 // BP-5 (catalog D2 "Per-model-family base-prompt selection", cx§2
2073 // "Per-model base instructions"): the base prompt is chosen for the
2074 // model in force, not fixed before the model is known — the exact
2075 // residue the ledger row named. `base_prompt_for_config` is
2076 // `config.system_prompt` verbatim for every config that sets no
2077 // family table, so this is a no-op by default.
2078 let base_prompt_live = base_prompt_for_config(&config);
2079 // BP-5 (catalog D2 "Output style / personality module"): a custom
2080 // style may REPLACE the base coding instructions rather than append
2081 // to them (cc§7 `keep-coding-instructions`); every other style is
2082 // appended at the end of the assembled prompt, below.
2083 let output_style = crate::output_style::resolve(&config);
2084 let mut system = match output_style.as_ref() {
2085 Some(style) if style.replaces_base => style.text.clone(),
2086 _ => base_prompt_live.clone(),
2087 };
2088 if config.load_project_context {
2089 system.push_str(&assemble_project_instructions(&config));
2090 }
2091 // BP-5 (catalog D2 "Path-scoped rules", cc§2 `.claude/rules`): the
2092 // UNSCOPED rules join the instruction blob here. A rule carrying a
2093 // `paths:` selector deliberately does not — it waits for a tool to
2094 // touch a matching file (`tools::builtins::path_rules_notice`).
2095 let path_rules = crate::path_rules::load(&config);
2096 system.push_str(&crate::path_rules::always_on_text(&path_rules));
2097 // P4b (§1.4/§3.1 `core.env_context`, catalog §4a "Environment
2098 // context block injection"): `false` (the default) is a no-op —
2099 // byte-identical to today's behavior. BP-4 keeps the rendered block
2100 // on the agent (`env_context_live`) so `refresh_env_context` can
2101 // find and REPLACE exactly this text when cwd/policy/branch move,
2102 // rather than leaving a stale block in the prompt forever.
2103 let env_context_live = if config.env_context {
2104 let block = env_context_block(&config);
2105 system.push_str(&block);
2106 Some(block)
2107 } else {
2108 None
2109 };
2110 // P4e (§1.4/§3.1 `core.context_injections`, catalog:91 "Synthetic
2111 // context-injection blocks"): same assembly site, right after
2112 // `env_context`. `false` (the default) is a no-op — byte-identical
2113 // to today's behavior. BP-4 routes it through
2114 // `crate::context_injection`, so the gate now delivers the built-in
2115 // ambient blocks the row is about (and stays extensible at runtime
2116 // through `Self::inject_context_block`) instead of only whatever
2117 // static list an embedder happened to populate.
2118 system.push_str(&crate::context_injection::assemble(&config, &[]));
2119 // P3 (design §5.2, §1.4 obligation 4, D-7): the skills prompt
2120 // section is a MODULE-GATED prompt section, the design's own
2121 // illustration of "a disabled module contributes no prompt
2122 // sections" — only assembled at all under
2123 // `[experimental] module_registry = true` (§5.3 risk 2: flag-off is
2124 // byte-for-byte today's behavior, and today's behavior never emits
2125 // this section, since it doesn't exist pre-P3). Gated further by
2126 // D-7 itself: `core.skills` requires a read pathway (`read_file` or
2127 // `bash`) — absent either, no section is appended, matching the
2128 // hard-dependency shape `configfile::validate_modules` enforces at
2129 // resolve time.
2130 //
2131 // BP-6 (catalog D2 "Skills (progressive-disclosure packages)"): the
2132 // section is now the discovered SKILL.md INDEX — each package's
2133 // frontmatter `name` and `description`, nothing else. A body is
2134 // never assembled here; it is read on invocation only, which is
2135 // what "progressive disclosure" means. The `[core.prompts]`
2136 // template names keep their own sub-list below it.
2137 let skills = crate::skills::load_for_config(&config);
2138 if config.module_registry && config.skills_enabled {
2139 let has_read_pathway = config
2140 .core_tools_enabled
2141 .iter()
2142 .any(|t| t == "read_file" || t == "bash");
2143 if has_read_pathway {
2144 system.push_str(&skills_prompt_section(&config, &skills));
2145 }
2146 }
2147 // BP-5: the style layer lands LAST, where cc puts it ("output styles
2148 // append custom instructions to the END of the system prompt").
2149 // Empty for a neutral style (`default`/`none`) and for a style that
2150 // already replaced the base above.
2151 if let Some(style) = output_style.as_ref().filter(|s| !s.replaces_base) {
2152 system.push_str(&style.section());
2153 }
2154 // BP-5: the SCOPED rules travel with the tool context, which is
2155 // where a "a tool touched a matching file" event can see them.
2156 ctx.path_rules = std::sync::Arc::new(path_rules);
2157 let history = vec![ChatMessage::system(system)];
2158 // P4e (§1.6/§3.1 `core.session.git_metadata`, catalog:331): captured
2159 // once here, alongside `env_context`'s own git probe — `false` (the
2160 // default) is a no-op, byte-identical to today's behavior.
2161 let git_metadata = if config.session_git_metadata {
2162 crate::git_metadata::capture(&config.cwd, now_ms())
2163 } else {
2164 None
2165 };
2166 // BP-7 (catalog §4a "Named agent definitions as data"): discover
2167 // `<cwd>/.claude/agents/*.md` for EVERY harness that turns the
2168 // subagents module on, not only the Claude emulate/resume path —
2169 // that restriction was the second half of the ledger row's
2170 // residue.
2171 merge_project_agent_definitions(&mut config);
2172 // P5-3: captured before `config` moves into the literal below (a
2173 // `usize` field READ, not a move, but it must happen before the
2174 // `config` shorthand field consumes the binding).
2175 let subagent_depth = config.subagent_depth;
2176 // BP-5 (catalog D2 "Shell-output injection in templates/skills"):
2177 // the authorization every `` !`cmd` `` in a skill/command body is
2178 // evaluated under — this config's own permission rules, resolved
2179 // once. Disabled unless `[core.skills] shell_injection` is on.
2180 let shell_injection = crate::skills::ShellInjection::from_config(&config);
2181 // BP-8 (catalog:151): `[capabilities.session_tree] enabled` finally
2182 // has a reader. An armed tree starts empty and grows one node per
2183 // recorded message — the degenerate single-path case, byte-for-byte
2184 // the same conversation, until a rewind or branch actually forks it.
2185 let session_tree = if config.session_tree_enabled {
2186 Some(crate::session_tree::SessionTree::new())
2187 } else {
2188 None
2189 };
2190 // BP-7: resolved once here so the request path never re-does the
2191 // lookup, and so `Self::model_price` is `None` exactly when this
2192 // build cannot price the model.
2193 let model_price = crate::pricing::resolve(
2194 &config.model,
2195 config.price_input_per_mtok,
2196 config.price_output_per_mtok,
2197 );
2198 // BP-10: same reason — built before `config` moves into the
2199 // literal. `Config::permissions_approvals_persist` off (the
2200 // default) makes this the pre-BP-10 in-memory cache and touches no
2201 // filesystem.
2202 let permissions_approval_cache = crate::permissions::cache_for_config(&config);
2203 Agent {
2204 config,
2205 provider: std::sync::Arc::from(provider),
2206 registry,
2207 history,
2208 ctx,
2209 total_output_tokens: 0,
2210 activated_tools: HashSet::new(),
2211 recorder: None,
2212 journal: None,
2213 session_tree,
2214 rewind_undo: Vec::new(),
2215 journaled_plan: Vec::new(),
2216 reduction_policy: None,
2217 reduction_log: ReductionLog::default(),
2218 imported_prefix_len: None,
2219 compacting_manually: false,
2220 env_context_live,
2221 base_prompt_live,
2222 shell_injection,
2223 spliced_context_blocks: Vec::new(),
2224 span_summarizer: None,
2225 last_tool_schema_tier_signature: None,
2226 context_limit: None,
2227 requests_issued: false,
2228 last_cache_activity_ms: None,
2229 cache_established: false,
2230 pending_cache_turn: (false, false, None),
2231 session_titler: None,
2232 usage_log: Vec::new(),
2233 turn_index: 0,
2234 turn_records: Vec::new(),
2235 retry_log: std::sync::Arc::new(crate::provider::RetryLog::default()),
2236 model_price,
2237 total_cost_usd: 0.0,
2238 total_steps: 0,
2239 reaped_subagents: std::collections::HashMap::new(),
2240 goal: None,
2241 steer_queue: std::sync::Arc::new(std::sync::Mutex::new(SteerInbox::default())),
2242 follow_up_queue: std::collections::VecDeque::new(),
2243 doom_loop_last_call: None,
2244 doom_loop_streak: 0,
2245 model_change_log: Vec::new(),
2246 git_metadata,
2247 permissions_approval_cache,
2248 permissions_approval_handler: None,
2249 mcp_prompts: std::collections::HashMap::new(),
2250 skills,
2251 subagent_depth,
2252 subagent_concurrency_gauge: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
2253 background_subagents: std::collections::HashMap::new(),
2254 pending_child_approvals: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
2255 child_approval_handler_factory: None,
2256 subagent_store: None,
2257 claude_runtime_manifest: None,
2258 background_jobs: std::collections::HashMap::new(),
2259 background_concurrency_gauge: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(
2260 0,
2261 )),
2262 checkpoint_observer,
2263 lsp_manager,
2264 }
2265 }
2266
2267 /// A handle to this agent's model transport, for sharing with subagents.
2268 pub fn provider_arc(&self) -> std::sync::Arc<dyn Provider> {
2269 self.provider.clone()
2270 }
2271
2272 /// Read-only access to this agent's resolved [`Config`] — e.g. so a
2273 /// caller (`crates/cli`'s `attach_mcp`) can consult
2274 /// [`Config::module_registry`]/[`Config::module_activation`] AFTER
2275 /// construction without having to separately thread the config through
2276 /// every call site that builds an `Agent` and later needs it again.
2277 /// Same trust boundary as every other already-public `Agent` accessor
2278 /// (`history`, `provider_arc`) — the caller is the same process that
2279 /// built this `Config` in the first place, not a new exposure surface.
2280 pub fn config(&self) -> &Config {
2281 &self.config
2282 }
2283
2284 /// P5-9 (§2 module 20 `checkpoint`): this agent's checkpoint engine, if
2285 /// `Config::checkpoint_enabled` is `true` and the shadow store opened
2286 /// successfully — `None` otherwise (the default-off case, or a
2287 /// graceful-degrade after an I/O failure). A caller (CLI/TUI/embedder)
2288 /// uses this to `list`/`turn_diff`/`restore` WITHOUT re-deriving the
2289 /// shadow-store root itself. Deliberately named `checkpoint_observer`,
2290 /// not `checkpoint` — [`Self::checkpoint`] already names the unrelated
2291 /// in-memory conversation-position marker (see that method's doc
2292 /// comment).
2293 pub fn checkpoint_observer(&self) -> Option<&crate::checkpoint::CheckpointObserver> {
2294 self.checkpoint_observer.as_deref()
2295 }
2296
2297 /// P5-11 (§2 module 28 `lsp`): this agent's LSP server registry, if
2298 /// `Config::lsp_enabled` is `true` — `None` otherwise (the default-off
2299 /// case). `impl Drop for Agent` already covers production teardown via
2300 /// [`crate::lsp::LspManager::kill_all_sync`] (a real, group-killing OS
2301 /// process kill — see `crate::lsp`'s module doc). This accessor exists
2302 /// for an OPTIONAL caller (CLI/TUI/embedder) that manages its own
2303 /// `Agent` lifecycle and additionally wants to reach
2304 /// [`crate::lsp::LspManager::shutdown_all`] for a graceful LSP
2305 /// `shutdown`/`exit` handshake BEFORE dropping the agent — nothing
2306 /// calls `shutdown_all` automatically today.
2307 pub fn lsp_manager(&self) -> Option<&crate::lsp::LspManager> {
2308 self.lsp_manager.as_deref()
2309 }
2310
2311 /// Spawn a subagent that shares this agent's model transport, runs `task`
2312 /// to completion with its own fresh conversation (seeded with `system`), and
2313 /// returns its final answer. The analog of `Agent` / `spawn_agent`.
2314 pub async fn run_subagent(
2315 &self,
2316 system: impl Into<String>,
2317 task: impl Into<String>,
2318 ) -> Result<String> {
2319 let mut sub_config = Config::builder()
2320 .model(self.config.model.clone())
2321 .system_prompt(system)
2322 .cwd(self.config.cwd.clone())
2323 .sandbox(self.config.sandbox)
2324 .max_iterations(self.config.max_iterations)
2325 .build();
2326 sub_config.base_url = self.config.base_url.clone();
2327 let mut sub = Agent::with_provider_arc(sub_config, self.provider.clone());
2328 sub.send(task).await
2329 }
2330
2331 /// Like [`Self::with_provider`] but sharing an existing transport handle.
2332 pub fn with_provider_arc(mut config: Config, provider: std::sync::Arc<dyn Provider>) -> Self {
2333 let (ctx, checkpoint_observer, lsp_manager) = build_tool_context(&config);
2334 let history = vec![ChatMessage::system(config.system_prompt.clone())];
2335 // P3 (design §5.2): see the `Self::new` doc note — a no-op when
2336 // `config.module_registry` is off (the default).
2337 let mut registry = ToolRegistry::from_config(&config);
2338 // P5-12: see `Self::with_parts`'s identical call — a no-op when
2339 // `config.plugins_enabled` is `false` (the default).
2340 crate::plugins::register_into(&config, &mut registry);
2341 // P4e: see `Self::with_parts`'s identical capture.
2342 let git_metadata = if config.session_git_metadata {
2343 crate::git_metadata::capture(&config.cwd, now_ms())
2344 } else {
2345 None
2346 };
2347 // BP-7 (catalog §4a "Named agent definitions as data"): discover
2348 // `<cwd>/.claude/agents/*.md` for EVERY harness that turns the
2349 // subagents module on, not only the Claude emulate/resume path —
2350 // that restriction was the second half of the ledger row's
2351 // residue.
2352 merge_project_agent_definitions(&mut config);
2353 // P5-3: captured before `config` moves into the literal below (a
2354 // `usize` field READ, not a move, but it must happen before the
2355 // `config` shorthand field consumes the binding).
2356 let subagent_depth = config.subagent_depth;
2357 // BP-6: this constructor assembles no prompt sections at all (it
2358 // takes `config.system_prompt` verbatim), so there is no skills
2359 // INDEX here — but the discovered set still rides along, so an
2360 // explicit invocation (`/name`, `$slug`, the `skill` tool) resolves
2361 // the same packages the registry's own `skill` tool holds.
2362 let skills = crate::skills::load_for_config(&config);
2363 let base_prompt_live = config.system_prompt.clone();
2364 let shell_injection = crate::skills::ShellInjection::from_config(&config);
2365 // BP-8 (catalog:151): `[capabilities.session_tree] enabled` finally
2366 // has a reader. An armed tree starts empty and grows one node per
2367 // recorded message — the degenerate single-path case, byte-for-byte
2368 // the same conversation, until a rewind or branch actually forks it.
2369 let session_tree = if config.session_tree_enabled {
2370 Some(crate::session_tree::SessionTree::new())
2371 } else {
2372 None
2373 };
2374 // BP-7: resolved once here so the request path never re-does the
2375 // lookup, and so `Self::model_price` is `None` exactly when this
2376 // build cannot price the model.
2377 let model_price = crate::pricing::resolve(
2378 &config.model,
2379 config.price_input_per_mtok,
2380 config.price_output_per_mtok,
2381 );
2382 // BP-10: see the sibling constructor — built before `config` moves.
2383 let permissions_approval_cache = crate::permissions::cache_for_config(&config);
2384 Agent {
2385 config,
2386 provider,
2387 registry,
2388 history,
2389 ctx,
2390 total_output_tokens: 0,
2391 activated_tools: HashSet::new(),
2392 recorder: None,
2393 journal: None,
2394 session_tree,
2395 rewind_undo: Vec::new(),
2396 journaled_plan: Vec::new(),
2397 reduction_policy: None,
2398 reduction_log: ReductionLog::default(),
2399 imported_prefix_len: None,
2400 compacting_manually: false,
2401 env_context_live: None,
2402 // BP-5: this constructor assembles no prompt sections (see the
2403 // skills note above) — `config.system_prompt` IS the whole
2404 // system message, so that is what a later `set_model` would
2405 // have to replace.
2406 base_prompt_live,
2407 shell_injection,
2408 spliced_context_blocks: Vec::new(),
2409 span_summarizer: None,
2410 last_tool_schema_tier_signature: None,
2411 context_limit: None,
2412 requests_issued: false,
2413 last_cache_activity_ms: None,
2414 cache_established: false,
2415 pending_cache_turn: (false, false, None),
2416 session_titler: None,
2417 usage_log: Vec::new(),
2418 turn_index: 0,
2419 turn_records: Vec::new(),
2420 retry_log: std::sync::Arc::new(crate::provider::RetryLog::default()),
2421 model_price,
2422 total_cost_usd: 0.0,
2423 total_steps: 0,
2424 reaped_subagents: std::collections::HashMap::new(),
2425 goal: None,
2426 steer_queue: std::sync::Arc::new(std::sync::Mutex::new(SteerInbox::default())),
2427 follow_up_queue: std::collections::VecDeque::new(),
2428 doom_loop_last_call: None,
2429 doom_loop_streak: 0,
2430 model_change_log: Vec::new(),
2431 git_metadata,
2432 permissions_approval_cache,
2433 permissions_approval_handler: None,
2434 mcp_prompts: std::collections::HashMap::new(),
2435 skills,
2436 subagent_depth,
2437 subagent_concurrency_gauge: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
2438 background_subagents: std::collections::HashMap::new(),
2439 pending_child_approvals: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
2440 child_approval_handler_factory: None,
2441 subagent_store: None,
2442 claude_runtime_manifest: None,
2443 background_jobs: std::collections::HashMap::new(),
2444 background_concurrency_gauge: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(
2445 0,
2446 )),
2447 checkpoint_observer,
2448 lsp_manager,
2449 }
2450 }
2451
2452 /// Run a prompt on a background task, returning a handle that resolves to
2453 /// the final answer (and the agent, so the caller can continue it). The
2454 /// analog of background/async agent runs.
2455 pub fn run_in_background(
2456 mut self,
2457 prompt: impl Into<String>,
2458 ) -> tokio::task::JoinHandle<(Self, Result<String>)>
2459 where
2460 Self: Send + 'static,
2461 {
2462 let prompt = prompt.into();
2463 tokio::spawn(async move {
2464 let result = self.send(prompt).await;
2465 (self, result)
2466 })
2467 }
2468
2469 /// Build an agent and seed it with a previously-recorded [`Session`] so it
2470 /// can continue where Claude Code or Codex left off.
2471 pub fn resume(config: Config, session: Session) -> Result<Self> {
2472 let mut agent = Agent::new(config)?;
2473 agent.load_session(session);
2474 Ok(agent)
2475 }
2476
2477 /// Like [`Self::resume`], but also begins recording (A2/A3): a fresh
2478 /// native-v2 sidecar is created at `sidecar_path` from `session` (header +
2479 /// `session.raw` verbatim — the imported prefix's own fidelity), and every
2480 /// subsequent turn this agent produces is appended to it at full fidelity,
2481 /// independent of whatever `cap_tool_output`/`maybe_compact` (D6) do to
2482 /// `history`.
2483 ///
2484 /// Invariant this establishes ONLY once [`Self::set_reduction_policy`] is
2485 /// also called (the D6/A7 supersession gate, `Self::run_loop`): at any
2486 /// instant, `Session::from_native_str(sidecar).messages` equals
2487 /// `session.messages` (the imported prefix) followed by every message
2488 /// appended since — i.e. `self.history()[1..]` (`history[0]` is this
2489 /// agent's own system prompt, per [`Self::load_session`]; it is never
2490 /// part of `session` and is never written to the sidecar). Recording
2491 /// alone (no policy) leaves the gate off: `cap_tool_output` still runs on
2492 /// oversized tool results, and `history` can diverge from the sidecar for
2493 /// them — honestly, via the notice's "full output in session sidecar"
2494 /// label, never silently.
2495 pub fn resume_recorded(
2496 config: Config,
2497 session: Session,
2498 sidecar_path: &std::path::Path,
2499 ) -> Result<Self> {
2500 let mut agent = Agent::new(config)?;
2501 let recorder = SidecarWriter::create(sidecar_path, &session)?;
2502 agent.load_session(session);
2503 agent.recorder = Some(recorder);
2504 Ok(agent)
2505 }
2506
2507 /// Install (or replace) this agent's sidecar recorder (A3).
2508 pub fn set_recorder(&mut self, w: SidecarWriter) {
2509 self.recorder = Some(w);
2510 }
2511
2512 // ---- BP-8: the append-only journal (catalog:150/152/154/156) --------
2513
2514 /// Install (or replace) this agent's append-only session journal — the
2515 /// durable, flush-per-record log of every message it produces plus
2516 /// every queue/rewind/plan operation performed on it. Installing one
2517 /// alone changes nothing about the conversation; it only makes the
2518 /// session survive a crash mid-turn.
2519 pub fn set_journal(&mut self, journal: crate::session_journal::SessionJournal) {
2520 self.journal = Some(std::sync::Arc::new(std::sync::Mutex::new(journal)));
2521 }
2522
2523 /// Whether an append-only journal is installed.
2524 pub fn has_journal(&self) -> bool {
2525 self.journal.is_some()
2526 }
2527
2528 /// Append one operation to the journal, if installed. Best-effort by
2529 /// design: losing a durability record must never fail the turn it
2530 /// describes, so the failure is logged and the loop continues — the
2531 /// same contract the compaction-marker `record` call keeps.
2532 fn journal_op(&self, op: crate::session_journal::JournalOp) {
2533 let Some(journal) = &self.journal else { return };
2534 let mut guard = journal
2535 .lock()
2536 .unwrap_or_else(std::sync::PoisonError::into_inner);
2537 if let Err(error) = guard.append(op) {
2538 tracing::warn!("failed to append a session-journal record: {error}");
2539 }
2540 }
2541
2542 /// Declare the durable view caught up: `<name>.jsonl` now holds
2543 /// `messages` messages and every journal record before this point is
2544 /// already in it. Everything journaled AFTER the last such record is
2545 /// exactly what a crash would have lost — see
2546 /// [`crate::session_journal::JournalState::unpersisted`].
2547 pub fn journal_checkpoint(&self, messages: usize) {
2548 self.journal_op(crate::session_journal::JournalOp::Checkpoint { messages });
2549 }
2550
2551 /// BP-13: record one per-turn usage entry in the append-only journal.
2552 pub fn journal_usage(&self, record: &crate::usage_log::UsageRecord) {
2553 self.journal_op(crate::session_journal::JournalOp::Usage {
2554 record: record.clone(),
2555 });
2556 }
2557
2558 /// BP-13: record one mid-session model change in the append-only
2559 /// journal — the ONE persisted home for a routing record (BP-8's
2560 /// journal), never a second file.
2561 pub fn journal_model_change(&self, record: &crate::model_change::ModelChangeRecord) {
2562 self.journal_op(crate::session_journal::JournalOp::ModelChange {
2563 record: record.clone(),
2564 });
2565 }
2566
2567 /// BP-8 (catalog:151): this session's conversation tree, when the
2568 /// module is on.
2569 pub fn session_tree(&self) -> Option<&crate::session_tree::SessionTree> {
2570 self.session_tree.as_ref()
2571 }
2572
2573 /// Install a tree loaded from the store (a resume), replacing whatever
2574 /// this agent built. A no-op when the module is off — a session whose
2575 /// preset does not enable `session_tree` must not acquire one through
2576 /// the back door of an old sidecar.
2577 pub fn set_session_tree(&mut self, tree: crate::session_tree::SessionTree) {
2578 if self.config.session_tree_enabled {
2579 self.session_tree = Some(tree);
2580 }
2581 }
2582
2583 /// BP-8: rebuild the tree from the current linear history — used after
2584 /// a resume that loaded a transcript but had no `.tree.json` to restore
2585 /// (every session recorded before the module was on).
2586 pub fn rebuild_session_tree_from_history(&mut self) {
2587 if !self.config.session_tree_enabled {
2588 return;
2589 }
2590 let linear: Vec<ChatMessage> = self.history.iter().skip(1).cloned().collect();
2591 self.session_tree = Some(crate::session_tree::SessionTree::from_linear(
2592 &linear,
2593 now_ms(),
2594 ));
2595 }
2596
2597 /// BP-8 (catalog:152 "Rewind/rollback conversation"): move THIS
2598 /// conversation back to an earlier point — the whole row, not the
2599 /// last-exchange special case [`Self::rewind_to`] serves and not
2600 /// `sessions fork --at`, which makes a different session.
2601 ///
2602 /// `keep` is a message count (index into `history`), so `keep = 1`
2603 /// leaves only the system message. Three things happen, in this order:
2604 ///
2605 /// 1. the removed tail is pushed onto an undo stack, so
2606 /// [`Self::undo_rewind`] can put it back;
2607 /// 2. a [`crate::session_journal::JournalOp::Rewind`] record is
2608 /// APPENDED — nothing is deleted from disk, so the rewound-away
2609 /// messages remain recoverable from the log;
2610 /// 3. when the tree module is on, the active branch's leaf moves to the
2611 /// node at `keep`, and the old leaf is preserved under a fresh
2612 /// sibling branch — the next message appended forks there rather
2613 /// than overwriting.
2614 ///
2615 /// Returns what it did. Rewinding to a point at or past the end is a
2616 /// no-op with `removed = 0`, never an error.
2617 pub fn rewind_conversation(&mut self, keep: usize) -> RewindOutcome {
2618 let keep = keep.max(1).min(self.history.len());
2619 let removed: Vec<ChatMessage> = self.history.split_off(keep);
2620 if removed.is_empty() {
2621 return RewindOutcome {
2622 kept: self.history.len(),
2623 removed: 0,
2624 preserved_branch: None,
2625 };
2626 }
2627 let removed_count = removed.len();
2628 self.rewind_undo.push(removed);
2629 // `keep` counts the system message; the journal records only
2630 // `history[1..]`, so its own view is one shorter.
2631 self.journal_op(crate::session_journal::JournalOp::Rewind { to: keep - 1 });
2632 let preserved_branch = self.session_tree.as_mut().and_then(|tree| {
2633 let path = tree.active_path().unwrap_or_default();
2634 // `keep - 1` messages remain after the system message, so the
2635 // new leaf is the node at index `keep - 2`.
2636 match keep.checked_sub(2).and_then(|i| path.get(i).cloned()) {
2637 Some(node) => tree.rewind(&node, now_ms()).ok().flatten(),
2638 None => None,
2639 }
2640 });
2641 RewindOutcome {
2642 kept: self.history.len(),
2643 removed: removed_count,
2644 preserved_branch,
2645 }
2646 }
2647
2648 /// BP-8: invert the most recent [`Self::rewind_conversation`] — the
2649 /// messages come back, and the inversion is itself an appended journal
2650 /// record. `false` when there is nothing to undo.
2651 pub fn undo_rewind(&mut self) -> bool {
2652 let Some(mut tail) = self.rewind_undo.pop() else {
2653 return false;
2654 };
2655 self.history.append(&mut tail);
2656 self.journal_op(crate::session_journal::JournalOp::Unrewind);
2657 if self.config.session_tree_enabled {
2658 self.rebuild_session_tree_from_history();
2659 }
2660 true
2661 }
2662
2663 /// BP-8 (catalog:150): append messages recovered from the journal
2664 /// after a crash — they were already recorded, so this deliberately
2665 /// does NOT re-journal them; it puts the live conversation back where
2666 /// the interrupted process left it.
2667 pub fn append_recovered_messages(&mut self, messages: &[ChatMessage]) {
2668 for msg in messages {
2669 if let Some(tree) = self.session_tree.as_mut() {
2670 tree.append_message(msg.clone(), now_ms());
2671 }
2672 self.history.push(msg.clone());
2673 }
2674 }
2675
2676 /// BP-8: how many rewinds are currently undoable.
2677 pub fn undoable_rewinds(&self) -> usize {
2678 self.rewind_undo.len()
2679 }
2680
2681 /// BP-8: restore the undo stack a previous process left in the journal,
2682 /// so `/rewind undo` works across a restart.
2683 pub fn restore_rewind_undo(&mut self, stack: Vec<Vec<ChatMessage>>) {
2684 self.rewind_undo = stack;
2685 }
2686
2687 /// BP-8 (catalog:154 "Queued-prompt persistence"): re-queue pending
2688 /// inputs recovered from the journal WITHOUT re-recording them — they
2689 /// are already in the log, and journaling them again would double them
2690 /// on the next restart.
2691 pub fn restore_queues(&mut self, steer: &[String], follow_up: &[String]) {
2692 for message in steer {
2693 self.steer_queue
2694 .lock()
2695 .unwrap_or_else(std::sync::PoisonError::into_inner)
2696 .queue_unchecked(message.clone());
2697 }
2698 for message in follow_up {
2699 self.follow_up_queue.push_back(message.clone());
2700 }
2701 }
2702
2703 /// BP-8 (catalog:156 "Todos/plan persisted per session"): the session's
2704 /// current `update_plan` checklist.
2705 pub fn plan(&self) -> Vec<crate::session_journal::PlanEntry> {
2706 self.ctx.plan_snapshot()
2707 }
2708
2709 /// BP-8: restore a plan read back from the store on resume. Marked as
2710 /// already-journaled, so a resume that changes nothing writes nothing.
2711 pub fn set_plan(&mut self, steps: Vec<crate::session_journal::PlanEntry>) {
2712 self.ctx.set_plan(steps.clone());
2713 self.journaled_plan = steps;
2714 }
2715
2716 /// BP-8 (catalog:154): record that `count` pending inputs left `queue`
2717 /// and became conversation. A no-op when nothing was taken, or when
2718 /// queue persistence is off.
2719 fn journal_queue_drain(&self, queue: crate::session_journal::QueueKind, count: usize) {
2720 if count == 0 || !self.config.session_queue_persist {
2721 return;
2722 }
2723 self.journal_op(crate::session_journal::JournalOp::Dequeue { queue, count });
2724 }
2725
2726 /// BP-8: journal the plan if `update_plan` changed it since the last
2727 /// time this ran. Called at every loop boundary — a plan that a crash
2728 /// would otherwise strand in the tool's memory is on disk within one
2729 /// iteration of being written.
2730 fn journal_plan_if_changed(&mut self) {
2731 if !self.config.todos_persist {
2732 return;
2733 }
2734 let current = self.ctx.plan_snapshot();
2735 if current == self.journaled_plan {
2736 return;
2737 }
2738 self.journaled_plan.clone_from(¤t);
2739 self.journal_op(crate::session_journal::JournalOp::Plan { steps: current });
2740 }
2741
2742 /// Install (or replace) this agent's reduction policy (A5/A7/A10). Once
2743 /// set, every provider request is built from a *projected* view of
2744 /// `history[1..]` (`reduce::project_messages`) rather than `history`
2745 /// verbatim — `history` itself is never shrunk or mutated by this; only
2746 /// the request view does.
2747 pub fn set_reduction_policy(&mut self, policy: ReductionPolicy) {
2748 self.reduction_policy = Some(policy);
2749 }
2750
2751 /// This agent's reduction policy, if one is installed.
2752 pub fn reduction_policy(&self) -> Option<&ReductionPolicy> {
2753 self.reduction_policy.as_ref()
2754 }
2755
2756 /// Change the global tool-schema tier (TR-8/T5) mid-session. Takes effect
2757 /// starting with the NEXT request this agent builds. Under
2758 /// [`CachePlan::ImportedPrefix`], the first request built after a change
2759 /// is flagged as a cache-bust event and its cache-control annotation is
2760 /// skipped for that one request (see [`provider::tier_change_is_cache_bust`],
2761 /// consulted in `Self::build_request_messages`) — normal annotation
2762 /// resumes on the next request if the tier doesn't change again.
2763 pub fn set_schema_tier(&mut self, tier: crate::tools::SchemaTier) {
2764 self.config.tool_schema_tier = tier;
2765 }
2766
2767 /// Override the schema tier for a single tool (TR-8/T5) mid-session, same
2768 /// cache-bust interaction as [`Self::set_schema_tier`].
2769 pub fn set_tool_schema_tier(
2770 &mut self,
2771 name: impl Into<String>,
2772 tier: crate::tools::SchemaTier,
2773 ) {
2774 self.config
2775 .tool_overrides
2776 .entry(name.into())
2777 .or_default()
2778 .schema_tier = Some(tier);
2779 }
2780
2781 /// A deterministic fingerprint of the current tool-schema tier
2782 /// configuration (global knob + every per-tool override), used to detect
2783 /// a mid-session tier change (TR-8/T5, dev/05). Order-independent over
2784 /// `tool_overrides` (sorted by name before hashing) so insertion order
2785 /// never spuriously changes the signature.
2786 fn schema_tier_signature(&self) -> u64 {
2787 use std::hash::{Hash, Hasher};
2788 let mut hasher = std::collections::hash_map::DefaultHasher::new();
2789 self.config.tool_schema_tier.hash(&mut hasher);
2790 let mut overrides: Vec<(&str, crate::tools::SchemaTier)> = self
2791 .config
2792 .tool_overrides
2793 .iter()
2794 .filter_map(|(name, o)| o.schema_tier.map(|t| (name.as_str(), t)))
2795 .collect();
2796 overrides.sort_by_key(|(name, _)| *name);
2797 for (name, tier) in overrides {
2798 name.hash(&mut hasher);
2799 tier.hash(&mut hasher);
2800 }
2801 hasher.finish()
2802 }
2803
2804 /// Install (or replace) this agent's TR-7 span summarizer — the
2805 /// injectable side-call `Self::build_request_messages` uses to turn an
2806 /// A10 `TurnsCleared` span into an LLM-written summary paragraph when
2807 /// `policy.summarize_cleared_turns` is on. Installing one alone changes
2808 /// nothing: [`ReductionPolicy::summarize_cleared_turns`] (off by
2809 /// default) is the actual gate, so tests/callers that want the
2810 /// deterministic stub can simply never call this.
2811 pub fn set_span_summarizer(
2812 &mut self,
2813 summarizer: impl reduce::summarize::SpanSummarizer + Send + Sync + 'static,
2814 ) {
2815 self.span_summarizer = Some(std::sync::Arc::new(summarizer));
2816 }
2817
2818 /// Install an already-shared summarizer — same seam as
2819 /// [`Self::set_span_summarizer`], for callers (and tests) that need to
2820 /// keep their own handle on it.
2821 pub fn set_span_summarizer_arc(
2822 &mut self,
2823 summarizer: std::sync::Arc<dyn reduce::summarize::SpanSummarizer + Send + Sync>,
2824 ) {
2825 self.span_summarizer = Some(summarizer);
2826 }
2827
2828 /// Prepare TR-7 metadata with this agent's installed summarizer for a
2829 /// projection performed by an outer driver before session history/log
2830 /// are loaded (the CLI foreign-resume preflight). `None` preserves the
2831 /// deterministic fallback when the gate is off, no summarizer exists,
2832 /// the span is below the cost floor, or the side-call fails.
2833 pub fn prepare_cleared_turns_summary(
2834 &self,
2835 msgs: &[ChatMessage],
2836 policy: &ReductionPolicy,
2837 prior: &ReductionLog,
2838 ) -> Option<reduce::PreparedClearSummary> {
2839 let summarizer = self.span_summarizer.as_deref()?;
2840 reduce::prepare_cleared_turns_summary(msgs, policy, prior, summarizer)
2841 }
2842
2843 /// P5-4: install (or replace) this agent's [`crate::EventSink`] AFTER
2844 /// construction — `Config::event_sink` is otherwise only set at
2845 /// `Config`-build time (before `Agent::new`), which is too early for a
2846 /// `tui` embedder that only knows it's activating (and needs to
2847 /// replace whatever print-mode/REPL sink was already installed with
2848 /// one that feeds its own render loop instead of writing straight to
2849 /// stdout) once it already holds a live `Agent`. Mirrors [`Self::
2850 /// set_permissions_approval_handler`]'s "installing one alone changes
2851 /// nothing beyond what already consults `Config::event_sink`" pattern
2852 /// — this is a plain replacement, not a new activation gate.
2853 pub fn set_event_sink(&mut self, sink: crate::EventSink) {
2854 self.config.event_sink = Some(sink);
2855 }
2856
2857 /// P5-1: install (or replace) this agent's permissions-engine approval
2858 /// handler — see [`crate::permissions::PermissionsApprovalHandler`].
2859 /// This is the non-interactive decision seam a CLI/TUI/SDK embedder
2860 /// implements for the `Ask`-tier prompt; the TUI's actual interactive
2861 /// UI is a separate module (P5 row 4), not built here. Installing one
2862 /// alone changes nothing: [`Config::permissions_enabled`] (off by
2863 /// default) is the actual gate — with no handler installed, every
2864 /// `Ask`-tier decision denies (fail-closed, see that trait's doc
2865 /// comment).
2866 ///
2867 /// P5-10 (§2 module 12, `escalation = "ask"`): the SAME handler also
2868 /// backs a sandbox-unenforceable `ask` decision
2869 /// (`crate::sandbox::decide_fs`'s `approval` parameter) — one installed
2870 /// seam serves both `permissions.rules`' `Ask` tier and
2871 /// `permissions.sandbox`'s `escalation = "ask"`, rather than requiring
2872 /// an embedder to install two near-identical handlers. Kept in sync on
2873 /// `self.ctx` (not just `self.permissions_approval_handler`) because
2874 /// `BashTool::execute`/`PersistentShellTool::execute` only ever see
2875 /// `&ToolContext`, never `&Agent` — see `ToolContext::
2876 /// sandbox_approval_handler`'s doc comment.
2877 /// BP-10 (catalog row "Session approval caching"): this agent's
2878 /// approval cache — the door an embedder/TUI uses to inspect or REVOKE
2879 /// remembered grants (`ApprovalCache::clear` forgets every one, in
2880 /// memory and on disk, and the next matching call asks again). Also
2881 /// how a test proves a grant really did survive the process:
2882 /// `store_path()` names the file a second agent reads back.
2883 pub fn permissions_approval_cache(&self) -> &crate::permissions::ApprovalCache {
2884 &self.permissions_approval_cache
2885 }
2886
2887 pub fn set_permissions_approval_handler(
2888 &mut self,
2889 handler: impl crate::permissions::PermissionsApprovalHandler + 'static,
2890 ) {
2891 let handler: std::sync::Arc<dyn crate::permissions::PermissionsApprovalHandler> =
2892 std::sync::Arc::new(handler);
2893 self.permissions_approval_handler = Some(handler.clone());
2894 self.ctx.sandbox_approval_handler =
2895 Some(crate::sandbox::SandboxApprovalHandler(handler.clone()));
2896 // BP-3 (§2 module 8): `exit_plan_mode` presents its plan on this
2897 // same door — one approval seam for the session, not a second one
2898 // the operator would have to answer separately.
2899 self.ctx.approval_handler = Some(crate::tools::ToolApprovalHandler(handler));
2900 }
2901
2902 /// BP-3 (§2 module 6 `tools.question`): install the door `ask_user`
2903 /// asks the human through — the `elicitation/create` handler the design
2904 /// names as the module's protocol side. SDK-owned frontends pass the
2905 /// broker-backed handler (`crate::server::FrontendRequestBridge::
2906 /// elicitation_handler`), which is what makes the question a real
2907 /// frontend request the turn waits on. `None` (the default, nothing
2908 /// installed) leaves the tool deny-default: it reports that nobody can
2909 /// be asked instead of blocking.
2910 ///
2911 /// Installing one alone changes nothing about whether the tool EXISTS —
2912 /// `[capabilities.tools_question]` is that gate, applied by
2913 /// `crate::tools::ToolRegistry::from_config`.
2914 pub fn set_user_question_handler(
2915 &mut self,
2916 handler: std::sync::Arc<dyn crate::mcp::McpElicitationHandler>,
2917 ) {
2918 self.ctx.question_handler = Some(crate::tools::UserQuestionHandler(handler));
2919 }
2920
2921 /// BP-3 (§2 module 8): the shared plan-mode state, so a frontend (the
2922 /// REPL's `/plan`, a TUI toggle) can enter or leave the read-only
2923 /// research phase the same tools and permission gate see.
2924 pub fn plan_mode(&self) -> &std::sync::Arc<crate::tools::PlanModeState> {
2925 &self.ctx.plan_mode
2926 }
2927
2928 /// Install the compatibility approval seam used when the composable
2929 /// permissions engine is disabled. SDK-owned interactive frontends call
2930 /// this alongside [`Self::set_permissions_approval_handler`] so the same
2931 /// authenticated request channel works under either policy engine; the
2932 /// selected engine remains entirely a configuration decision.
2933 pub fn set_legacy_approval_handler(&mut self, handler: crate::config::ApprovalHandler) {
2934 self.config.approval_handler = Some(handler);
2935 }
2936
2937 /// P5-3 (§2 module 9 D5 "subagent transcripts… persisted + linked"):
2938 /// install a [`crate::store::SessionStore`] (+ this agent's own session
2939 /// name in it) so `spawn_subagent` persists each child's transcript
2940 /// (via [`crate::store::SessionStore::save_subagent_transcript`]) and
2941 /// lineage record (via
2942 /// [`crate::store::SessionStore::save_subagent_lineage`]) once the
2943 /// child finishes. Installing one alone changes nothing about whether
2944 /// spawning WORKS — [`Config::subagents_enabled`] is the actual gate;
2945 /// this only controls whether a completed spawn's transcript additionally
2946 /// lands on disk.
2947 pub fn set_subagent_store(
2948 &mut self,
2949 store: std::sync::Arc<crate::store::SessionStore>,
2950 session_name: impl Into<String>,
2951 ) {
2952 self.subagent_store = Some((store, session_name.into()));
2953 }
2954
2955 /// Seed the Claude runtime manifest reconstructed during resume.
2956 ///
2957 /// Installing state enables the matching Claude runtime tool schemas so
2958 /// a disk-reloaded continuation does not lose that vocabulary, but never
2959 /// starts a timer by itself. The supplied execution posture is preserved:
2960 /// an embedding scheduler may deliberately activate before installing it.
2961 pub fn set_claude_runtime_manifest(
2962 &mut self,
2963 manifest: crate::claude_runtime_state::ClaudeRuntimeManifest,
2964 ) {
2965 // A persisted manifest is itself the compatibility capability marker.
2966 // Reopening a Supercode session must not retain its timers while
2967 // silently dropping Claude's Cron*/ScheduleWakeup vocabulary.
2968 self.config.claude_runtime_tools_enabled = true;
2969 self.claude_runtime_manifest = Some(manifest);
2970 }
2971
2972 /// Reinstall project-scoped Claude named-agent definitions when a
2973 /// Supercode continuation carrying a Claude runtime manifest is reopened
2974 /// from disk. The manifest is the durable capability marker; definitions
2975 /// themselves remain authoritative in `<cwd>/.claude/agents/*.md`.
2976 pub fn restore_claude_project_agents(&mut self) -> Result<usize> {
2977 let definitions = crate::claude_compat::load_project_agents(&self.config.cwd)?;
2978 crate::claude_compat::enable_claude_subagent_compatibility(&mut self.config);
2979 for imported in &definitions {
2980 self.config.subagents_definitions.insert(
2981 imported.definition.name.clone(),
2982 imported.definition.clone(),
2983 );
2984 }
2985 Ok(definitions.len())
2986 }
2987
2988 /// Current imported Claude runtime state, including paused mutations made
2989 /// by `Cron*`/`ScheduleWakeup`, for persistence by the embedding loop.
2990 pub fn claude_runtime_manifest(
2991 &self,
2992 ) -> Option<&crate::claude_runtime_state::ClaudeRuntimeManifest> {
2993 self.claude_runtime_manifest.as_ref()
2994 }
2995
2996 /// Mutable access for an embedding scheduler driver to atomically claim
2997 /// due events and persist the resulting manifest. Merely borrowing this
2998 /// state does not start a timer; execution remains the driver's explicit
2999 /// responsibility.
3000 pub fn claude_runtime_manifest_mut(
3001 &mut self,
3002 ) -> Option<&mut crate::claude_runtime_state::ClaudeRuntimeManifest> {
3003 self.claude_runtime_manifest.as_mut()
3004 }
3005
3006 /// P5-4 (tui, closes the P5-3 §2.2 C6 deferred chain): install a
3007 /// factory this agent's `Self::run_spawn_subagent` calls (with the
3008 /// fresh child's own id and this agent's shared
3009 /// [`Self::pending_child_approvals`] queue) to build the
3010 /// `PermissionsApprovalHandler` a `background_prompts = "parent"`
3011 /// child gets, INSTEAD of the default
3012 /// [`crate::subagents::ParentQueueApprovalHandler`]. Installing one
3013 /// alone changes nothing about whether background spawning works —
3014 /// [`Config::subagents_background_prompts`] being
3015 /// [`crate::subagents::BackgroundPromptsPolicy::Parent`] is the actual
3016 /// gate that reaches this factory at all; a `Parent`-policy child
3017 /// spawned before this is installed (or on an agent that never installs
3018 /// it) still gets the immediate-deny default, unchanged.
3019 ///
3020 /// **Security note.** The factory only controls WHICH handler answers
3021 /// an `Ask`-tier request — it can never widen what gets asked in the
3022 /// first place: [`crate::permissions::approval::resolve_ask`] only
3023 /// calls a handler's `ask` when the rule engine has already resolved
3024 /// the call to `Ask` (`Deny` short-circuits before any handler is
3025 /// consulted; `Allow` never needs one), so a parent's "allow" answer
3026 /// here can only grant what the policy already routed to a prompt —
3027 /// never override a `Deny` the engine already decided.
3028 pub fn set_child_approval_handler_factory(
3029 &mut self,
3030 factory: impl Fn(
3031 String,
3032 std::sync::Arc<std::sync::Mutex<Vec<crate::subagents::QueuedApproval>>>,
3033 ) -> std::sync::Arc<dyn crate::permissions::PermissionsApprovalHandler>
3034 + Send
3035 + Sync
3036 + 'static,
3037 ) {
3038 self.child_approval_handler_factory = Some(std::sync::Arc::new(factory));
3039 }
3040
3041 /// P5-3 (§2.2 C6 "parent-surfaced queue"): every approval request a
3042 /// `background_prompts = "parent"` child has raised so far, oldest
3043 /// first — a read-only audit view, not a mutable queue the caller
3044 /// answers. Under P5-3's own default handler (no P5-4 TUI factory
3045 /// installed) every entry here WAS already resolved `Deny` (a
3046 /// background call can't wait for an answer with no handler
3047 /// installed) — but once a `crate::tui::TuiChildApprovalHandler`
3048 /// factory is installed (P5-4,
3049 /// [`Self::set_child_approval_handler_factory`]), the underlying call
3050 /// genuinely blocks and may resolve `Allow`/`AllowForSession`; this
3051 /// method still records the SAME entry for the audit trail either
3052 /// way, so "queued here" no longer implies "was denied" in general —
3053 /// see [`crate::subagents::QueuedApproval`]'s doc comment.
3054 pub fn pending_child_approvals(&self) -> Vec<crate::subagents::QueuedApproval> {
3055 self.pending_child_approvals
3056 .lock()
3057 .map(|q| q.clone())
3058 .unwrap_or_default()
3059 }
3060
3061 /// P4b: install (or replace) this agent's auto-title side-call — see
3062 /// [`crate::session_title::SessionTitler`]. Installing one alone changes
3063 /// nothing: [`Config::auto_title`] (off by default) is the actual gate a
3064 /// caller should consult before calling [`Self::auto_title`].
3065 pub fn set_session_titler(
3066 &mut self,
3067 titler: impl crate::session_title::SessionTitler + Send + Sync + 'static,
3068 ) {
3069 self.session_titler = Some(std::sync::Arc::new(titler));
3070 }
3071
3072 /// P4b: produce a title for this agent's current conversation via the
3073 /// installed [`Self::set_session_titler`] side-call. Returns `None` (never
3074 /// panics, never blocks longer than the titler itself does) if no
3075 /// titler is installed, or the side-call itself declined (see
3076 /// [`crate::session_title::auto_title`]). Does NOT consult
3077 /// [`Config::auto_title`] itself — that gate is the caller's
3078 /// responsibility, matching `Self::span_summarizer`'s precedent of
3079 /// keeping the mechanism and the policy gate separate.
3080 pub fn auto_title(&self) -> Option<String> {
3081 let titler = self.session_titler.as_deref()?;
3082 crate::session_title::auto_title(&self.history, titler)
3083 }
3084
3085 /// P4b (§1.6, catalog §4a "persisted per-turn usage records"): every
3086 /// [`crate::usage_log::UsageRecord`] this agent has accumulated so far.
3087 pub fn usage_records(&self) -> &[crate::usage_log::UsageRecord] {
3088 &self.usage_log
3089 }
3090
3091 /// P4b: persist this agent's accumulated usage log to `store` under
3092 /// `name` — a thin wrapper over [`crate::store::SessionStore::save_usage_log`]
3093 /// so callers don't need to import both types.
3094 pub fn save_usage_log(&self, store: &crate::store::SessionStore, name: &str) -> Result<()> {
3095 store.save_usage_log(name, &self.usage_log)
3096 }
3097
3098 /// BP-7 (catalog §4a "Turn/step bracketing records"): every
3099 /// [`crate::turn_record::TurnRecord`] this agent has accumulated —
3100 /// the context/usage/finish brackets of each model round-trip plus the
3101 /// retry, abort, effort and goal markers between them.
3102 pub fn turn_records(&self) -> &[crate::turn_record::TurnRecord] {
3103 &self.turn_records
3104 }
3105
3106 /// BP-7: persist the marker log to `store` under `name`
3107 /// (`<name>.events.jsonl`), the same thin-wrapper shape
3108 /// [`Self::save_usage_log`] has.
3109 pub fn save_turn_records(&self, store: &crate::store::SessionStore, name: &str) -> Result<()> {
3110 store.save_turn_records(name, &self.turn_records)
3111 }
3112
3113 /// BP-7 (catalog §4a "Per-turn cost/usage accounting"): dollars this
3114 /// agent has spent so far. `0.0` when the model is unpriceable — read
3115 /// [`Self::model_priced`] to tell "free" from "unknown".
3116 pub fn total_cost_usd(&self) -> f64 {
3117 self.total_cost_usd
3118 }
3119
3120 /// BP-7: whether this build can price this agent's model, i.e. whether
3121 /// [`Self::total_cost_usd`] is a real figure rather than a floor.
3122 pub fn model_priced(&self) -> bool {
3123 self.model_price.is_some()
3124 }
3125
3126 /// BP-7 (catalog §4a "Turn/budget caps"): tool calls this agent has
3127 /// executed so far — the counter [`Config::max_steps`] bounds.
3128 pub fn total_steps(&self) -> usize {
3129 self.total_steps
3130 }
3131
3132 /// BP-7 (catalog §4a "Interrupt/abort with state preserved"): record
3133 /// that the in-flight turn was interrupted.
3134 ///
3135 /// Called by whoever owns the cancellation (the CLI's Ctrl-C race), NOT
3136 /// by the loop itself: a cancelled `send` future is dropped mid-await,
3137 /// so the loop never runs another line. The partial work already
3138 /// appended to the transcript stands; this marker is what makes the
3139 /// interruption a persisted FACT — the residue the ledger row named —
3140 /// rather than something a reader has to infer from a dangling tool
3141 /// call on reload. Emits [`AgentEvent::TurnAborted`] as the live
3142 /// counterpart.
3143 pub fn note_abort(&mut self, source: &str) {
3144 let messages = self.history.len();
3145 self.emit(AgentEvent::TurnAborted {
3146 source: source.to_string(),
3147 });
3148 self.push_turn_marker(crate::turn_record::TurnMarker::Aborted {
3149 source: source.to_string(),
3150 messages,
3151 });
3152 }
3153
3154 // ---- BP-7: goals (catalog §4a "Goals — persistent objective across
3155 // turns"; §2 module 7 `todos`, §3.1 `capabilities.todos.goals`) ----
3156
3157 /// Set (or revise) this session's standing objective.
3158 ///
3159 /// Returns `false`, changing nothing, when `capabilities.todos.goals`
3160 /// is off — the module gate, not a silent success. A goal restates
3161 /// itself at the tail of every request until [`Self::clear_goal`], and
3162 /// each change appends a `goal` marker to the turn-record log.
3163 pub fn set_goal(&mut self, objective: impl Into<String>) -> bool {
3164 if !self.config.goals_enabled {
3165 return false;
3166 }
3167 let objective = objective.into();
3168 let now = now_ms();
3169 match &mut self.goal {
3170 Some(goal) => goal.revise(objective.clone(), now),
3171 slot @ None => *slot = Some(crate::goals::GoalRecord::new(objective.clone(), now)),
3172 }
3173 self.push_turn_marker(crate::turn_record::TurnMarker::Goal { objective });
3174 true
3175 }
3176
3177 /// This session's standing objective, if one is set.
3178 pub fn goal(&self) -> Option<&crate::goals::GoalRecord> {
3179 self.goal.as_ref()
3180 }
3181
3182 /// Drop the standing objective. `true` when there was one to drop.
3183 pub fn clear_goal(&mut self) -> bool {
3184 if self.goal.take().is_none() {
3185 return false;
3186 }
3187 self.push_turn_marker(crate::turn_record::TurnMarker::Goal {
3188 objective: String::new(),
3189 });
3190 true
3191 }
3192
3193 /// BP-7: persist (or, when cleared, remove) the standing objective
3194 /// beside the session — same thin-wrapper shape as
3195 /// [`Self::save_usage_log`].
3196 pub fn save_goal(&self, store: &crate::store::SessionStore, name: &str) -> Result<()> {
3197 match &self.goal {
3198 Some(goal) => store.save_goal(name, goal),
3199 None => store.clear_goal(name),
3200 }
3201 }
3202
3203 /// BP-7: adopt a goal loaded from the store (a resumed session picks up
3204 /// exactly where it left off). Bypasses the module gate on purpose: a
3205 /// goal already persisted is data to restore, not a new capability
3206 /// being turned on, and dropping it silently would lose session state.
3207 pub fn restore_goal(&mut self, goal: Option<crate::goals::GoalRecord>) {
3208 self.goal = goal;
3209 }
3210
3211 // ---- BP-7: extended-thinking control (catalog §4a "Extended thinking
3212 // control": "Reasoning on/off/levels mid-session") ----
3213
3214 /// The reasoning-effort level in force for the NEXT request, or `None`
3215 /// when extended thinking is off.
3216 pub fn effort(&self) -> Option<&str> {
3217 self.config.effort.as_deref()
3218 }
3219
3220 /// Change the reasoning-effort level mid-session.
3221 ///
3222 /// `Some(level)` sets the level; `None` turns extended thinking OFF —
3223 /// the on/off toggle the ledger row named as distinct from the level.
3224 /// `run_loop` reads `self.config.effort` fresh when it builds each
3225 /// `ChatRequest`, so this takes effect on the very next request with no
3226 /// other copy to update (the same contract [`Self::set_model`] has).
3227 /// The change is appended to the turn-record log as an `effort` marker,
3228 /// the extended-thinking analog of the `model_change` log.
3229 ///
3230 /// Returns the PREVIOUS setting.
3231 pub fn set_effort(&mut self, effort: Option<String>) -> Option<String> {
3232 let previous = self.config.effort.clone();
3233 if previous == effort {
3234 return previous;
3235 }
3236 self.config.effort = effort.clone();
3237 self.push_turn_marker(crate::turn_record::TurnMarker::Effort {
3238 from: previous.clone(),
3239 to: effort,
3240 });
3241 previous
3242 }
3243
3244 // ---- BP-7: review mode (catalog §4a "Review mode — dedicated
3245 // code-review flow"; §3.1 `[core.prompts]`) ----
3246
3247 /// The purpose-built review turn's prompt: the `code-review` template
3248 /// from [`Config::prompts`] with `{args}` replaced by `args`.
3249 ///
3250 /// `None` when the resolved config carries no `code-review` template —
3251 /// the preset decides whether this harness has a review mode, and the
3252 /// template IS the report format (both parity presets pin one).
3253 pub fn review_prompt(&self, args: &str) -> Option<String> {
3254 self.config
3255 .prompts
3256 .get(REVIEW_PROMPT_NAME)
3257 .map(|template| template.replace("{args}", args.trim()))
3258 }
3259
3260 /// Run the review turn: an ordinary [`Self::send`] of
3261 /// [`Self::review_prompt`], so the review's request, tools, transcript
3262 /// and records are the session's own — a purpose-built TURN, not a
3263 /// second agent.
3264 pub async fn review(&mut self, args: &str) -> Result<String> {
3265 let prompt = self.review_prompt(args).ok_or_else(|| {
3266 Error::Other(format!(
3267 "no `{REVIEW_PROMPT_NAME}` prompt template is configured for this harness"
3268 ))
3269 })?;
3270 self.send(prompt).await
3271 }
3272
3273 // ---- BP-7: side/ephemeral Q&A (catalog §4a "Side/ephemeral Q&A":
3274 // "Tool-less question over full context, never enters history") ----
3275
3276 /// Answer `question` over this session's FULL current context without
3277 /// recording anything.
3278 ///
3279 /// Three properties, all load-bearing and all asserted by this build's
3280 /// tests: the request carries the whole conversation as the next turn
3281 /// would see it; it advertises NO tools, so the model can only answer;
3282 /// and neither `history`, the sidecar recorder, the usage log nor the
3283 /// turn-record log is touched — `&self`, not `&mut self`, is the type
3284 /// system saying so. cc's `/btw` and cx's `/side`.
3285 pub async fn side_question(&self, question: &str) -> Result<String> {
3286 let mut messages = self.history.clone();
3287 if let Some(goal) = &self.goal {
3288 messages.push(ChatMessage::system(goal.reminder()));
3289 }
3290 messages.push(ChatMessage::user(format!(
3291 "{SIDE_QUESTION_PREAMBLE}
3292
3293{question}"
3294 )));
3295 let mut req = ChatRequest {
3296 model: self.config.model.clone(),
3297 messages,
3298 tools: Vec::new(),
3299 temperature: self.config.temperature,
3300 max_tokens: self.config.max_tokens,
3301 effort: self.config.effort.clone(),
3302 response_format: None,
3303 service_tier: None,
3304 thinking_budget: None,
3305 extra_body: self.config.extra_body.clone(),
3306 };
3307 // BP-13: a side question is still a request to THIS model, so it
3308 // carries the same routing decisions the loop's own requests do.
3309 self.apply_routing(&mut req);
3310 let (assistant, _usage) = self.provider.complete(&req, &|_: &str| {}).await?;
3311 Ok(assistant.content.unwrap_or_default())
3312 }
3313
3314 /// BP-7: append one marker against the NEXT round-trip's index — the
3315 /// right frame for a marker written between turns (a goal change, an
3316 /// effort change, an abort).
3317 fn push_turn_marker(&mut self, marker: crate::turn_record::TurnMarker) {
3318 self.push_turn_marker_at(self.turn_index, marker);
3319 }
3320
3321 /// BP-7: append one marker against an explicit round-trip index — used
3322 /// inside `Self::run_loop`, where markers are written on both sides of
3323 /// the `turn_index` advance and must all carry the round-trip they
3324 /// describe.
3325 fn push_turn_marker_at(&mut self, turn: usize, marker: crate::turn_record::TurnMarker) {
3326 self.turn_records.push(crate::turn_record::TurnRecord::new(
3327 turn,
3328 &self.config.model,
3329 now_ms(),
3330 marker,
3331 ));
3332 }
3333
3334 /// P4b (§1.7, pi§3 semantics): queue a mid-turn steering message —
3335 /// delivered "after current tool calls" (pi's phrasing): at the top of
3336 /// `Self::run_loop`'s NEXT iteration, before the next model request is
3337 /// built, regardless of whether this turn is still mid-flight with
3338 /// pending tool calls. Drained per [`Config::steering_mode`].
3339 pub fn queue_steer(&self, message: impl Into<String>) {
3340 let message = message.into();
3341 // BP-8 (catalog:154 "Queued-prompt persistence"): the input is
3342 // recorded BEFORE it is queued, so the window in which a crash
3343 // could lose it is zero. A no-op when `core.session.queue_persist`
3344 // is off (cx-parity: stock Codex has no queue-operation records).
3345 if self.config.session_queue_persist {
3346 self.journal_op(crate::session_journal::JournalOp::Enqueue {
3347 queue: crate::session_journal::QueueKind::Steer,
3348 text: message.clone(),
3349 });
3350 }
3351 self.steer_queue
3352 .lock()
3353 .unwrap_or_else(std::sync::PoisonError::into_inner)
3354 .queue_unchecked(message);
3355 }
3356
3357 /// Crate-internal shared steering handle used by the canonical SDK
3358 /// runtime. It remains writable while an active turn holds `&mut Agent`,
3359 /// allowing local and remote frontends to steer without owning the loop.
3360 pub(crate) fn steer_queue_handle(&self) -> std::sync::Arc<std::sync::Mutex<SteerInbox>> {
3361 self.steer_queue.clone()
3362 }
3363
3364 /// P4b: queue a follow-up message — delivered "at idle" (pi's phrasing):
3365 /// only once `Self::run_loop` would otherwise return a final answer
3366 /// (no more tool calls pending). Drained per [`Config::follow_up_mode`].
3367 pub fn queue_follow_up(&mut self, message: impl Into<String>) {
3368 let message = message.into();
3369 // BP-8 (catalog:154): same record-then-queue order as
3370 // [`Self::queue_steer`].
3371 if self.config.session_queue_persist {
3372 self.journal_op(crate::session_journal::JournalOp::Enqueue {
3373 queue: crate::session_journal::QueueKind::FollowUp,
3374 text: message.clone(),
3375 });
3376 }
3377 self.follow_up_queue.push_back(message);
3378 }
3379
3380 /// P4b: how many steering messages are currently queued (mid-turn +
3381 /// follow-up combined) — mostly for tests/diagnostics.
3382 pub fn queued_steer_count(&self) -> usize {
3383 self.steer_queue
3384 .lock()
3385 .unwrap_or_else(std::sync::PoisonError::into_inner)
3386 .len()
3387 + self.follow_up_queue.len()
3388 }
3389
3390 /// The accumulating reduction log (A5) — every reduction applied to any
3391 /// projected request view so far. Combined with a full-fidelity sidecar
3392 /// Session, this is enough to `reduce::invert` any projected view back to
3393 /// the exact original.
3394 pub fn reduction_log(&self) -> &ReductionLog {
3395 &self.reduction_log
3396 }
3397
3398 /// PARITY-18 D4 — arm the per-send context guard: `Self::run_loop`
3399 /// will refuse (via [`Error::ContextLimitExceeded`]) to build and issue
3400 /// ANY request — the first or any later turn — whose
3401 /// [`crate::tokens::context_guard`] verdict is "does not fit" against
3402 /// `limit`. Call this once the target model's context-window size is
3403 /// known (`resume --reduced`'s preflight already computes it). Leaving
3404 /// this unset (the default) is a no-op: no guard runs, exactly today's
3405 /// pre-PARITY-18 behavior.
3406 pub fn set_context_limit(&mut self, limit: u64) {
3407 self.context_limit = Some(limit);
3408 }
3409
3410 /// This agent's armed context limit, if [`Self::set_context_limit`] has
3411 /// been called.
3412 pub fn context_limit(&self) -> Option<u64> {
3413 self.context_limit
3414 }
3415
3416 /// The model identifier this agent sends on its next request
3417 /// ([`Config::model`], as of construction/resume or the last
3418 /// [`Self::set_model`] call).
3419 pub fn model(&self) -> &str {
3420 &self.config.model
3421 }
3422
3423 /// UX-30 dev/02 — switch the model this agent sends, starting with the
3424 /// NEXT request it builds (and every one after, until changed again).
3425 /// `Self::run_loop` reads `self.config.model` fresh on every request
3426 /// (see its `ChatRequest` construction), so this alone is enough —
3427 /// there is no cached/baked-in copy anywhere else to also update.
3428 /// Takes effect immediately; safe to call only between turns (the
3429 /// REPL's `/model` picker runs at the prompt, never mid-turn). Touches
3430 /// nothing else: history, the sidecar, and reduction state are exactly
3431 /// as untouched as [`Self::set_schema_tier`] leaves them for a
3432 /// mid-session tier change.
3433 ///
3434 /// P4c-review note: this is the LOW-LEVEL primitive — it swaps
3435 /// [`Config::model`] and nothing else. It does NOT run dep 8's
3436 /// reasoning-artifact filter
3437 /// ([`reduce::rehydrate::filter_reasoning_artifacts`]) and does NOT
3438 /// create a [`crate::model_change::ModelChangeRecord`], so calling it
3439 /// directly for a mid-session handoff between two DIFFERENT models
3440 /// leaves model-A's reasoning artifacts in `history` for model-B to
3441 /// inherit. [`Self::switch_model`] is the safe superset — gated by
3442 /// [`Config::model_switch_allow_switch`], it filters and records the
3443 /// switch before delegating to this method — and is what callers
3444 /// performing a governed mid-session model switch should use instead.
3445 pub fn set_model(&mut self, model: impl Into<String>) {
3446 self.config.model = model.into();
3447 // BP-5 (catalog D2 "Per-model-family base-prompt selection"): the
3448 // family's base prompt follows the model. Codex re-selects
3449 // `base_instructions` when the model changes; leaving model-A's
3450 // base prompt in front of model-B is exactly the mismatch the row
3451 // exists to prevent. Same locate-and-replace mechanism
3452 // `refresh_env_context` uses, and a no-op whenever the selection
3453 // did not actually change (always, for a config with no family
3454 // table).
3455 self.refresh_base_prompt();
3456 // BP-7: the price follows the model, or the per-turn cost figure
3457 // would keep billing the OLD model's rates after a switch.
3458 self.model_price = crate::pricing::resolve(
3459 &self.config.model,
3460 self.config.price_input_per_mtok,
3461 self.config.price_output_per_mtok,
3462 );
3463 }
3464
3465 /// P4c (§1.10/§3.1 `core.model_switch.allow_switch`, D9 row, dep 8,
3466 /// design's "core NEW-significant" item): the mid-session model
3467 /// switch — a superset of [`Self::set_model`] gated by
3468 /// [`Config::model_switch_allow_switch`].
3469 ///
3470 /// **`allow_switch = false` (the default): EXACTLY [`Self::set_model`]**
3471 /// — same single field write, nothing else touched, no
3472 /// [`crate::model_change::ModelChangeRecord`] created. Byte-identical to
3473 /// calling `set_model` directly.
3474 ///
3475 /// **`allow_switch = true`:** additionally, before the swap takes
3476 /// effect, runs [`reduce::rehydrate::filter_reasoning_artifacts`] over
3477 /// [`Self::history`] — model-A's reasoning/thinking artifacts (any
3478 /// [`crate::message::ChatMessage::metadata`] key in
3479 /// [`reduce::rehydrate::REASONING_METADATA_KEYS`], any `content_parts`
3480 /// block whose `"type"` is in
3481 /// [`reduce::rehydrate::REASONING_CONTENT_PART_TYPES`]) are stripped
3482 /// BEFORE model-B ever builds a request from this history — then
3483 /// appends a typed, translatable [`crate::model_change::ModelChangeRecord`]
3484 /// to [`Self::model_change_records`] (persist it via
3485 /// [`Self::save_model_change_log`]). A switch TO the current model
3486 /// (`model == Self::model()`) is treated as a no-op — still exactly
3487 /// `set_model`'s mechanics, no record for a switch that didn't actually
3488 /// change anything (and nothing to filter FOR, since there was no
3489 /// handoff).
3490 pub fn switch_model(&mut self, model: impl Into<String>) {
3491 let to = model.into();
3492 if !self.config.model_switch_allow_switch || self.config.model == to {
3493 self.set_model(to);
3494 return;
3495 }
3496 let from = self.config.model.clone();
3497 self.record_model_change(&from, &to, None);
3498 }
3499
3500 /// BP-13 — the ONE place a mid-session model change is performed and
3501 /// recorded, shared by [`Self::switch_model`] (a user asked) and the
3502 /// run loop's fallback pass (a provider failed).
3503 ///
3504 /// It does four things, in this order, and nothing else: strips model-A
3505 /// reasoning artifacts out of the live history (dep 8 — model B must
3506 /// never inherit them), moves [`Config::model`], appends the typed
3507 /// [`crate::model_change::ModelChangeRecord`], and writes that same
3508 /// record into the append-only session journal (BP-8) — which is where
3509 /// every persisted routing record lives; there is no second file. The
3510 /// change is also EMITTED, so a surface that renders events shows the
3511 /// switch instead of silently answering as a different model.
3512 pub fn record_model_change(&mut self, from: &str, to: &str, reason: Option<&str>) {
3513 if from == to {
3514 return;
3515 }
3516 let touched = reduce::rehydrate::filter_reasoning_artifacts(&mut self.history);
3517 self.set_model(to.to_string());
3518 let record = crate::model_change::ModelChangeRecord::new(
3519 self.turn_index,
3520 from,
3521 to,
3522 true,
3523 touched,
3524 now_ms(),
3525 )
3526 .with_reason(reason.map(str::to_string));
3527 self.journal_model_change(&record);
3528 self.model_change_log.push(record);
3529 self.emit(AgentEvent::ModelChanged {
3530 from: from.to_string(),
3531 to: to.to_string(),
3532 reason: reason.map(str::to_string),
3533 });
3534 // A switch also re-injects the switch NOTICE when the config asks
3535 // for one (Codex's own mid-session behavior: the conversation is
3536 // told the model changed, so the new model reads the handoff rather
3537 // than inferring it from a style break).
3538 if self.config.model_switch_notice {
3539 let notice = ChatMessage::user(format!(
3540 "[model changed: {from} -> {to}{}]",
3541 match reason {
3542 Some(r) => format!(" ({r})"),
3543 None => String::new(),
3544 }
3545 ));
3546 let _ = self.record(¬ice);
3547 self.history.push(notice);
3548 }
3549 }
3550
3551 /// BP-13 (catalog D9 "Fast mode / service tiers"): set or clear the
3552 /// session-level service-tier override. `Some(tier)` WINS over the
3553 /// `[capabilities.model_catalog] service_tier` rule for every
3554 /// subsequent request (it is the live toggle the user just pulled);
3555 /// `None` puts the configured rule back in charge. Takes effect on the
3556 /// next request the loop builds, like [`Self::set_model`].
3557 pub fn set_service_tier(&mut self, tier: Option<String>) {
3558 self.config.service_tier = tier;
3559 }
3560
3561 /// BP-13 — apply the routing table to a request that already names its
3562 /// model: effort LEVEL (per-model override of `[core] effort`, clamped
3563 /// by whichever effort cap applies), thinking-token BUDGET, and service
3564 /// TIER (the live `/fast` override winning over the configured rule).
3565 /// Called for every request the loop builds AND again for every
3566 /// fallback hop, so a hop to a different model gets that model's
3567 /// routing rather than the previous model's.
3568 fn apply_routing(&self, req: &mut ChatRequest) {
3569 let routing = &self.config.model_routing;
3570 let rules = routing.rules_for(&req.model);
3571 // Plan mode's own effort tier, when the mode is live, is the
3572 // session level for this request — Codex's `/plan` is effort
3573 // steering (cx§6), so planning need not think at the executing
3574 // level. It is still clamped by whatever effort cap applies,
3575 // because `effective_effort` does the clamping, not this line.
3576 let session_effort = match (
3577 self.ctx.plan_mode.is_active(),
3578 self.config.plan_mode_effort.as_deref(),
3579 ) {
3580 (true, Some(effort)) => Some(effort),
3581 _ => self.config.effort.as_deref(),
3582 };
3583 req.effort = routing.effective_effort(&req.model, session_effort);
3584 req.thinking_budget = rules.thinking_budget;
3585 req.service_tier = self.config.service_tier.clone().or(rules.service_tier);
3586 }
3587
3588 /// BP-13 — send `req`, walking [`Config::model_fallback`] when the
3589 /// failure is one another model could plausibly answer.
3590 ///
3591 /// Returns the final outcome plus the hops actually taken, so the
3592 /// caller (which owns `&mut self`) can record each one. Each hop
3593 /// re-applies routing for the new model and strips model-A reasoning
3594 /// artifacts from the request's own message copy before model B sees
3595 /// them — the same dep-8 guarantee [`Self::record_model_change`] gives
3596 /// the live history.
3597 async fn complete_with_fallback(
3598 &self,
3599 req: &mut ChatRequest,
3600 on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
3601 ) -> (Result<(ChatMessage, provider::Usage)>, Vec<FallbackHop>) {
3602 let mut hops = Vec::new();
3603 let mut result = self.provider.complete(req, on_delta).await;
3604 for next in &self.config.model_fallback {
3605 let Err(error) = &result else {
3606 break;
3607 };
3608 if !is_failover_worthy(error) {
3609 break;
3610 }
3611 if next.is_empty() || next == &req.model {
3612 continue;
3613 }
3614 let reason = error.to_string();
3615 let from = std::mem::replace(&mut req.model, next.clone());
3616 reduce::rehydrate::filter_reasoning_artifacts(&mut req.messages);
3617 self.apply_routing(req);
3618 hops.push(FallbackHop {
3619 from,
3620 to: next.clone(),
3621 reason,
3622 });
3623 result = self.provider.complete(req, on_delta).await;
3624 }
3625 (result, hops)
3626 }
3627
3628 /// P4c: every [`crate::model_change::ModelChangeRecord`] this agent has
3629 /// accumulated so far (via [`Self::switch_model`] with `allow_switch`
3630 /// on). Empty when the knob is off or no switch has happened yet.
3631 pub fn model_change_records(&self) -> &[crate::model_change::ModelChangeRecord] {
3632 &self.model_change_log
3633 }
3634
3635 /// P4c: persist this agent's accumulated model-change log to `store`
3636 /// under `name` — the [`crate::model_change::ModelChangeRecord`] analog
3637 /// of [`Self::save_usage_log`].
3638 pub fn save_model_change_log(
3639 &self,
3640 store: &crate::store::SessionStore,
3641 name: &str,
3642 ) -> Result<()> {
3643 store.save_model_change_log(name, &self.model_change_log)
3644 }
3645
3646 /// P4e (§1.6/§3.1 `core.session.git_metadata`, catalog:331): this
3647 /// agent's captured git provenance, if [`Config::session_git_metadata`]
3648 /// was on at construction and the best-effort probe found a repo.
3649 pub fn git_metadata(&self) -> Option<&crate::git_metadata::GitMetadataRecord> {
3650 self.git_metadata.as_ref()
3651 }
3652
3653 /// P4e: persist this agent's captured git metadata to `store` under
3654 /// `name` — a thin wrapper over
3655 /// [`crate::store::SessionStore::save_git_metadata`], the
3656 /// [`crate::git_metadata::GitMetadataRecord`] analog of
3657 /// [`Self::save_usage_log`]. A no-op (`Ok(())`, nothing written) when
3658 /// [`Self::git_metadata`] is `None`.
3659 pub fn save_git_metadata(&self, store: &crate::store::SessionStore, name: &str) -> Result<()> {
3660 match &self.git_metadata {
3661 Some(record) => store.save_git_metadata(name, record),
3662 None => Ok(()),
3663 }
3664 }
3665
3666 /// P4e DEFECT-FIX (independent Fable-5 review of P4e: `core.session.persist`
3667 /// had a `Config` field and CLI plumbing at `ConfigProfile` → `Config` but
3668 /// no consumer at all): whether a CLI caller's session-store save sites
3669 /// (`persist_session`, `persist_full_view`) should actually write to
3670 /// disk. `true` (the default) is byte-identical to pre-fix behavior —
3671 /// every session persists. `false` makes a session ephemeral: it runs
3672 /// exactly as before, but no `<name>.jsonl`/sidecar family is ever
3673 /// written for it. A plain getter, same posture as [`Self::model`] —
3674 /// this crate itself never reads or enforces it; the CLI's save sites do.
3675 pub fn session_persist(&self) -> bool {
3676 self.config.session_persist
3677 }
3678
3679 /// P4e DEFECT-FIX (independent Fable-5 review of P4e: `core.session.name`
3680 /// had a `Config` field and CLI plumbing but no consumer): the
3681 /// caller-configured session name, if `[core.session] name` was set.
3682 /// `None` (the default) leaves session naming exactly as before —
3683 /// `mint_session_name`'s auto-generated `<tag>-<adjective>-<noun>` shape.
3684 /// A plain getter, same posture as [`Self::session_persist`].
3685 pub fn session_name(&self) -> Option<&str> {
3686 self.config.session_name.as_deref()
3687 }
3688
3689 /// PARITY-18 D3 — whether this agent has actually issued at least one
3690 /// live request to its [`Provider`] so far (set the instant
3691 /// `Self::run_loop` reaches its real send site, regardless of whether
3692 /// that call then succeeds or fails). Callers should report
3693 /// "request sent" from THIS, never from having merely passed the
3694 /// context guard or having called [`Self::send`] — either of those can
3695 /// happen with zero requests actually issued (a guard refusal, an
3696 /// interactive session quit before any turn completes).
3697 pub fn request_issued(&self) -> bool {
3698 self.requests_issued
3699 }
3700
3701 /// P5-2 (§2.2 C2): whether this agent currently considers its
3702 /// [`CachePlan::ImportedPrefix`] cache entry warm — mirrors
3703 /// [`Self::request_issued`]'s read-only-observability precedent, so a
3704 /// caller (or a test) can confirm [`Self::register_tool`]'s C2
3705 /// invalidation actually took effect without reaching into private
3706 /// state.
3707 pub fn cache_established(&self) -> bool {
3708 self.cache_established
3709 }
3710
3711 /// B7: length of the imported-prefix protected by [`CachePlan::ImportedPrefix`]
3712 /// (this agent's own system message plus every message of a
3713 /// previously-imported session), set by [`Self::load_session`]. `None`
3714 /// until a session has been loaded.
3715 pub fn imported_prefix_len(&self) -> Option<usize> {
3716 self.imported_prefix_len
3717 }
3718
3719 /// Replace this agent's accumulating reduction log (C4: `/expand`/`/reduce`
3720 /// mutate the log directly via `reduce::invert_one`/`reduce::project_messages`
3721 /// and must feed the result back here so the *next* request build or
3722 /// persist sees the updated state instead of silently recomputing from an
3723 /// empty log). Also lets a caller (`resume_cmd`, C1) seed the log with the
3724 /// initial projection it already computed for the entry banner, so
3725 /// `reduction_log()` reflects reality even before this agent's first
3726 /// `send()` (which is otherwise the only place `build_request_messages`
3727 /// populates it).
3728 pub fn set_reduction_log(&mut self, log: ReductionLog) {
3729 self.reduction_log = log;
3730 }
3731
3732 /// Replace the conversation with a loaded session, keeping this agent's own
3733 /// system prompt at the front. The session's own system/developer turns are
3734 /// preserved after it for context.
3735 pub fn load_session(&mut self, session: Session) {
3736 let system = self.history.first().cloned();
3737 self.history.clear();
3738 if let Some(sys) = system {
3739 self.history.push(sys);
3740 }
3741 self.history.extend(session.messages);
3742 // B7: the whole of `history` at this point — this agent's own system
3743 // message plus every imported message — is the stable prefix a
3744 // resumed session resends byte-identically every turn.
3745 self.imported_prefix_len = Some(self.history.len());
3746 // UX-26 (B7-warn): a freshly loaded prefix has no established cache
3747 // entry of THIS agent's own making yet (even if this agent was
3748 // resumed once before — that earlier prefix is gone). Seed the
3749 // activity clock from the loaded session's own last message
3750 // timestamp (walking backward past any trailing message that
3751 // carries none), so a session that's been sitting idle since
3752 // Claude Code/Codex/a prior supercode run last touched it is
3753 // correctly treated as already-cold on its very first turn here —
3754 // `None` (no timestamp anywhere in the loaded messages) leaves the
3755 // TTL check disarmed rather than guessing.
3756 self.cache_established = false;
3757 self.last_cache_activity_ms = self
3758 .history
3759 .iter()
3760 .rev()
3761 .find_map(|m| m.metadata.get("timestamp"))
3762 .and_then(|ts| crate::sidecar::rfc3339_to_ms(ts));
3763 }
3764
3765 /// Append `msg` to the sidecar recorder (A3), if one is installed — a
3766 /// no-op, at zero cost, when `recorder` is `None` (today's behavior).
3767 fn record(&mut self, msg: &ChatMessage) -> Result<()> {
3768 if let Some(recorder) = self.recorder.as_mut() {
3769 recorder.append(msg)?;
3770 }
3771 // BP-8 (catalog:150): the append-only half — written and FLUSHED
3772 // here, at the moment the message exists, not at the end of the
3773 // turn. A journal failure is logged, never fatal: durability
3774 // bookkeeping must not be able to fail a turn.
3775 if let Some(journal) = &self.journal {
3776 let mut guard = journal
3777 .lock()
3778 .unwrap_or_else(std::sync::PoisonError::into_inner);
3779 if let Err(error) = guard.append_message(msg) {
3780 tracing::warn!("failed to journal a message: {error}");
3781 }
3782 }
3783 // BP-8 (catalog:151): the same message becomes a tree node, so the
3784 // tree and the linear history never disagree about what was said.
3785 if let Some(tree) = self.session_tree.as_mut() {
3786 tree.append_message(msg.clone(), now_ms());
3787 }
3788 Ok(())
3789 }
3790
3791 /// Persist the live conversation to `path` as JSONL (one [`ChatMessage`]
3792 /// per line) so the session can be resumed later — supercode's own sessions
3793 /// become first-class, resumable artifacts.
3794 pub fn save_transcript(&self, path: impl AsRef<std::path::Path>) -> Result<()> {
3795 let mut out = String::new();
3796 for m in &self.history {
3797 out.push_str(&serde_json::to_string(m).map_err(Error::Decode)?);
3798 out.push('\n');
3799 }
3800 std::fs::write(path, out)?;
3801 Ok(())
3802 }
3803
3804 /// Restore a conversation previously written with [`Self::save_transcript`],
3805 /// replacing the current history.
3806 pub fn load_transcript(&mut self, path: impl AsRef<std::path::Path>) -> Result<()> {
3807 let text = std::fs::read_to_string(path)?;
3808 let mut history = Vec::new();
3809 for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) {
3810 history.push(serde_json::from_str::<ChatMessage>(line).map_err(Error::Decode)?);
3811 }
3812 self.history = history;
3813 Ok(())
3814 }
3815
3816 /// Take a checkpoint of the current conversation position. Pass it to
3817 /// [`Self::rewind_to`] to discard everything sent since (the rewind/undo
3818 /// analog of `fork`/checkpoint).
3819 pub fn checkpoint(&self) -> usize {
3820 self.history.len()
3821 }
3822
3823 /// Rewind the conversation to a [`Self::checkpoint`], discarding later turns.
3824 pub fn rewind_to(&mut self, checkpoint: usize) {
3825 self.history.truncate(checkpoint.min(self.history.len()));
3826 }
3827
3828 /// Send a message with file inputs attached — the `--file` / `-i` analog.
3829 /// Each file's contents are injected into the prompt: UTF-8 text inline,
3830 /// binary (e.g. images) noted with a size marker. (Native image *vision*
3831 /// would additionally require multimodal content parts.)
3832 pub async fn send_with_files(
3833 &mut self,
3834 text: impl Into<String>,
3835 files: &[std::path::PathBuf],
3836 ) -> Result<String> {
3837 let mut prompt = text.into();
3838 for path in files {
3839 let block = match std::fs::read(path) {
3840 Ok(bytes) => match String::from_utf8(bytes.clone()) {
3841 Ok(s) => format!("\n\n[file: {}]\n{}", path.display(), s),
3842 Err(_) => format!(
3843 "\n\n[file: {} — {} bytes, binary content omitted]",
3844 path.display(),
3845 bytes.len()
3846 ),
3847 },
3848 Err(e) => format!("\n\n[file: {} — could not read: {e}]", path.display()),
3849 };
3850 prompt.push_str(&block);
3851 }
3852 let expanded = self.expand_prompt_async(&prompt).await;
3853 let msg = ChatMessage::user(expanded);
3854 self.guard_candidate_message(&msg)?;
3855 self.record(&msg)?;
3856 self.history.push(msg);
3857 self.run_loop().await
3858 }
3859
3860 /// Send a message with image inputs to a vision model — the `-i/--image`
3861 /// analog. `image_urls` may be `https://…` links or `data:image/…;base64,…`
3862 /// URLs; they're attached as multimodal `image_url` content parts.
3863 pub async fn send_with_images(
3864 &mut self,
3865 text: impl Into<String>,
3866 image_urls: &[String],
3867 ) -> Result<String> {
3868 let expanded = self.expand_prompt_async(&text.into()).await;
3869 let msg = ChatMessage::user_with_images(expanded, image_urls);
3870 self.guard_candidate_message(&msg)?;
3871 self.record(&msg)?;
3872 self.history.push(msg);
3873 self.run_loop().await
3874 }
3875
3876 /// Expand a `/<name> <args>` slash command against the registered prompt
3877 /// templates (`{args}` is replaced with the trailing text). Non-matching
3878 /// input is returned unchanged.
3879 /// BP-6 additionally resolves SKILL.md invocations here, after the
3880 /// template table misses: `/skill:name args` (pi§2 "Skill commands"),
3881 /// `/name args` when the config follows Claude Code (cc§7: "a `SKILL.md`
3882 /// in a directory = a `/name` command"), and `$slug` mentions (cx§7).
3883 /// `$ARGUMENTS` in the body is replaced with the trailing text.
3884 pub fn expand_prompt(&self, input: &str) -> String {
3885 // BP-5 (catalog D2 "@-file mentions / attachments"): `@path`
3886 // expansion happens FIRST, so a mention works in a bare message, in
3887 // a slash-command's arguments, and in the text a `$slug` mention
3888 // appends to — one rule, every prompt shape.
3889 let input = &self.expand_file_mentions(input);
3890 let trimmed = input.trim_start();
3891 let Some(rest) = trimmed.strip_prefix('/') else {
3892 return self.expand_skill_mentions(input);
3893 };
3894 let (name, args) = match rest.split_once(char::is_whitespace) {
3895 Some((n, a)) => (n, a.trim()),
3896 None => (rest, ""),
3897 };
3898 match self.config.prompts.get(name) {
3899 Some(template) => template.replace("{args}", args),
3900 None => match self.expand_skill_command(name, args) {
3901 Some(expanded) => expanded,
3902 None => self.expand_skill_mentions(input),
3903 },
3904 }
3905 }
3906
3907 /// BP-5 (catalog D2 "@-file mentions / attachments"; cc§2 "`@` in the
3908 /// prompt triggers file-path autocomplete and injects file context …
3909 /// Read deny rules best-effort apply to `@file` mentions"; cx§2
3910 /// "`@`-mentions (files)"): replace each `@path` token in `input` with
3911 /// that file's contents.
3912 ///
3913 /// **Deny-rule aware, through the one permissions engine.** Each
3914 /// mention is resolved with
3915 /// [`crate::permissions::evaluate_path_safe`] — the same
3916 /// traversal/symlink-resolving check a `read_file` tool call goes
3917 /// through — against this config's own rules and protected-path floor.
3918 /// Anything short of `Allow` inlines the refusal instead of the file, so
3919 /// `@.env` under a preset whose protected paths cover it says so rather
3920 /// than quietly leaking it.
3921 ///
3922 /// A token that names nothing readable is left exactly as the user typed
3923 /// it: an email address, a decorator, or a `@`-prefixed word in prose is
3924 /// not a file mention, and must survive untouched.
3925 /// Off by default (`[core.file_mentions]`).
3926 fn expand_file_mentions(&self, input: &str) -> String {
3927 if !self.config.file_mentions || !input.contains('@') {
3928 return input.to_string();
3929 }
3930 let mut attachments = String::new();
3931 let mut seen: Vec<String> = Vec::new();
3932 for token in input.split_whitespace() {
3933 let Some(rel) = token.strip_prefix('@') else {
3934 continue;
3935 };
3936 let rel = rel.trim_end_matches([',', ';', ':', '.', ')', ']', '"', '\'']);
3937 if rel.is_empty() || seen.iter().any(|s| s == rel) {
3938 continue;
3939 }
3940 let path = if std::path::Path::new(rel).is_absolute() {
3941 std::path::PathBuf::from(rel)
3942 } else {
3943 self.config.cwd.join(rel)
3944 };
3945 if !path.is_file() {
3946 continue;
3947 }
3948 seen.push(rel.to_string());
3949 attachments.push_str(&self.render_mention(rel, &path));
3950 if seen.len() >= MAX_FILE_MENTIONS_PER_MESSAGE {
3951 break;
3952 }
3953 }
3954 if attachments.is_empty() {
3955 return input.to_string();
3956 }
3957 format!("{input}{attachments}")
3958 }
3959
3960 /// One mention's block: the permission verdict first, then the bytes.
3961 /// Text is inlined; a binary file is named with its size, the same
3962 /// shape [`Self::send_with_files`] already uses for an explicit
3963 /// attachment, so a mention and a `--file` read the same way.
3964 fn render_mention(&self, shown: &str, path: &std::path::Path) -> String {
3965 use crate::permissions::{Decision, PathKind};
3966 let rules = crate::permissions::rules_for_config(&self.config);
3967 // BP-10's multi-root form: a mention is checked against every
3968 // granted root (cwd + `additional_dirs`), folded to the strictest —
3969 // the same call the tool-dispatch gate makes for a `read_file`
3970 // path, so a mention can never reach a file a read could not.
3971 let mut roots = vec![self.config.cwd.clone()];
3972 roots.extend(self.config.additional_dirs.iter().cloned());
3973 let decision = crate::permissions::evaluate_path_safe_roots(
3974 &rules,
3975 PathKind::Read,
3976 &roots,
3977 &path.to_string_lossy(),
3978 Decision::Allow,
3979 );
3980 if decision != Decision::Allow {
3981 return format!(
3982 "\n\n[file: {shown} — not attached; the permission rules for this session \
3983 resolve reading it to {decision:?}]"
3984 );
3985 }
3986 match std::fs::read(path) {
3987 Ok(bytes) => match String::from_utf8(bytes) {
3988 Ok(text) => {
3989 let mut text = text;
3990 if text.len() > MAX_FILE_MENTION_BYTES {
3991 let mut cut = MAX_FILE_MENTION_BYTES;
3992 while cut > 0 && !text.is_char_boundary(cut) {
3993 cut -= 1;
3994 }
3995 text.truncate(cut);
3996 text.push_str("\n[file truncated]");
3997 }
3998 format!("\n\n[file: {shown}]\n{text}")
3999 }
4000 Err(e) => format!(
4001 "\n\n[file: {shown} — {} bytes, binary content omitted]",
4002 e.into_bytes().len()
4003 ),
4004 },
4005 Err(e) => format!("\n\n[file: {shown} — could not read: {e}]"),
4006 }
4007 }
4008
4009 /// The SKILL.md packages this agent discovered (frontmatter only) — the
4010 /// exact set its prompt index lists and its `skill` tool can load.
4011 pub fn skills(&self) -> &[crate::skills::LoopSkill] {
4012 &self.skills
4013 }
4014
4015 /// BP-6: resolve a slash command against the discovered skills.
4016 ///
4017 /// `/skill:<name>` is pi's own form and is accepted under every config
4018 /// (it can never collide with a template name, which cannot contain a
4019 /// colon-prefixed `skill` segment by construction). The BARE `/<name>`
4020 /// form is Claude Code's — there, a skill IS a slash command — so it is
4021 /// honored only when the config reads Claude Code's roots; under
4022 /// `cx-parity`, where Codex has no skill slash commands, `/deploy` stays
4023 /// the literal text the user typed.
4024 fn expand_skill_command(&self, name: &str, args: &str) -> Option<String> {
4025 if self.skills.is_empty() {
4026 return None;
4027 }
4028 let bare = match name.strip_prefix("skill:") {
4029 Some(rest) => rest,
4030 None if self.config.skills_harness.as_deref()
4031 == Some(crate::HarnessId::CLAUDE_CODE) =>
4032 {
4033 name
4034 }
4035 None => return None,
4036 };
4037 let skill = self.find_skill(bare)?;
4038 skill
4039 .body_with_shell(args, &self.shell_injection)
4040 .ok()
4041 .map(|body| crate::skills::render_skill(skill, &body))
4042 }
4043
4044 /// BP-6: `$slug` mentions (cx§7 `TOOL_MENTION_SIGIL = '$'`) and — only
4045 /// under `[core.skills] implicit_match` — a description match.
4046 ///
4047 /// The user's own text is never replaced: a loaded body is APPENDED, the
4048 /// way Codex splices a skill into the turn. Mentions are only honored
4049 /// for a config that reads Codex's roots; `$WORD` is ordinary shell text
4050 /// everywhere else.
4051 fn expand_skill_mentions(&self, input: &str) -> String {
4052 if self.skills.is_empty() {
4053 return input.to_string();
4054 }
4055 let mut loaded: Vec<String> = Vec::new();
4056 let mut names: Vec<String> = Vec::new();
4057 if self.config.skills_harness.as_deref() == Some(crate::HarnessId::CODEX) {
4058 for token in input.split_whitespace() {
4059 let Some(slug) = token.strip_prefix('$') else {
4060 continue;
4061 };
4062 let slug =
4063 slug.trim_matches(|c: char| !c.is_alphanumeric() && c != '-' && c != ':');
4064 if slug.is_empty() {
4065 continue;
4066 }
4067 let Some(skill) = self.find_skill(slug) else {
4068 continue;
4069 };
4070 if names.contains(&skill.name) || loaded.len() >= MAX_SKILL_LOADS_PER_MESSAGE {
4071 continue;
4072 }
4073 if let Ok(body) = skill.body_with_shell("", &self.shell_injection) {
4074 names.push(skill.name.clone());
4075 loaded.push(crate::skills::render_skill(skill, &body));
4076 }
4077 }
4078 }
4079 if loaded.is_empty() && self.config.skills_implicit_match {
4080 if let Some(skill) = crate::skills::implicit_skill_match(&self.skills, input) {
4081 if let Ok(body) = skill.body_with_shell("", &self.shell_injection) {
4082 loaded.push(crate::skills::render_skill(skill, &body));
4083 }
4084 }
4085 }
4086 if loaded.is_empty() {
4087 return input.to_string();
4088 }
4089 format!("{input}\n\n{}", loaded.join("\n\n"))
4090 }
4091
4092 /// Resolve one invocation name against the discovered set — the same
4093 /// resolver the `skill` tool uses, so every door agrees on what a name
4094 /// means.
4095 fn find_skill(&self, name: &str) -> Option<&crate::skills::LoopSkill> {
4096 crate::skills::find_skill(&self.skills, name)
4097 }
4098
4099 /// P5-2 (§2 module 15 D7 row 4 "prompts-as-commands"): like
4100 /// [`Self::expand_prompt`], but also consults MCP-server-sourced
4101 /// prompts registered via [`Self::register_mcp_prompt`] when the local
4102 /// `Config::prompts` table has no match — a live `prompts/get`
4103 /// round-trip, which is why this is async and [`Self::expand_prompt`]
4104 /// itself stays synchronous (its public sync signature is unchanged,
4105 /// for every existing caller that doesn't need MCP prompts).
4106 ///
4107 /// **Argument mapping (a scope decision, not a protocol requirement —
4108 /// the MCP spec leaves "how does free CLI text become named prompt
4109 /// arguments" to the client):** a prompt with zero or one declared
4110 /// arguments gets the whole trailing text (empty string if the prompt
4111 /// takes no arguments and none was given); a prompt with two or more
4112 /// declared arguments expects `key=value` pairs, whitespace-separated
4113 /// (`/mcp__server__prompt lang=rust topic=async`) — an unparseable pair
4114 /// (no `=`) is simply skipped, never a hard error (matches this
4115 /// method's "non-matching input passes through" fail-open posture for
4116 /// the LOCAL-prompt case above).
4117 pub async fn expand_prompt_async(&self, input: &str) -> String {
4118 let local = self.expand_prompt(input);
4119 if local != input {
4120 return local; // a local `Config::prompts` template matched
4121 }
4122 let trimmed = input.trim_start();
4123 let Some(rest) = trimmed.strip_prefix('/') else {
4124 return input.to_string();
4125 };
4126 let (name, args) = match rest.split_once(char::is_whitespace) {
4127 Some((n, a)) => (n, a.trim()),
4128 None => (rest, ""),
4129 };
4130 let Some(source) = self.mcp_prompts.get(name) else {
4131 return input.to_string();
4132 };
4133 let arg_map = match source.arg_names() {
4134 [] => std::collections::BTreeMap::new(),
4135 [single] => {
4136 let mut m = std::collections::BTreeMap::new();
4137 if !args.is_empty() {
4138 m.insert(single.clone(), args.to_string());
4139 }
4140 m
4141 }
4142 _ => args
4143 .split_whitespace()
4144 .filter_map(|pair| pair.split_once('='))
4145 .map(|(k, v)| (k.to_string(), v.to_string()))
4146 .collect(),
4147 };
4148 match source.render(arg_map).await {
4149 Ok(rendered) => rendered,
4150 Err(e) => format!("Error: mcp prompt `{name}` failed: {e}"),
4151 }
4152 }
4153
4154 /// P5-2 (§2 module 15 D7 row 4): register an MCP server's prompt as a
4155 /// slash-command source — `command_name` MUST already be the
4156 /// namespaced `mcp__<server>__<prompt>` form
4157 /// ([`crate::mcp::McpServerHandle::prompts`] produces exactly that
4158 /// shape); this method does not re-namespace or validate it, so a
4159 /// caller that hands it a bare name defeats the collision protection
4160 /// [`crate::mcp::McpPromptSource`]'s doc comment describes. Overwrites
4161 /// any prior registration under the same command name (re-attaching
4162 /// the same server replaces its own earlier prompt list; this can
4163 /// never touch a NON-`mcp__`-prefixed key, i.e. never a local
4164 /// `Config::prompts` entry).
4165 pub fn register_mcp_prompt(
4166 &mut self,
4167 command_name: impl Into<String>,
4168 source: impl crate::sdk::SdkPromptSource + 'static,
4169 ) {
4170 self.mcp_prompts
4171 .insert(command_name.into(), Box::new(source));
4172 }
4173
4174 /// P5-2 (§2 module 15 D7 row 5 "instructions"): fold an MCP server's
4175 /// `initialize`-time instructions (or any other free-text note) into
4176 /// this agent's system message — the context-assembly site every other
4177 /// `core.*`/`capabilities.*` prompt-section append already uses
4178 /// (`Self::with_parts`), except this one fires AFTER construction
4179 /// (attaching MCP servers happens once the agent already exists — see
4180 /// `crates/cli/src/main.rs`'s `attach_mcp`). A no-op if `history` is
4181 /// somehow empty or its first message isn't a system message (never
4182 /// true for an `Agent` built via `Self::new`/`Self::with_parts`, but
4183 /// checked rather than assumed).
4184 pub fn append_system_note(&mut self, text: &str) {
4185 if let Some(system) = self.history.first_mut() {
4186 if system.role == Role::System {
4187 system
4188 .content
4189 .get_or_insert_with(String::new)
4190 .push_str(text);
4191 }
4192 }
4193 }
4194
4195 /// BP-4 (catalog:90, cx§2 `<environment_context>` "re-emitted on
4196 /// change"): re-derive the `# Environment` block and, if anything in it
4197 /// moved — cwd, the approval/sandbox policy, the git branch or its
4198 /// dirty state, the date — replace the stale copy in the system message
4199 /// with the fresh one. Returns whether the block changed.
4200 ///
4201 /// A no-op (and free — no git subprocess) when `core.env_context` is
4202 /// off, which is the default and every non-parity config. Replacing in
4203 /// place rather than appending a second block is deliberate: two
4204 /// `# Environment` sections disagreeing about cwd is worse context than
4205 /// one stale one, and the system message is re-sent on every request,
4206 /// so the rewrite IS the re-emission the model sees.
4207 pub fn refresh_env_context(&mut self) -> bool {
4208 if !self.config.env_context {
4209 return false;
4210 }
4211 let fresh = env_context_block(&self.config);
4212 let Some(stale) = self.env_context_live.clone() else {
4213 // Nothing was spliced at construction (e.g. `with_provider_arc`);
4214 // splice it now rather than silently never emitting one.
4215 self.append_system_note(&fresh);
4216 self.env_context_live = Some(fresh);
4217 return true;
4218 };
4219 if stale == fresh {
4220 return false;
4221 }
4222 if let Some(system) = self.history.first_mut() {
4223 if system.role == Role::System {
4224 if let Some(content) = system.content.as_mut() {
4225 if let Some(at) = content.find(&stale) {
4226 content.replace_range(at..at + stale.len(), &fresh);
4227 self.env_context_live = Some(fresh);
4228 return true;
4229 }
4230 }
4231 }
4232 }
4233 false
4234 }
4235
4236 /// BP-5 (catalog D2 "Per-model-family base-prompt selection"): re-select
4237 /// the base prompt for the model now in force and replace the stale one
4238 /// in place. Returns whether the system message changed.
4239 ///
4240 /// A no-op — not even a string search — when the selection is unchanged,
4241 /// which is every config that sets no `base_prompts` table.
4242 fn refresh_base_prompt(&mut self) -> bool {
4243 let fresh = base_prompt_for_config(&self.config);
4244 if fresh == self.base_prompt_live {
4245 return false;
4246 }
4247 let stale = std::mem::replace(&mut self.base_prompt_live, fresh.clone());
4248 if stale.is_empty() {
4249 return false;
4250 }
4251 if let Some(system) = self.history.first_mut() {
4252 if system.role == Role::System {
4253 if let Some(content) = system.content.as_mut() {
4254 if let Some(at) = content.find(&stale) {
4255 content.replace_range(at..at + stale.len(), &fresh);
4256 return true;
4257 }
4258 }
4259 }
4260 }
4261 false
4262 }
4263
4264 /// BP-5: assemble the request from an already-built message list and
4265 /// tool-schema list. The ONE place a [`ChatRequest`] is constructed from
4266 /// this agent's config, so the request `Self::run_loop` issues and the
4267 /// request [`Self::model_input`] renders cannot drift apart.
4268 fn chat_request(&self, messages: Vec<ChatMessage>, tools: Vec<ToolSchema>) -> ChatRequest {
4269 let mut req = ChatRequest {
4270 model: self.config.model.clone(),
4271 messages,
4272 tools,
4273 temperature: self.config.temperature,
4274 max_tokens: self.config.max_tokens,
4275 effort: self.config.effort.clone(),
4276 response_format: self.config.response_format.clone(),
4277 service_tier: None,
4278 thinking_budget: None,
4279 extra_body: self.config.extra_body.clone(),
4280 };
4281 // BP-13 (catalog Domain 9): the per-request routing decisions —
4282 // effort LEVEL, thinking-token BUDGET and service TIER — all come
4283 // out of `Config::model_routing` keyed by the model this request is
4284 // actually going to. Applied HERE so `model_input`'s rendering and
4285 // the loop's own send can never disagree about what would be sent,
4286 // and so a mid-session switch re-decides all three for the new
4287 // model on the next pass.
4288 self.apply_routing(&mut req);
4289 req
4290 }
4291
4292 /// BP-5 (catalog D2 "Prompt-input debugging": *render the exact
4293 /// model-visible input for inspection*; cx§2 `codex debug prompt-input`,
4294 /// which "renders the exact model-visible input list as JSON"): the
4295 /// request this agent would send next.
4296 ///
4297 /// Built by the SAME two calls the loop makes
4298 /// ([`Self::build_request_messages`], [`Self::tool_schemas`]) and
4299 /// assembled by the SAME [`Self::chat_request`] — it is the real
4300 /// request, not a reconstruction of one. `&mut self` because
4301 /// `build_request_messages` is: rendering the input is exactly as
4302 /// stateful as building it for a send.
4303 pub fn model_input(&mut self) -> ChatRequest {
4304 let tools = self.tool_schemas();
4305 let messages = self.build_request_messages();
4306 self.chat_request(messages, tools)
4307 }
4308
4309 /// BP-5: [`Self::model_input`] for a turn that has not been sent —
4310 /// `prompt` is expanded exactly as [`Self::send`] would expand it
4311 /// (slash templates, skills, `@path` mentions, MCP prompts) and appended
4312 /// to the conversation IN MEMORY, then the request is rendered.
4313 ///
4314 /// Deliberately not recorded: this door inspects an input, it does not
4315 /// take a turn. Nothing is written to the session store, no journal
4316 /// entry is made, and no request is issued.
4317 pub async fn model_input_for(&mut self, prompt: &str) -> ChatRequest {
4318 let expanded = self.expand_prompt_async(prompt).await;
4319 self.history.push(ChatMessage::user(expanded));
4320 self.model_input()
4321 }
4322
4323 /// BP-5: a [`ChatRequest`] as the JSON a human (or `jq`) inspects — the
4324 /// system prompt, every message in order, and every advertised tool
4325 /// schema, plus the sampling controls that travel with them.
4326 pub fn render_model_input(req: &ChatRequest) -> serde_json::Value {
4327 serde_json::json!({
4328 "model": req.model,
4329 "temperature": req.temperature,
4330 "max_tokens": req.max_tokens,
4331 "effort": req.effort,
4332 "response_format": req.response_format,
4333 // Serialized through `ChatMessage`'s OWN wire serializer and
4334 // `ToolSchema`'s own — i.e. the exact bytes the provider is
4335 // handed, not a second rendering of them.
4336 "messages": serde_json::to_value(&req.messages).unwrap_or(serde_json::Value::Null),
4337 "tools": serde_json::to_value(&req.tools).unwrap_or(serde_json::Value::Null),
4338 })
4339 }
4340
4341 /// BP-5 (catalog D2 "Per-model-family base-prompt selection"): the base
4342 /// system prompt currently in force for this agent's model.
4343 pub fn base_prompt(&self) -> &str {
4344 &self.base_prompt_live
4345 }
4346
4347 /// BP-4 (catalog:91 "Synthetic context-injection blocks"): splice one
4348 /// named ambient block into the live context — the seam a hook's
4349 /// `additionalContext`, a frontend nudge or an orchestrator's brief
4350 /// enters through, mid-session, after construction.
4351 ///
4352 /// Requires `core.context_injections` (returns `false` otherwise): the
4353 /// gate governs the whole registry, not just its startup half. The
4354 /// block is appended to the system message and remembered, so it is
4355 /// carried by every later request and re-rendered by
4356 /// [`crate::context_injection::assemble`] wherever the prompt is
4357 /// rebuilt.
4358 pub fn inject_context_block(
4359 &mut self,
4360 name: impl Into<String>,
4361 content: impl Into<String>,
4362 ) -> bool {
4363 if !self.config.context_injections {
4364 return false;
4365 }
4366 let block = crate::config::ContextInjectionBlock::new(name, content);
4367 let rendered = crate::context_injection::render(std::slice::from_ref(&block));
4368 self.spliced_context_blocks.push(block);
4369 self.append_system_note(&rendered);
4370 true
4371 }
4372
4373 /// The blocks spliced in since construction — see
4374 /// [`Self::inject_context_block`].
4375 pub fn spliced_context_blocks(&self) -> &[crate::config::ContextInjectionBlock] {
4376 &self.spliced_context_blocks
4377 }
4378
4379 /// Compact the conversation if it has grown past the configured
4380 /// threshold.
4381 ///
4382 /// **Re-founded (A10):** with a [`ReductionPolicy`] installed
4383 /// ([`Self::set_reduction_policy`]), this no longer touches `self.history`
4384 /// at all. It derives `policy.clear_turns_older_than` from
4385 /// `compact_after_messages` so the *next* projected request view
4386 /// (`reduce::project_messages`, built in `Self::run_loop`) collapses the
4387 /// old turns into one reversible `TurnsCleared` stub instead —
4388 /// `history()` and the sidecar keep every message forever; only the view
4389 /// shrinks. Returns whether the live (unreduced) history currently
4390 /// exceeds the threshold, i.e. whether a clearing will actually be
4391 /// visible in the next projected view.
4392 ///
4393 /// **Legacy path (no policy) — LOSSY, kept only for byte-identical
4394 /// backward compatibility (D6):** destructively rewrites `self.history`,
4395 /// permanently discarding the dropped middle turns (replaced by a single
4396 /// non-reversible summary marker that becomes their SOLE remaining copy —
4397 /// exactly the lossy compaction this reduction layer differentiates
4398 /// against). Once a sidecar/recorder or a [`ReductionPolicy`] is in play,
4399 /// prefer installing a policy so this method takes the re-founded path
4400 /// above instead.
4401 pub fn maybe_compact(&mut self) -> bool {
4402 // P4e (§1.5/§3.1 `core.compaction.enabled`, "no master gate exists
4403 // yet"): checked FIRST, before either trigger — `false` disables
4404 // every auto-compaction trigger unconditionally (message-count AND
4405 // pressure), composing with them rather than replacing their own
4406 // logic. `true` (the default, matching today's pre-P4e behavior,
4407 // where nothing ever gated compaction) falls straight through to
4408 // the existing trigger checks below, unchanged.
4409 if !self.config.compaction_enabled {
4410 return false;
4411 }
4412 let threshold = self.config.compact_after_messages;
4413 // P4b (§1.5/§3.1 `core.compaction.reserve_tokens`, pi§2 shape): a
4414 // SECOND, independent trigger — context-window pressure — alongside
4415 // (not instead of) the message-count one above. `None` (the
4416 // default) is byte-identical to today's message-count-only
4417 // behavior; this whole block is a no-op then.
4418 let message_trigger = threshold.is_some_and(|t| self.history.len() > t);
4419 let pressure_trigger = self.compaction_pressure_triggered();
4420 if threshold.is_none() && self.config.compaction_reserve_tokens.is_none() {
4421 return false;
4422 }
4423 if !message_trigger && !pressure_trigger {
4424 return false;
4425 }
4426 if let Some(policy) = self.reduction_policy.as_mut() {
4427 if let Some(t) = threshold {
4428 policy.clear_turns_older_than = Some(t);
4429 }
4430 // P4b scope note: the token-PRESSURE trigger's "how much to
4431 // clear" derivation (below, for the legacy in-place path) has no
4432 // `ReductionPolicy`/A10 analog yet — that mechanism decides its
4433 // own clearing window once `clear_turns_older_than` is set, so
4434 // pressure firing alone (no message threshold configured) has
4435 // nothing new to hand it in this pass. Report the message-count
4436 // verdict only, matching today's pre-P4b behavior exactly when
4437 // only `threshold` is set.
4438 return message_trigger;
4439 }
4440 // Legacy in-place compaction (no `ReductionPolicy` installed) below.
4441 // `keep_recent`: the message-count trigger's own `threshold / 2`
4442 // shape when it's what fired (or both fired); otherwise (pressure
4443 // fired alone) a token-budget-derived count.
4444 let keep_recent = if message_trigger {
4445 (threshold.unwrap() / 2).max(2)
4446 } else {
4447 self.keep_recent_count_by_tokens()
4448 };
4449 self.compact_in_place(keep_recent, None)
4450 }
4451
4452 /// BP-4 (catalog:98 "Manual compact with focus instructions", cc§2 /
4453 /// cx§2 `/compact [instructions]`): compact NOW, regardless of whether
4454 /// either automatic trigger has fired — the mechanism behind the REPL's
4455 /// `/compact [focus]`.
4456 ///
4457 /// `focus` is this invocation's steering text: it overrides the standing
4458 /// `core.compaction.focus_instructions` for this compaction only, is
4459 /// carried into the SUMMARIZER's input (so the model-written summary
4460 /// preserves what the user asked for), and is stated on the marker. An
4461 /// empty/whitespace `focus` falls back to the configured standing value,
4462 /// which is what a bare `/compact` means.
4463 ///
4464 /// Returns whether anything was compacted (`false` when the history is
4465 /// already at or below the keep-window, or when a [`ReductionPolicy`] is
4466 /// installed — under a policy the reversible A10 path owns clearing, and
4467 /// a manual compact would be the lossy one).
4468 pub fn compact_now(&mut self, focus: Option<&str>) -> bool {
4469 self.compacting_manually = true;
4470 let compacted = self.compact_now_inner(focus);
4471 self.compacting_manually = false;
4472 compacted
4473 }
4474
4475 fn compact_now_inner(&mut self, focus: Option<&str>) -> bool {
4476 if self.reduction_policy.is_some() {
4477 return false;
4478 }
4479 // A manual compact must actually compact. The token budget alone
4480 // (`core.compaction.keep_recent_tokens`, 20k) keeps EVERYTHING on
4481 // any ordinary conversation, which is right for the pressure
4482 // trigger (it fires only when the window is nearly full) and wrong
4483 // for `/compact`, whose whole point is compacting before the
4484 // pressure arrives. So the keep-window is the tighter of the two:
4485 // the token budget, and the message-count trigger's own established
4486 // "keep the most recent half" shape (`maybe_compact`'s
4487 // `threshold / 2`, floor 2).
4488 let keep_recent = self
4489 .keep_recent_count_by_tokens()
4490 .min((self.history.len() / 2).max(2));
4491 let focus = focus.map(str::trim).filter(|f| !f.is_empty());
4492 self.compact_in_place(keep_recent, focus)
4493 }
4494
4495 /// The legacy (no-[`ReductionPolicy`]) in-place compaction both
4496 /// [`Self::maybe_compact`] and [`Self::compact_now`] run: collapse
4497 /// `history[first..cut)` into one marker, keeping the newest
4498 /// `keep_recent` messages.
4499 fn compact_in_place(&mut self, keep_recent: usize, focus_override: Option<&str>) -> bool {
4500 if self.history.len() <= keep_recent {
4501 return false;
4502 }
4503 // Indices: 0 is the system prompt; collapse [first .. len-keep_recent).
4504 // `first` is 1 (only the system prompt is ever auto-preserved) unless
4505 // B7's coordination clamp widens it.
4506 let mut first = 1usize;
4507 // B7 coordination clamp: this legacy (no-`ReductionPolicy`) path
4508 // mutates `self.history` directly, so — unlike the re-founded A10
4509 // path (clamped inside `reduce::project_messages`, threaded from
4510 // `build_request_messages`) — it must clamp itself. Widening `first`
4511 // (not `cut`) is what actually protects the imported prefix: the
4512 // drop range is `[first, cut)`, so raising `cut` alone would only
4513 // drop MORE messages, not fewer. `imported_prefix_len` is already an
4514 // absolute `history` index count (it protects `history[0..len]`), so
4515 // no offset conversion is needed here.
4516 if matches!(self.config.cache_plan, CachePlan::ImportedPrefix) {
4517 if let Some(protected) = self.imported_prefix_len {
4518 first = first.max(protected);
4519 }
4520 }
4521 let mut cut = self.history.len() - keep_recent;
4522 if cut <= first {
4523 return false;
4524 }
4525 // Never begin the kept window on a tool result: its originating
4526 // assistant turn (with the matching `tool_calls`) is about to be
4527 // dropped, which would orphan the tool message and make the replayed
4528 // conversation invalid. Advance past any leading tool results.
4529 while cut < self.history.len() && self.history[cut].role == Role::Tool {
4530 cut += 1;
4531 }
4532 if cut >= self.history.len() {
4533 return false;
4534 }
4535 let dropped = cut - first;
4536 // BP-11: the compaction is decided from here on — the one point both
4537 // the automatic triggers and `/compact` pass through — so this is
4538 // where `pre_compact` observers hear about it.
4539 self.fire_lifecycle(&crate::config::LifecycleEvent::PreCompact {
4540 messages: self.history.len(),
4541 dropped,
4542 manual: focus_override.is_some() || self.compacting_manually,
4543 });
4544 // P4b (§1.5/§3.1 `core.compaction.focus_instructions`, catalog D2
4545 // "no instruction steering" gap): appended to the marker whenever
4546 // set, regardless of which trigger fired. `None` (the default)
4547 // leaves this byte-identical to the pre-P4b marker text.
4548 //
4549 // BP-4: `focus_override` — the per-invocation `/compact <focus>`
4550 // text — wins over the standing config value for THIS compaction,
4551 // which is what "`/compact [instructions]` steers what's preserved"
4552 // means. Neither is required.
4553 let focus: Option<String> = focus_override.map(str::to_string).or_else(|| {
4554 self.config
4555 .compaction_focus_instructions
4556 .clone()
4557 .filter(|f| !f.is_empty())
4558 });
4559 // BP-1 (§1.5/§3.1 `core.compaction.summarize`): the verb the marker
4560 // uses is now the config's to state. `true` (the default, and what
4561 // every preset sets) keeps the historical "summarized" text
4562 // byte-identical; `false` says only what actually happened to the
4563 // span, so a config that turns summarization off does not leave a
4564 // marker claiming a summary exists.
4565 let verb = if self.config.compaction_summarize {
4566 "summarized"
4567 } else {
4568 "cleared"
4569 };
4570 // BP-4 (catalog:107 "LLM summaries of cleared spans", design §1.5:
4571 // obligation 5 is "auto-compaction … + a persisted marker + AN LLM
4572 // SUMMARY OF THE COMPACTED SPAN", knob `[core.compaction] summarize`
4573 // — "the summary side-call depends on a utility model … core falls
4574 // back to the main model"). The side-call is therefore CORE, not a
4575 // reduction-module privilege: when `core.compaction.summarize` is on
4576 // and a summarizer is installed, the span is summarized by the model
4577 // and the marker carries that summary instead of only a count.
4578 //
4579 // Every failure mode degrades to the count-only marker: no
4580 // summarizer installed, an `Err` from the side-call, or an empty
4581 // reply. It never blocks or fails compaction — the same contract
4582 // TR-7's own side-call site keeps.
4583 let summary_body = if self.config.compaction_summarize {
4584 self.summarize_span(first..cut, focus.as_deref())
4585 } else {
4586 None
4587 };
4588 // BP-4 (catalog:99 "Compaction markers persisted in transcript"):
4589 // the marker states where the originals went, which is the whole
4590 // point of a boundary record — a reader must be able to tell a
4591 // reversible compaction from a lossy one without knowing which
4592 // modules were on.
4593 let retention = if self.recorder.is_some() {
4594 "The compacted messages remain in this session's transcript sidecar."
4595 } else {
4596 "No transcript sidecar is attached, so this marker is the only remaining record of them."
4597 };
4598 let mut summary_text = format!(
4599 "[earlier conversation compacted: {dropped} message(s) {verb} to save context]\n{retention}"
4600 );
4601 if let Some(focus) = &focus {
4602 summary_text.push_str(&format!("\n\nFocus: {focus}"));
4603 }
4604 if let Some(body) = &summary_body {
4605 summary_text.push_str(&format!("\n\nSummary of the compacted span:\n{body}"));
4606 }
4607 let summary = ChatMessage::system(summary_text);
4608 // BP-4 (catalog:99): PERSIST the boundary. Before this the legacy
4609 // path rewrote `self.history` and never called `record`, so the
4610 // marker existed only in the live window and a resumed session had
4611 // no on-disk trace that a compaction ever happened. A recorder
4612 // failure is logged, never fatal — losing the boundary record must
4613 // not lose the compaction.
4614 if let Err(error) = self.record(&summary) {
4615 tracing::warn!("failed to persist the compaction marker: {error}");
4616 }
4617 let mut new_history = Vec::with_capacity(first + keep_recent + 2);
4618 new_history.extend(self.history[..first].iter().cloned());
4619 new_history.push(summary);
4620 new_history.extend(self.history.split_off(cut));
4621 self.history = new_history;
4622 self.fire_lifecycle(&crate::config::LifecycleEvent::PostCompact {
4623 messages: self.history.len(),
4624 dropped,
4625 });
4626 // BP-8 (catalog:150): compaction RESHAPES the live view rather than
4627 // appending to it, so the journal's "everything since the last
4628 // checkpoint is unpersisted" accounting has to be re-based here —
4629 // otherwise a crash-recovery replay would re-append messages this
4630 // compaction deliberately set aside. The set-aside messages' own
4631 // bytes stay in the log above, untouched.
4632 self.journal_checkpoint(self.history.len());
4633 true
4634 }
4635
4636 /// BP-4 (catalog:107): run the installed [`reduce::summarize::SpanSummarizer`]
4637 /// over `history[span]`, with `focus` (the `/compact <focus>` text)
4638 /// carried into the summarizer's INPUT so the model-written summary
4639 /// preserves what the user asked to keep.
4640 ///
4641 /// `None` — never an error — whenever no summarizer is installed, the
4642 /// span renders empty, the side-call fails, or it returns nothing. The
4643 /// caller falls back to the count-only marker.
4644 fn summarize_span(&self, span: std::ops::Range<usize>, focus: Option<&str>) -> Option<String> {
4645 let summarizer = self.span_summarizer.as_deref()?;
4646 let mut span_text = String::new();
4647 // The focus rides at the head of the span text (the trait's one
4648 // input) as an explicit, labeled line rather than a silent prompt
4649 // mutation: the fixed prompt's "do not state anything not present
4650 // in the span" still holds, because the focus IS present in it.
4651 if let Some(focus) = focus {
4652 span_text.push_str(&format!("[compaction focus requested: {focus}]\n\n"));
4653 }
4654 for msg in self.history.get(span)? {
4655 let role = match msg.role {
4656 Role::System => "system",
4657 Role::User => "user",
4658 Role::Assistant => "assistant",
4659 Role::Tool => "tool",
4660 };
4661 span_text.push_str(role);
4662 span_text.push_str(": ");
4663 span_text.push_str(msg.content.as_deref().unwrap_or(""));
4664 span_text.push('\n');
4665 }
4666 match summarizer.summarize(&span_text) {
4667 Ok(text) if !text.trim().is_empty() => Some(text.trim().to_string()),
4668 Ok(_) => None,
4669 Err(error) => {
4670 tracing::warn!("compaction span summarizer failed: {error}");
4671 None
4672 }
4673 }
4674 }
4675
4676 /// BP-4 (catalog:106 "Handoff (fresh objective + curated keep-set)",
4677 /// cx§1 `new_context`): reset the live working view to a fresh
4678 /// objective plus a curated keep-set, in-session.
4679 ///
4680 /// The new view is: the system prompt (plus any imported prefix a
4681 /// `CachePlan::ImportedPrefix` config protects — same clamp compaction
4682 /// uses), then a handoff marker stating the objective and what was set
4683 /// aside, then the most recent `keep_recent` messages (`None` = the
4684 /// token-budget-derived count `core.compaction.keep_recent_tokens`
4685 /// already governs, so the keep-set is curated by the same budget the
4686 /// rest of the compaction machinery uses, not by a magic number). The
4687 /// keep-set never begins on a tool result, so no tool message is left
4688 /// orphaned from its originating assistant turn.
4689 ///
4690 /// Returns how many messages were set aside. Like compaction, the
4691 /// marker is PERSISTED through the recorder, so a resumed session can
4692 /// see where the handoff happened; and like compaction, the set-aside
4693 /// messages remain in the transcript sidecar whenever one is attached.
4694 ///
4695 /// Scope note: this is the in-session `new_context` mechanism, NOT
4696 /// `Config::handoff_enabled`'s reversible ReductionLog snapshot (the
4697 /// offline `supercode handoff` projection) — that one is the reduction
4698 /// module's, and stays there.
4699 pub fn new_context(&mut self, objective: &str, keep_recent: Option<usize>) -> usize {
4700 let keep_recent = keep_recent.unwrap_or_else(|| self.keep_recent_count_by_tokens());
4701 let mut first = 1usize;
4702 if matches!(self.config.cache_plan, CachePlan::ImportedPrefix) {
4703 if let Some(protected) = self.imported_prefix_len {
4704 first = first.max(protected);
4705 }
4706 }
4707 let mut cut = self.history.len().saturating_sub(keep_recent).max(first);
4708 while cut < self.history.len() && self.history[cut].role == Role::Tool {
4709 cut += 1;
4710 }
4711 let dropped = cut.saturating_sub(first);
4712 let objective = objective.trim();
4713 let retention = if self.recorder.is_some() {
4714 "They remain in this session's transcript sidecar."
4715 } else {
4716 "No transcript sidecar is attached, so they are not retained."
4717 };
4718 let marker = ChatMessage::system(format!(
4719 "[handoff: a fresh working context starts here]\nObjective: {objective}\n\
4720 {dropped} earlier message(s) were set aside; the most recent {kept} were kept. \
4721 {retention}",
4722 kept = self.history.len() - cut,
4723 ));
4724 if let Err(error) = self.record(&marker) {
4725 tracing::warn!("failed to persist the handoff marker: {error}");
4726 }
4727 let mut new_history = Vec::with_capacity(first + keep_recent + 2);
4728 new_history.extend(self.history[..first].iter().cloned());
4729 new_history.push(marker);
4730 new_history.extend(self.history.split_off(cut));
4731 self.history = new_history;
4732 // BP-8: same re-basing as `maybe_compact` — see its comment.
4733 self.journal_checkpoint(self.history.len());
4734 dropped
4735 }
4736
4737 /// P4b (§1.5/§3.1 `core.compaction.reserve_tokens`, pi§2 shape:
4738 /// `contextTokens > contextWindow - reserveTokens`): whether the
4739 /// estimated token size of the live history is within `reserve_tokens`
4740 /// of the model's context window. `false` when
4741 /// [`Config::compaction_reserve_tokens`] is unset (the default).
4742 fn compaction_pressure_triggered(&self) -> bool {
4743 let Some(reserve) = self.config.compaction_reserve_tokens else {
4744 return false;
4745 };
4746 let limit = provider::model_context_limit(&self.config.model)
4747 .unwrap_or(provider::UNKNOWN_MODEL_CONTEXT_FLOOR);
4748 let used = crate::tokens::estimate_view_tokens(&self.history);
4749 used.saturating_add(reserve) > limit
4750 }
4751
4752 /// P4b (§1.5/§3.1 `core.compaction.keep_recent_tokens`): how many of the
4753 /// most recent messages (walking backward from the end of `self.history`,
4754 /// skipping the system prompt) fit within the configured token budget
4755 /// (default 20,000, pi§6 precedent). Always keeps at least 2 messages,
4756 /// matching the message-count trigger's own floor.
4757 fn keep_recent_count_by_tokens(&self) -> usize {
4758 let budget = self.config.compaction_keep_recent_tokens.unwrap_or(20_000);
4759 let mut used = 0u64;
4760 let mut count = 0usize;
4761 for msg in self.history.iter().skip(1).rev() {
4762 let t = crate::tokens::estimate_view_tokens(std::slice::from_ref(msg));
4763 if used.saturating_add(t) > budget && count > 0 {
4764 break;
4765 }
4766 used = used.saturating_add(t);
4767 count += 1;
4768 }
4769 count.max(2)
4770 }
4771
4772 /// Register an additional tool (e.g. your own capability).
4773 ///
4774 /// P5-2 (§2.2 C2 "connect invalidates cache prefix"): registering a
4775 /// tool AFTER this agent has already issued a request
4776 /// ([`Self::request_issued`]) changes the tools schema every
4777 /// subsequent request carries — the exact prefix-churn shape C2
4778 /// describes, MCP-sourced or not. Resets [`Self::cache_established`] so
4779 /// the next cache-warmth check (`provider::cache_cold_reason`) doesn't
4780 /// wrongly assume the entry is still warm. A no-op call before the
4781 /// first request (the common case: `attach_mcp` registers tools once at
4782 /// startup, before any turn runs) changes nothing — byte-identical to
4783 /// today.
4784 pub fn register_tool(&mut self, tool: impl crate::tools::Tool + 'static) {
4785 self.registry.register(tool);
4786 if self.requests_issued {
4787 self.cache_established = false;
4788 }
4789 }
4790
4791 /// The current conversation, including the system prompt.
4792 pub fn history(&self) -> &[ChatMessage] {
4793 &self.history
4794 }
4795
4796 /// Send a user message and run the loop until the model produces a final
4797 /// answer (text with no tool calls) or the iteration budget is exhausted.
4798 pub async fn send(&mut self, user_input: impl Into<String>) -> Result<String> {
4799 let expanded = self.expand_prompt_async(&user_input.into()).await;
4800 let msg = ChatMessage::user(expanded);
4801 self.guard_candidate_message(&msg)?;
4802 self.record(&msg)?;
4803 self.history.push(msg);
4804 self.run_loop().await
4805 }
4806
4807 /// BP-4 (catalog:109 "Context-usage introspection", cc§2 `/context`
4808 /// grid, cx§8 `/status` + `get_context_remaining`): the LIVE
4809 /// context-window accounting for this session — the same numbers
4810 /// `resume --dry-run`'s preflight already computes
4811 /// (`tokens::estimate_request_tokens` / `tokens::context_guard`), read
4812 /// out mid-session instead of only before one.
4813 ///
4814 /// Pure: it projects the request view exactly as
4815 /// [`Self::guard_candidate_message`] does (reduction stubs included,
4816 /// cache annotation included) without mutating the reduction log, so
4817 /// asking "how full am I?" can never change what the next request
4818 /// carries.
4819 pub fn context_usage(&self) -> ContextUsage {
4820 let messages = self.projected_view(None);
4821 let tools = self.tool_schemas();
4822 let message_tokens = crate::tokens::estimate_view_tokens(&messages);
4823 let request_tokens = crate::tokens::estimate_request_tokens(&messages, &tools);
4824 let limit = self.context_limit.or_else(|| {
4825 crate::provider::model_context_limit(&self.config.model)
4826 .or(Some(crate::provider::UNKNOWN_MODEL_CONTEXT_FLOOR))
4827 });
4828 let projected_tokens = crate::tokens::with_guard_margin(request_tokens);
4829 let reserve = crate::tokens::CONTEXT_RESPONSE_RESERVE_TOKENS;
4830 let (fits, remaining_tokens, used_pct) = match limit {
4831 Some(limit) => (
4832 projected_tokens.saturating_add(reserve) <= limit,
4833 limit
4834 .saturating_sub(reserve)
4835 .saturating_sub(projected_tokens),
4836 if limit == 0 {
4837 0
4838 } else {
4839 (projected_tokens as f64 / limit as f64 * 100.0).round() as u32
4840 },
4841 ),
4842 None => (true, 0, 0),
4843 };
4844 ContextUsage {
4845 model: self.config.model.clone(),
4846 messages: messages.len(),
4847 message_tokens,
4848 tool_count: tools.len(),
4849 tool_schema_tokens: request_tokens.saturating_sub(message_tokens),
4850 request_tokens,
4851 projected_tokens,
4852 response_reserve_tokens: reserve,
4853 context_limit: limit,
4854 remaining_tokens,
4855 used_pct,
4856 fits,
4857 }
4858 }
4859
4860 /// The messages a request would carry right now — the read-only half of
4861 /// [`Self::guard_candidate_message`]/[`Self::build_request_messages`],
4862 /// with `candidate` optionally appended as a not-yet-committed turn.
4863 /// Never mutates `self`.
4864 fn projected_view(&self, candidate: Option<&ChatMessage>) -> Vec<ChatMessage> {
4865 let messages = match &self.reduction_policy {
4866 None => {
4867 let mut messages = self.history.clone();
4868 if let Some(candidate) = candidate {
4869 messages.push(candidate.clone());
4870 }
4871 messages
4872 }
4873 Some(policy) => {
4874 let has_system = self.history.first().is_some_and(|m| m.role == Role::System);
4875 let mut reducible = self.history[usize::from(has_system)..].to_vec();
4876 if let Some(candidate) = candidate {
4877 reducible.push(candidate.clone());
4878 }
4879 let mut prepared = policy.clone();
4880 reduce::prepare_read_freshness(&mut prepared, &reducible);
4881 let (view, _) =
4882 reduce::project_messages(&reducible, &prepared, &self.reduction_log);
4883 let mut messages = Vec::with_capacity(view.len() + usize::from(has_system));
4884 if has_system {
4885 messages.push(self.history[0].clone());
4886 }
4887 messages.extend(view);
4888 messages
4889 }
4890 };
4891 provider::apply_cache_plan(&messages, self.config.cache_plan, self.imported_prefix_len)
4892 }
4893
4894 /// Refuse an oversized new user turn before it mutates canonical history
4895 /// or an attached sidecar. The in-loop guard remains authoritative for
4896 /// every actual request; this preflight closes the first-request seam
4897 /// where `send*` used to record/push the message before that guard ran.
4898 fn guard_candidate_message(&self, msg: &ChatMessage) -> Result<()> {
4899 let Some(limit) = self.context_limit else {
4900 return Ok(());
4901 };
4902
4903 let messages = self.projected_view(Some(msg));
4904 let tools = self.tool_schemas();
4905 let (fits, projected_tokens) = crate::tokens::context_guard(&messages, &tools, limit);
4906 if !fits {
4907 return Err(Error::ContextLimitExceeded {
4908 projected_tokens,
4909 reserve_tokens: crate::tokens::CONTEXT_RESPONSE_RESERVE_TOKENS,
4910 context_limit: limit,
4911 model: self.config.model.clone(),
4912 });
4913 }
4914 Ok(())
4915 }
4916
4917 /// The messages a provider request should carry for the CURRENT turn
4918 /// (A5/A7/A8/A10): with no [`ReductionPolicy`] installed, exactly
4919 /// `self.history.clone()` — byte-identical to every version of this
4920 /// method before reduction landed. With a policy installed, `history[0]`
4921 /// (this agent's own system prompt, never a reduction target) followed by
4922 /// [`reduce::project_messages`]'s projected view of `history[1..]`, fed
4923 /// with `self.reduction_log` so already-applied reductions reproduce
4924 /// verbatim across turns (prefix stability, A5) — the updated log is
4925 /// stored back onto `self` so the NEXT call (this turn, next turn, or a
4926 /// later `send`) sees the same accumulating state. `self.history` itself
4927 /// is never read back into or mutated by this: it stays the full
4928 /// canonical view, in lockstep with the sidecar (A3).
4929 ///
4930 /// When `policy.elide_stale_reads` is set, this re-runs
4931 /// [`reduce::probe_read_freshness`] (the one place A8's disk I/O happens)
4932 /// against `history[1..]` before projecting, so every request sees
4933 /// up-to-date freshness verdicts — `project_messages` itself stays pure.
4934 ///
4935 /// Finally, B7's [`provider::apply_cache_plan`] runs over the assembled
4936 /// view (regardless of whether a [`ReductionPolicy`] is installed) — a
4937 /// pure, cloning annotation step, so this method's `&mut self` mutations
4938 /// above (`self.reduction_log`) are already committed before it runs and
4939 /// its own output is never written back onto `self.history` or the log:
4940 /// purity for B7's cache breakpoints holds independently of A5's.
4941 fn build_request_messages(&mut self) -> Vec<ChatMessage> {
4942 let messages = match self.reduction_policy.clone() {
4943 None => self.history.clone(),
4944 Some(mut policy) => {
4945 reduce::prepare_read_freshness(&mut policy, &self.history[1..]);
4946 // B7 coordination clamp: while `CachePlan::ImportedPrefix` is
4947 // active, A10 turn-clearing must never establish a range
4948 // that dips into the imported prefix (protects the cache
4949 // breakpoint the request build will place there below).
4950 // `imported_prefix_len` counts `history[0]` (this agent's own
4951 // system message) plus the imported messages, but
4952 // `project_messages` only ever sees `history[1..]` — hence
4953 // the `- 1`.
4954 if matches!(self.config.cache_plan, CachePlan::ImportedPrefix) {
4955 policy.protect_imported_prefix =
4956 self.imported_prefix_len.map(|n| n.saturating_sub(1));
4957 }
4958 // TR-7 (T20): the one side-call site, run BEFORE
4959 // `project_messages` (which stays pure/I-O-free) — mirrors
4960 // `elide_stale_reads`/`probe_read_freshness` immediately
4961 // above. Only ever does anything when both the policy gate
4962 // AND a summarizer are present; either being absent means
4963 // `cleared_turns_summary` stays `None` and `project_messages`
4964 // renders the deterministic stub, same as before TR-7
4965 // existed.
4966 if policy.summarize_cleared_turns {
4967 if let Some(summarizer) = self.span_summarizer.as_deref() {
4968 policy.cleared_turns_summary = reduce::prepare_cleared_turns_summary(
4969 &self.history[1..],
4970 &policy,
4971 &self.reduction_log,
4972 summarizer,
4973 );
4974 }
4975 }
4976 let (view, log) =
4977 reduce::project_messages(&self.history[1..], &policy, &self.reduction_log);
4978 self.reduction_log = log;
4979 let mut messages = Vec::with_capacity(view.len() + 1);
4980 messages.push(self.history[0].clone());
4981 messages.extend(view);
4982 messages
4983 }
4984 };
4985 // TR-8 (T5): a tool-schema tier change since the last request is a
4986 // cache-bust event under `CachePlan::ImportedPrefix` — the `tools`
4987 // array is part of the cache key alongside `messages`, so flag it by
4988 // skipping this one request's cache annotation rather than claiming
4989 // a prefix hit that won't actually land. Recorded unconditionally
4990 // (even under `CachePlan::Off`) so the signature stays current
4991 // regardless of which plan is active.
4992 let tier_sig = self.schema_tier_signature();
4993 let busted =
4994 provider::tier_change_is_cache_bust(self.last_tool_schema_tier_signature, tier_sig);
4995 self.last_tool_schema_tier_signature = Some(tier_sig);
4996 let effective_cache_plan = if busted {
4997 CachePlan::Off
4998 } else {
4999 self.config.cache_plan
5000 };
5001 // UX-26 (B7-warn): mirror `apply_cache_plan`'s own placement gate
5002 // (`ImportedPrefix` AND a non-zero prefix) to know whether THIS
5003 // request will actually carry a `cache_control` annotation. `busted`
5004 // requests (schema-tier change) and `CachePlan::Off` never annotate,
5005 // so `provider::cache_cold_reason` can never flag them — there was
5006 // nothing to reuse, by construction. `idle_secs` is computed
5007 // whenever a signal exists at all (even before this agent's first
5008 // annotated send — see `Self::last_cache_activity_ms`'s doc comment
5009 // on why the pre-establishment case matters); `cache_established`
5010 // additionally gates the usage-ratio check specifically (see
5011 // `provider::cache_cold_reason`'s doc comment for why those two
5012 // checks need independent gates).
5013 let will_annotate = matches!(effective_cache_plan, CachePlan::ImportedPrefix)
5014 && self.imported_prefix_len.is_some_and(|n| n > 0);
5015 let idle_secs = self
5016 .last_cache_activity_ms
5017 .map(|last| (now_ms() - last).max(0) / 1000);
5018 self.pending_cache_turn = (will_annotate, self.cache_established, idle_secs);
5019 let mut messages =
5020 provider::apply_cache_plan(&messages, effective_cache_plan, self.imported_prefix_len);
5021 // BP-7 (catalog §4a "Goals"): the standing objective, restated at
5022 // the TAIL of the request — after the cache annotation, which sits
5023 // on the PREFIX, so a goal that changes mid-session never busts the
5024 // cached prefix. Request-view only: `history` is untouched, so the
5025 // persisted transcript is exactly the conversation and a translator
5026 // never has to invent a message for a harness-tracked goal.
5027 if let Some(goal) = &self.goal {
5028 messages.push(ChatMessage::system(goal.reminder()));
5029 }
5030 messages
5031 }
5032
5033 /// Run the model/tool loop over the current history until a final answer or
5034 /// the iteration budget is exhausted. (Shared by `send`, `send_with_files`,
5035 /// and `send_with_images`.)
5036 /// P4b (§1.7, pi§3 semantics): pop the next message(s) to deliver from
5037 /// `queue` per `mode` — `All` drains everything and joins it with a
5038 /// blank line, `OneAtATime` pops exactly one. `None` when `queue` is
5039 /// empty (the default state, at zero cost).
5040 fn drain_steer_queue(
5041 queue: &mut std::collections::VecDeque<String>,
5042 mode: SteeringMode,
5043 ) -> Option<String> {
5044 if queue.is_empty() {
5045 return None;
5046 }
5047 match mode {
5048 SteeringMode::All => Some(queue.drain(..).collect::<Vec<_>>().join("\n\n")),
5049 SteeringMode::OneAtATime => queue.pop_front(),
5050 }
5051 }
5052
5053 async fn run_loop(&mut self) -> Result<String> {
5054 let _steer_turn = SteerTurnGuard::new(self.steer_queue.clone());
5055 let mut output_tokens_used: u64 = 0;
5056
5057 // BP-7 (catalog §4a "Turn/budget caps"): the SPEND cap, checked
5058 // before this `send` can issue anything. Unlike
5059 // `max_total_output_tokens` (a per-`send` allowance, unchanged),
5060 // spend accumulates over the agent's whole lifetime — a dollar
5061 // budget that resets on every prompt is not a budget. A cap reached
5062 // MID-loop ends that loop cleanly with a `spend_budget` finish
5063 // marker (below); a cap already exhausted at entry is an error,
5064 // because there is nothing to return.
5065 if let Some(budget) = self.config.max_budget_usd.filter(|b| *b > 0.0) {
5066 if self.total_cost_usd >= budget {
5067 return Err(Error::BudgetExhausted {
5068 spent_usd: self.total_cost_usd,
5069 budget_usd: budget,
5070 });
5071 }
5072 }
5073
5074 // P5-9 (§2 module 20, cc's "per-prompt file-history-snapshot"):
5075 // open a fresh checkpoint for THIS turn — `run_loop` is called
5076 // exactly once per `send`/`send_with_files`/`send_with_images`
5077 // call (never recursively for the same turn), so this fires once
5078 // per user prompt, matching the design's per-prompt granularity.
5079 // `self.history.last()` is the user message that call just pushed.
5080 // `None` (`checkpoint_observer` unset, the default) is a no-op —
5081 // zero cost, no disk touched.
5082 if let Some(cp) = &self.checkpoint_observer {
5083 let label = self
5084 .history
5085 .last()
5086 .and_then(|m| m.content.as_deref())
5087 .unwrap_or("")
5088 .to_string();
5089 cp.begin_turn(&label);
5090 }
5091
5092 // BP-4 (catalog:90, cx§2 "re-emitted on change"): once per user
5093 // turn — not per loop iteration — re-derive the environment block
5094 // so a cwd change, an approval/sandbox policy change or a branch
5095 // switch since the last turn reaches the model instead of leaving
5096 // it reading the startup snapshot. A no-op, with no subprocess, for
5097 // every config that doesn't set `core.env_context`.
5098 self.refresh_env_context();
5099
5100 for _ in 0..self.config.max_iterations {
5101 // BP-8 (catalog:156): flush a plan `update_plan` wrote during
5102 // the previous iteration's tool calls. A no-op when
5103 // `todos.persist` is off or the plan did not change.
5104 self.journal_plan_if_changed();
5105 // BP-7: the index of the round-trip this iteration is about to
5106 // make. Captured here because `self.turn_index` advances the
5107 // moment the usage record is written, and every marker in this
5108 // iteration — including the ones written after that point —
5109 // must carry the SAME index, or the marker log would not join
5110 // to the usage log on `turn`.
5111 let round_trip = self.turn_index;
5112 self.maybe_compact();
5113 // P4b (§1.7, pi§3 "steer = after current tool calls"): drain any
5114 // queued mid-turn steering message(s) BEFORE building the next
5115 // request — the top of every loop iteration is exactly "after
5116 // whatever tool calls the previous iteration just ran" (or, on
5117 // the very first iteration, before anything has happened yet,
5118 // which is an equally valid "deliver immediately" reading).
5119 // Empty queue (today's default state) is a no-op.
5120 let (steer_msg, steer_taken) = {
5121 let mut inbox = self
5122 .steer_queue
5123 .lock()
5124 .unwrap_or_else(std::sync::PoisonError::into_inner);
5125 let before = inbox.len();
5126 let drained = inbox.drain(self.config.steering_mode);
5127 let taken = before - inbox.len();
5128 (drained, taken)
5129 };
5130 if let Some(steer_msg) = steer_msg {
5131 // BP-8 (catalog:154): the queue record's other half —
5132 // without it a replayed journal would keep re-delivering an
5133 // input the conversation already consumed.
5134 self.journal_queue_drain(crate::session_journal::QueueKind::Steer, steer_taken);
5135 let msg = ChatMessage::user(steer_msg);
5136 self.record(&msg)?;
5137 self.history.push(msg);
5138 }
5139 // Recomputed every iteration (not hoisted): under `Deferred`
5140 // advertising, a `tool_search` call earlier in this same loop
5141 // activates tools that must be advertised starting with the very
5142 // next request (B6).
5143 let tools = self.tool_schemas();
5144 let messages = self.build_request_messages();
5145
5146 // PARITY-18 D4 — re-check the context guard before EVERY
5147 // request this loop builds, not just the caller's one-shot
5148 // preflight: interactive turns 2+, `/expand all`, and any
5149 // mid-loop tool round-trip that grows `messages` can push a
5150 // barely-passing session over the limit between sends. Only
5151 // armed when a caller has opted in via `set_context_limit`.
5152 // Uses the exact same `tokens::context_guard`
5153 // formula the CLI preflight uses, so the two can never disagree.
5154 if let Some(limit) = self.context_limit {
5155 let (fits, projected_tokens) =
5156 crate::tokens::context_guard(&messages, &tools, limit);
5157 if !fits {
5158 return Err(Error::ContextLimitExceeded {
5159 projected_tokens,
5160 reserve_tokens: crate::tokens::CONTEXT_RESPONSE_RESERVE_TOKENS,
5161 context_limit: limit,
5162 model: self.config.model.clone(),
5163 });
5164 }
5165 }
5166
5167 // BP-7 (catalog §4a "Turn/step bracketing records"): the
5168 // OPENING bracket, written before the request is issued so it
5169 // survives a request that never returns (a cancelled turn keeps
5170 // its `context` marker with no `usage`/`finish` after it).
5171 // Uses `tokens::estimate_request_tokens` — the same estimator
5172 // the context guard above uses, so the two can never disagree.
5173 self.push_turn_marker_at(
5174 round_trip,
5175 crate::turn_record::TurnMarker::Context {
5176 messages: messages.len(),
5177 tools: tools.len(),
5178 estimated_tokens: crate::tokens::estimate_request_tokens(&messages, &tools),
5179 },
5180 );
5181
5182 let mut req = self.chat_request(messages, tools);
5183
5184 let fallback_hops: Vec<FallbackHop>;
5185 let completion = {
5186 let sink = self.config.event_sink.as_ref();
5187 let on_delta = move |s: &str| {
5188 if let Some(sink) = sink {
5189 sink(AgentEvent::TextDelta(s.to_string()));
5190 }
5191 };
5192 // PARITY-18 D3 — the real send site: flip the flag
5193 // immediately before issuing the request, regardless of
5194 // whether `complete` then succeeds or fails, so
5195 // `request_issued()` truthfully reflects "a live request
5196 // was attempted" rather than "the run reached this line and
5197 // later succeeded."
5198 self.requests_issued = true;
5199 // P4b (§1.1/§3.1 `core.retry`, pi§3 shape): retry-with-
5200 // backoff already lives at the TRANSPORT layer
5201 // (`provider::OpenAiProvider::send_with_retry`, pre-existing
5202 // — connection failures and 5xx responses are retried
5203 // there); `Config.retry_*` (see `Agent::new`) makes that
5204 // EXISTING mechanism config-file-settable instead of
5205 // duplicating a second retry loop here, which would nest
5206 // retries confusingly on top of the transport's own.
5207 // BP-13 (D9 "Failure fallback model chains"): the chain is
5208 // EXECUTED here, not merely resolved. On a failure another
5209 // model could plausibly answer (overload / rate limit /
5210 // unavailability — `is_failover_worthy`), the request is
5211 // re-sent against the next entry of
5212 // `Config::model_fallback`, with routing re-applied for
5213 // that model. A 4xx that is not a rate limit is the caller's
5214 // problem, not the model's, and is never retried elsewhere.
5215 // The transport-level retry above has already run and given
5216 // up by the time a hop is considered.
5217 let (result, hops) = self.complete_with_fallback(&mut req, &on_delta).await;
5218 fallback_hops = hops;
5219 result
5220 };
5221 // BP-7: drained whether the request succeeded or failed, and
5222 // BEFORE the `?` — a request that exhausted its retries and
5223 // then errored is exactly the case a retry record exists for.
5224 for notice in self.retry_log.drain() {
5225 self.emit(AgentEvent::ProviderRetry {
5226 attempt: notice.attempt,
5227 delay_ms: notice.delay_ms,
5228 reason: notice.reason.clone(),
5229 });
5230 self.push_turn_marker_at(
5231 round_trip,
5232 crate::turn_record::TurnMarker::Retry {
5233 attempt: notice.attempt,
5234 delay_ms: notice.delay_ms,
5235 reason: notice.reason,
5236 },
5237 );
5238 }
5239 // The switch the fallback pass performed is a real mid-session
5240 // model change: it moves `Config::model` for every subsequent
5241 // request and is recorded exactly like a user-driven `/model`
5242 // switch (typed record + journal line), never as a silent retry.
5243 for hop in fallback_hops {
5244 self.record_model_change(&hop.from, &hop.to, Some(hop.reason.as_str()));
5245 }
5246 let (mut assistant, usage) = completion?;
5247 // Persist the actual generating model on the message itself.
5248 // A resumed foreign session keeps its original model in
5249 // `SessionMeta`; using only that session-level value on export
5250 // misattributes every Supercode continuation turn to the source
5251 // harness model. Per-message provenance lets native exporters
5252 // preserve the boundary accurately (for example, Claude history
5253 // followed by a GLM continuation).
5254 assistant
5255 .metadata
5256 .insert("model".to_string(), self.config.model.clone());
5257 output_tokens_used += usage.completion_tokens;
5258 self.total_output_tokens += usage.completion_tokens;
5259
5260 // UX-26 (B7-warn): consult the verdict computed at build time
5261 // (before this request was sent) now that `usage` — the only
5262 // piece that couldn't be known pre-send — is in hand. Gated on
5263 // `Config::cache_warnings` (default on; `--no-cache-warnings` /
5264 // `SUPERCODE_CACHE_WARNINGS=0` at the CLI layer, dev/03) so this
5265 // stays a zero-behavior-change no-op for every caller that
5266 // hasn't opted into `CachePlan::ImportedPrefix` in the first
5267 // place (`pending_cache_turn.0` is `false` whenever
5268 // `CachePlan::Off`, so the predicate always returns `None` then
5269 // regardless of this flag).
5270 let (will_annotate, cache_established, idle_secs) = self.pending_cache_turn;
5271 // UX-26 T2 (accuracy fold-in): `CacheColdReason::message` asserts
5272 // Anthropic-specific facts (a fixed 5-minute ephemeral TTL, and
5273 // cache-read-ratio semantics that assume Anthropic's exact-count
5274 // billing) that are only true for Anthropic-family models. This
5275 // is a WARNING-only gate, deliberately not folded into
5276 // `will_annotate`/the breakpoint-placement gate above: whether a
5277 // `cache_control` breakpoint is safe/inert to send to a
5278 // non-Anthropic model through OpenRouter is a separate cache-
5279 // behavior question this ticket doesn't touch (see
5280 // `.volter/tracker/markdown/UX-26.md`'s T2 note) — narrowing only
5281 // the warning keeps this fix scoped to warning ACCURACY, with
5282 // zero change to what gets sent on the wire.
5283 let warning_applies_to_this_model =
5284 provider::is_anthropic_family_model(&self.config.model);
5285 if self.config.cache_warnings && warning_applies_to_this_model {
5286 if let Some(reason) =
5287 provider::cache_cold_reason(will_annotate, cache_established, idle_secs, &usage)
5288 {
5289 self.emit(AgentEvent::CacheWarning {
5290 message: reason.message(),
5291 });
5292 }
5293 }
5294 // Refresh the activity clock / establish-once flag for the NEXT
5295 // turn's comparison, but only when THIS request actually carried
5296 // the annotation — an unannotated (busted/Off) request neither
5297 // warms nor cools a cache entry it never touched.
5298 if will_annotate {
5299 self.last_cache_activity_ms = Some(now_ms());
5300 self.cache_established = true;
5301 }
5302
5303 // UX-23: emitted before `TurnCompleted` so a `--trace`/
5304 // `stream-json` consumer sees "this round-trip cost N tokens"
5305 // land right alongside the round-trip it describes, rather than
5306 // needing to correlate it with a later event.
5307 self.emit(AgentEvent::Usage(usage.clone()));
5308 self.emit(AgentEvent::TurnCompleted);
5309 // P4b (§1.6, catalog §4a "persisted per-turn usage records"):
5310 // EventSink already streamed `Usage` above — this durably
5311 // accumulates the same data as a typed record (see
5312 // `Self::usage_records`/`Self::save_usage_log`), never a lossy
5313 // display-only channel.
5314 // BP-7 (catalog §4a "Per-turn cost/usage accounting"): the
5315 // record now carries the round-trip's DOLLAR cost too — the
5316 // half the row's semantics name alongside tokens — whenever
5317 // this build can price the model.
5318 // BP-13 (D9 "Model-served-vs-requested provenance"): the record
5319 // now carries BOTH sides — the model this agent asked for and,
5320 // when the provider reported one, the model that actually
5321 // answered. They can genuinely differ (a gateway aliasing a
5322 // name to a dated snapshot, a fallback hop, a routed tier), and
5323 // a record that can only ever state the request cannot show it.
5324 let served = assistant
5325 .metadata
5326 .get(crate::provider::SERVED_MODEL_KEY)
5327 .cloned();
5328 let record = crate::usage_log::UsageRecord::from_usage(
5329 self.turn_index,
5330 &self.config.model,
5331 &usage,
5332 now_ms(),
5333 )
5334 .priced(self.model_price)
5335 .with_served_model(served);
5336 self.total_cost_usd += record.cost_usd.unwrap_or(0.0);
5337 // BP-7: the round-trip's usage bracket, written from the same
5338 // point as the usage record so the two logs never disagree.
5339 self.push_turn_marker_at(
5340 round_trip,
5341 crate::turn_record::TurnMarker::Usage {
5342 prompt_tokens: record.prompt_tokens,
5343 completion_tokens: record.completion_tokens,
5344 total_tokens: record.total_tokens,
5345 cached_tokens: record.cached_tokens,
5346 cost_usd: record.cost_usd,
5347 },
5348 );
5349 self.journal_usage(&record);
5350 self.usage_log.push(record);
5351 self.turn_index += 1;
5352 self.record(&assistant)?;
5353 self.history.push(assistant.clone());
5354
5355 let calls = assistant.tool_calls().to_vec();
5356 if calls.is_empty() {
5357 // Close steering acceptance under the same lock as the last
5358 // drain. A message accepted before this boundary extends the
5359 // current turn; anything later is rejected by the SDK and
5360 // can never leak into a future turn.
5361 let (steer_msg, steer_taken) = {
5362 let mut inbox = self
5363 .steer_queue
5364 .lock()
5365 .unwrap_or_else(std::sync::PoisonError::into_inner);
5366 let before = inbox.len();
5367 let drained = inbox.drain_or_close(self.config.steering_mode);
5368 let taken = before - inbox.len();
5369 (drained, taken)
5370 };
5371 if let Some(steer_msg) = steer_msg {
5372 self.journal_queue_drain(crate::session_journal::QueueKind::Steer, steer_taken);
5373 let msg = ChatMessage::user(steer_msg);
5374 self.record(&msg)?;
5375 self.history.push(msg);
5376 continue;
5377 }
5378 // P4b (§1.7, pi§3 "follow-up = at idle"): a queued follow-up
5379 // message takes priority over the stop-gate — it's more
5380 // input to answer, not a veto of an answer already given.
5381 let follow_up_before = self.follow_up_queue.len();
5382 if let Some(follow_up_msg) =
5383 Self::drain_steer_queue(&mut self.follow_up_queue, self.config.follow_up_mode)
5384 {
5385 self.journal_queue_drain(
5386 crate::session_journal::QueueKind::FollowUp,
5387 follow_up_before - self.follow_up_queue.len(),
5388 );
5389 let msg = ChatMessage::user(follow_up_msg);
5390 self.record(&msg)?;
5391 self.history.push(msg);
5392 continue;
5393 }
5394 // P4b (§1.9/§3.1 `[core] stop_gate`, D3 "stop/completion
5395 // gating"): consulted exactly once per iteration that would
5396 // otherwise return — computed into an owned `Option<String>`
5397 // so the immutable borrow of `self.config.stop_gate` ends
5398 // before the `self.record`/`self.history.push` calls below
5399 // need `&mut self`.
5400 let final_content = assistant.content.clone().unwrap_or_default();
5401 let veto_reason: Option<String> = self
5402 .config
5403 .stop_gate
5404 .as_ref()
5405 .and_then(|gate| gate(&final_content));
5406 if let Some(reason) = veto_reason {
5407 let msg = ChatMessage::user(reason);
5408 self.record(&msg)?;
5409 self.history.push(msg);
5410 continue;
5411 }
5412 // BP-8 (catalog:156): the last iteration's tool calls are
5413 // the ones the top-of-loop flush above never sees.
5414 self.journal_plan_if_changed();
5415 self.push_turn_marker_at(
5416 round_trip,
5417 crate::turn_record::TurnMarker::Finish {
5418 reason: crate::turn_record::FinishReason::EndTurn,
5419 },
5420 );
5421 return Ok(assistant.content.unwrap_or_default());
5422 }
5423
5424 // BP-7: this round-trip ended by asking for tool calls; the
5425 // loop continues. The budget arms below mark the LOOP's end
5426 // separately when one of them stops it here.
5427 self.push_turn_marker_at(
5428 round_trip,
5429 crate::turn_record::TurnMarker::Finish {
5430 reason: crate::turn_record::FinishReason::ToolCalls,
5431 },
5432 );
5433
5434 // Output-token budget (output only — input tokens are not counted,
5435 // so this does not bound cost): stop spawning further model turns
5436 // once the cumulative output-token budget for this `send` is
5437 // exhausted.
5438 if let Some(budget) = self.config.max_total_output_tokens {
5439 if output_tokens_used >= budget {
5440 // The assistant turn we just pushed carries unanswered
5441 // tool_calls. Leaving them dangling yields an invalid
5442 // history (assistant tool_calls with no tool results) that
5443 // the provider rejects on the next `send`/resume. Emit
5444 // synthetic results so the transcript stays well-formed.
5445 for call in &calls {
5446 let msg = ChatMessage::tool_result(
5447 call.id.clone(),
5448 call.function.name.clone(),
5449 "[skipped: output token budget reached]".to_string(),
5450 );
5451 self.record(&msg)?;
5452 self.history.push(msg);
5453 }
5454 self.push_turn_marker_at(
5455 round_trip,
5456 crate::turn_record::TurnMarker::Finish {
5457 reason: crate::turn_record::FinishReason::OutputTokenBudget,
5458 },
5459 );
5460 return Ok(assistant.content.clone().unwrap_or_default());
5461 }
5462 }
5463
5464 // BP-7 (catalog §4a "Turn/budget caps"): the SPEND and STEP
5465 // caps, at the same point and with the same shape as the
5466 // output-token cap above — checked before this turn's tool
5467 // calls run, with synthetic results so the transcript stays
5468 // well-formed for a resume.
5469 let spend_exhausted = self
5470 .config
5471 .max_budget_usd
5472 .is_some_and(|b| b > 0.0 && self.total_cost_usd >= b);
5473 let steps_exhausted = self
5474 .config
5475 .max_steps
5476 .is_some_and(|n| n > 0 && self.total_steps + calls.len() > n);
5477 if spend_exhausted || steps_exhausted {
5478 let (label, reason) = if spend_exhausted {
5479 (
5480 "[skipped: spend budget reached]",
5481 crate::turn_record::FinishReason::SpendBudget,
5482 )
5483 } else {
5484 (
5485 "[skipped: step budget reached]",
5486 crate::turn_record::FinishReason::StepBudget,
5487 )
5488 };
5489 for call in &calls {
5490 let msg = ChatMessage::tool_result(
5491 call.id.clone(),
5492 call.function.name.clone(),
5493 label.to_string(),
5494 );
5495 self.record(&msg)?;
5496 self.history.push(msg);
5497 }
5498 self.push_turn_marker_at(
5499 round_trip,
5500 crate::turn_record::TurnMarker::Finish { reason },
5501 );
5502 return Ok(assistant.content.clone().unwrap_or_default());
5503 }
5504 self.total_steps += calls.len();
5505
5506 // P4e (§3.1 `core.parallel_tool_calls`, catalog:59): off (the
5507 // default) or a single call takes the EXACT pre-P4e sequential
5508 // path below, byte-identical. Only `true` with 2+ calls in this
5509 // turn takes `Self::run_tools_concurrently` — see its doc
5510 // comment for exactly what does and doesn't run concurrently.
5511 if self.config.parallel_tool_calls && calls.len() > 1 {
5512 for call in &calls {
5513 self.emit(AgentEvent::tool_started(call));
5514 }
5515 let results = self.run_tools_concurrently(&calls).await;
5516 for (call, (output, is_error)) in calls.iter().zip(results) {
5517 self.emit(AgentEvent::ToolCallCompleted {
5518 id: call.id.clone(),
5519 name: call.function.name.clone(),
5520 output: output.clone(),
5521 is_error,
5522 });
5523 self.apply_tool_result(call, output, is_error)?;
5524 }
5525 } else {
5526 for call in &calls {
5527 self.emit(AgentEvent::tool_started(call));
5528 let (output, is_error) = self.run_tool(call).await;
5529 self.emit(AgentEvent::ToolCallCompleted {
5530 id: call.id.clone(),
5531 name: call.function.name.clone(),
5532 output: output.clone(),
5533 is_error,
5534 });
5535 self.apply_tool_result(call, output, is_error)?;
5536 }
5537 }
5538 // BP-3 (catalog row "Context-budget tools"): a `new_context`
5539 // call parks its request on the shared budget; this is where
5540 // the agent — the one owner of `history` — applies it, so the
5541 // NEXT request built by this loop is already the fresh window.
5542 // No parked request (every session that never calls the tool)
5543 // is a single `Option` check.
5544 self.apply_pending_new_context();
5545 }
5546
5547 self.push_turn_marker_at(
5548 self.turn_index.saturating_sub(1),
5549 crate::turn_record::TurnMarker::Finish {
5550 reason: crate::turn_record::FinishReason::MaxIterations,
5551 },
5552 );
5553 Err(Error::MaxIterations(self.config.max_iterations))
5554 }
5555
5556 /// BP-3: apply a parked [`crate::tools::NewContextRequest`], if any.
5557 ///
5558 /// The rewrite itself is BP-4's [`Self::new_context`] — the SAME
5559 /// mechanism the operator's `/handoff` runs, so the model's door and the
5560 /// human's door can never drift into two different notions of "a fresh
5561 /// window". This function is only the hand-off point between the tool
5562 /// that asked and the agent that owns `history`.
5563 fn apply_pending_new_context(&mut self) {
5564 let Some(request) = self.ctx.context_budget.take_new_context() else {
5565 return;
5566 };
5567 self.new_context(&request.objective, request.keep_recent);
5568 }
5569
5570 /// The exact post-execution handling every tool result gets, regardless
5571 /// of whether it was produced by the sequential loop or
5572 /// [`Self::run_tools_concurrently`] — factored out of `Self::run_loop`'s
5573 /// tool-dispatch section (P4e) so both paths share one copy: multimodal
5574 /// image-marker detection, A7 output capping (gated exactly as before),
5575 /// TR-10 error stamping, and the `record`/`history` append. Always
5576 /// called in ORIGINAL call order, one call at a time, so the lossless
5577 /// sidecar's append-order invariant (S1.13) holds regardless of which
5578 /// dispatch path produced the result.
5579 fn apply_tool_result(
5580 &mut self,
5581 call: &crate::message::ToolCall,
5582 output: String,
5583 is_error: bool,
5584 ) -> Result<()> {
5585 // P4c (§1.2 `core.tools.read_file.multimodal` / `view_image`):
5586 // a successful tool result carrying the image-data-URL
5587 // marker becomes a `content_parts` image block instead of
5588 // plain text — checked BEFORE `cap_tool_output` (a data URL
5589 // is not meaningfully "capped" by a byte-length text notice)
5590 // and recorded identically on both the full and history
5591 // copies, mirroring `ImageRedacted`'s "images are their own
5592 // axis, orthogonal to A7 text truncation" treatment
5593 // (reduce.rs). An ERRORED call never carries the marker (a
5594 // tool only emits it on success), so `is_error` is not
5595 // re-checked here.
5596 if let Some(data_url) = output.strip_prefix(crate::tools::MULTIMODAL_IMAGE_MARKER) {
5597 let notice = format!("[{}: image content attached below]", call.function.name);
5598 let full_result = ChatMessage::tool_result_with_image(
5599 call.id.clone(),
5600 call.function.name.clone(),
5601 notice.clone(),
5602 data_url.to_string(),
5603 );
5604 let hist_result = ChatMessage::tool_result_with_image(
5605 call.id.clone(),
5606 call.function.name.clone(),
5607 notice,
5608 data_url.to_string(),
5609 );
5610 self.record(&full_result)?;
5611 self.history.push(hist_result);
5612 return Ok(());
5613 }
5614 // Record the FULL output before capping (A3): what the
5615 // sidecar keeps must never be the already-lossy, truncated
5616 // copy (#8/#40) — `history` alone governs what shrinks.
5617 let mut full_result =
5618 ChatMessage::tool_result(call.id.clone(), call.function.name.clone(), output.clone());
5619 // D6/A7 supersession gate (TR-12 land-blocker fix): `history`
5620 // is the exact slice `reduce::project_messages` mints A7/A10
5621 // reduction hashes from (`Self::build_request_messages`
5622 // below). Capping it here — as this unconditionally used to
5623 // do — would silently shrink the bytes those hashes cover, so
5624 // a hash minted now could never recompute the same way once
5625 // the sidecar is reloaded from disk later (`verify_log`/
5626 // `invert`, offline). Gate `cap_tool_output` off in exactly
5627 // the combination where reductions can be minted over
5628 // `history` AND the full bytes are durably retained: a
5629 // recorder AND a `ReductionPolicy` both installed. A7 then
5630 // owns tool-output bounding, reversibly, at projection time
5631 // (SPEC.md D6/A7) — `history`/the sidecar keep everything,
5632 // only the request view shrinks. With a policy but no
5633 // recorder (constructible via `set_reduction_policy` alone),
5634 // nothing durable backs the full bytes, so capping stays on —
5635 // the same honest-labeling spirit as `cap_tool_output`'s own
5636 // retention branch below, just applied at the gate instead of
5637 // the notice text. With no policy at all, this is untouched:
5638 // today's byte-identical legacy cap.
5639 let for_history = if self.recorder.is_some() && self.reduction_policy.is_some() {
5640 output
5641 } else {
5642 self.cap_tool_output(output)
5643 };
5644 let mut hist_result =
5645 ChatMessage::tool_result(call.id.clone(), call.function.name.clone(), for_history);
5646 if is_error {
5647 // TR-10: the reduction layer's success/failure boundary
5648 // (`ReductionKind::ToolInputElided` must never target an
5649 // errored call — TR-6's territory) has no other
5650 // structural signal on `ChatMessage`; stamp both the
5651 // recorded copy (so it survives a sidecar round-trip via
5652 // `NativeTurn`) and the live-history copy (so an
5653 // in-process `project_messages` sees it immediately).
5654 reduce::mark_tool_error(&mut full_result);
5655 reduce::mark_tool_error(&mut hist_result);
5656 }
5657 self.record(&full_result)?;
5658 self.history.push(hist_result);
5659 Ok(())
5660 }
5661
5662 /// Truncate an oversized tool result so a single runaway command can't blow
5663 /// up the context window. Cuts on a char boundary and appends a notice.
5664 fn cap_tool_output(&self, output: String) -> String {
5665 let Some(max) = self.config.max_tool_output_bytes else {
5666 return output;
5667 };
5668 if max == 0 || output.len() <= max {
5669 return output;
5670 }
5671 // Find the largest char boundary <= max.
5672 let mut end = max;
5673 while end > 0 && !output.is_char_boundary(end) {
5674 end -= 1;
5675 }
5676 let total = output.len();
5677 let mut s = output[..end].to_string();
5678 // BP-2 (catalog:58, `core.tool_output_spill`): write the full bytes
5679 // to a per-session file the model can read back. Off (the default)
5680 // leaves the notice byte-identical to before.
5681 let spill = if self.config.tool_output_spill {
5682 self.spill_tool_output(&output)
5683 } else {
5684 None
5685 };
5686 // Honest retention labeling (D6, B10-AC4): only claim the sidecar has
5687 // the full output when a recorder is actually installed — or, BP-2,
5688 // that the spill file has it when one was actually written.
5689 let retention = if self.recorder.is_some() {
5690 "full output in session sidecar"
5691 } else if spill.is_some() {
5692 "full output on disk"
5693 } else {
5694 "full output not retained"
5695 };
5696 let recovery = match &spill {
5697 // The door is named in the notice, so it works WITHOUT
5698 // `capabilities.reduction`: under a preset with a read tool
5699 // that is `read_file`; under a shell-only preset (cx-parity,
5700 // whose whole read pathway is the shell) it is `cat`.
5701 Some(path) => {
5702 let door = if self.registry.get("read_file").is_some() {
5703 "read it with `read_file`"
5704 } else {
5705 "read it with `cat`"
5706 };
5707 format!("; full output spilled to {} — {door}", path.display())
5708 }
5709 None => String::new(),
5710 };
5711 s.push_str(&format!(
5712 "{CAP_NOTICE_MARKER}{total} bytes total, showing first {end}; {retention}{recovery}]"
5713 ));
5714 s
5715 }
5716
5717 /// BP-2 (catalog:58 "Oversized output truncated; full content kept
5718 /// reachable"): write `full` to this session's spill directory and
5719 /// return the path, or `None` if it could not be written (a spill is a
5720 /// recovery convenience — it must never fail the tool call).
5721 ///
5722 /// The file is named by content hash, so the same output spilled twice
5723 /// costs one file and a re-run of an identical command reuses it.
5724 fn spill_tool_output(&self, full: &str) -> Option<std::path::PathBuf> {
5725 let dir = self.spill_dir();
5726 std::fs::create_dir_all(&dir).ok()?;
5727 let digest = blake3::hash(full.as_bytes()).to_hex();
5728 let path = dir.join(format!("tool-output-{}.txt", &digest[..16]));
5729 if !path.exists() {
5730 std::fs::write(&path, full).ok()?;
5731 }
5732 Some(path)
5733 }
5734
5735 /// BP-2: where this agent's spilled outputs live — beside the session
5736 /// sidecar when one is recording (per-SESSION, the same identity the
5737 /// sidecar has), else a per-PROCESS temp directory, which is as
5738 /// specific as an agent with no sidecar can honestly be.
5739 fn spill_dir(&self) -> std::path::PathBuf {
5740 if let Some(recorder) = &self.recorder {
5741 let path = recorder.path();
5742 if let (Some(parent), Some(stem)) = (path.parent(), path.file_stem()) {
5743 return parent.join(format!("{}.spill", stem.to_string_lossy()));
5744 }
5745 }
5746 std::env::temp_dir().join(format!("supercode-spill-{}", std::process::id()))
5747 }
5748
5749 /// P5-3 note on the signature: written as a plain fn returning an
5750 /// explicitly boxed future (`Pin<Box<dyn Future + Send>>`) rather than
5751 /// as `async fn`. `spawn_subagent` makes this function genuinely
5752 /// recursive at the TYPE level: `run_tool` -> `run_spawn_subagent` ->
5753 /// (a child) `Agent::send` -> `run_loop` -> `run_tool` again — an
5754 /// `async fn`'s return type is an anonymous, compiler-inferred
5755 /// self-referential state machine, and inferring one that embeds
5756 /// itself (even indirectly, through several other functions) is a
5757 /// compile error (an infinitely-sized/cyclic opaque type). Declaring
5758 /// `run_tool`'s return type EXPLICITLY as a boxed trait object breaks
5759 /// the cycle: every other function on the call graph now embeds a
5760 /// concrete, already-known type here instead of one the compiler would
5761 /// otherwise need to (cyclically) infer. Callers are unaffected —
5762 /// `self.run_tool(call).await` reads identically either way.
5763 fn run_tool<'a>(
5764 &'a mut self,
5765 call: &'a crate::message::ToolCall,
5766 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = (String, bool)> + Send + 'a>> {
5767 Box::pin(async move {
5768 let translated_builtin = if self.config.claude_runtime_tools_enabled {
5769 match self.translate_claude_builtin_call(call) {
5770 Ok(translated) => translated,
5771 Err(error) => return (format!("Error: {error}"), true),
5772 }
5773 } else {
5774 None
5775 };
5776 let call = translated_builtin.as_ref().unwrap_or(call);
5777 if self.config.claude_runtime_tools_enabled
5778 && matches!(
5779 call.function.name.as_str(),
5780 CLAUDE_CRON_CREATE
5781 | CLAUDE_CRON_DELETE
5782 | CLAUDE_CRON_LIST
5783 | CLAUDE_SCHEDULE_WAKEUP
5784 )
5785 {
5786 return self.run_claude_runtime_tool(call);
5787 }
5788 // P5-3: `spawn_subagent`/`subagent_status` need full async
5789 // `&mut self` access (running a child agent's loop, or
5790 // awaiting an already-finished background `JoinHandle`) —
5791 // `prepare_tool_call` is purely synchronous, so these are
5792 // intercepted HERE, one level above it, rather than inside it
5793 // like `TOOL_SEARCH`/`EXPAND_REDUCTION`/`SIDECAR_SEARCH`.
5794 if call.function.name == CLAUDE_AGENT && self.config.subagents_claude_agent_alias {
5795 return match self.translate_claude_agent_call(call) {
5796 Ok(translated) => self.run_spawn_subagent(&translated).await,
5797 Err(error) => (format!("Error: {error}"), true),
5798 };
5799 }
5800 if call.function.name == SPAWN_SUBAGENT {
5801 return self.run_spawn_subagent(call).await;
5802 }
5803 // P5-3 safety-hardening fix (Fable-5 review, LOW "wrong error
5804 // when disabled"): gated on `subagents_enabled`, matching
5805 // `run_spawn_subagent`'s own already-correct disabled behavior
5806 // (that one gates INTERNALLY, at its own top; this one gates
5807 // HERE, at the interception point, because unlike
5808 // `spawn_subagent` it has no other reason to run any logic at
5809 // all when subagents are off). When disabled, a hallucinated
5810 // `subagent_status` call must NOT be intercepted — it falls
5811 // through to `prepare_tool_call`'s normal unknown-tool path
5812 // below, which returns `Error::UnknownTool("subagent_status")`,
5813 // byte-identical to the pre-P5-3 (and disabled-spawn_subagent)
5814 // error text — never `Error::SubagentNotFound`'s "unknown
5815 // subagent id" text, which would wrongly imply subagents are on
5816 // but this particular id is bogus.
5817 if call.function.name == SUBAGENT_STATUS && self.config.subagents_enabled {
5818 return self.run_subagent_status(call).await;
5819 }
5820 // BP-7: same interception shape and same `subagents_enabled`
5821 // gate as `SUBAGENT_STATUS` above — when the module is off a
5822 // hallucinated call falls through to the ordinary unknown-tool
5823 // error rather than a misleading "unknown subagent id".
5824 if call.function.name == SUBAGENT_MESSAGE && self.config.subagents_enabled {
5825 return self.run_subagent_message(call);
5826 }
5827 if call.function.name == SUBAGENT_RESUME && self.config.subagents_enabled {
5828 return self.run_subagent_resume(call).await;
5829 }
5830 match self.prepare_tool_call(call) {
5831 PreparedCall::Done(result) => result,
5832 PreparedCall::Ready { name, args } => {
5833 // `prepare_tool_call` already confirmed the registry has
5834 // this tool.
5835 let tool = self.registry.get(&name).expect("prepared as Ready");
5836 let (output, is_error) = match tool.execute(args, &self.ctx).await {
5837 Ok(out) => (out, false),
5838 Err(e) => (format!("Error: {e}"), true),
5839 };
5840 if let Some(hook) = &self.config.post_tool_hook {
5841 hook(&name, &output, is_error);
5842 }
5843 (output, is_error)
5844 }
5845 }
5846 })
5847 }
5848
5849 /// P4e (§3.1 `core.parallel_tool_calls`, catalog:59): the SYNCHRONOUS
5850 /// half of dispatching one tool call — everything `Self::run_tool` did
5851 /// BEFORE its single `tool.execute(...).await`, factored out so
5852 /// [`Self::run_tools_concurrently`] can run these cheap, stateful,
5853 /// `&mut self` checks (agent intrinsics, unknown-tool, approval,
5854 /// doom-loop, pre-tool-hook) SEQUENTIALLY and in ORIGINAL call order —
5855 /// exactly as `run_tool` always has — before handing the remaining
5856 /// calls' `execute()` futures to `join_all`. `Self::run_tool` itself is
5857 /// now a thin wrapper over this (a pure refactor: byte-identical
5858 /// observable behavior, verified by the existing test suite).
5859 fn prepare_tool_call(&mut self, call: &crate::message::ToolCall) -> PreparedCall {
5860 let name = &call.function.name;
5861 // BP-3 (catalog row "Context-budget tools"): hand the model's
5862 // `get_context_remaining` the agent's OWN accounting — BP-4's
5863 // [`Self::context_usage`], the same struct `/context` prints and
5864 // the same estimates the context guard enforces, so what the model
5865 // reads and what refuses an oversized turn can never disagree.
5866 // Computed at the moment the question is asked (the freshest
5867 // possible view) and ONLY then: `context_usage` projects the whole
5868 // request view, which is not a cost to pay on unrelated calls.
5869 if name == crate::tools::GET_CONTEXT_REMAINING {
5870 if let Ok(usage) = serde_json::to_value(self.context_usage()) {
5871 self.ctx.context_budget.publish(usage);
5872 }
5873 }
5874 if name == TOOL_SEARCH {
5875 // Agent intrinsic (B6): intercepted before registry lookup, since
5876 // `Tool::execute` has no access to the registry or `activated_tools`.
5877 return PreparedCall::Done(self.run_tool_search(call));
5878 }
5879 if name == EXPAND_REDUCTION {
5880 // Agent intrinsic (T12/TR-1): intercepted before registry lookup,
5881 // same reason — resolves against `self.reduction_log`/`self.history`,
5882 // which `Tool::execute` has no access to.
5883 return PreparedCall::Done(self.run_expand_reduction(call));
5884 }
5885 if name == SIDECAR_SEARCH {
5886 return PreparedCall::Done(self.run_sidecar_search(call));
5887 }
5888 // P5-6 (§2 module 4 `tools.background`): gated at the interception
5889 // point itself (not internally, at each method's own top) —
5890 // mirroring `SUBAGENT_STATUS`'s own fix (Fable-5 review, LOW "wrong
5891 // error when disabled"): a hallucinated call when the module is off
5892 // must fall through to the plain `Error::UnknownTool` path below,
5893 // never a background-specific error that would wrongly imply the
5894 // module is on. Unlike `SPAWN_SUBAGENT`/`SUBAGENT_STATUS`, none of
5895 // these four need async `&mut self` access (spawning a process,
5896 // `Child::try_wait`, and `Child::start_kill` are all synchronous),
5897 // so they're intercepted here in `prepare_tool_call` rather than in
5898 // `Self::run_tool`.
5899 if self.config.tools_background_enabled {
5900 if name == BACKGROUND_EXEC {
5901 return PreparedCall::Done(self.run_background_exec(call));
5902 }
5903 if name == BACKGROUND_STATUS {
5904 return PreparedCall::Done(self.run_background_status(call));
5905 }
5906 if name == BACKGROUND_LIST {
5907 return PreparedCall::Done(self.run_background_list(call));
5908 }
5909 if name == BACKGROUND_KILL {
5910 return PreparedCall::Done(self.run_background_kill(call));
5911 }
5912 }
5913 if self.registry.get(name).is_none() {
5914 let err = Error::UnknownTool(name.clone());
5915 return PreparedCall::Done((format!("Error: {err}"), true));
5916 }
5917
5918 // P5-1 (§2 modules 10-11, integration point named in
5919 // COMPOSABLE-HARNESS-DESIGN.md's activation set): the permissions
5920 // ENGINE governs the gate when `capabilities.permissions.enabled`
5921 // is on; every other config resolves this to `false`
5922 // (`Config::default`), which takes the `else` branch below —
5923 // the EXACT pre-P5-1 code, untouched, so the default posture
5924 // (approval=never/sandbox=none) and every existing test's observed
5925 // behavior is byte-for-byte unchanged.
5926 if self.config.permissions_enabled {
5927 // The engine needs the command/path TEXT the legacy tool-name-
5928 // only gate below never looked at, so args must be parsed
5929 // BEFORE the gate here (not after, like the legacy branch).
5930 let args = match call.function.parsed_arguments() {
5931 Ok(v) => v,
5932 Err(e) => {
5933 let err = Error::InvalidArguments {
5934 tool: name.clone(),
5935 message: e.to_string(),
5936 };
5937 return PreparedCall::Done((format!("Error: {err}"), true));
5938 }
5939 };
5940 // P4c doom-loop breaker, unchanged, still before any gate.
5941 if let Some(reason) = self.check_doom_loop(name, &args) {
5942 return PreparedCall::Done((format!("Error: {reason}"), true));
5943 }
5944 // BP-10: the hook runs BEFORE the engine on this path, so its
5945 // rewrite is what the rules see and its allow/ask/deny is a
5946 // tier inside them — see `run_pre_tool_hook`.
5947 let (args, hook_decision) = match self.run_pre_tool_hook(name, args) {
5948 Ok(pair) => pair,
5949 Err(done) => return done,
5950 };
5951 if let Some(reason) = self.permissions_gate_denial(name, &args, hook_decision) {
5952 return PreparedCall::Done((format!("Error: {reason}"), true));
5953 }
5954 PreparedCall::Ready {
5955 name: name.clone(),
5956 args,
5957 }
5958 } else {
5959 // ---- pre-P5-1 gate, byte-for-byte unchanged ----
5960 // Approval gate: if the policy requires it, consult the handler
5961 // (absent handler denies, so an OnRequest/Untrusted policy is
5962 // fail-closed).
5963 if self.config.needs_approval(name) {
5964 let approved = self
5965 .config
5966 .approval_handler
5967 .as_ref()
5968 .map(|h| h(call))
5969 .unwrap_or(false);
5970 if !approved {
5971 return PreparedCall::Done((
5972 format!("Error: tool `{name}` was not approved for execution"),
5973 true,
5974 ));
5975 }
5976 }
5977 let args = match call.function.parsed_arguments() {
5978 Ok(v) => v,
5979 Err(e) => {
5980 let err = Error::InvalidArguments {
5981 tool: name.clone(),
5982 message: e.to_string(),
5983 };
5984 return PreparedCall::Done((format!("Error: {err}"), true));
5985 }
5986 };
5987 self.finish_prepare(name.clone(), args)
5988 }
5989 }
5990
5991 /// P5-1: the shared tail of [`Self::prepare_tool_call`] — doom-loop
5992 /// check, pre-tool hook, `Ready` construction — factored out so both
5993 /// the legacy gate and the new permissions-engine gate run the exact
5994 /// same downstream checks in the exact same order (§5.3 risk 1: the
5995 /// permissions engine changes WHO gets to run, never what happens once
5996 /// they're approved).
5997 fn finish_prepare(&mut self, name: String, args: serde_json::Value) -> PreparedCall {
5998 // P4c (§5.2 P4 "doom-loop breaker", oc UNIQUE `doom_loop` row,
5999 // catalog D3): a default, always-available veto point distinct from
6000 // `Config.pre_tool_hook` (a single user-installable slot — the
6001 // breaker must coexist with a caller's own hook, not compete for the
6002 // one slot). `None`/`Some(0|1)` is a no-op — byte-identical to
6003 // today (no repetition tracking, no call is ever refused on this
6004 // basis).
6005 if let Some(reason) = self.check_doom_loop(&name, &args) {
6006 return PreparedCall::Done((format!("Error: {reason}"), true));
6007 }
6008 // Pre-tool hook may block the call. BP-10: the pre-P5-1 gate has
6009 // no permissions engine for a hook's `Allow`/`Ask` to be a tier
6010 // OF, so only the deny half can mean anything here — an
6011 // `updated_args` rewrite still applies (it is a property of the
6012 // call, not of any gate), and `Allow`/`Ask` are no-ops, exactly
6013 // as `None` was before BP-10.
6014 let mut args = args;
6015 if let Some(hook) = &self.config.pre_tool_hook {
6016 let outcome = hook(&name, &args);
6017 if let Some(rewritten) = outcome.updated_args {
6018 args = rewritten;
6019 }
6020 if outcome.decision == crate::config::HookDecision::Deny {
6021 let reason = outcome.reason.unwrap_or_else(|| "denied".to_string());
6022 return PreparedCall::Done((
6023 format!("Error: blocked by pre-tool hook: {reason}"),
6024 true,
6025 ));
6026 }
6027 }
6028 PreparedCall::Ready { name, args }
6029 }
6030
6031 /// BP-10 (catalog row "Hook/plugin permission veto"): fire the pre-tool
6032 /// hook for the permissions-engine path, where it runs BEFORE the gate
6033 /// (CC's own order: a `PreToolUse` hook answers the permission question
6034 /// rather than being asked after it). Returns the possibly-REWRITTEN
6035 /// arguments plus the [`crate::config::HookDecision`] the engine folds
6036 /// in, or the finished denial when the hook refused outright.
6037 ///
6038 /// The rewrite lands BEFORE the gate deliberately: the engine must
6039 /// evaluate what will actually run, so a hook cannot launder a denied
6040 /// command by rewriting it past the rules.
6041 #[allow(clippy::type_complexity)]
6042 fn run_pre_tool_hook(
6043 &self,
6044 name: &str,
6045 args: serde_json::Value,
6046 ) -> std::result::Result<(serde_json::Value, crate::config::HookDecision), PreparedCall> {
6047 let Some(hook) = &self.config.pre_tool_hook else {
6048 return Ok((args, crate::config::HookDecision::Pass));
6049 };
6050 let outcome = hook(name, &args);
6051 let args = outcome.updated_args.unwrap_or(args);
6052 if outcome.decision == crate::config::HookDecision::Deny {
6053 let reason = outcome.reason.unwrap_or_else(|| "denied".to_string());
6054 return Err(PreparedCall::Done((
6055 format!("Error: blocked by pre-tool hook: {reason}"),
6056 true,
6057 )));
6058 }
6059 Ok((args, outcome.decision))
6060 }
6061
6062 /// D-2 (Fable-5 delta review — LOW-MEDIUM, "over-grant residual"): the
6063 /// built-in tools whose `command`/`path`/`patch` arg IS semantically
6064 /// the whole call — the ONLY tools [`Self::permissions_gate_denial`]
6065 /// is allowed to turn into an [`permissions::ApprovalRequest::subject`]
6066 /// (see that method's own doc comment on the `subject` line for the
6067 /// full story). Bash-family (`bash`, and `shell` —
6068 /// [`crate::tools::builtins::PersistentShellTool`]'s registered name,
6069 /// what the review's "persistent-shell" refers to), the file tools
6070 /// (`read_file`/`write_file`/`edit_file`/`view_image`, whose `path` IS
6071 /// the subject), and `apply_patch` (whose `patch` envelope is handled
6072 /// separately but is unconditionally this tool only, see the `patch`
6073 /// local a few lines below). Deliberately NOT `list_dir`/`glob`/
6074 /// `search` — this crate's F2 fix (`ApprovalCache::key_for_request`)
6075 /// already falls back to a full-args digest for anything not on this
6076 /// list, which is a strictly SAFER (if slightly less cache-granular)
6077 /// default than guessing at more built-ins that weren't part of this
6078 /// finding.
6079 const SUBJECT_BEARING_BUILTIN_TOOLS: &'static [&'static str] = &[
6080 "bash",
6081 "shell",
6082 "read_file",
6083 "write_file",
6084 "edit_file",
6085 "view_image",
6086 "apply_patch",
6087 ];
6088
6089 /// P5-1: evaluate `name`'s call (with parsed `args`) against the
6090 /// permissions engine (`crate::permissions`) — builds the
6091 /// [`crate::permissions::RuleSet`] from `Config`'s deny/ask/allow
6092 /// pattern lists (folding [`Config::permissions_protected_paths`] into
6093 /// the `deny` tier, module 13), picks a command-, path-, or name-only
6094 /// evaluation depending on what `args` carries, resolves an `Ask`
6095 /// decision via the session cache + THIS agent's own installed
6096 /// [`crate::permissions::PermissionsApprovalHandler`], and returns
6097 /// `Some(reason)` when the call is refused (`None` = proceed). Thin
6098 /// wrapper over [`Self::permissions_gate_denial_impl`] — see that
6099 /// method's doc comment for why the handler is a parameter there.
6100 fn permissions_gate_denial(
6101 &self,
6102 name: &str,
6103 args: &serde_json::Value,
6104 hook: crate::config::HookDecision,
6105 ) -> Option<String> {
6106 self.permissions_gate_denial_impl(
6107 name,
6108 args,
6109 self.permissions_approval_handler.as_deref(),
6110 hook,
6111 )
6112 }
6113
6114 /// P5-6 (§2.2 C6, build brief "wire to the P5-1 engine's non-
6115 /// interactive fail-closed path"): the SAME rule-evaluation body as
6116 /// [`Self::permissions_gate_denial`], but the approval `handler` is a
6117 /// PARAMETER instead of always reading `self.permissions_approval_handler`
6118 /// — `Agent::background_permission_denial` calls this with `handler:
6119 /// None` (or a [`crate::subagents::ParentQueueApprovalHandler`]) so a
6120 /// `background_exec` call's `Ask`-tier decisions resolve exactly like a
6121 /// P5-3 background child's do (`crate::permissions::resolve_ask`'s
6122 /// pre-existing "no handler ⇒ deny" contract), REGARDLESS of whether
6123 /// this agent itself has an interactive handler installed for its own
6124 /// foreground calls — a background job must never block on a prompt it
6125 /// has no way to answer, even if the agent hosting it could otherwise
6126 /// answer one. The rule SET and default-policy baseline are otherwise
6127 /// identical to a foreground call's — only how an `Ask` decision
6128 /// resolves ever differs, and only in the strictly-narrower direction
6129 /// (never escalates past what a foreground call of the same command is
6130 /// allowed).
6131 fn permissions_gate_denial_impl(
6132 &self,
6133 name: &str,
6134 args: &serde_json::Value,
6135 handler: Option<&dyn crate::permissions::PermissionsApprovalHandler>,
6136 hook: crate::config::HookDecision,
6137 ) -> Option<String> {
6138 use crate::permissions::{self, Decision, PathKind};
6139
6140 // `Config.tool_deny_patterns`/`tool_allow_patterns` (P4a) ARE the
6141 // engine's deny/allow tiers — the same `capabilities.permissions.
6142 // rules.deny`/`.allow` keys, one source of truth, no duplication.
6143 // Protected paths (module 13) are an unconditional deny floor,
6144 // folded in here rather than checked separately, so they benefit
6145 // from the SAME first-match deny-wins priority every other deny
6146 // rule gets.
6147 // BP-5: the rule set itself is `permissions::rules_for_config` —
6148 // ONE construction shared with every other surface that has to ask
6149 // this engine a question (see that function's doc comment). Plan
6150 // mode's narrowing is layered on top of it here, because it is
6151 // this agent's live state, not the config's.
6152 let mut rules = permissions::rules_for_config(&self.config);
6153 // BP-3 (§2 module 8 `plan_mode`, dependency edge `plan_mode →
6154 // permissions.rules|sandbox`): the read-only research phase IS a
6155 // narrowing of this rule set — while the mode is active the write
6156 // and execution tools join the deny tier, and get the same
6157 // first-match, never-overridable treatment every other deny rule
6158 // gets. Inactive (the default, and the only state a config without
6159 // the module can reach) contributes NOTHING, so the rule set is
6160 // byte-identical to before.
6161 rules
6162 .deny
6163 .extend(crate::tools::plan_mode::deny_rules(&self.ctx.plan_mode));
6164
6165 // The baseline decision when NO rule matches at all — derived from
6166 // `ApprovalPolicy`, the same per-policy shape
6167 // `Config::needs_approval` uses for the legacy gate (see
6168 // `ApprovalPolicy::ModelRequested`'s doc comment for why this
6169 // richer gate approximates Codex's real "mostly silent" posture
6170 // instead of that method's conservative OnRequest-alike treatment
6171 // — explicit deny/ask rules still apply on top regardless).
6172 let default = permissions::default_decision(&self.config, name);
6173
6174 // BP-10: path rules are evaluated relative to EVERY granted root
6175 // (cwd + `core.additional_dirs`/`--add-dir`), folded to the
6176 // strictest — see `permissions::evaluate_path_safe_roots`'s doc
6177 // comment for why a grant must not also remove the root-relative
6178 // protected-path floor inside the granted directory. With no extra
6179 // dirs (the default) this is the single `cwd` list, byte-identical
6180 // to before.
6181 let mut roots = vec![self.config.cwd.clone()];
6182 roots.extend(self.config.additional_dirs.iter().cloned());
6183
6184 let command = args.get("command").and_then(|v| v.as_str());
6185 let path = args.get("path").and_then(|v| v.as_str());
6186 // F4 (Fable-5 adversarial review): `apply_patch`'s args carry a
6187 // patch ENVELOPE body (`args["patch"]`), not a `command` or a
6188 // `path` — the two branches above never fire for it, which is
6189 // exactly how a patch touching a protected path bypassed
6190 // `protected_paths` entirely. Only consulted for the `apply_patch`
6191 // tool specifically (a `patch`-shaped arg on some other tool is not
6192 // this envelope format and isn't given this treatment).
6193 let patch = (name == "apply_patch")
6194 .then(|| args.get("patch").and_then(|v| v.as_str()))
6195 .flatten();
6196 let decision = if let Some(command) = command {
6197 permissions::evaluate_command(&rules, name, command, default)
6198 } else if let Some(patch) = patch {
6199 // Same dual-check shape as the path branch below (pseudo-tool
6200 // `write(...)` rules from `protected_paths`, AND a rule
6201 // authored against the real `apply_patch` tool name), applied
6202 // to EVERY path the envelope's ops touch (`Add`/`Delete`/
6203 // `Update`'s `path`, plus `*** Move to:`). A patch that fails
6204 // to parse can't be proven to avoid a protected path — fail
6205 // closed to at least `Ask`, the same floor an unparseable bash
6206 // command gets in `permissions::evaluate_command`, rather than
6207 // silently let it through on `default`.
6208 let mut d = rules.evaluate(name, None).unwrap_or(default);
6209 match crate::tools::patch_target_paths(patch) {
6210 Ok(paths) => {
6211 for p in &paths {
6212 // SECURITY (CRITICAL fix): route both checks through
6213 // the safe-path-resolving variants — a patch target
6214 // like `x/../.git/config` must be caught exactly
6215 // like a `write_file`/`edit_file` `path` argument
6216 // would be (see `evaluate_path_safe`'s doc comment).
6217 let pseudo = permissions::evaluate_path_safe_roots(
6218 &rules,
6219 PathKind::Write,
6220 &roots,
6221 p,
6222 Decision::Allow,
6223 );
6224 let real_tool = permissions::evaluate_path_subject_safe_roots(
6225 &rules,
6226 name,
6227 &roots,
6228 p,
6229 Decision::Allow,
6230 );
6231 d = d.stricter(pseudo).stricter(real_tool);
6232 }
6233 }
6234 Err(_) => {
6235 d = d.stricter(Decision::Ask);
6236 }
6237 }
6238 d
6239 } else if let Some(path) = path {
6240 let kind = if matches!(name, "write_file" | "edit_file") {
6241 PathKind::Write
6242 } else {
6243 PathKind::Read
6244 };
6245 // TWO independent sources of path-shaped rules can apply to the
6246 // same call, and BOTH must be checked:
6247 // (a) the `read(...)`/`write(...)` pseudo-tool (tool-agnostic —
6248 // applies no matter WHICH tool touches the path; this is
6249 // what `Config::permissions_protected_paths`/module 13
6250 // expands into, via `protected_path_deny_rules`);
6251 // (b) a rule authored against the REAL tool name with the path
6252 // as its subject — design §4.4's own oc-parity worked
6253 // example writes exactly this shape (`"read_file(*.env)"`,
6254 // not a pseudo-tool), matching how `bash(cmdglob)` rules
6255 // are authored. `RuleSet::evaluate`'s bare-tool-name-glob
6256 // branch (no parens) ALSO fires here regardless of
6257 // `subject`, so this one call additionally covers a
6258 // blanket "deny this tool entirely" rule — no separate
6259 // `rules.evaluate(name, None)` call is needed.
6260 // SECURITY (CRITICAL fix, guarantor audit): both checks now
6261 // route through the safe-path-resolving variants (see
6262 // `evaluate_path_safe`'s doc comment) instead of glob-matching
6263 // the raw model-supplied `path` string directly — this is what
6264 // closes the traversal bypass (`write_file
6265 // path="x/../.git/config"`) and the analogous symlink escape.
6266 let pseudo_decision =
6267 permissions::evaluate_path_safe_roots(&rules, kind, &roots, path, default);
6268 let real_tool_decision =
6269 permissions::evaluate_path_subject_safe_roots(&rules, name, &roots, path, default);
6270 pseudo_decision.stricter(real_tool_decision)
6271 } else {
6272 rules.evaluate(name, None).unwrap_or(default)
6273 };
6274
6275 // BP-10 (catalog row "Hook/plugin permission veto"): the hook's
6276 // verdict is a TIER of this engine, folded in the one direction
6277 // that is always safe — `Ask` tightens (`stricter` never loosens),
6278 // `Deny` is a floor. `Allow` deliberately does NOT change the
6279 // decision here: it answers the `Ask` tier below (the
6280 // `PermissionRequest`-class reply CC's hooks give on the user's
6281 // behalf), so a `deny` rule still refuses the call outright — a
6282 // hook may skip a prompt, never a floor.
6283 let decision = match hook {
6284 crate::config::HookDecision::Deny => Decision::Deny,
6285 crate::config::HookDecision::Ask => decision.stricter(Decision::Ask),
6286 crate::config::HookDecision::Allow | crate::config::HookDecision::Pass => decision,
6287 };
6288
6289 // BP-10 (catalog row "Sandbox-escalation path", cx§4
6290 // `sandbox_permissions: "require_escalated"` + justification): the
6291 // model's channel to ASK for an unsandboxed run is a rule inside
6292 // this one engine, not a switch beside it. A call carrying
6293 // `with_escalated_permissions: true` is forced to at least the
6294 // `Ask` tier — never below whatever the rules already decided, so
6295 // a denied command cannot escalate its way out (`stricter` only
6296 // tightens), and never silently allowed under
6297 // `ApprovalPolicy::ModelRequested`/`Never`, whose `Allow` baseline
6298 // is exactly what made "the model requests escalation" a no-op
6299 // before. The `justification` rides in `raw_args` below, so the
6300 // approval door shows the user the model's own reason.
6301 let decision = if args
6302 .get("with_escalated_permissions")
6303 .and_then(|v| v.as_bool())
6304 .unwrap_or(false)
6305 {
6306 decision.stricter(Decision::Ask)
6307 } else {
6308 decision
6309 };
6310
6311 // D-2 (Fable-5 delta review — LOW-MEDIUM): `command`/`path`/`patch`
6312 // above are extracted (and used to DRIVE the decision above) for
6313 // ANY tool that happens to carry one of those arg names — that
6314 // part is unchanged and correct (a rule authored against, say, an
6315 // MCP tool's own name legitimately wants to glob-match its
6316 // `command`-shaped arg too). But the narrower single-field
6317 // `subject` handed to the cache/handler below must NOT do the
6318 // same for a non-built-in tool: an MCP (or other) tool's
6319 // `command`/`path` is just one field among potentially several
6320 // that together define what the call actually does — collapsing
6321 // an `AllowForSession` grant down to that one field would silently
6322 // auto-allow a later call with the SAME `command` but different
6323 // OTHER args (e.g. `{"command":"sync","target":"staging"}`
6324 // auto-allowing `{"command":"sync","target":"production"}`).
6325 // Restricting this to the known built-ins whose `subject` really
6326 // IS the whole call leaves every other tool with `subject: None`,
6327 // which routes it through `ApprovalCache::key_for_request`'s
6328 // full-args-digest fallback (F2) instead.
6329 let subject = Self::SUBJECT_BEARING_BUILTIN_TOOLS
6330 .contains(&name)
6331 .then(|| command.or(path).or(patch))
6332 .flatten();
6333 let req = permissions::ApprovalRequest {
6334 tool: name,
6335 subject,
6336 raw_args: args,
6337 };
6338 let approved = permissions::decision_to_approved(decision, || {
6339 // BP-10: a hook `Allow` answers this ask without a prompt (and
6340 // without a cache entry — the hook is consulted on every call,
6341 // so caching its answer would be a second, staler copy of the
6342 // same decision).
6343 if hook == crate::config::HookDecision::Allow {
6344 return true;
6345 }
6346 permissions::resolve_ask(&self.permissions_approval_cache, handler, &req)
6347 });
6348 if approved {
6349 None
6350 } else {
6351 Some(format!(
6352 "tool `{name}` was not approved for execution (permissions engine: {decision:?})"
6353 ))
6354 }
6355 }
6356
6357 /// P5-6 (§2.2 C6, build brief "a bg `rm -rf` subject to the same deny
6358 /// rules... must never escalate past what a foreground exec of the
6359 /// same command is allowed"): the permission gate `background_exec`
6360 /// runs BEFORE spawning anything. Evaluated against the tool name
6361 /// `"bash"` (not `"background_exec"`) deliberately — so any
6362 /// `bash(...)`-authored deny/ask/allow rule (or protected-path floor)
6363 /// applies to a background command byte-for-byte identically to a
6364 /// foreground `bash` call, the SAME rule set + default baseline
6365 /// [`Self::permissions_gate_denial`] would use for one.
6366 ///
6367 /// The one deliberate difference (C6 itself): an `Ask`-tier decision
6368 /// NEVER reaches an interactive handler here — a background job has no
6369 /// way to block on a prompt it can't answer. When
6370 /// [`Config::subagents_background_prompts`] is
6371 /// [`crate::subagents::BackgroundPromptsPolicy::Parent`], the denied
6372 /// request is additionally queued onto [`Self::pending_child_approvals`]
6373 /// (via [`crate::subagents::ParentQueueApprovalHandler`], reused
6374 /// verbatim — the SAME "parent-surfaced queue" §2.2 C6 names for
6375 /// `subagents.background`, with the job id standing in for a child
6376 /// agent id) for later inspection; any other configuration (including
6377 /// no `background_prompts` set at all) resolves via `handler: None` —
6378 /// [`crate::permissions::resolve_ask`]'s pre-existing "no handler ⇒
6379 /// deny" fail-closed default, identical to `subagents`'s own
6380 /// `AutoPolicy` reading. Either way, `Ask` always denies; only `Allow`
6381 /// (from the rule engine itself, or a PRIOR interactively-granted
6382 /// `AllowForSession` cache entry) ever lets a background command run —
6383 /// so this can only ever be as-or-more restrictive than a foreground
6384 /// call, never looser, regardless of configuration.
6385 ///
6386 /// Covers BOTH gate generations: when [`Config::permissions_enabled`]
6387 /// is on, the P5-1 engine (above) is used; otherwise the legacy
6388 /// [`Config::needs_approval`] gate is consulted but its
6389 /// `approval_handler` closure is NEVER invoked (that closure could
6390 /// itself block, e.g. a real interactive prompt) — an approval-required
6391 /// legacy policy simply denies a background command outright, the same
6392 /// never-hang guarantee under the older gate.
6393 fn background_permission_denial(
6394 &self,
6395 command: &str,
6396 job_id: &str,
6397 hook: crate::config::HookDecision,
6398 ) -> Option<String> {
6399 let args = serde_json::json!({ "command": command });
6400 if self.config.permissions_enabled {
6401 if let Some(crate::subagents::BackgroundPromptsPolicy::Parent) =
6402 self.config.subagents_background_prompts
6403 {
6404 let handler = crate::subagents::ParentQueueApprovalHandler {
6405 child_agent_id: format!("bg:{job_id}"),
6406 queue: self.pending_child_approvals.clone(),
6407 };
6408 self.permissions_gate_denial_impl("bash", &args, Some(&handler), hook)
6409 } else {
6410 self.permissions_gate_denial_impl("bash", &args, None, hook)
6411 }
6412 } else if self.config.needs_approval("bash") {
6413 Some(
6414 "tool `bash` requires approval, which a background job cannot request \
6415 interactively (§2.2 C6: auto-policy denies)"
6416 .to_string(),
6417 )
6418 } else {
6419 None
6420 }
6421 }
6422
6423 /// P4e (§3.1 `core.parallel_tool_calls`, catalog:59 "Independent
6424 /// sibling calls run concurrently"): runs `calls`' `Tool::execute()`
6425 /// futures CONCURRENTLY via `futures::future::join_all`, for whichever
6426 /// calls [`Self::prepare_tool_call`] resolves to [`PreparedCall::Ready`]
6427 /// — i.e. every plain (non-intrinsic) registry-tool call that passes
6428 /// its synchronous approval/doom-loop/pre-tool-hook checks. A call that
6429 /// resolves to [`PreparedCall::Done`] (an intrinsic, an unknown tool, a
6430 /// denied/blocked call) is NOT parallelized — its result is already in
6431 /// hand from the synchronous prepare pass. Every prepare check still
6432 /// runs sequentially, in original call order, before ANY `execute()`
6433 /// future starts (only the actual tool I/O overlaps) — so doom-loop
6434 /// bookkeeping and pre-tool-hook vetoes see the exact same call order
6435 /// they would under the sequential path. Returns results in the SAME
6436 /// order as `calls`, so callers can always `zip` the two. Post-tool
6437 /// hooks fire per call, in original order, once every result is in
6438 /// hand — a caller-visible timing difference from the sequential path
6439 /// ONLY when this method runs at all (i.e. only when
6440 /// `Config::parallel_tool_calls` is on): hooks see "this batch
6441 /// finished" ordering rather than "this one call finished" ordering.
6442 /// Documented, not a bug.
6443 async fn run_tools_concurrently(
6444 &mut self,
6445 calls: &[crate::message::ToolCall],
6446 ) -> Vec<(String, bool)> {
6447 // P5-3: `spawn_subagent`/`subagent_status` need sequential `&mut
6448 // self` access `prepare_tool_call`'s synchronous-only signature
6449 // can't give them (see `Self::run_tool`'s identical interception).
6450 // A batch that includes one falls back to dispatching the WHOLE
6451 // batch sequentially via `Self::run_tool` — a documented, narrow
6452 // simplification (not a partial-parallelization attempt) rather
6453 // than restructuring `PreparedCall` to carry a future; a batch with
6454 // no subagent intrinsic is completely unaffected and still
6455 // parallelizes exactly as before.
6456 if calls.iter().any(|c| {
6457 c.function.name == SPAWN_SUBAGENT
6458 || c.function.name == SUBAGENT_STATUS
6459 || c.function.name == SUBAGENT_MESSAGE
6460 || c.function.name == SUBAGENT_RESUME
6461 || (self.config.claude_runtime_tools_enabled
6462 && matches!(
6463 c.function.name.as_str(),
6464 CLAUDE_CRON_CREATE
6465 | CLAUDE_CRON_DELETE
6466 | CLAUDE_CRON_LIST
6467 | CLAUDE_SCHEDULE_WAKEUP
6468 ))
6469 }) {
6470 let mut out = Vec::with_capacity(calls.len());
6471 for call in calls {
6472 out.push(self.run_tool(call).await);
6473 }
6474 return out;
6475 }
6476 let prepared: Vec<PreparedCall> = calls.iter().map(|c| self.prepare_tool_call(c)).collect();
6477 let mut slots: Vec<Option<(String, bool)>> = prepared
6478 .iter()
6479 .map(|p| match p {
6480 PreparedCall::Done(r) => Some(r.clone()),
6481 PreparedCall::Ready { .. } => None,
6482 })
6483 .collect();
6484
6485 let ready_idxs: Vec<usize> = prepared
6486 .iter()
6487 .enumerate()
6488 .filter(|(_, p)| matches!(p, PreparedCall::Ready { .. }))
6489 .map(|(i, _)| i)
6490 .collect();
6491
6492 if !ready_idxs.is_empty() {
6493 let futs = ready_idxs.iter().map(|&i| {
6494 let PreparedCall::Ready { name, args } = &prepared[i] else {
6495 unreachable!("filtered to Ready above")
6496 };
6497 // `self.registry.get` borrows `self.registry` immutably;
6498 // `self.ctx` is `Clone` (P4c precedent) so each future owns
6499 // its own copy rather than borrowing `self` across the
6500 // `.await` inside `join_all`.
6501 let tool = self.registry.get(name).expect("prepared as Ready");
6502 let args = args.clone();
6503 let ctx = self.ctx.clone();
6504 async move {
6505 match tool.execute(args, &ctx).await {
6506 Ok(out) => (out, false),
6507 Err(e) => (format!("Error: {e}"), true),
6508 }
6509 }
6510 });
6511 let results = futures::future::join_all(futs).await;
6512 for (idx, result) in ready_idxs.iter().zip(results) {
6513 slots[*idx] = Some(result);
6514 }
6515 }
6516
6517 let out: Vec<(String, bool)> = slots
6518 .into_iter()
6519 .map(|s| s.expect("every call resolved to Some above"))
6520 .collect();
6521 // Post-tool hook, in original order — only for calls that actually
6522 // reached `execute()` (matches `run_tool`'s existing behavior: an
6523 // intrinsic/denied/blocked call never fires the post-tool hook).
6524 let ready_set: std::collections::HashSet<usize> = ready_idxs.into_iter().collect();
6525 for (i, call) in calls.iter().enumerate() {
6526 if !ready_set.contains(&i) {
6527 continue;
6528 }
6529 let (output, is_error) = &out[i];
6530 if let Some(hook) = &self.config.post_tool_hook {
6531 hook(&call.function.name, output, *is_error);
6532 }
6533 }
6534 out
6535 }
6536
6537 /// P4c (§5.2 P4 "doom-loop breaker", §3.1 `core.doom_loop_threshold`):
6538 /// update the consecutive-identical-call streak for `(name, args)` and
6539 /// return `Some(reason)` the moment the streak reaches
6540 /// `Config.doom_loop_threshold` (a call whose name AND JSON-canonical
6541 /// arguments are byte-identical to the immediately preceding call
6542 /// extends the streak; anything else resets it to 1). `None`
6543 /// (`Config.doom_loop_threshold` unset, or `Some(n)` with `n < 2` — a
6544 /// threshold below 2 can never fire since the FIRST call already
6545 /// "repeats zero times") never touches the streak fields at all.
6546 fn check_doom_loop(&mut self, name: &str, args: &serde_json::Value) -> Option<String> {
6547 let threshold = self.config.doom_loop_threshold?;
6548 if threshold < 2 {
6549 return None;
6550 }
6551 // `serde_json::Value::Object` is a `BTreeMap` in this workspace (no
6552 // `preserve_order` feature), so `to_string()` is already
6553 // key-order-canonical — two calls that differ only in argument key
6554 // order are still treated as identical.
6555 let key = (name.to_string(), args.to_string());
6556 if self.doom_loop_last_call.as_ref() == Some(&key) {
6557 self.doom_loop_streak += 1;
6558 } else {
6559 self.doom_loop_last_call = Some(key);
6560 self.doom_loop_streak = 1;
6561 }
6562 if self.doom_loop_streak >= threshold {
6563 Some(format!(
6564 "doom-loop breaker: `{name}` called with identical arguments {} times in a row \
6565 — try a different approach instead of repeating the same call",
6566 self.doom_loop_streak
6567 ))
6568 } else {
6569 None
6570 }
6571 }
6572
6573 /// Whether `name` is in the eagerly-advertised "core" set for the current
6574 /// [`ToolAdvertising`] mode: every enabled tool under `Full`, or the
6575 /// explicit `core` allowlist under `Deferred`.
6576 fn is_core_tool(&self, name: &str) -> bool {
6577 match &self.config.tool_advertising {
6578 ToolAdvertising::Full => true,
6579 ToolAdvertising::Deferred { core } => core.iter().any(|c| c == name),
6580 }
6581 }
6582
6583 /// The schema advertised on the wire for `t`: the raw (as-shipped)
6584 /// schema with TR-8/T5's per-tool schema tier applied. This is what
6585 /// [`Self::tool_schemas`] sends every request.
6586 fn schema_for(&self, t: &dyn crate::tools::Tool) -> ToolSchema {
6587 let raw = self.raw_schema_for(t);
6588 let tier = self.config.schema_tier_for(t.name());
6589 let (description, parameters) =
6590 crate::tools::tiers::minify(&raw.description, &raw.parameters, tier);
6591 ToolSchema {
6592 name: raw.name,
6593 description,
6594 parameters,
6595 }
6596 }
6597
6598 /// The ORIGINAL, as-shipped schema for `t` — never tier-minified. This is
6599 /// the full contract [`Self::run_tool_search`] hands back on activation
6600 /// (TR-8/T5 dev/03: the B6 fetch path is the invert of tiering, so a
6601 /// model that fetched a tool via `tool_search` always sees the complete
6602 /// schema, byte-equal to `t.description()`/`t.parameters()` — modulo the
6603 /// pre-existing [`crate::Config::tool_description`] override, which is
6604 /// orthogonal to tiering).
6605 fn raw_schema_for(&self, t: &dyn crate::tools::Tool) -> ToolSchema {
6606 ToolSchema {
6607 name: t.name().to_string(),
6608 description: self
6609 .config
6610 .tool_description(t.name(), t.description())
6611 .to_string(),
6612 parameters: t.parameters(),
6613 }
6614 }
6615
6616 /// The synthetic `tool_search` schema advertised under `Deferred` (B6).
6617 fn tool_search_schema() -> ToolSchema {
6618 ToolSchema {
6619 name: TOOL_SEARCH.to_string(),
6620 description: "Search for additional tools not currently advertised (the deferred \
6621 MCP surface and any other non-core tools). Matches keywords case-insensitively \
6622 against each tool's name and description. Matched tools become callable starting \
6623 with your NEXT message, not this one."
6624 .to_string(),
6625 parameters: serde_json::json!({
6626 "type": "object",
6627 "properties": {
6628 "query": {
6629 "type": "string",
6630 "description": "Keyword(s) to search for in tool names and descriptions."
6631 },
6632 "max_results": {
6633 "type": "integer",
6634 "description": "Maximum number of matching tools to return."
6635 }
6636 },
6637 "required": ["query"],
6638 "additionalProperties": false
6639 }),
6640 }
6641 }
6642
6643 /// The tool-schema array this agent would advertise on its NEXT
6644 /// request, exactly as `Self::run_loop` computes it. Public
6645 /// (PARITY-18 D1) so a caller can measure the real request-token cost
6646 /// of an agent's tool surface — including the current
6647 /// [`crate::config::ToolAdvertising`] mode's core/deferred split and
6648 /// the synthetic `tool_search`/`expand_reduction`/`sidecar_search`
6649 /// schemas — BEFORE ever calling [`Self::send`], e.g. for a preflight
6650 /// context-guard check.
6651 pub fn tool_schemas(&self) -> Vec<ToolSchema> {
6652 let mut out = match &self.config.tool_advertising {
6653 ToolAdvertising::Full => self
6654 .registry
6655 .iter()
6656 .filter(|t| self.config.tool_enabled(t.name()))
6657 .map(|t| self.schema_for(t))
6658 .collect(),
6659 ToolAdvertising::Deferred { .. } => {
6660 let mut out: Vec<ToolSchema> = self
6661 .registry
6662 .iter()
6663 .filter(|t| self.config.tool_enabled(t.name()))
6664 .filter(|t| {
6665 self.is_core_tool(t.name()) || self.activated_tools.contains(t.name())
6666 })
6667 .map(|t| self.schema_for(t))
6668 .collect();
6669 out.push(Self::tool_search_schema());
6670 out
6671 }
6672 };
6673 // T12/TR-1: `expand_reduction`/`sidecar_search` are orthogonal to
6674 // `tool_advertising` (which governs the ordinary tool surface) —
6675 // advertised whenever a `ReductionPolicy` is installed, regardless of
6676 // Full/Deferred, since only a reduced session ever has anything to
6677 // expand or search (SPEC.md TR-1 dev/01).
6678 if self.reduction_policy.is_some() {
6679 out.push(Self::expand_reduction_schema());
6680 out.push(Self::sidecar_search_schema());
6681 }
6682 // P5-3 (§2 module 9): `spawn_subagent`/`subagent_status` are
6683 // orthogonal to `tool_advertising` too, same reasoning as
6684 // `expand_reduction`/`sidecar_search` above — advertised whenever
6685 // `Config::subagents_enabled` is on, Full or Deferred alike.
6686 // `false` (the default) never appends either, so a config that
6687 // never turns the module on gets byte-identical tool schemas to
6688 // today.
6689 if self.config.subagents_enabled {
6690 out.push(self.spawn_subagent_schema());
6691 if self.config.subagents_claude_agent_alias {
6692 out.push(self.claude_agent_schema());
6693 }
6694 if self.config.subagents_background {
6695 out.push(Self::subagent_status_schema());
6696 // BP-7 (catalog §4a "Background subagents + resume"): the
6697 // two halves the row named as missing — a mailbox into a
6698 // still-running child, and a resume of a finished one with
6699 // its context intact.
6700 out.push(Self::subagent_message_schema());
6701 out.push(Self::subagent_resume_schema());
6702 }
6703 }
6704 if self.config.claude_runtime_tools_enabled {
6705 out.extend(self.claude_builtin_tool_schemas());
6706 out.push(Self::claude_cron_create_schema());
6707 out.push(Self::claude_cron_delete_schema());
6708 out.push(Self::claude_cron_list_schema());
6709 out.push(Self::claude_schedule_wakeup_schema());
6710 }
6711 // P5-6 (§2 module 4 `tools.background`): same orthogonal-to-
6712 // `tool_advertising` treatment, advertised whenever
6713 // `Config::tools_background_enabled` is on. `false` (the default)
6714 // never appends any of the four, so a config that never turns the
6715 // module on gets byte-identical tool schemas to today.
6716 if self.config.tools_background_enabled {
6717 out.push(Self::background_exec_schema());
6718 out.push(Self::background_status_schema());
6719 out.push(Self::background_list_schema());
6720 out.push(Self::background_kill_schema());
6721 }
6722 // BP-10 (catalog row "Tool hiding via policy", cc§4 "bare-name
6723 // deny"): a policy deny does not merely REFUSE the call at
6724 // dispatch — it removes the tool from the model's view. Applied
6725 // once, here, over the finished array, so every family appended
6726 // above (`spawn_subagent`, `background_*`, the Claude aliases,
6727 // `expand_reduction`, …) is hidden by the same one rule, not by a
6728 // per-family repeat of it. See [`Self::policy_hides_tool`] for
6729 // which deny tier is consulted and why.
6730 out.retain(|schema| !self.policy_hides_tool(&schema.name));
6731 out
6732 }
6733
6734 /// BP-10: whether the CONFIG-DECLARED deny tier hides `name` from the
6735 /// model's tool surface entirely (cc§4: CC's bare-name deny "removes
6736 /// the tool from the model's view", where an ordinary rule only
6737 /// refuses the call).
6738 ///
6739 /// The ONE engine decides: this is
6740 /// [`crate::permissions::RuleSet::evaluate`] with `subject: None`, so
6741 /// exactly the patterns that can be satisfied by a tool NAME ALONE
6742 /// (`"bash"`, `"mcp_*"`, `"*"`) hide; a rule that names a
6743 /// command/path constraint (`"bash(rm -rf*)"`, `"write(.git/**)"`) is
6744 /// not satisfiable without a subject and therefore never hides a tool
6745 /// — the same `rule_matches` contract the dispatch gate uses.
6746 ///
6747 /// **Which deny tier.** `Config::tool_deny_patterns` — the
6748 /// `capabilities.permissions.rules.deny` array — and NOT the two
6749 /// runtime narrowings the dispatch gate folds in beside it:
6750 /// `protected_paths` expands to `read(...)`/`write(...)` patterns that
6751 /// carry a subject by construction (so they could never match here
6752 /// anyway), and `plan_mode::deny_rules` is a MODE, not a policy — CC's
6753 /// plan mode refuses a write, it does not make Write disappear and
6754 /// reappear as the mode toggles mid-session. Hiding is a property of
6755 /// the configured policy, which is fixed for the run.
6756 ///
6757 /// Gated on [`Config::permissions_enabled`]: a config that never turns
6758 /// the module on gets byte-identical schemas to before this existed.
6759 fn policy_hides_tool(&self, name: &str) -> bool {
6760 if !self.config.permissions_enabled || self.config.tool_deny_patterns.is_empty() {
6761 return false;
6762 }
6763 let rules = crate::permissions::RuleSet {
6764 deny: self.config.tool_deny_patterns.clone(),
6765 ..Default::default()
6766 };
6767 rules.evaluate(name, None) == Some(crate::permissions::Decision::Deny)
6768 }
6769
6770 fn claude_builtin_tool_schemas(&self) -> Vec<ToolSchema> {
6771 let mut schemas = Vec::new();
6772 let mut push = |alias: &str, native: &str, description: &str, parameters| {
6773 if self.registry.get(native).is_some() && self.config.tool_enabled(native) {
6774 schemas.push(ToolSchema {
6775 name: alias.to_string(),
6776 description: description.to_string(),
6777 parameters,
6778 });
6779 }
6780 };
6781 push(
6782 CLAUDE_BASH,
6783 "bash",
6784 "Claude Code-compatible shell command execution.",
6785 serde_json::json!({
6786 "type": "object",
6787 "properties": {
6788 "command": {"type": "string"},
6789 "timeout": {"type": "integer", "description": "Timeout in milliseconds."},
6790 "description": {"type": "string"}
6791 },
6792 "required": ["command"],
6793 "additionalProperties": true
6794 }),
6795 );
6796 push(
6797 CLAUDE_READ,
6798 "read_file",
6799 "Claude Code-compatible file reader.",
6800 serde_json::json!({
6801 "type": "object",
6802 "properties": {
6803 "file_path": {"type": "string"},
6804 "offset": {"type": "integer"},
6805 "limit": {"type": "integer"}
6806 },
6807 "required": ["file_path"],
6808 "additionalProperties": false
6809 }),
6810 );
6811 push(
6812 CLAUDE_WRITE,
6813 "write_file",
6814 "Claude Code-compatible file writer.",
6815 serde_json::json!({
6816 "type": "object",
6817 "properties": {"file_path": {"type": "string"}, "content": {"type": "string"}},
6818 "required": ["file_path", "content"],
6819 "additionalProperties": false
6820 }),
6821 );
6822 push(
6823 CLAUDE_EDIT,
6824 "edit_file",
6825 "Claude Code-compatible exact file edit.",
6826 serde_json::json!({
6827 "type": "object",
6828 "properties": {
6829 "file_path": {"type": "string"},
6830 "old_string": {"type": "string"},
6831 "new_string": {"type": "string"},
6832 "replace_all": {"type": "boolean"}
6833 },
6834 "required": ["file_path", "old_string", "new_string"],
6835 "additionalProperties": false
6836 }),
6837 );
6838 push(
6839 CLAUDE_GLOB,
6840 "glob",
6841 "Claude Code-compatible file glob.",
6842 serde_json::json!({
6843 "type": "object",
6844 "properties": {"pattern": {"type": "string"}, "path": {"type": "string"}},
6845 "required": ["pattern"],
6846 "additionalProperties": false
6847 }),
6848 );
6849 push(
6850 CLAUDE_GREP,
6851 "search",
6852 "Claude Code-compatible content search.",
6853 serde_json::json!({
6854 "type": "object",
6855 "properties": {"pattern": {"type": "string"}, "path": {"type": "string"}},
6856 "required": ["pattern"],
6857 "additionalProperties": true
6858 }),
6859 );
6860 schemas
6861 }
6862
6863 fn translate_claude_builtin_call(
6864 &self,
6865 call: &crate::message::ToolCall,
6866 ) -> Result<Option<crate::message::ToolCall>> {
6867 let native = match call.function.name.as_str() {
6868 CLAUDE_BASH => "bash",
6869 CLAUDE_READ => "read_file",
6870 CLAUDE_WRITE => "write_file",
6871 CLAUDE_EDIT => "edit_file",
6872 CLAUDE_GLOB => "glob",
6873 CLAUDE_GREP => "search",
6874 _ => return Ok(None),
6875 };
6876 let mut args = call.function.parsed_arguments()?;
6877 let object = args
6878 .as_object_mut()
6879 .ok_or_else(|| Error::InvalidArguments {
6880 tool: call.function.name.clone(),
6881 message: "expected a JSON object".to_string(),
6882 })?;
6883 if let Some(path) = object.remove("file_path") {
6884 object.entry("path".to_string()).or_insert(path);
6885 }
6886 if call.function.name == CLAUDE_BASH {
6887 if let Some(timeout) = object.remove("timeout") {
6888 object.entry("timeout_ms".to_string()).or_insert(timeout);
6889 }
6890 }
6891 if call.function.name == CLAUDE_GLOB {
6892 if let Some(path) = object
6893 .remove("path")
6894 .and_then(|value| value.as_str().map(str::to_owned))
6895 {
6896 if let Some(pattern) = object.get_mut("pattern") {
6897 if let Some(value) = pattern.as_str() {
6898 if !std::path::Path::new(value).is_absolute() {
6899 *pattern = serde_json::Value::String(
6900 std::path::Path::new(&path)
6901 .join(value)
6902 .to_string_lossy()
6903 .into_owned(),
6904 );
6905 }
6906 }
6907 }
6908 }
6909 }
6910 let mut translated = call.clone();
6911 translated.function.name = native.to_string();
6912 translated.function.arguments = serde_json::to_string(&args)?;
6913 Ok(Some(translated))
6914 }
6915
6916 fn claude_cron_create_schema() -> ToolSchema {
6917 ToolSchema {
6918 name: CLAUDE_CRON_CREATE.to_string(),
6919 description: "Record a Claude-compatible cron job in the imported runtime manifest. \
6920 The job inherits the manifest's ACTIVE or PAUSED posture; an embedding scheduler, \
6921 not this agent loop, owns execution."
6922 .to_string(),
6923 parameters: serde_json::json!({
6924 "type": "object",
6925 "properties": {
6926 "cron": {"type": "string", "description": "Cron expression to preserve."},
6927 "prompt": {"type": "string", "description": "Prompt associated with the job."},
6928 "recurring": {"type": "boolean", "default": false},
6929 "durable": {"type": "boolean", "default": false}
6930 },
6931 "required": ["cron", "prompt"],
6932 "additionalProperties": false
6933 }),
6934 }
6935 }
6936
6937 fn claude_cron_delete_schema() -> ToolSchema {
6938 ToolSchema {
6939 name: CLAUDE_CRON_DELETE.to_string(),
6940 description: "Delete a Claude-compatible cron job from the imported manifest. \
6941 This updates state only; an embedding scheduler owns execution."
6942 .to_string(),
6943 parameters: serde_json::json!({
6944 "type": "object",
6945 "properties": {"id": {"type": "string"}},
6946 "required": ["id"],
6947 "additionalProperties": false
6948 }),
6949 }
6950 }
6951
6952 fn claude_cron_list_schema() -> ToolSchema {
6953 ToolSchema {
6954 name: CLAUDE_CRON_LIST.to_string(),
6955 description: "List imported Claude cron jobs and their explicit ACTIVE or PAUSED \
6956 manifest posture. This agent loop itself does not run a scheduler."
6957 .to_string(),
6958 parameters: serde_json::json!({
6959 "type": "object",
6960 "properties": {},
6961 "additionalProperties": false
6962 }),
6963 }
6964 }
6965
6966 fn claude_schedule_wakeup_schema() -> ToolSchema {
6967 ToolSchema {
6968 name: CLAUDE_SCHEDULE_WAKEUP.to_string(),
6969 description: "Replace the one-shot wakeup stored in the imported Claude manifest. \
6970 The wakeup inherits the manifest's ACTIVE or PAUSED posture; an embedding scheduler \
6971 owns timer execution."
6972 .to_string(),
6973 parameters: serde_json::json!({
6974 "type": "object",
6975 "properties": {
6976 "delaySeconds": {"type": "integer", "minimum": 0},
6977 "reason": {"type": "string"},
6978 "prompt": {"type": "string"}
6979 },
6980 "required": ["delaySeconds"],
6981 "additionalProperties": false
6982 }),
6983 }
6984 }
6985
6986 /// The `background_exec` schema (P5-6, §2 module 4, D1 "background
6987 /// exec").
6988 fn background_exec_schema() -> ToolSchema {
6989 ToolSchema {
6990 name: BACKGROUND_EXEC.to_string(),
6991 description: "Run a shell command in the BACKGROUND: spawns it as a detached \
6992 process and returns a `job_id` IMMEDIATELY, before the command finishes — this \
6993 call never returns the command's output. Poll `background_status` with the \
6994 `job_id` to check progress and retrieve captured output; use `background_kill` \
6995 to cancel it early. The command goes through the exact same sandbox/permission \
6996 checks as a foreground `bash` call, and any check that would need an \
6997 interactive approval is denied automatically (a background job cannot wait for \
6998 one)."
6999 .to_string(),
7000 parameters: serde_json::json!({
7001 "type": "object",
7002 "properties": {
7003 "command": {
7004 "type": "string",
7005 "description": "Shell command to run in the background via `sh -c`."
7006 }
7007 },
7008 "required": ["command"],
7009 "additionalProperties": false
7010 }),
7011 }
7012 }
7013
7014 /// The `background_status` schema (P5-6, D1 "monitor/event feed").
7015 fn background_status_schema() -> ToolSchema {
7016 ToolSchema {
7017 name: BACKGROUND_STATUS.to_string(),
7018 description: "Check on a background job spawned via background_exec: its \
7019 running/exited/killed status, exit code (once known), and the command's \
7020 captured stdout/stderr so far (bounded — very large output is truncated with a \
7021 marker). Once the job has exited or been killed, this call also reaps it (it \
7022 will no longer appear in background_list or accept further status polls)."
7023 .to_string(),
7024 parameters: serde_json::json!({
7025 "type": "object",
7026 "properties": {
7027 "job_id": {
7028 "type": "string",
7029 "description": "The id `background_exec` returned when this job was \
7030 started."
7031 }
7032 },
7033 "required": ["job_id"],
7034 "additionalProperties": false
7035 }),
7036 }
7037 }
7038
7039 /// The `background_list` schema (P5-6, D10 "bg-manager").
7040 fn background_list_schema() -> ToolSchema {
7041 ToolSchema {
7042 name: BACKGROUND_LIST.to_string(),
7043 description: "List every background job currently tracked (running, or finished \
7044 but not yet polled via background_status) — job id, command, status, pid, and \
7045 start time for each. Does not retrieve output or reap anything."
7046 .to_string(),
7047 parameters: serde_json::json!({
7048 "type": "object",
7049 "properties": {},
7050 "additionalProperties": false
7051 }),
7052 }
7053 }
7054
7055 /// The `background_kill` schema (P5-6, D10 "bg-manager").
7056 fn background_kill_schema() -> ToolSchema {
7057 ToolSchema {
7058 name: BACKGROUND_KILL.to_string(),
7059 description: "Kill a background job's real process immediately (a no-op, not an \
7060 error, if it already exited on its own) and reap it."
7061 .to_string(),
7062 parameters: serde_json::json!({
7063 "type": "object",
7064 "properties": {
7065 "job_id": {
7066 "type": "string",
7067 "description": "The id `background_exec` returned when this job was \
7068 started."
7069 }
7070 },
7071 "required": ["job_id"],
7072 "additionalProperties": false
7073 }),
7074 }
7075 }
7076
7077 /// The `spawn_subagent` schema (P5-3, §2 module 9 D1 "spawn tool").
7078 /// Lists every configured `agent_type` name so the model knows what's
7079 /// available, but `agent_type` stays optional — an ad-hoc spawn with an
7080 /// inline `system_prompt` is always allowed too.
7081 fn spawn_subagent_schema(&self) -> ToolSchema {
7082 let mut names: Vec<&str> = self
7083 .config
7084 .subagents_definitions
7085 .keys()
7086 .map(String::as_str)
7087 .collect();
7088 names.sort_unstable();
7089 let agent_type_desc = if names.is_empty() {
7090 "Optional named subagent type to run (none configured — omit this and pass \
7091 `system_prompt` instead)."
7092 .to_string()
7093 } else {
7094 format!(
7095 "Optional named subagent type to run: {}. Omit to run an ad-hoc subagent with \
7096 your own `system_prompt` instead.",
7097 names.join(", ")
7098 )
7099 };
7100 let background_desc = if self.config.subagents_background {
7101 "Run this subagent in the background instead of waiting for it — this call \
7102 returns immediately with a `subagent_id`; poll `subagent_status` with that id for \
7103 the result."
7104 } else {
7105 "Background subagents are disabled for this agent — this must be omitted or false."
7106 };
7107 ToolSchema {
7108 name: SPAWN_SUBAGENT.to_string(),
7109 description: "Spawn a subagent to work on a self-contained task and (by default) \
7110 wait for its final answer, which is returned as this call's result. The \
7111 subagent runs its own independent reasoning/tool loop; it does not see your \
7112 conversation except for the `task` text you give it here."
7113 .to_string(),
7114 parameters: serde_json::json!({
7115 "type": "object",
7116 "properties": {
7117 "task": {
7118 "type": "string",
7119 "description": "The self-contained task/prompt for the subagent."
7120 },
7121 "agent_type": {
7122 "type": "string",
7123 "description": agent_type_desc
7124 },
7125 "system_prompt": {
7126 "type": "string",
7127 "description": "Inline system prompt for an ad-hoc subagent (ignored \
7128 if `agent_type` is given — the named type's own prompt is used \
7129 instead)."
7130 },
7131 "background": {
7132 "type": "boolean",
7133 "description": background_desc
7134 }
7135 },
7136 "required": ["task"],
7137 "additionalProperties": false
7138 }),
7139 }
7140 }
7141
7142 /// Claude Code-compatible alias for [`Self::spawn_subagent_schema`].
7143 fn claude_agent_schema(&self) -> ToolSchema {
7144 let mut names: Vec<String> = self.config.subagents_definitions.keys().cloned().collect();
7145 names.push("general-purpose".into());
7146 names.sort_unstable();
7147 names.dedup();
7148 ToolSchema {
7149 name: CLAUDE_AGENT.to_string(),
7150 description: "Claude Code-compatible subagent dispatcher. Runs a named or ad-hoc \
7151 child agent; children default to background execution in this compatibility mode."
7152 .to_string(),
7153 parameters: serde_json::json!({
7154 "type": "object",
7155 "properties": {
7156 "prompt": {"type": "string", "description": "Self-contained child task."},
7157 "subagent_type": {
7158 "type": "string",
7159 "description": format!("Named agent type. Available: {}", names.join(", "))
7160 },
7161 "description": {
7162 "type": "string",
7163 "description": "Short human-facing task label; preserved as descriptive input."
7164 },
7165 "model": {
7166 "type": "string",
7167 "description": "Optional model alias or full provider slug for this child."
7168 },
7169 "run_in_background": {
7170 "type": "boolean",
7171 "description": "Whether to return immediately with a child id (default true)."
7172 }
7173 },
7174 "required": ["prompt"],
7175 "additionalProperties": false
7176 }),
7177 }
7178 }
7179
7180 /// Translate Claude's `Agent` arguments to the native subagent intrinsic.
7181 fn translate_claude_agent_call(
7182 &self,
7183 call: &crate::message::ToolCall,
7184 ) -> Result<crate::message::ToolCall> {
7185 let args = call
7186 .function
7187 .parsed_arguments()
7188 .map_err(|error| Error::InvalidArguments {
7189 tool: CLAUDE_AGENT.to_string(),
7190 message: error.to_string(),
7191 })?;
7192 let object = args.as_object().ok_or_else(|| Error::InvalidArguments {
7193 tool: CLAUDE_AGENT.to_string(),
7194 message: "arguments must be an object".to_string(),
7195 })?;
7196 let mut translated = serde_json::Map::new();
7197 if let Some(value) = object.get("prompt") {
7198 translated.insert("task".to_string(), value.clone());
7199 }
7200 if let Some(value) = object.get("subagent_type") {
7201 // `general-purpose` is a built-in Claude agent, not a project
7202 // definition file. Supercode's equivalent is an ad-hoc child
7203 // using the inherited default system prompt, represented by an
7204 // omitted `agent_type`.
7205 if value.as_str() != Some("general-purpose") {
7206 translated.insert("agent_type".to_string(), value.clone());
7207 }
7208 }
7209 if let Some(value) = object.get("model") {
7210 translated.insert("model".to_string(), value.clone());
7211 }
7212 translated.insert(
7213 "background".to_string(),
7214 object
7215 .get("run_in_background")
7216 .cloned()
7217 .unwrap_or(serde_json::Value::Bool(true)),
7218 );
7219 Ok(crate::message::ToolCall {
7220 id: call.id.clone(),
7221 kind: call.kind.clone(),
7222 function: crate::message::FunctionCall {
7223 name: SPAWN_SUBAGENT.to_string(),
7224 arguments: serde_json::Value::Object(translated).to_string(),
7225 },
7226 })
7227 }
7228
7229 /// Execute Claude's scheduling vocabulary against the imported manifest.
7230 ///
7231 /// This is intentionally a state editor, not a scheduler: it owns no
7232 /// timer/task handle, nothing downstream of it fires, and every
7233 /// successful response says so, so the model is never told a job it just
7234 /// created will run here.
7235 fn run_claude_runtime_tool(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
7236 let args = match call.function.parsed_arguments() {
7237 Ok(value) if value.is_object() => value,
7238 Ok(_) => {
7239 return (
7240 format!("Error: {} arguments must be an object", call.function.name),
7241 true,
7242 )
7243 }
7244 Err(error) => return (format!("Error: {error}"), true),
7245 };
7246 let object = args.as_object().expect("checked object above");
7247
7248 let Some(manifest) = self.claude_runtime_manifest.as_mut() else {
7249 return (
7250 "Error: Claude runtime compatibility was enabled without an imported runtime \
7251 manifest; refusing to invent scheduler state"
7252 .to_string(),
7253 true,
7254 );
7255 };
7256 // Every imported schedule is carried and inert. `state` is the single
7257 // fact the model is told about it, in the manifest's own vocabulary.
7258 let state = "paused";
7259
7260 match call.function.name.as_str() {
7261 CLAUDE_CRON_LIST => {
7262 let jobs: Vec<serde_json::Value> = manifest
7263 .active_crons
7264 .iter()
7265 .map(|job| {
7266 serde_json::json!({
7267 "id": job.id,
7268 "cron": job.schedule,
7269 "prompt": job.prompt,
7270 "recurring": job.recurring,
7271 "durable": job.durable_requested,
7272 "state": state
7273 })
7274 })
7275 .collect();
7276 let notice = "Imported jobs are preserved but no scheduler is running.";
7277 (
7278 serde_json::json!({
7279 "execution_state": state,
7280 "execution_notice": notice,
7281 "jobs": jobs
7282 })
7283 .to_string(),
7284 false,
7285 )
7286 }
7287 CLAUDE_CRON_CREATE => {
7288 let Some(schedule) = object.get("cron").and_then(serde_json::Value::as_str) else {
7289 return ("Error: CronCreate requires string `cron`".to_string(), true);
7290 };
7291 let Some(prompt) = object.get("prompt").and_then(serde_json::Value::as_str) else {
7292 return (
7293 "Error: CronCreate requires string `prompt`".to_string(),
7294 true,
7295 );
7296 };
7297 let recurring = object
7298 .get("recurring")
7299 .and_then(serde_json::Value::as_bool)
7300 .unwrap_or(false);
7301 let durable_requested = object
7302 .get("durable")
7303 .and_then(serde_json::Value::as_bool)
7304 .unwrap_or(false);
7305 let mut sequence = 1_u64;
7306 let id = loop {
7307 let candidate = format!("sc{sequence:06}");
7308 if !manifest.active_crons.iter().any(|job| job.id == candidate) {
7309 break candidate;
7310 }
7311 sequence += 1;
7312 };
7313 let kind = if recurring { "recurring " } else { "" };
7314 let result = format!(
7315 "Scheduled {kind}job {id} ({schedule}) in PAUSED state. The job is preserved \
7316 in the continuation manifest but no scheduler is running and it will not execute."
7317 );
7318 manifest
7319 .active_crons
7320 .push(crate::claude_runtime_state::ClaudeCronJob {
7321 id: id.clone(),
7322 tool_use_id: call.id.clone(),
7323 schedule: schedule.to_string(),
7324 recurring,
7325 durable_requested,
7326 prompt: prompt.to_string(),
7327 // The creation instant is a fact about this
7328 // continuation, recorded like every other manifest
7329 // field. Nothing consults it as a due time.
7330 created_at: Some(crate::sidecar::ms_to_rfc3339(now_ms())),
7331 expires_after_seconds: None,
7332 creation_result: result.clone(),
7333 });
7334 manifest
7335 .active_crons
7336 .sort_by(|left, right| left.id.cmp(&right.id));
7337 (result, false)
7338 }
7339 CLAUDE_CRON_DELETE => {
7340 let Some(id) = object.get("id").and_then(serde_json::Value::as_str) else {
7341 return ("Error: CronDelete requires string `id`".to_string(), true);
7342 };
7343 let Some(index) = manifest.active_crons.iter().position(|job| job.id == id) else {
7344 return (
7345 format!("Error: unknown {state} Claude cron job `{id}`"),
7346 true,
7347 );
7348 };
7349 manifest.active_crons.remove(index);
7350 (
7351 format!("Cancelled job {id}. The job was PAUSED; no execution occurred."),
7352 false,
7353 )
7354 }
7355 CLAUDE_SCHEDULE_WAKEUP => {
7356 let Some(delay_seconds) = object
7357 .get("delaySeconds")
7358 .and_then(serde_json::Value::as_u64)
7359 else {
7360 return (
7361 "Error: ScheduleWakeup requires integer `delaySeconds`".to_string(),
7362 true,
7363 );
7364 };
7365 let reason = object
7366 .get("reason")
7367 .and_then(serde_json::Value::as_str)
7368 .map(str::to_string);
7369 let prompt = object
7370 .get("prompt")
7371 .and_then(serde_json::Value::as_str)
7372 .map(str::to_string);
7373 let now = now_ms();
7374 let created_at = Some(crate::sidecar::ms_to_rfc3339(now));
7375 // The instant the wakeup asks for, recorded as the request
7376 // made it. No timer consults it here.
7377 let delay_ms = i64::try_from(delay_seconds)
7378 .unwrap_or(i64::MAX)
7379 .saturating_mul(1_000);
7380 let scheduled_for = crate::sidecar::ms_to_rfc3339(now.saturating_add(delay_ms));
7381 let result = format!(
7382 "Next wakeup recorded for {scheduled_for} (in {delay_seconds}s) in PAUSED \
7383 state. The request replaced the prior wakeup in the manifest, but no timer \
7384 is running and it will not execute."
7385 );
7386 manifest.pending_wakeups.clear();
7387 manifest
7388 .pending_wakeups
7389 .push(crate::claude_runtime_state::ClaudeWakeup {
7390 tool_use_id: call.id.clone(),
7391 delay_seconds,
7392 reason,
7393 prompt,
7394 created_at,
7395 scheduled_for: Some(scheduled_for),
7396 creation_result: result.clone(),
7397 });
7398 (result, false)
7399 }
7400 _ => unreachable!("runtime tool dispatch is name-gated"),
7401 }
7402 }
7403
7404 /// The `subagent_status` schema (P5-3, D3 "background+resume").
7405 fn subagent_status_schema() -> ToolSchema {
7406 ToolSchema {
7407 name: SUBAGENT_STATUS.to_string(),
7408 description: "Check on (and, once finished, retrieve the result of) a background \
7409 subagent spawned via spawn_subagent with background=true. Pass the \
7410 `subagent_id` that spawn returned."
7411 .to_string(),
7412 parameters: serde_json::json!({
7413 "type": "object",
7414 "properties": {
7415 "subagent_id": {
7416 "type": "string",
7417 "description": "The id `spawn_subagent` returned when this subagent \
7418 was spawned."
7419 }
7420 },
7421 "required": ["subagent_id"],
7422 "additionalProperties": false
7423 }),
7424 }
7425 }
7426
7427 /// BP-7: the `subagent_message` schema.
7428 fn subagent_message_schema() -> ToolSchema {
7429 ToolSchema {
7430 name: SUBAGENT_MESSAGE.to_string(),
7431 description: "Send a message to a background subagent that is STILL RUNNING. The message is delivered to that subagent at the start of its next step, without interrupting the step it is on. Use `subagent_status` to check whether it is still running and to collect its result."
7432 .to_string(),
7433 parameters: serde_json::json!({
7434 "type": "object",
7435 "properties": {
7436 "subagent_id": {
7437 "type": "string",
7438 "description": "The id `spawn_subagent` returned."
7439 },
7440 "message": {
7441 "type": "string",
7442 "description": "What to tell the running subagent."
7443 }
7444 },
7445 "required": ["subagent_id", "message"],
7446 "additionalProperties": false
7447 }),
7448 }
7449 }
7450
7451 /// BP-7: the `subagent_resume` schema.
7452 fn subagent_resume_schema() -> ToolSchema {
7453 ToolSchema {
7454 name: SUBAGENT_RESUME.to_string(),
7455 description: "Continue a subagent that has already FINISHED, with its own previous conversation restored, so it keeps everything it learned instead of being briefed again from scratch. Pass the id it was spawned with and the next task."
7456 .to_string(),
7457 parameters: serde_json::json!({
7458 "type": "object",
7459 "properties": {
7460 "subagent_id": {
7461 "type": "string",
7462 "description": "The id of a subagent that has already finished."
7463 },
7464 "task": {
7465 "type": "string",
7466 "description": "What the resumed subagent should do next."
7467 }
7468 },
7469 "required": ["subagent_id", "task"],
7470 "additionalProperties": false
7471 }),
7472 }
7473 }
7474
7475 /// BP-7 (catalog §4a "Background subagents + resume"): deliver a
7476 /// message into a still-running background child's mailbox.
7477 ///
7478 /// The mailbox is the child's own `SteerInbox` — the seam P4b built for
7479 /// mid-turn steering, which is writable while the child's turn holds
7480 /// `&mut Agent`. So delivery ordering is already defined: the message
7481 /// arrives at the top of the child's next loop iteration, i.e. after
7482 /// whatever tool calls it is currently running, per its
7483 /// `steering_mode`. A child that has already FINISHED is refused with
7484 /// a pointer at `subagent_resume`, which is the operation for that
7485 /// case — never silently dropped.
7486 fn run_subagent_message(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
7487 let args = match call.function.parsed_arguments() {
7488 Ok(v) => v,
7489 Err(e) => {
7490 let err = Error::InvalidArguments {
7491 tool: SUBAGENT_MESSAGE.to_string(),
7492 message: e.to_string(),
7493 };
7494 return (format!("Error: {err}"), true);
7495 }
7496 };
7497 let Some(id) = args.get("subagent_id").and_then(serde_json::Value::as_str) else {
7498 let err = Error::InvalidArguments {
7499 tool: SUBAGENT_MESSAGE.to_string(),
7500 message: "`subagent_id` is required".to_string(),
7501 };
7502 return (format!("Error: {err}"), true);
7503 };
7504 let message = args
7505 .get("message")
7506 .and_then(serde_json::Value::as_str)
7507 .unwrap_or("");
7508 if message.is_empty() {
7509 let err = Error::InvalidArguments {
7510 tool: SUBAGENT_MESSAGE.to_string(),
7511 message: "`message` is required and must be non-empty".to_string(),
7512 };
7513 return (format!("Error: {err}"), true);
7514 }
7515 let Some(entry) = self.background_subagents.get(id) else {
7516 let err = Error::SubagentNotFound(id.to_string());
7517 return (format!("Error: {err}"), true);
7518 };
7519 if entry.handle.is_finished() {
7520 let out = serde_json::json!({
7521 "subagent_id": id,
7522 "status": "finished",
7523 "delivered": false,
7524 "hint": "this subagent already finished — collect it with subagent_status, then continue it with subagent_resume",
7525 });
7526 return (out.to_string(), false);
7527 }
7528 entry
7529 .mailbox
7530 .lock()
7531 .unwrap_or_else(std::sync::PoisonError::into_inner)
7532 .queue_unchecked(message.to_string());
7533 let out = serde_json::json!({
7534 "subagent_id": id,
7535 "status": "running",
7536 "delivered": true,
7537 });
7538 (out.to_string(), false)
7539 }
7540
7541 /// BP-7 (catalog §4a "Background subagents + resume": "resumable with
7542 /// context intact"): continue a finished child over its OWN transcript.
7543 ///
7544 /// The context comes from the reap (kept in-process) or, for a session
7545 /// that attached a subagent store, from that child's persisted
7546 /// `<parent>.subagents/<id>.sidecar.jsonl`. Either way the resumed
7547 /// child is rebuilt through `build_child_config` from the SAME named
7548 /// definition it was spawned with, so its permission posture on resume
7549 /// is the one it had originally — never a fresh, looser default.
7550 async fn run_subagent_resume(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
7551 let args = match call.function.parsed_arguments() {
7552 Ok(v) => v,
7553 Err(e) => {
7554 let err = Error::InvalidArguments {
7555 tool: SUBAGENT_RESUME.to_string(),
7556 message: e.to_string(),
7557 };
7558 return (format!("Error: {err}"), true);
7559 }
7560 };
7561 let Some(id) = args
7562 .get("subagent_id")
7563 .and_then(serde_json::Value::as_str)
7564 .map(String::from)
7565 else {
7566 let err = Error::InvalidArguments {
7567 tool: SUBAGENT_RESUME.to_string(),
7568 message: "`subagent_id` is required".to_string(),
7569 };
7570 return (format!("Error: {err}"), true);
7571 };
7572 let task = args
7573 .get("task")
7574 .and_then(serde_json::Value::as_str)
7575 .unwrap_or("")
7576 .to_string();
7577 if task.is_empty() {
7578 let err = Error::InvalidArguments {
7579 tool: SUBAGENT_RESUME.to_string(),
7580 message: "`task` is required and must be non-empty".to_string(),
7581 };
7582 return (format!("Error: {err}"), true);
7583 }
7584 if self
7585 .background_subagents
7586 .get(&id)
7587 .is_some_and(|e| !e.handle.is_finished())
7588 {
7589 let err = Error::tool(
7590 SUBAGENT_RESUME,
7591 format!(
7592 "subagent `{id}` is still running — send it a message with subagent_message, or collect it with subagent_status first"
7593 ),
7594 );
7595 return (format!("Error: {err}"), true);
7596 }
7597 let lineage = self
7598 .subagent_store
7599 .as_ref()
7600 .and_then(|(store, parent)| store.load_subagent_lineage(parent, &id).ok().flatten());
7601 let Some(prior) = self.prior_subagent_transcript(&id) else {
7602 let err = Error::SubagentNotFound(id.clone());
7603 return (format!("Error: {err}"), true);
7604 };
7605
7606 let agent_type = lineage.as_ref().and_then(|l| l.agent_type.clone());
7607 let definition = agent_type
7608 .as_ref()
7609 .and_then(|name| self.config.subagents_definitions.get(name).cloned());
7610 let Some(guard) = crate::subagents::try_acquire(
7611 &self.subagent_concurrency_gauge,
7612 self.config.subagents_max_concurrent,
7613 ) else {
7614 let err = Error::SubagentConcurrencyExceeded {
7615 max_concurrent: self.config.subagents_max_concurrent,
7616 };
7617 return (format!("Error: {err}"), true);
7618 };
7619 let child_config = self.build_child_config(
7620 definition.as_ref(),
7621 None,
7622 lineage
7623 .as_ref()
7624 .map(|l| l.model.clone())
7625 .or_else(|| definition.as_ref().and_then(|d| d.model.clone())),
7626 );
7627 let mut child = Agent::with_provider_arc(child_config, self.provider.clone());
7628 child.subagent_depth = self.subagent_depth + 1;
7629 child.subagent_concurrency_gauge = self.subagent_concurrency_gauge.clone();
7630 // Context intact: the child's own prior messages, appended after
7631 // its (re-derived, identical) system prompt.
7632 child.history.extend(prior);
7633
7634 let result = child.send(task).await;
7635 let transcript = child.history()[1..].to_vec();
7636 if let Some(lineage) = &lineage {
7637 self.persist_subagent_transcript(&id, lineage, &transcript);
7638 }
7639 self.reaped_subagents.insert(id.clone(), transcript);
7640 drop(guard);
7641 match result {
7642 Ok(text) => {
7643 let out = serde_json::json!({
7644 "subagent_id": id,
7645 "status": "done",
7646 "resumed": true,
7647 "result": text,
7648 });
7649 (out.to_string(), false)
7650 }
7651 Err(e) => {
7652 let out = serde_json::json!({
7653 "subagent_id": id,
7654 "status": "error",
7655 "resumed": true,
7656 "message": e.to_string(),
7657 });
7658 (out.to_string(), true)
7659 }
7660 }
7661 }
7662
7663 /// BP-7 (catalog §4a "Named agent definitions as data"): the child
7664 /// `Config` a `spawn_subagent` of `agent_type` would build — the
7665 /// resolved posture a named definition actually produces, including
7666 /// its [`crate::subagents::AgentPermissions`] bundle applied through
7667 /// the tightening-only rules. `None` when no definition of that name
7668 /// is registered or discovered.
7669 ///
7670 /// Exposed so a caller (and this build's tests) can ask what a named
7671 /// agent WOULD run as without spawning it and paying for a turn.
7672 pub fn child_config_for_agent_type(&self, agent_type: &str) -> Option<Config> {
7673 let definition = self.config.subagents_definitions.get(agent_type)?.clone();
7674 Some(self.build_child_config(Some(&definition), None, definition.model.clone()))
7675 }
7676
7677 /// BP-7 (catalog §4a "Background subagents + resume"): the ids of
7678 /// children that have finished and been reaped, and can therefore be
7679 /// continued with [`SUBAGENT_RESUME`].
7680 pub fn reaped_subagent_ids(&self) -> Vec<String> {
7681 let mut ids: Vec<String> = self.reaped_subagents.keys().cloned().collect();
7682 ids.sort();
7683 ids
7684 }
7685
7686 /// BP-7: a finished child's own messages — from the in-process reap
7687 /// cache first, then this session's subagent store.
7688 fn prior_subagent_transcript(&self, id: &str) -> Option<Vec<ChatMessage>> {
7689 if let Some(messages) = self.reaped_subagents.get(id) {
7690 return Some(messages.clone());
7691 }
7692 let (store, parent) = self.subagent_store.as_ref()?;
7693 let jsonl = store.load_subagent_transcript(parent, id).ok()??;
7694 let session = crate::session::Session::from_sidecar_str(&jsonl).ok()?;
7695 Some(
7696 session
7697 .messages
7698 .into_iter()
7699 .filter(|m| m.role != crate::message::Role::System)
7700 .collect(),
7701 )
7702 }
7703
7704 /// Build the CHILD `Config` a `spawn_subagent` call constructs its
7705 /// [`Agent`] from. The whole point of this method (§5.3-style
7706 /// "monotonic posture", build-brief "a subagent inherits or narrows —
7707 /// never widens — the parent's permission posture"): every field that
7708 /// governs what the child is ALLOWED to do (sandbox, approval,
7709 /// tool_overrides, deny/allow patterns, protected paths, the subagents
7710 /// caps themselves) is copied VERBATIM from `self.config` — never
7711 /// loosened — and the only NARROWING lever is `definition.tools`
7712 /// (intersected with whatever the parent already had enabled, never
7713 /// unioned in anything new).
7714 ///
7715 /// P5-3 safety hardening (Fable-5 review, LOW-MEDIUM "child safety-limit
7716 /// inheritance"): the monotonic-posture guarantee above was, before this
7717 /// fix, scoped to PERMISSION fields only — a child could still silently
7718 /// get a LOOSER safety BUDGET/BREAKER than its parent, because
7719 /// `max_total_output_tokens`/`max_tool_output_bytes`/`max_tokens`/
7720 /// `doom_loop_threshold`/`edit_file_require_read_before_edit` were never
7721 /// copied and so fell back to `Config::default()`'s (looser/uncapped)
7722 /// values on every spawn regardless of what the parent had configured.
7723 /// These are now copied verbatim alongside the permission-posture
7724 /// fields — a parent that capped its own output/tool-output/doom-loop
7725 /// exposure, or required read-before-edit, gets a child that is bound
7726 /// by the exact same ceiling, never a wider one.
7727 ///
7728 /// **Full field-by-field accounting** (every [`Config`] field, so this
7729 /// doc comment stays the single place that answers "did we forget
7730 /// one?"): fields already copied above/below this note (permission
7731 /// posture: `sandbox`/`approval`/`tool_overrides`/`auto_approved_tools`/
7732 /// `tool_deny_patterns`/`tool_allow_patterns`/`permissions_enabled`/
7733 /// `permissions_ask_patterns`/`permissions_protected_paths`/
7734 /// `network_policy`/`core_tools_enabled`/`module_registry`/
7735 /// `module_activation`/every `subagents_*` field; safety limits:
7736 /// `max_iterations`/`max_total_output_tokens`/`max_tool_output_bytes`/
7737 /// `max_tokens`/`doom_loop_threshold`/`edit_file_require_read_before_edit`;
7738 /// identity/transport: `model`/`system_prompt`/`cwd`/`base_url`/
7739 /// `api_key`/`api_key_env`/`api_key_cmd`) are the ones that gate
7740 /// harm/spend/hazard exposure. Every OTHER field is deliberately left at
7741 /// `Config::default()` because none of them is a safety ceiling the
7742 /// child could "loosen" by missing it:
7743 /// - `temperature`/`effort`/`response_format`/`extra_body`/`extra_headers`/
7744 /// `tool_advertising`/`tool_schema_tier`/`cache_plan`/`cache_warnings`/
7745 /// `reduction_policy`/
7746 /// `session_*`/`small_model`/`model_fallback`/`env_context`/
7747 /// `project_root_markers`/`project_doc_max_bytes`/`instruction_imports`/
7748 /// `retry_*`/`compaction_*`/`auto_title`/`steering_mode`/
7749 /// `follow_up_mode`/`read_file_multimodal`/`edit_file_notebook_aware`/
7750 /// `shell_env_snapshot`/`nested_instructions`/`model_switch_allow_switch`/
7751 /// `context_injections`/`context_injection_blocks`/`parallel_tool_calls`
7752 /// are behavior/cost-shaping or presentation knobs, not hard guards —
7753 /// a child defaulting on any of these can do LESS (e.g. no multimodal
7754 /// read, no notebook-aware edits, no proactive compaction) or the same,
7755 /// never something the parent hadn't already exposed it to. Several
7756 /// default to their OFF/conservative state (`false`/`None`), which is
7757 /// the tight direction, not the loose one.
7758 /// - `additional_dirs`: governs which extra roots are reachable at all
7759 /// (`presets.rs`'s `[core] additional_dirs` note) — a child that
7760 /// doesn't inherit it has FEWER reachable roots than its parent, i.e.
7761 /// strictly tighter, never looser.
7762 /// - `load_project_context`: whether instruction files are auto-loaded
7763 /// into the system prompt — a read-time convenience, not an access
7764 /// grant (`sandbox`/`permissions_protected_paths` already gate actual
7765 /// file access).
7766 /// - `prompts`: named `/slash` command templates for THIS agent's own
7767 /// user-facing input surface, not something the model can invoke
7768 /// against the child's tool surface.
7769 /// - `stop_gate`/`post_tool_hook`/`approval_handler`/`event_sink`:
7770 /// code-only `Box<dyn Fn>` callbacks (see the `pre_tool_hook` note
7771 /// immediately below — same non-`Clone` shape) that are observational
7772 /// or terminate-only, not a call-time veto over what a tool is allowed
7773 /// to do; `approval_handler` specifically is ALREADY documented at
7774 /// this method's call site (`Self::run_spawn_subagent`) as
7775 /// intentionally never set here — a foreground child gets no handler
7776 /// by design, an embedder installs its own after spawn if it wants
7777 /// one.
7778 ///
7779 /// **`pre_tool_hook` cannot propagate, and this is deliberate + named,
7780 /// not a silent gap**: `Config::pre_tool_hook` is a `Box<dyn Fn(&str,
7781 /// &serde_json::Value) -> Option<String> + Send + Sync>` — an
7782 /// embedder's own call-time veto over every tool call. `Box<dyn Fn>` is
7783 /// not `Clone` (there is no generic way to duplicate an opaque closure),
7784 /// so it genuinely CANNOT be copied into a child `Config` the way every
7785 /// `Clone`-able field above is — there is no fix that makes this one
7786 /// "verbatim copy" like the others. An embedder relying on a
7787 /// `pre_tool_hook` veto reaching spawned children as well as the parent
7788 /// MUST re-install one on the child explicitly (e.g. via a
7789 /// `spawn_subagent`-adjacent hook of their own, or by not relying on
7790 /// `pre_tool_hook` alone for anything safety-critical across a spawn
7791 /// boundary) — named here so this is a documented contract, not a gap
7792 /// an embedder discovers by a child silently misbehaving.
7793 fn build_child_config(
7794 &self,
7795 definition: Option<&crate::subagents::NamedAgentDefinition>,
7796 inline_system_prompt: Option<String>,
7797 model_override: Option<String>,
7798 ) -> Config {
7799 let system_prompt = definition
7800 .map(|d| d.system_prompt.clone())
7801 .filter(|s| !s.is_empty())
7802 .or(inline_system_prompt)
7803 .unwrap_or_else(|| self.config.system_prompt.clone());
7804 let model = model_override.unwrap_or_else(|| self.config.model.clone());
7805
7806 let mut child = Config::builder()
7807 .model(model)
7808 .system_prompt(system_prompt)
7809 .cwd(self.config.cwd.clone())
7810 // Monotonic: verbatim, never loosened.
7811 .sandbox(self.config.sandbox)
7812 .approval(self.config.approval)
7813 .max_iterations(self.config.max_iterations)
7814 .build();
7815 child.base_url = self.config.base_url.clone();
7816 child.api_key = self.config.api_key.clone();
7817 child.api_key_env = self.config.api_key_env.clone();
7818 child.api_key_cmd = self.config.api_key_cmd.clone();
7819 // P5-3 safety hardening (Fable-5 review, LOW-MEDIUM "child
7820 // safety-limit inheritance"): the monotonic-posture spirit extends
7821 // to safety BUDGETS/BREAKERS, not just permissions — a child must
7822 // not get a looser cap/breaker than its parent by simply falling
7823 // back to `Config::default()`'s (looser) values. See this method's
7824 // doc comment for the full field-by-field accounting.
7825 child.max_total_output_tokens = self.config.max_total_output_tokens;
7826 child.max_tool_output_bytes = self.config.max_tool_output_bytes;
7827 child.max_tokens = self.config.max_tokens;
7828 child.doom_loop_threshold = self.config.doom_loop_threshold;
7829 child.edit_file_require_read_before_edit = self.config.edit_file_require_read_before_edit;
7830 // Monotonic tool posture: start from the PARENT's own overrides
7831 // (so anything the parent already disabled stays disabled), then
7832 // narrow further if a named definition restricts the tool set.
7833 child.tool_overrides = self.config.tool_overrides.clone();
7834 child.auto_approved_tools = self.config.auto_approved_tools.clone();
7835 child.tool_deny_patterns = self.config.tool_deny_patterns.clone();
7836 child.tool_allow_patterns = self.config.tool_allow_patterns.clone();
7837 child.permissions_enabled = self.config.permissions_enabled;
7838 child.permissions_ask_patterns = self.config.permissions_ask_patterns.clone();
7839 child.permissions_protected_paths = self.config.permissions_protected_paths.clone();
7840 child.network_policy = self.config.network_policy.clone();
7841 // P5-10 (§2 module 12): same monotonic-posture treatment as
7842 // `sandbox`/`approval` above — a subagent must inherit its
7843 // parent's OS-sandbox posture verbatim, never a looser
7844 // `Config::default()` fallback (`sandbox_os_enabled: None`,
7845 // `escalation: Deny`, `env_policy: Inherit` would otherwise be
7846 // right back to "confine only when the tier itself says so" for a
7847 // child whose parent explicitly forced the backstop on/off).
7848 child.sandbox_os_enabled = self.config.sandbox_os_enabled;
7849 child.sandbox_escalation = self.config.sandbox_escalation;
7850 child.sandbox_env_policy = self.config.sandbox_env_policy;
7851 if let Some(def) = definition {
7852 if let Some(allowed) = &def.tools {
7853 for name in &self.config.core_tools_enabled {
7854 if !allowed.iter().any(|t| t == name) {
7855 child
7856 .tool_overrides
7857 .entry(name.clone())
7858 .or_default()
7859 .enabled = Some(false);
7860 }
7861 }
7862 }
7863 // BP-7 (catalog §4a "Named agent definitions as data": the
7864 // `permissions` component of `prompt+model+tools+permissions`).
7865 // Every arm below can only TIGHTEN — the two policy values go
7866 // through the SAME strictness ranks `configfile::
7867 // clamp_project_permissions` uses for the untrusted project
7868 // layer (a looser value is ignored, never honored), the
7869 // auto-approve list is INTERSECTED with the parent's, and the
7870 // deny list is a union. A definition may come from a
7871 // `.claude/agents/*.md` file in the repo, so it sits at the
7872 // project trust tier and must never be an escalation door.
7873 if let Some(perms) = &def.permissions {
7874 if let Some(approval) = perms.approval {
7875 if crate::configfile::approval_rank(approval)
7876 < crate::configfile::approval_rank(child.approval)
7877 {
7878 child.approval = approval;
7879 }
7880 }
7881 if let Some(sandbox) = perms.sandbox {
7882 if crate::configfile::sandbox_rank(sandbox)
7883 < crate::configfile::sandbox_rank(child.sandbox)
7884 {
7885 child.sandbox = sandbox;
7886 }
7887 }
7888 if let Some(allowed) = &perms.auto_approved_tools {
7889 child
7890 .auto_approved_tools
7891 .retain(|tool| allowed.iter().any(|a| a == tool));
7892 }
7893 for pattern in &perms.deny {
7894 if !child.tool_deny_patterns.iter().any(|p| p == pattern) {
7895 child.tool_deny_patterns.push(pattern.clone());
7896 }
7897 }
7898 }
7899 }
7900 child.core_tools_enabled = self.config.core_tools_enabled.clone();
7901 child.module_registry = self.config.module_registry;
7902 child.module_activation = self.config.module_activation.clone();
7903 // The subagents module itself never widens either: a child spawned
7904 // at depth d+1 inherits the SAME caps (never a looser depth/
7905 // concurrency/background posture than its own parent).
7906 child.subagents_enabled = self.config.subagents_enabled;
7907 child.subagents_max_depth = self.config.subagents_max_depth;
7908 child.subagents_max_concurrent = self.config.subagents_max_concurrent;
7909 child.subagents_background = self.config.subagents_background;
7910 child.subagents_background_prompts = self.config.subagents_background_prompts;
7911 child.subagents_claude_agent_alias = self.config.subagents_claude_agent_alias;
7912 child.subagents_definitions = self.config.subagents_definitions.clone();
7913 child.subagent_depth = self.subagent_depth + 1;
7914 child
7915 }
7916
7917 /// Execute the `spawn_subagent` intrinsic (P5-3, §2 module 9). See
7918 /// `Self::build_child_config` for the monotonic-posture guarantee and
7919 /// `crate::subagents` for the depth/concurrency resource bounds and the
7920 /// §2.2 C6 background-policy enforcement.
7921 async fn run_spawn_subagent(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
7922 // BP-11: `subagent_start`/`subagent_stop` bracket a call that passed
7923 // the same validation the runner applies (subagents on, non-empty
7924 // task); a refused call fires neither.
7925 let task = if self.config.subagents_enabled {
7926 call.function
7927 .parsed_arguments()
7928 .ok()
7929 .and_then(|v| {
7930 v.get("task")
7931 .and_then(serde_json::Value::as_str)
7932 .map(str::to_string)
7933 })
7934 .filter(|t| !t.is_empty())
7935 } else {
7936 None
7937 };
7938 if let Some(task) = &task {
7939 self.fire_lifecycle(&crate::config::LifecycleEvent::SubagentStart {
7940 task: task.clone(),
7941 });
7942 }
7943 let (output, is_error) = self.run_spawn_subagent_inner(call).await;
7944 if let Some(task) = task {
7945 self.fire_lifecycle(&crate::config::LifecycleEvent::SubagentStop {
7946 task,
7947 is_error,
7948 output_len: output.len(),
7949 });
7950 }
7951 (output, is_error)
7952 }
7953
7954 /// Hands a lifecycle moment to the installed observer, if any (BP-11).
7955 fn fire_lifecycle(&self, event: &crate::config::LifecycleEvent) {
7956 if let Some(hook) = self.config.lifecycle_hook.as_ref() {
7957 hook(event);
7958 }
7959 }
7960
7961 /// Installs the lifecycle observer (compaction and subagent boundaries).
7962 pub fn set_lifecycle_hook(&mut self, hook: crate::config::LifecycleHook) {
7963 self.config.lifecycle_hook = Some(hook);
7964 }
7965
7966 async fn run_spawn_subagent_inner(
7967 &mut self,
7968 call: &crate::message::ToolCall,
7969 ) -> (String, bool) {
7970 if !self.config.subagents_enabled {
7971 let err = Error::UnknownTool(SPAWN_SUBAGENT.to_string());
7972 return (format!("Error: {err}"), true);
7973 }
7974 let args = match call.function.parsed_arguments() {
7975 Ok(v) => v,
7976 Err(e) => {
7977 let err = Error::InvalidArguments {
7978 tool: SPAWN_SUBAGENT.to_string(),
7979 message: e.to_string(),
7980 };
7981 return (format!("Error: {err}"), true);
7982 }
7983 };
7984 let task = args
7985 .get("task")
7986 .and_then(serde_json::Value::as_str)
7987 .unwrap_or("")
7988 .to_string();
7989 if task.is_empty() {
7990 let err = Error::InvalidArguments {
7991 tool: SPAWN_SUBAGENT.to_string(),
7992 message: "`task` is required and must be non-empty".to_string(),
7993 };
7994 return (format!("Error: {err}"), true);
7995 }
7996 let agent_type = args
7997 .get("agent_type")
7998 .and_then(serde_json::Value::as_str)
7999 .map(String::from);
8000 let inline_system_prompt = args
8001 .get("system_prompt")
8002 .and_then(serde_json::Value::as_str)
8003 .map(String::from);
8004 let background = args
8005 .get("background")
8006 .and_then(serde_json::Value::as_bool)
8007 .unwrap_or(false);
8008 let requested_model = args
8009 .get("model")
8010 .and_then(serde_json::Value::as_str)
8011 .map(|model| crate::model_catalog::resolve_alias(model));
8012
8013 let definition = match &agent_type {
8014 Some(name) => match self.config.subagents_definitions.get(name) {
8015 Some(d) => Some(d.clone()),
8016 None => {
8017 let err = Error::SubagentDefinitionNotFound(name.clone());
8018 return (format!("Error: {err}"), true);
8019 }
8020 },
8021 None => None,
8022 };
8023
8024 if background {
8025 if !self.config.subagents_background {
8026 let err = Error::tool(
8027 SPAWN_SUBAGENT,
8028 "background=true requires capabilities.subagents.background = true",
8029 );
8030 return (format!("Error: {err}"), true);
8031 }
8032 // §2.2 C6, defensive re-check (belt-and-suspenders — see
8033 // `Error::SubagentBackgroundPolicyMissing`'s doc comment for why
8034 // this can't just trust the resolver already checked it).
8035 if self.config.subagents_background_prompts.is_none() {
8036 let err = Error::SubagentBackgroundPolicyMissing;
8037 return (format!("Error: {err}"), true);
8038 }
8039 }
8040
8041 // Resource bounds (fail-closed): depth first (cheap, no side
8042 // effect on failure), THEN concurrency (holds a slot — must be the
8043 // LAST check before actually spawning, so a refused spawn never
8044 // leaves a stray slot held).
8045 if let Err(e) =
8046 crate::subagents::check_depth(self.subagent_depth, self.config.subagents_max_depth)
8047 {
8048 return (format!("Error: {e}"), true);
8049 }
8050 let Some(guard) = crate::subagents::try_acquire(
8051 &self.subagent_concurrency_gauge,
8052 self.config.subagents_max_concurrent,
8053 ) else {
8054 let err = Error::SubagentConcurrencyExceeded {
8055 max_concurrent: self.config.subagents_max_concurrent,
8056 };
8057 return (format!("Error: {err}"), true);
8058 };
8059
8060 let child_id = next_subagent_id();
8061 let child_config = self.build_child_config(
8062 definition.as_ref(),
8063 inline_system_prompt,
8064 requested_model.or_else(|| definition.as_ref().and_then(|d| d.model.clone())),
8065 );
8066 let child_model = child_config.model.clone();
8067 let mut child = Agent::with_provider_arc(child_config, self.provider.clone());
8068 child.subagent_depth = self.subagent_depth + 1;
8069 child.subagent_concurrency_gauge = self.subagent_concurrency_gauge.clone();
8070
8071 // §2.2 C6: a background child NEVER gets a BLOCKING-BY-DEFAULT
8072 // interactive approval handler — either no handler at all
8073 // (`AutoPolicy`: the engine's pre-existing "no handler ⇒ deny"
8074 // fail-closed default), or (`Parent`) the never-blocking
8075 // `ParentQueueApprovalHandler`, UNLESS a `tui` embedder has
8076 // installed [`Self::child_approval_handler_factory`] (P5-4), in
8077 // which case THAT builds the handler instead — see
8078 // [`Self::set_child_approval_handler_factory`]'s doc comment for
8079 // why this can't escalate past what the rule engine already routed
8080 // to `Ask`. A foreground child also gets no handler here (today's
8081 // existing default posture; an embedder that wants an interactive
8082 // child installs its own via `set_permissions_approval_handler`
8083 // after this call returns, out of this method's scope).
8084 if background {
8085 if let Some(crate::subagents::BackgroundPromptsPolicy::Parent) =
8086 self.config.subagents_background_prompts
8087 {
8088 let handler: std::sync::Arc<dyn crate::permissions::PermissionsApprovalHandler> =
8089 match &self.child_approval_handler_factory {
8090 Some(factory) => {
8091 factory(child_id.clone(), self.pending_child_approvals.clone())
8092 }
8093 None => std::sync::Arc::new(crate::subagents::ParentQueueApprovalHandler {
8094 child_agent_id: child_id.clone(),
8095 queue: self.pending_child_approvals.clone(),
8096 }),
8097 };
8098 child.ctx.sandbox_approval_handler =
8099 Some(crate::sandbox::SandboxApprovalHandler(handler.clone()));
8100 child.permissions_approval_handler = Some(handler);
8101 }
8102 }
8103
8104 let lineage = crate::subagents::SubagentLineage {
8105 child_agent_id: child_id.clone(),
8106 parent_session_id: self.subagent_store.as_ref().map(|(_, name)| name.clone()),
8107 parent_tool_use_id: call.id.clone(),
8108 depth: self.subagent_depth + 1,
8109 agent_type: agent_type.clone(),
8110 task: task.clone(),
8111 background,
8112 spawned_at_ms: now_ms(),
8113 model: child_model,
8114 };
8115 if let Some((store, parent_name)) = &self.subagent_store {
8116 let _ = store.save_subagent_lineage(parent_name, &child_id, &lineage);
8117 }
8118
8119 if background {
8120 let spawned_task_text = task.clone();
8121 // BP-7: captured BEFORE `child` moves into the task — this is
8122 // the handle `subagent_message` writes into.
8123 let mailbox = child.steer_queue_handle();
8124 self.background_subagents.insert(
8125 child_id.clone(),
8126 BackgroundSubagent {
8127 handle: tokio::spawn(async move {
8128 // The concurrency slot lives for exactly as long as
8129 // this future runs — moved in here, dropped when the
8130 // child's `send` (and this future) finishes.
8131 let _guard = guard;
8132 let result = child.send(spawned_task_text).await;
8133 let transcript = child.history()[1..].to_vec();
8134 (child_id, result, transcript)
8135 }),
8136 task,
8137 agent_type,
8138 started_at_ms: lineage.spawned_at_ms,
8139 mailbox,
8140 },
8141 );
8142 let out = serde_json::json!({
8143 "subagent_id": lineage.child_agent_id,
8144 "status": "spawned",
8145 "background": true,
8146 });
8147 return (out.to_string(), false);
8148 }
8149
8150 // Foreground: run to completion now, guard held until this
8151 // function returns (then drops, freeing the slot).
8152 let result = child.send(task).await;
8153 let transcript = child.history()[1..].to_vec();
8154 self.persist_subagent_transcript(&child_id, &lineage, &transcript);
8155 // BP-7: kept in-process so `subagent_resume` can restore this
8156 // child's context even with no session store attached.
8157 self.reaped_subagents
8158 .insert(child_id.clone(), transcript.clone());
8159 drop(guard);
8160 match result {
8161 Ok(text) => (text, false),
8162 Err(e) => (format!("Error: subagent `{child_id}` failed: {e}"), true),
8163 }
8164 }
8165
8166 /// Execute the `subagent_status` intrinsic (P5-3, D3
8167 /// "background+resume"): poll a background child; once its `JoinHandle`
8168 /// is finished, reap it (removing it from `Self::background_subagents`
8169 /// and persisting its transcript, same as the foreground path).
8170 async fn run_subagent_status(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
8171 let args = match call.function.parsed_arguments() {
8172 Ok(v) => v,
8173 Err(e) => {
8174 let err = Error::InvalidArguments {
8175 tool: SUBAGENT_STATUS.to_string(),
8176 message: e.to_string(),
8177 };
8178 return (format!("Error: {err}"), true);
8179 }
8180 };
8181 let Some(id) = args.get("subagent_id").and_then(serde_json::Value::as_str) else {
8182 let err = Error::InvalidArguments {
8183 tool: SUBAGENT_STATUS.to_string(),
8184 message: "`subagent_id` is required".to_string(),
8185 };
8186 return (format!("Error: {err}"), true);
8187 };
8188 let Some(entry) = self.background_subagents.get(id) else {
8189 let err = Error::SubagentNotFound(id.to_string());
8190 return (format!("Error: {err}"), true);
8191 };
8192 if !entry.handle.is_finished() {
8193 let out = serde_json::json!({
8194 "subagent_id": id,
8195 "status": "pending",
8196 "task": entry.task,
8197 "agent_type": entry.agent_type,
8198 "started_at_ms": entry.started_at_ms,
8199 });
8200 return (out.to_string(), false);
8201 }
8202 // Finished — reap it. `.await` on an already-finished handle
8203 // resolves immediately (never actually blocks).
8204 let entry = self
8205 .background_subagents
8206 .remove(id)
8207 .expect("checked Some above");
8208 let (child_id, result, transcript) = match entry.handle.await {
8209 Ok(v) => v,
8210 Err(join_err) => {
8211 let err = Error::tool(
8212 SUBAGENT_STATUS,
8213 format!("subagent `{id}` task panicked: {join_err}"),
8214 );
8215 return (format!("Error: {err}"), true);
8216 }
8217 };
8218 // Re-derive the lineage record for persistence (cheap; the fields
8219 // are all still in hand) — mirrors the foreground path's single
8220 // `persist_subagent_transcript` call site.
8221 if let Some((store, parent_name)) = self.subagent_store.clone() {
8222 if let Ok(Some(lineage)) = store.load_subagent_lineage(&parent_name, &child_id) {
8223 self.persist_subagent_transcript(&child_id, &lineage, &transcript);
8224 }
8225 }
8226 // BP-7: see the foreground path's identical line.
8227 self.reaped_subagents
8228 .insert(child_id.clone(), transcript.clone());
8229 match result {
8230 Ok(text) => {
8231 let out = serde_json::json!({
8232 "subagent_id": child_id,
8233 "status": "done",
8234 "result": text,
8235 });
8236 (out.to_string(), false)
8237 }
8238 Err(e) => {
8239 let out = serde_json::json!({
8240 "subagent_id": child_id,
8241 "status": "error",
8242 "message": e.to_string(),
8243 });
8244 (out.to_string(), true)
8245 }
8246 }
8247 }
8248
8249 /// Execute the `background_exec` intrinsic (P5-6, §2 module 4, D1
8250 /// "background exec"): spawn `args.command` as a detached OS process
8251 /// via `crate::tools::build_sandboxed_sh` — the SAME sandboxed-spawn
8252 /// path [`crate::tools::BashTool::execute`] uses — and return its job
8253 /// id IMMEDIATELY, never the command's output. Gated by the same
8254 /// permission check a foreground `bash` call gets
8255 /// ([`Self::background_permission_denial`]), then a fail-closed
8256 /// concurrency cap ([`Config::tools_background_max_concurrent`]), THEN
8257 /// the actual spawn — in that order, so a refused call never holds a
8258 /// concurrency slot and never touches the process table.
8259 fn run_background_exec(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
8260 let args = match call.function.parsed_arguments() {
8261 Ok(v) => v,
8262 Err(e) => {
8263 let err = Error::InvalidArguments {
8264 tool: BACKGROUND_EXEC.to_string(),
8265 message: e.to_string(),
8266 };
8267 return (format!("Error: {err}"), true);
8268 }
8269 };
8270 let command = args
8271 .get("command")
8272 .and_then(serde_json::Value::as_str)
8273 .unwrap_or("")
8274 .to_string();
8275 if command.is_empty() {
8276 let err = Error::InvalidArguments {
8277 tool: BACKGROUND_EXEC.to_string(),
8278 message: "`command` is required and must be non-empty".to_string(),
8279 };
8280 return (format!("Error: {err}"), true);
8281 }
8282
8283 // A job id up front (before spawning) — used both as the audit
8284 // handle for a §2.2 C6 `Parent`-policy queued denial (this call may
8285 // never actually reach the spawn below) and, if the call proceeds,
8286 // as `Self::background_jobs`'s real key.
8287 let job_id = crate::background::next_job_id(now_ms());
8288
8289 // Fable-5 review (LOW, "pre_tool_hook + doom-loop don't cover
8290 // background_exec"): this intrinsic is intercepted in
8291 // `Self::prepare_tool_call` and returns before `Self::finish_prepare`
8292 // ever runs, so — unlike a foreground `bash` call — it was reaching
8293 // this real spawn below WITHOUT ever offering `Config.pre_tool_hook`
8294 // a chance to veto it. `background_exec` runs a REAL command (unlike
8295 // the purely in-process meta-intrinsics `tool_search`/
8296 // `expand_reduction`/`sidecar_search`, which have no such gap to
8297 // close), so it belongs behind the same security-relevant veto a
8298 // foreground call gets. Scoped to this one call site — the other
8299 // meta-intrinsics are unchanged. The doom-loop counter
8300 // (`Self::check_doom_loop`) is deliberately NOT wired here: it is a
8301 // foreground repetition breaker keyed on `(self.doom_loop_last_call,
8302 // self.doom_loop_streak)`, a single piece of state shared with the
8303 // ordinary tool-call loop — folding background jobs into that same
8304 // streak would make an interleaved foreground/background pattern
8305 // trip (or fail to trip) the breaker in ways that have nothing to
8306 // do with the foreground loop actually repeating itself; the
8307 // pre_tool_hook veto below is the security-relevant half of this
8308 // fix, the doom-loop breaker is not.
8309 // BP-10: the hook now runs BEFORE this path's permissions gate, the
8310 // same order the foreground path uses — so a rewrite is what the
8311 // rules evaluate and what actually runs, and the hook's
8312 // `Allow`/`Ask` are tiers inside the engine rather than a second
8313 // verdict beside it.
8314 let mut command = command;
8315 let mut hook_decision = crate::config::HookDecision::Pass;
8316 if let Some(hook) = &self.config.pre_tool_hook {
8317 let outcome = hook(BACKGROUND_EXEC, &args);
8318 if outcome.decision == crate::config::HookDecision::Deny {
8319 let reason = outcome.reason.unwrap_or_else(|| "denied".to_string());
8320 return (format!("Error: blocked by pre-tool hook: {reason}"), true);
8321 }
8322 if let Some(rewritten) = outcome.updated_args {
8323 command = rewritten
8324 .get("command")
8325 .and_then(|v| v.as_str())
8326 .unwrap_or(&command)
8327 .to_string();
8328 }
8329 hook_decision = outcome.decision;
8330 }
8331
8332 if let Some(reason) = self.background_permission_denial(&command, &job_id, hook_decision) {
8333 return (format!("Error: {reason}"), true);
8334 }
8335
8336 let Some(guard) = crate::subagents::try_acquire(
8337 &self.background_concurrency_gauge,
8338 self.config.tools_background_max_concurrent,
8339 ) else {
8340 let err = Error::BackgroundJobConcurrencyExceeded {
8341 max_concurrent: self.config.tools_background_max_concurrent,
8342 };
8343 return (format!("Error: {err}"), true);
8344 };
8345
8346 let mut cmd = match crate::tools::build_sandboxed_sh(&command, &self.ctx) {
8347 Ok(cmd) => cmd,
8348 Err(e) => return (format!("Error: {e}"), true),
8349 };
8350 cmd.current_dir(&self.ctx.cwd)
8351 .stdin(std::process::Stdio::null())
8352 .stdout(std::process::Stdio::piped())
8353 .stderr(std::process::Stdio::piped())
8354 // Defense-in-depth for the "must be killed on drop" guarantee —
8355 // see `impl Drop for Agent`'s doc comment; the EXPLICIT
8356 // `start_kill()` loop there is what makes the guarantee
8357 // provable, this is a second, independent line of defense for
8358 // the same outcome.
8359 .kill_on_drop(true);
8360 // Fable-5 review (HIGH, "grandchildren orphaned on kill AND
8361 // agent-drop"): `Child::start_kill` only signals the DIRECT child.
8362 // A background command that spawns a surviving subprocess (a `&`
8363 // job, a pipeline, a double-forking daemon — or, on macOS, the
8364 // `sandbox-exec` wrapper itself in `build_sandboxed_sh`, whose real
8365 // `sh` and ITS children are all grandchildren of the tracked pid)
8366 // leaves those processes running, reparented to init, after the
8367 // tracked job is "killed". Putting this job in its OWN new process
8368 // group (`pgid == its own pid`, since every descendant inherits the
8369 // group unless it explicitly opts out) lets `kill_job_process_group`
8370 // below signal the WHOLE tree at kill/drop time, not just the one
8371 // pid we happen to be tracking. No portable equivalent on Windows —
8372 // see `kill_job_process_group`'s `#[cfg(not(unix))]` fallback.
8373 #[cfg(unix)]
8374 cmd.process_group(0);
8375 // P4c (`core.shell_env_snapshot`)/P5-10 (`env_policy`):
8376 // `build_sandboxed_sh` (above) already applied both via its own
8377 // `apply_sandbox_env_policy` last step — no separate `ctx.shell_env`
8378 // application here (that would re-add a secret `Filtered`/`None`
8379 // just stripped, on top of the already-`env_clear`'d command).
8380
8381 let mut child = match cmd.spawn() {
8382 Ok(c) => c,
8383 Err(e) => {
8384 drop(guard);
8385 let err = Error::tool(
8386 BACKGROUND_EXEC,
8387 format!("failed to spawn background command: {e}"),
8388 );
8389 return (format!("Error: {err}"), true);
8390 }
8391 };
8392 let pid = child.id();
8393 let output = std::sync::Arc::new(crate::background::CapturedOutput::new());
8394 let cap = self.config.tools_background_max_output_bytes;
8395 // Fire-and-forget: the reader tasks outlive this method call and
8396 // exit on their own at pipe EOF — see `spawn_output_reader`'s doc
8397 // comment. Bound to named (not `_`) locals only to keep clippy's
8398 // `let_underscore_future` lint quiet; neither handle is awaited or
8399 // aborted anywhere.
8400 if let Some(stdout) = child.stdout.take() {
8401 let _stdout_reader = spawn_output_reader(stdout, output.clone(), cap);
8402 }
8403 if let Some(stderr) = child.stderr.take() {
8404 let _stderr_reader = spawn_output_reader(stderr, output.clone(), cap);
8405 }
8406
8407 let started_at_ms = now_ms();
8408 self.background_jobs.insert(
8409 job_id.clone(),
8410 BackgroundJob {
8411 child,
8412 command: command.clone(),
8413 pid,
8414 output,
8415 started_at_ms,
8416 killed: false,
8417 _guard: guard,
8418 },
8419 );
8420
8421 let out = serde_json::json!({
8422 "job_id": job_id,
8423 "status": "running",
8424 "pid": pid,
8425 "command": command,
8426 });
8427 (out.to_string(), false)
8428 }
8429
8430 /// Execute the `background_status` intrinsic (P5-6, D1 "monitor/event
8431 /// feed"): non-blocking poll of one job's run status (via
8432 /// `Child::try_wait`), drain its output captured since the LAST poll
8433 /// and emit it as an [`AgentEvent::BackgroundOutput`] event (the
8434 /// "event feed" — a real `EventSink` consumer sees each poll's new
8435 /// output live), and return the full captured output (bounded, per
8436 /// [`Config::tools_background_max_output_bytes`]) so far either way.
8437 /// Once the job is terminal (exited or killed), this reaps it — removes
8438 /// it from [`Self::background_jobs`], freeing its concurrency slot —
8439 /// same "poll once more to reap" contract [`Self::run_subagent_status`]
8440 /// already established for background subagents.
8441 fn run_background_status(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
8442 let args = match call.function.parsed_arguments() {
8443 Ok(v) => v,
8444 Err(e) => {
8445 let err = Error::InvalidArguments {
8446 tool: BACKGROUND_STATUS.to_string(),
8447 message: e.to_string(),
8448 };
8449 return (format!("Error: {err}"), true);
8450 }
8451 };
8452 let Some(job_id) = args.get("job_id").and_then(serde_json::Value::as_str) else {
8453 let err = Error::InvalidArguments {
8454 tool: BACKGROUND_STATUS.to_string(),
8455 message: "`job_id` is required".to_string(),
8456 };
8457 return (format!("Error: {err}"), true);
8458 };
8459 let job_id = job_id.to_string();
8460
8461 // Scoped so the mutable borrow of `self.background_jobs` ends
8462 // before `self.emit(...)`/`self.background_jobs.remove(...)` below
8463 // need their own (mutable) access to `self`.
8464 let (command, pid, started_at_ms, status, output_so_far, truncated, delta) = {
8465 let Some(job) = self.background_jobs.get_mut(&job_id) else {
8466 let err = Error::BackgroundJobNotFound(job_id);
8467 return (format!("Error: {err}"), true);
8468 };
8469 let status = background_job_status(job);
8470 let (output_so_far, truncated) = job.output.snapshot();
8471 let delta = job.output.drain_new();
8472 (
8473 job.command.clone(),
8474 job.pid,
8475 job.started_at_ms,
8476 status,
8477 output_so_far,
8478 truncated,
8479 delta,
8480 )
8481 };
8482
8483 if !delta.is_empty() {
8484 self.emit(AgentEvent::BackgroundOutput {
8485 job_id: job_id.clone(),
8486 chunk: delta,
8487 truncated,
8488 });
8489 }
8490
8491 let exit_code = match status {
8492 crate::background::JobStatus::Exited(code) => code,
8493 _ => None,
8494 };
8495 let out = serde_json::json!({
8496 "job_id": job_id,
8497 "command": command,
8498 "status": status.as_str(),
8499 "exit_code": exit_code,
8500 "pid": pid,
8501 "started_at_ms": started_at_ms,
8502 "output": output_so_far,
8503 "output_truncated": truncated,
8504 });
8505 if !matches!(status, crate::background::JobStatus::Running) {
8506 self.background_jobs.remove(&job_id);
8507 }
8508 (out.to_string(), false)
8509 }
8510
8511 /// Execute the `background_list` intrinsic (P5-6, D10 "bg-manager"):
8512 /// list every background job this agent is currently tracking, without
8513 /// draining output or reaping anything (a read-only listing —
8514 /// `background_status` is the reaping poll).
8515 fn run_background_list(&mut self, _call: &crate::message::ToolCall) -> (String, bool) {
8516 let mut jobs = Vec::new();
8517 for (job_id, job) in self.background_jobs.iter_mut() {
8518 let status = background_job_status(job);
8519 jobs.push(serde_json::json!({
8520 "job_id": job_id,
8521 "command": job.command,
8522 "status": status.as_str(),
8523 "pid": job.pid,
8524 "started_at_ms": job.started_at_ms,
8525 }));
8526 }
8527 let out = serde_json::json!({ "jobs": jobs });
8528 (out.to_string(), false)
8529 }
8530
8531 /// Execute the `background_kill` intrinsic (P5-6, D10 "bg-manager",
8532 /// build brief "kill/cancel a job"): request REAL termination of a
8533 /// background job's OS process AND its whole process group (see
8534 /// [`kill_job_process_group`] — Fable-5 review, HIGH, "grandchildren
8535 /// orphaned on kill"; a documented no-op if the process already
8536 /// exited) and reap it immediately, freeing its concurrency slot.
8537 fn run_background_kill(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
8538 let args = match call.function.parsed_arguments() {
8539 Ok(v) => v,
8540 Err(e) => {
8541 let err = Error::InvalidArguments {
8542 tool: BACKGROUND_KILL.to_string(),
8543 message: e.to_string(),
8544 };
8545 return (format!("Error: {err}"), true);
8546 }
8547 };
8548 let Some(job_id) = args.get("job_id").and_then(serde_json::Value::as_str) else {
8549 let err = Error::InvalidArguments {
8550 tool: BACKGROUND_KILL.to_string(),
8551 message: "`job_id` is required".to_string(),
8552 };
8553 return (format!("Error: {err}"), true);
8554 };
8555 let job_id = job_id.to_string();
8556 let Some(mut job) = self.background_jobs.remove(&job_id) else {
8557 let err = Error::BackgroundJobNotFound(job_id);
8558 return (format!("Error: {err}"), true);
8559 };
8560 kill_job_process_group(&mut job);
8561 job.killed = true;
8562 let out = serde_json::json!({
8563 "job_id": job_id,
8564 "status": "killed",
8565 "pid": job.pid,
8566 });
8567 // `job` (and its `ConcurrencyGuard`) drops here, freeing the slot.
8568 (out.to_string(), false)
8569 }
8570
8571 /// P5-3 (D5 "subagent transcripts… persisted + linked"): write a
8572 /// finished child's transcript to `Self::subagent_store`, if one is
8573 /// installed — a no-op otherwise (see that field's doc comment). Builds
8574 /// the child's `Session` the same way `to_native_jsonl_v2`'s doc
8575 /// comment describes (an empty imported prefix + `transcript` as
8576 /// `appended` `NativeTurn`s), with `meta.agent_id`/`parent_tool_use_id`/
8577 /// `lineage` populated from `lineage` so the native-v2 header carries
8578 /// the full lineage record on disk (see `Session::to_native_jsonl_v2`'s
8579 /// P5-3 doc note).
8580 ///
8581 /// P5-3 safety-hardening fix (Fable-5 review, LOW "translation-fidelity
8582 /// cosmetic"): `Session::from_claude_code_str("")` is used ONLY to get
8583 /// a blank `raw`/`messages` skeleton cheaply (an empty string parses
8584 /// identically under any loader) — it is NOT claiming this child's
8585 /// session actually came from Claude Code. Before this fix, that
8586 /// borrowed constructor's `meta.source` (`SessionSource::ClaudeCode`)
8587 /// leaked straight through to the persisted sidecar's `source` header,
8588 /// mislabeling a native `spawn_subagent` child as an imported CC
8589 /// session. Corrected to `SessionSource::Native` immediately after —
8590 /// see that variant's doc comment.
8591 fn persist_subagent_transcript(
8592 &self,
8593 child_id: &str,
8594 lineage: &crate::subagents::SubagentLineage,
8595 transcript: &[ChatMessage],
8596 ) {
8597 let Some((store, parent_name)) = &self.subagent_store else {
8598 return;
8599 };
8600 let mut session = match Session::from_claude_code_str("") {
8601 Ok(s) => s,
8602 Err(_) => return,
8603 };
8604 session.meta.source = crate::session::SessionSource::Native;
8605 session.meta.agent_id = Some(lineage.child_agent_id.clone());
8606 session.meta.parent_tool_use_id = Some(lineage.parent_tool_use_id.clone());
8607 session.meta.lineage = lineage.to_lineage_map();
8608 let sidecar_jsonl = session.to_native_jsonl_v2(transcript);
8609 let _ = store.save_subagent_transcript(parent_name, child_id, &sidecar_jsonl);
8610 let _ = store.save_subagent_lineage(parent_name, child_id, lineage);
8611 }
8612
8613 /// Execute the `tool_search` intrinsic (B6): case-insensitive keyword
8614 /// match over `name` + `description` of every registered, enabled,
8615 /// non-core, not-yet-activated tool (builtin and `mcp__*` alike). Matches
8616 /// are activated (advertised starting with the next request) and
8617 /// returned as a JSON array of their full [`ToolSchema`]s.
8618 fn run_tool_search(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
8619 let args = match call.function.parsed_arguments() {
8620 Ok(v) => v,
8621 Err(e) => {
8622 let err = Error::InvalidArguments {
8623 tool: TOOL_SEARCH.to_string(),
8624 message: e.to_string(),
8625 };
8626 return (format!("Error: {err}"), true);
8627 }
8628 };
8629 let query = args
8630 .get("query")
8631 .and_then(serde_json::Value::as_str)
8632 .unwrap_or("")
8633 .to_lowercase();
8634 let max_results = args
8635 .get("max_results")
8636 .and_then(serde_json::Value::as_u64)
8637 .map(|n| n as usize);
8638
8639 let mut matches: Vec<ToolSchema> = self
8640 .registry
8641 .iter()
8642 .filter(|t| self.config.tool_enabled(t.name()))
8643 .filter(|t| !self.is_core_tool(t.name()))
8644 .filter(|t| !self.activated_tools.contains(t.name()))
8645 .filter(|t| {
8646 query.is_empty()
8647 || t.name().to_lowercase().contains(&query)
8648 || self
8649 .config
8650 .tool_description(t.name(), t.description())
8651 .to_lowercase()
8652 .contains(&query)
8653 })
8654 // TR-8/T5 dev/03: the on-demand fetch always returns the ORIGINAL
8655 // full schema, never the tier-minified one — that's the invert.
8656 .map(|t| self.raw_schema_for(t))
8657 .collect();
8658
8659 if let Some(max) = max_results {
8660 matches.truncate(max);
8661 }
8662
8663 for m in &matches {
8664 self.activated_tools.insert(m.name.clone());
8665 }
8666
8667 let result = serde_json::to_string(&matches).unwrap_or_else(|_| "[]".to_string());
8668 (result, false)
8669 }
8670
8671 /// The `expand_reduction` schema (T12/TR-1), advertised whenever a
8672 /// [`ReductionPolicy`] is installed.
8673 ///
8674 /// The description deliberately never spells the literal stub sentinel
8675 /// prefix: A11's export leak guard is unconditional, so an assistant
8676 /// turn that quoted a stub line verbatim (which teaching the syntax
8677 /// invites) would permanently fail export for that session. Stubs are
8678 /// described abstractly and the model is told to pass ids only.
8679 fn expand_reduction_schema() -> ToolSchema {
8680 ToolSchema {
8681 name: EXPAND_REDUCTION.to_string(),
8682 description: "Fetch back the original content hidden behind a reduction stub in \
8683 your current view — a truncated tool output, cleared old turns, or an elided \
8684 file read that was hidden to save context. Each stub line names a reduction id \
8685 like r0042-9f3c: pass ONLY that id here, and never quote or repeat a stub line \
8686 itself in your replies. The original is durably kept in the session sidecar. \
8687 Pass `byte_range` to fetch a slice of a large one at a time instead of all of \
8688 it at once; ranged results are prefixed with a `bytes start..end of total` \
8689 header so you can plan the next slice."
8690 .to_string(),
8691 parameters: serde_json::json!({
8692 "type": "object",
8693 "properties": {
8694 "reduction_id": {
8695 "type": "string",
8696 "description": "The reduction id named in the stub line, e.g. \
8697 \"r0042-9f3c\". Pass the id alone."
8698 },
8699 "byte_range": {
8700 "type": "array",
8701 "items": {"type": "integer"},
8702 "minItems": 2,
8703 "maxItems": 2,
8704 "description": "Optional [start, end) byte offsets within the original \
8705 content to fetch instead of all of it. Exactly two non-negative \
8706 integers with start <= end."
8707 }
8708 },
8709 "required": ["reduction_id"],
8710 "additionalProperties": false
8711 }),
8712 }
8713 }
8714
8715 /// The `sidecar_search` schema (T12/TR-1), advertised whenever a
8716 /// [`ReductionPolicy`] is installed. Same no-literal-sentinel rule as
8717 /// [`Self::expand_reduction_schema`].
8718 fn sidecar_search_schema() -> ToolSchema {
8719 ToolSchema {
8720 name: SIDECAR_SEARCH.to_string(),
8721 description: "Search content currently hidden from your view by reduction stubs \
8722 (large tool outputs, cleared old turns, elided file reads) for a substring or \
8723 regex. Only hidden content is searched, never what you can already see. \
8724 Returns match snippets with each match's reduction_id for use with \
8725 expand_reduction; refer to results by their reduction id rather than quoting \
8726 stub lines. Results are capped — if `truncated` is true, narrow the query."
8727 .to_string(),
8728 parameters: serde_json::json!({
8729 "type": "object",
8730 "properties": {
8731 "query": {
8732 "type": "string",
8733 "description": "Non-empty substring or regex to search for \
8734 (case-insensitive)."
8735 }
8736 },
8737 "required": ["query"],
8738 "additionalProperties": false
8739 }),
8740 }
8741 }
8742
8743 /// Reload the recorder's full recorded messages from disk (TR-1's
8744 /// `recorded` resolution source). Since TR-12's D6/A7 supersession gate
8745 /// (`Self::run_loop`), a `expand_reduction`/`sidecar_search` call only
8746 /// ever exists alongside an active [`ReductionPolicy`] (see
8747 /// [`EXPAND_REDUCTION`]'s doc), and pairing one with a recorder — as the
8748 /// CLI's reduced mode always does — means the gate is already on and
8749 /// `history[1..]` holds the same full bytes as this reload: this upgrade
8750 /// is then a dormant no-op (`reduce::rehydrate::prefer_recorded` sees
8751 /// `recorded == minted` and keeps `minted`). It stops being a no-op —
8752 /// defense in depth, not the common path — for a **legacy** sidecar
8753 /// recorded before this gate existed, or for a policy-without-recorder
8754 /// agent (gate off, so `history[1..]` still carries
8755 /// [`Self::cap_tool_output`]-capped copies): only there can `history[1..]`
8756 /// diverge from the sidecar, and only there does consulting this reload
8757 /// actually recover bytes `history[1..]` alone couldn't. `Ok(None)` when
8758 /// no recorder is attached (rehydration then resolves from history alone,
8759 /// whose capped copies — if any — carry their own honest cap notice). A
8760 /// disk-level reload is fine here regardless: these intrinsic calls are
8761 /// rare, model-initiated events, not per-request work.
8762 fn recorded_messages(&self) -> std::result::Result<Option<Vec<ChatMessage>>, String> {
8763 let Some(recorder) = &self.recorder else {
8764 return Ok(None);
8765 };
8766 let raw = std::fs::read_to_string(recorder.path())
8767 .map_err(|e| format!("failed to read the session sidecar: {e}"))?;
8768 let session = Session::from_sidecar_str(&raw)
8769 .map_err(|e| format!("failed to parse the session sidecar: {e}"))?;
8770 Ok(Some(session.messages))
8771 }
8772
8773 /// Execute the `expand_reduction` intrinsic (T12/TR-1): resolves against
8774 /// `self.reduction_log` + `self.history[1..]` (the hash-minting source),
8775 /// upgraded to the recorder's full recorded bytes for cap-diverged
8776 /// content ([`Self::recorded_messages`]; the two-source contract is
8777 /// documented on `reduce::rehydrate`). `byte_range` is validated
8778 /// strictly — any malformed shape is a model-recoverable error naming
8779 /// the expected form and the original's true size, never a silent
8780 /// whole-content (or empty) return.
8781 fn run_expand_reduction(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
8782 let args = match call.function.parsed_arguments() {
8783 Ok(v) => v,
8784 Err(e) => {
8785 let err = Error::InvalidArguments {
8786 tool: EXPAND_REDUCTION.to_string(),
8787 message: e.to_string(),
8788 };
8789 return (format!("Error: {err}"), true);
8790 }
8791 };
8792 let Some(id) = args.get("reduction_id").and_then(serde_json::Value::as_str) else {
8793 return (
8794 "Error: expand_reduction requires a `reduction_id` string argument".to_string(),
8795 true,
8796 );
8797 };
8798 let recorded = match self.recorded_messages() {
8799 Ok(r) => r,
8800 Err(e) => return (format!("Error: expand_reduction: {e}"), true),
8801 };
8802 let recorded = recorded.as_deref();
8803
8804 // B3: strict shape validation — exactly two non-negative integers.
8805 // Anything else errors (with the true total when resolvable) rather
8806 // than silently degrading to a whole-content expand.
8807 let byte_range = match args.get("byte_range") {
8808 None | Some(serde_json::Value::Null) => None,
8809 Some(v) => {
8810 let parsed = v
8811 .as_array()
8812 .filter(|a| a.len() == 2)
8813 .and_then(|a| Some((a[0].as_u64()? as usize, a[1].as_u64()? as usize)));
8814 match parsed {
8815 Some(range) => Some(range),
8816 None => {
8817 let total = reduce::rehydrate::reduction_total_bytes(
8818 &self.reduction_log,
8819 &self.history[1..],
8820 recorded,
8821 id,
8822 )
8823 .map(|n| format!("; the original is {n} bytes"))
8824 .unwrap_or_default();
8825 return (
8826 format!(
8827 "Error: expand_reduction: malformed byte_range {v} — expected \
8828 [start, end): exactly two non-negative integers with \
8829 start <= end{total}"
8830 ),
8831 true,
8832 );
8833 }
8834 }
8835 }
8836 };
8837 match reduce::rehydrate::expand_reduction(
8838 &self.reduction_log,
8839 &self.history[1..],
8840 recorded,
8841 id,
8842 byte_range,
8843 ) {
8844 // A ranged result carries a provenance header naming the slice
8845 // and the true total, so the model can plan its next slice; a
8846 // whole-content expand stays byte-exact (TR-1 dev/01).
8847 Ok(outcome) => match outcome.range {
8848 Some((start, end)) => (
8849 format!(
8850 "[{id}: bytes {start}..{end} of {total}]\n{content}",
8851 total = outcome.total_bytes,
8852 content = outcome.content
8853 ),
8854 false,
8855 ),
8856 None => (outcome.content, false),
8857 },
8858 Err(e) => (format!("Error: {e}"), true),
8859 }
8860 }
8861
8862 /// Execute the `sidecar_search` intrinsic (T12/TR-1); same two-source
8863 /// resolution as [`Self::run_expand_reduction`]. The result is bounded
8864 /// by construction (`reduce::rehydrate::SidecarSearchResult`'s caps), so
8865 /// a broad query can never re-inflate the context or bloat the sidecar
8866 /// the recorder appends this result to.
8867 fn run_sidecar_search(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
8868 let args = match call.function.parsed_arguments() {
8869 Ok(v) => v,
8870 Err(e) => {
8871 let err = Error::InvalidArguments {
8872 tool: SIDECAR_SEARCH.to_string(),
8873 message: e.to_string(),
8874 };
8875 return (format!("Error: {err}"), true);
8876 }
8877 };
8878 let query = args
8879 .get("query")
8880 .and_then(serde_json::Value::as_str)
8881 .unwrap_or("");
8882 if query.trim().is_empty() {
8883 return (
8884 "Error: sidecar_search requires a non-empty `query` string argument".to_string(),
8885 true,
8886 );
8887 }
8888 let recorded = match self.recorded_messages() {
8889 Ok(r) => r,
8890 Err(e) => return (format!("Error: sidecar_search: {e}"), true),
8891 };
8892 match reduce::rehydrate::sidecar_search(
8893 &self.reduction_log,
8894 &self.history[1..],
8895 recorded.as_deref(),
8896 query,
8897 ) {
8898 Ok(result) => (
8899 serde_json::to_string(&result).unwrap_or_else(|_| "{}".to_string()),
8900 false,
8901 ),
8902 Err(e) => (format!("Error: {e}"), true),
8903 }
8904 }
8905
8906 fn emit(&self, event: AgentEvent) {
8907 if let Some(sink) = &self.config.event_sink {
8908 sink(event);
8909 }
8910 }
8911
8912 /// Number of non-system messages exchanged so far.
8913 pub fn turn_count(&self) -> usize {
8914 self.history
8915 .iter()
8916 .filter(|m| m.role != Role::System)
8917 .count()
8918 }
8919
8920 /// Cumulative output (completion) tokens reported by the provider across
8921 /// every `send` on this agent. Zero if the provider reports no usage.
8922 pub fn total_output_tokens(&self) -> u64 {
8923 self.total_output_tokens
8924 }
8925}
8926
8927#[cfg(test)]
8928mod bp2_spill_tests {
8929 //! BP-2 (`.volter/tracker/markdown/BP-2.md`, catalog:58): under the
8930 //! parity presets a capped tool output stays RECOVERABLE by the model —
8931 //! without `capabilities.reduction`, which both presets leave off.
8932
8933 use super::*;
8934 use crate::configfile::{resolve, ResolveOptions};
8935
8936 /// Never called — these tests drive `cap_tool_output` directly.
8937 #[derive(Debug)]
8938 struct NeverCalledProvider;
8939
8940 #[async_trait::async_trait]
8941 impl Provider for NeverCalledProvider {
8942 async fn complete(
8943 &self,
8944 _req: &ChatRequest,
8945 _on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
8946 ) -> crate::Result<(ChatMessage, crate::provider::Usage)> {
8947 unreachable!("BP-2 spill tests never issue a request")
8948 }
8949 }
8950
8951 fn resolved(preset: &str) -> crate::configfile::Resolved {
8952 let toml = crate::presets::lookup(preset).unwrap();
8953 resolve(toml, None, &ResolveOptions { strict: true })
8954 .unwrap_or_else(|e| panic!("{preset} resolves: {e}"))
8955 }
8956
8957 /// The residue this closes: the cap notice said the full output was
8958 /// "not retained" / "in session sidecar" and the only door to it —
8959 /// `expand_reduction` — is advertised solely when a `ReductionPolicy`
8960 /// is installed, which `capabilities.reduction = false` never does. So
8961 /// under both parity presets a truncated output was simply lost.
8962 ///
8963 /// Now the notice NAMES a spill file, and the door is the preset's own
8964 /// read pathway: `read_file` under cc-parity, the shell under
8965 /// cx-parity (which registers no file tools at all).
8966 #[tokio::test]
8967 async fn parity_presets_spill_capped_output_and_name_a_door_the_preset_has() {
8968 for (preset, expected_door) in [
8969 ("cc-parity", "read it with `read_file`"),
8970 ("cx-parity", "read it with `cat`"),
8971 ] {
8972 let r = resolved(preset);
8973 assert!(
8974 r.config.tool_output_spill,
8975 "{preset} must set `core.tool_output_spill`"
8976 );
8977 assert_eq!(
8978 r.modules.get("reduction"),
8979 Some(&false),
8980 "{preset} leaves `capabilities.reduction` off — the spill must not depend on it"
8981 );
8982 let mut config = resolved(preset).config;
8983 config.max_tool_output_bytes = Some(1024);
8984 let registry = crate::tools::ToolRegistry::from_config(&config);
8985 let agent = Agent::with_parts(config, Box::new(NeverCalledProvider), registry);
8986 assert!(
8987 agent.reduction_policy.is_none(),
8988 "no reduction policy is installed under {preset}"
8989 );
8990
8991 let full = "R".repeat(50_000);
8992 let capped = agent.cap_tool_output(full.clone());
8993 assert!(capped.len() < full.len(), "{preset}: output must be capped");
8994 assert!(capped.contains(expected_door), "{preset}: {capped:?}");
8995
8996 // The path in the notice must actually hold the full bytes.
8997 let marker = capped.split("spilled to ").nth(1).unwrap_or_default();
8998 let path = marker.split(" — ").next().unwrap_or_default();
8999 assert!(!path.is_empty(), "{preset}: no spill path in {capped:?}");
9000 assert_eq!(
9001 std::fs::read_to_string(path).unwrap(),
9002 full,
9003 "{preset}: the spill file must hold the FULL output"
9004 );
9005
9006 // And the model can actually walk through that door: the
9007 // preset's own read pathway returns the spilled content.
9008 let ctx = build_tool_context(agent.config()).0;
9009 let recovered = match registry_read_tool(&agent) {
9010 Some(("read_file", tool)) => tool
9011 .execute(serde_json::json!({"path": path}), &ctx)
9012 .await
9013 .unwrap(),
9014 Some(("bash", tool)) => tool
9015 .execute(serde_json::json!({"command": format!("cat {path}")}), &ctx)
9016 .await
9017 .unwrap(),
9018 _ => panic!("{preset}: no read door registered"),
9019 };
9020 assert!(
9021 recovered.contains(&"R".repeat(2000)),
9022 "{preset}: the door must return the spilled output"
9023 );
9024 let _ = std::fs::remove_file(path);
9025 }
9026 }
9027
9028 /// The preset's read pathway: `read_file` where it exists, else the
9029 /// shell — the same choice the cap notice's wording makes.
9030 fn registry_read_tool<'a>(
9031 agent: &'a Agent,
9032 ) -> Option<(&'static str, &'a dyn crate::tools::Tool)> {
9033 if let Some(tool) = agent.registry.get("read_file") {
9034 return Some(("read_file", tool));
9035 }
9036 agent.registry.get("bash").map(|tool| ("bash", tool))
9037 }
9038
9039 /// Off (the default, every non-parity preset and every SDK embedder):
9040 /// no spill file, and the notice is byte-identical to before BP-2.
9041 #[test]
9042 fn spill_off_leaves_the_notice_unchanged_and_writes_nothing() {
9043 let config = Config::builder().max_tool_output_bytes(1024).build();
9044 assert!(!config.tool_output_spill);
9045 let agent = Agent::with_provider(config, Box::new(NeverCalledProvider));
9046 let capped = agent.cap_tool_output("S".repeat(50_000));
9047 assert!(capped.contains("full output not retained"), "{capped:?}");
9048 assert!(!capped.contains("spilled to"), "{capped:?}");
9049 }
9050}
9051
9052#[cfg(test)]
9053mod bp3_new_core_tool_tests {
9054 //! BP-3 (`.volter/tracker/markdown/BP-3.md`): the two behaviours the
9055 //! new tools can only have INSIDE the agent — plan mode narrowing the
9056 //! permissions engine, and `new_context` re-founding the request view
9057 //! through `reduce`'s handoff projection — driven over the RESOLVED
9058 //! parity presets.
9059
9060 use super::*;
9061 use crate::configfile::{resolve, ResolveOptions};
9062
9063 #[derive(Debug)]
9064 struct NeverCalledProvider;
9065
9066 #[async_trait::async_trait]
9067 impl Provider for NeverCalledProvider {
9068 async fn complete(
9069 &self,
9070 _req: &ChatRequest,
9071 _on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
9072 ) -> crate::Result<(ChatMessage, crate::provider::Usage)> {
9073 unreachable!("BP-3 tests never issue a request")
9074 }
9075 }
9076
9077 fn resolved(preset: &str) -> Config {
9078 let toml = crate::presets::lookup(preset).unwrap();
9079 resolve(toml, None, &ResolveOptions { strict: true })
9080 .unwrap_or_else(|e| panic!("{preset} resolves: {e}"))
9081 .config
9082 }
9083
9084 fn agent_for(preset: &str) -> Agent {
9085 Agent::with_provider(resolved(preset), Box::new(NeverCalledProvider))
9086 }
9087
9088 /// An approval door that says yes to everything — so a denial in these
9089 /// tests can only come from the DENY tier, never from cc-parity's
9090 /// `approval = "untrusted"` ask default.
9091 struct AlwaysAllow;
9092 impl crate::permissions::PermissionsApprovalHandler for AlwaysAllow {
9093 fn ask(
9094 &self,
9095 _req: &crate::permissions::ApprovalRequest,
9096 ) -> crate::permissions::ApprovalOutcome {
9097 crate::permissions::ApprovalOutcome::Allow
9098 }
9099 }
9100
9101 fn call(name: &str, args: serde_json::Value) -> crate::message::ToolCall {
9102 crate::message::ToolCall {
9103 id: format!("call-{name}"),
9104 kind: "function".to_string(),
9105 function: crate::message::FunctionCall {
9106 name: name.to_string(),
9107 arguments: args.to_string(),
9108 },
9109 }
9110 }
9111
9112 /// The row `plan-mode-read-only-research-phase` claims: under the
9113 /// resolved `cc-parity` preset, entering plan mode makes the
9114 /// permissions engine REFUSE write and execution tools — and the
9115 /// refusal survives an approval door that allows everything, because
9116 /// the mode contributes DENY rules, the tier no approval can override.
9117 #[test]
9118 fn cc_parity_plan_mode_denies_writes_through_the_permissions_engine() {
9119 let mut agent = agent_for("cc-parity");
9120 assert!(
9121 agent.config.permissions_enabled,
9122 "cc-parity runs the permissions engine; plan mode narrows it"
9123 );
9124 agent.set_permissions_approval_handler(AlwaysAllow);
9125
9126 let write = serde_json::json!({"path": "notes.txt", "content": "x"});
9127 let bash = serde_json::json!({"command": "echo hi"});
9128 assert!(
9129 agent
9130 .permissions_gate_denial("write_file", &write, crate::config::HookDecision::Pass)
9131 .is_none(),
9132 "outside plan mode an allowed write must pass"
9133 );
9134 assert!(agent
9135 .permissions_gate_denial("bash", &bash, crate::config::HookDecision::Pass)
9136 .is_none());
9137
9138 agent.plan_mode().enter(Some("research first"));
9139
9140 let denial = agent
9141 .permissions_gate_denial("write_file", &write, crate::config::HookDecision::Pass)
9142 .expect("plan mode must refuse a write");
9143 assert!(denial.contains("Deny"), "{denial}");
9144 assert!(agent
9145 .permissions_gate_denial("bash", &bash, crate::config::HookDecision::Pass)
9146 .is_some());
9147 assert!(agent
9148 .permissions_gate_denial(
9149 "apply_patch",
9150 &serde_json::json!({"patch": "*** Begin Patch\n*** End Patch"}),
9151 crate::config::HookDecision::Pass
9152 )
9153 .is_some());
9154
9155 // The research surface, and the way out, stay open.
9156 for (tool, args) in [
9157 ("read_file", serde_json::json!({"path": "notes.txt"})),
9158 ("glob", serde_json::json!({"pattern": "*.rs"})),
9159 ("exit_plan_mode", serde_json::json!({"plan": "the plan"})),
9160 ("ask_user", serde_json::json!({"questions": []})),
9161 ] {
9162 assert!(
9163 agent
9164 .permissions_gate_denial(tool, &args, crate::config::HookDecision::Pass)
9165 .is_none(),
9166 "plan mode must leave `{tool}` reachable"
9167 );
9168 }
9169
9170 agent.plan_mode().exit();
9171 assert!(
9172 agent
9173 .permissions_gate_denial("write_file", &write, crate::config::HookDecision::Pass)
9174 .is_none(),
9175 "leaving plan mode restores the write surface"
9176 );
9177 }
9178
9179 /// The `context-budget-tools` row's read half: the figure
9180 /// `get_context_remaining` reports is the agent's OWN accounting,
9181 /// computed at the moment the tool asks for it.
9182 #[test]
9183 fn cx_parity_publishes_its_context_accounting_when_the_budget_tool_runs() {
9184 let mut agent = agent_for("cx-parity");
9185 assert!(agent.ctx.context_budget.snapshot().is_none(), "nothing yet");
9186 agent
9187 .history
9188 .push(ChatMessage::user("x".repeat(4000).to_string()));
9189 let _ = agent.prepare_tool_call(&call("current_time", serde_json::json!({})));
9190 assert!(
9191 agent.ctx.context_budget.snapshot().is_none(),
9192 "an unrelated tool call must not pay for the accounting"
9193 );
9194
9195 let _ = agent.prepare_tool_call(&call("get_context_remaining", serde_json::json!({})));
9196 let published = agent
9197 .ctx
9198 .context_budget
9199 .snapshot()
9200 .expect("the budget tool's own call publishes it");
9201 // What the model reads IS `Agent::context_usage()` — the same
9202 // struct `/context` prints and the guard enforces, not a second
9203 // estimate that could disagree with it.
9204 assert_eq!(
9205 published,
9206 serde_json::to_value(agent.context_usage()).unwrap()
9207 );
9208 assert!(published["context_limit"].as_u64().unwrap() > 0);
9209 assert!(published["remaining_tokens"].as_u64().unwrap() > 0);
9210 }
9211
9212 /// The `context-budget-tools` row's write half, over the resolved
9213 /// `cx-parity` preset: a parked `new_context` request re-founds the
9214 /// request view through `reduce`'s handoff projection — objective in
9215 /// the leading system message, the tail kept, the rest covered by
9216 /// `TurnsCleared` spans that land in this agent's own reduction log.
9217 #[test]
9218 fn cx_parity_new_context_rebuilds_the_window_through_the_same_handoff_the_operator_gets() {
9219 let mut agent = agent_for("cx-parity");
9220 agent.history.push(ChatMessage::system("system"));
9221 for i in 0..12 {
9222 agent.history.push(ChatMessage::user(format!("turn {i}")));
9223 }
9224 let before = agent.history.clone();
9225
9226 agent
9227 .ctx
9228 .context_budget
9229 .request_new_context(crate::tools::NewContextRequest {
9230 objective: "finish the parser".to_string(),
9231 keep_recent: Some(2),
9232 });
9233 agent.apply_pending_new_context();
9234
9235 // Exactly what `/handoff` produces — the model's door and the
9236 // operator's door run one mechanism, so this compares against it.
9237 let mut expected =
9238 Agent::with_provider(resolved("cx-parity"), Box::new(NeverCalledProvider));
9239 expected.history = before.clone();
9240 expected.new_context("finish the parser", Some(2));
9241 assert_eq!(agent.history, expected.history);
9242
9243 assert!(
9244 agent.history.len() < before.len(),
9245 "the window must actually shrink: {} -> {}",
9246 before.len(),
9247 agent.history.len()
9248 );
9249 assert_eq!(agent.history[0], before[0], "the system prompt survives");
9250 let marker = agent.history[1].content.clone().unwrap_or_default();
9251 assert!(marker.contains("fresh working context"), "{marker}");
9252 assert!(marker.contains("finish the parser"), "{marker}");
9253 assert_eq!(
9254 agent.history[agent.history.len() - 2..],
9255 before[before.len() - 2..],
9256 "the requested tail is kept verbatim"
9257 );
9258 assert!(
9259 agent.ctx.context_budget.take_new_context().is_none(),
9260 "the request is consumed exactly once"
9261 );
9262 }
9263
9264 /// `new_context` never trades recoverability for a smaller window: with
9265 /// no sidecar recorder the request is refused, the reason is handed
9266 /// back to the model, and the transcript keeps every turn.
9267 #[test]
9268 fn cx_parity_new_context_states_the_retention_it_actually_has() {
9269 let mut agent = agent_for("cx-parity");
9270 agent.history.push(ChatMessage::system("system"));
9271 for i in 0..12 {
9272 agent.history.push(ChatMessage::user(format!("turn {i}")));
9273 }
9274 agent
9275 .ctx
9276 .context_budget
9277 .request_new_context(crate::tools::NewContextRequest {
9278 objective: "finish the parser".to_string(),
9279 keep_recent: Some(2),
9280 });
9281 agent.apply_pending_new_context();
9282
9283 // No recorder is installed here, and the marker says so rather than
9284 // implying the set-aside turns are still somewhere.
9285 let marker = agent.history[1].content.clone().unwrap_or_default();
9286 assert!(
9287 marker.contains("No transcript sidecar is attached"),
9288 "the marker must not overstate retention: {marker}"
9289 );
9290 }
9291}
9292
9293/// BP-8 (catalog:152): what [`Agent::rewind_conversation`] did.
9294#[derive(Debug, Clone, PartialEq, Eq)]
9295pub struct RewindOutcome {
9296 /// Messages remaining, including the system message at index 0.
9297 pub kept: usize,
9298 /// Messages removed from the live conversation (still on disk, in the
9299 /// journal, and still in the tree under `preserved_branch`).
9300 pub removed: usize,
9301 /// When the tree module is on and the rewind actually moved the leaf:
9302 /// the sibling branch the old leaf was preserved under, so the rewound
9303 /// path stays independently addressable.
9304 pub preserved_branch: Option<String>,
9305}
9306
9307#[cfg(test)]
9308mod bp1_compaction_tests {
9309 //! BP-1 (`.volter/tracker/markdown/BP-1.md` AC2): `cx-parity` fires
9310 //! [`Agent::maybe_compact`] at its own trigger, and
9311 //! `core.compaction.summarize` reaches [`Config`] and is what the
9312 //! compaction marker says.
9313
9314 use super::*;
9315 use crate::configfile::{resolve, ResolveOptions};
9316
9317 /// Never called — these tests drive `maybe_compact` directly, which
9318 /// makes no request.
9319 #[derive(Debug)]
9320 struct NeverCalledProvider;
9321
9322 #[async_trait::async_trait]
9323 impl Provider for NeverCalledProvider {
9324 async fn complete(
9325 &self,
9326 _req: &ChatRequest,
9327 _on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
9328 ) -> crate::Result<(ChatMessage, crate::provider::Usage)> {
9329 unreachable!("BP-1 compaction tests never issue a request")
9330 }
9331 }
9332
9333 fn resolved_cx_parity() -> Config {
9334 let toml = crate::presets::lookup("cx-parity").unwrap();
9335 resolve(toml, None, &ResolveOptions { strict: true })
9336 .expect("cx-parity resolves")
9337 .config
9338 }
9339
9340 /// Enough history to sit inside `reserve_tokens` of ANY model context
9341 /// window (`estimate_view_tokens` is size-proportional, and the largest
9342 /// window in the catalog is far below this).
9343 fn stuff_history(agent: &mut Agent) {
9344 agent.history.push(ChatMessage::system("system"));
9345 for i in 0..400 {
9346 agent
9347 .history
9348 .push(ChatMessage::user(format!("turn {i}: {}", "x".repeat(8000))));
9349 }
9350 }
9351
9352 /// The defect: `cx-parity` armed NEITHER compaction trigger, so
9353 /// `maybe_compact` returned `false` on its
9354 /// `threshold.is_none() && compaction_reserve_tokens.is_none()` guard
9355 /// no matter how large the conversation grew.
9356 #[test]
9357 fn cx_parity_fires_maybe_compact_at_its_pressure_trigger() {
9358 let config = resolved_cx_parity();
9359 assert!(config.compaction_enabled);
9360 assert_eq!(config.compaction_reserve_tokens, Some(16384));
9361 assert!(config.compaction_summarize);
9362
9363 let mut agent = Agent::with_provider(config, Box::new(NeverCalledProvider));
9364 stuff_history(&mut agent);
9365 let before = agent.history.len();
9366 assert!(
9367 agent.maybe_compact(),
9368 "cx-parity must compact under context pressure"
9369 );
9370 assert!(agent.history.len() < before, "history must actually shrink");
9371 let marker = agent
9372 .history
9373 .iter()
9374 .find(|m| {
9375 m.content
9376 .as_deref()
9377 .is_some_and(|c| c.contains("earlier conversation compacted"))
9378 })
9379 .expect("a compaction marker must be present");
9380 assert!(
9381 marker
9382 .content
9383 .as_deref()
9384 .unwrap()
9385 .contains("summarized to save context"),
9386 "cx-parity sets `core.compaction.summarize = true`"
9387 );
9388 }
9389
9390 /// `core.compaction.summarize = false` reaches `Config` too, and the
9391 /// marker then states only what actually happened to the span.
9392 #[test]
9393 fn compaction_summarize_false_reaches_config_and_changes_the_marker() {
9394 let toml = "extends = \"cx-parity\"\n[core.compaction]\nsummarize = false\n";
9395 let config = resolve(toml, None, &ResolveOptions::default())
9396 .expect("resolves")
9397 .config;
9398 assert!(!config.compaction_summarize);
9399
9400 let mut agent = Agent::with_provider(config, Box::new(NeverCalledProvider));
9401 stuff_history(&mut agent);
9402 assert!(agent.maybe_compact());
9403 let marker = agent
9404 .history
9405 .iter()
9406 .find(|m| {
9407 m.content
9408 .as_deref()
9409 .is_some_and(|c| c.contains("earlier conversation compacted"))
9410 })
9411 .expect("a compaction marker must be present");
9412 let text = marker.content.as_deref().unwrap();
9413 assert!(text.contains("cleared to save context"), "got: {text}");
9414 assert!(!text.contains("summarized"));
9415 }
9416}
9417
9418#[cfg(test)]
9419mod api_key_cmd_tests {
9420 //! P4 (design §5.2, §1.8 D6 row): `api_key_cmd` credential-helper
9421 //! resolution. `Agent::new` never makes a network call, so these tests
9422 //! exercise the real resolution chain end-to-end without mocking.
9423
9424 use super::*;
9425
9426 /// Default-off: with no `api_key`/`api_key_cmd` set and an env var that
9427 /// isn't set either, resolution fails exactly as it always has —
9428 /// `api_key_cmd` being a brand-new field changes nothing when unset.
9429 #[test]
9430 fn default_none_falls_through_to_missing_api_key_error() {
9431 let config = Config::builder()
9432 .api_key_env("SUPERCODE_TEST_UNSET_VAR_API_KEY_CMD")
9433 .build();
9434 assert!(config.api_key.is_none());
9435 assert!(config.api_key_cmd.is_none());
9436 let err = Agent::new(config).err().expect("no key source configured");
9437 assert!(matches!(err, Error::MissingApiKey(_)));
9438 }
9439
9440 /// Happy path: `api_key_cmd` alone (no `api_key`, no matching env var)
9441 /// is enough for `Agent::new` to succeed — the helper's stdout is
9442 /// resolved and used.
9443 #[test]
9444 fn api_key_cmd_alone_resolves_successfully() {
9445 let config = Config::builder()
9446 .api_key_cmd("echo sk-test-from-helper")
9447 .api_key_env("SUPERCODE_TEST_UNSET_VAR_API_KEY_CMD_2")
9448 .build();
9449 assert!(Agent::new(config).is_ok());
9450 }
9451
9452 /// A failing helper command (non-zero exit, or empty stdout) falls
9453 /// through to `api_key_env` rather than propagating the helper's own
9454 /// failure — same "try the next source" posture as every other layer.
9455 #[test]
9456 fn api_key_cmd_failure_falls_through_to_env() {
9457 std::env::set_var(
9458 "SUPERCODE_TEST_API_KEY_CMD_FALLBACK",
9459 "sk-from-env-fallback",
9460 );
9461 let config = Config::builder()
9462 .api_key_cmd("exit 1")
9463 .api_key_env("SUPERCODE_TEST_API_KEY_CMD_FALLBACK")
9464 .build();
9465 assert!(Agent::new(config).is_ok());
9466 std::env::remove_var("SUPERCODE_TEST_API_KEY_CMD_FALLBACK");
9467 }
9468
9469 /// A failing helper AND no fallback env var still produces the same
9470 /// `MissingApiKey` error today's no-key path always produced — the new
9471 /// source never turns a hard failure into a silent empty key.
9472 #[test]
9473 fn api_key_cmd_failure_with_no_fallback_still_errors() {
9474 let config = Config::builder()
9475 .api_key_cmd("exit 1")
9476 .api_key_env("SUPERCODE_TEST_UNSET_VAR_API_KEY_CMD_3")
9477 .build();
9478 let err = Agent::new(config)
9479 .err()
9480 .expect("helper failed, no env fallback");
9481 assert!(matches!(err, Error::MissingApiKey(_)));
9482 }
9483
9484 /// `run_api_key_cmd` directly: happy path trims trailing whitespace/
9485 /// newline from the command's stdout.
9486 #[test]
9487 fn run_api_key_cmd_trims_output() {
9488 assert_eq!(run_api_key_cmd("echo ' sk-abc123 '"), "sk-abc123");
9489 }
9490
9491 /// `run_api_key_cmd` directly: a nonexistent binary fails to spawn and
9492 /// returns an empty string rather than panicking.
9493 #[test]
9494 fn run_api_key_cmd_spawn_failure_returns_empty() {
9495 // `sh -c` itself always spawns; feed it a command that can't run.
9496 assert_eq!(
9497 run_api_key_cmd("/no/such/binary/at/all --flag"),
9498 String::new()
9499 );
9500 }
9501}
9502
9503#[cfg(test)]
9504mod bp4_prompt_context_tests {
9505 //! BP-4 (`.volter/tracker/markdown/BP-4.md`): the prompt/context knobs
9506 //! the parity presets never set, proved over the RESOLVED `cc-parity` /
9507 //! `cx-parity` configs (not over hand-built `Config`s — a preset that
9508 //! doesn't arm the knob would pass that weaker test).
9509
9510 use super::*;
9511 use crate::configfile::{resolve, ResolveOptions};
9512
9513 fn resolved(preset: &str) -> Config {
9514 let toml = crate::presets::lookup(preset).unwrap();
9515 resolve(toml, None, &ResolveOptions { strict: true })
9516 .unwrap_or_else(|e| panic!("{preset} resolves: {e}"))
9517 .config
9518 }
9519
9520 /// Never called — these tests assemble prompts and drive
9521 /// `refresh_env_context`/`inject_context_block`, none of which issue a
9522 /// request.
9523 #[derive(Debug)]
9524 struct NoProvider;
9525
9526 #[async_trait::async_trait]
9527 impl Provider for NoProvider {
9528 async fn complete(
9529 &self,
9530 _req: &ChatRequest,
9531 _on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
9532 ) -> crate::Result<(ChatMessage, crate::provider::Usage)> {
9533 unreachable!("BP-4 prompt/context tests never issue a request")
9534 }
9535 }
9536
9537 fn scratch(tag: &str) -> std::path::PathBuf {
9538 let dir = std::env::temp_dir().join(format!(
9539 "supercode-bp4-{tag}-{}-{}",
9540 std::process::id(),
9541 std::time::SystemTime::now()
9542 .duration_since(std::time::UNIX_EPOCH)
9543 .unwrap()
9544 .as_nanos()
9545 ));
9546 std::fs::create_dir_all(&dir).unwrap();
9547 dir
9548 }
9549
9550 /// Row `project-instruction-files-w-directory-walk`: a CLAUDE.md above
9551 /// the working directory is discovered, ordered root→cwd (nearest wins
9552 /// by appearing last), and the climb STOPS at the `.git` root — the
9553 /// directory above it is never read.
9554 #[test]
9555 fn presets_walk_ancestors_up_to_the_git_root_nearest_last() {
9556 for preset in ["cc-parity", "cx-parity"] {
9557 let base = scratch("walk");
9558 let above = base.join("above");
9559 let root = above.join("repo");
9560 let deep = root.join("crates").join("thing");
9561 std::fs::create_dir_all(&deep).unwrap();
9562 std::fs::create_dir_all(root.join(".git")).unwrap();
9563 std::fs::write(above.join("CLAUDE.md"), "ABOVE-THE-ROOT-MARKER").unwrap();
9564 std::fs::write(above.join("AGENTS.md"), "ABOVE-THE-ROOT-MARKER").unwrap();
9565 std::fs::write(root.join("CLAUDE.md"), "REPO-ROOT-MARKER").unwrap();
9566 std::fs::write(root.join("AGENTS.md"), "REPO-ROOT-MARKER").unwrap();
9567 std::fs::write(deep.join("CLAUDE.md"), "NEAREST-DIR-MARKER").unwrap();
9568 std::fs::write(deep.join("AGENTS.md"), "NEAREST-DIR-MARKER").unwrap();
9569
9570 let mut config = resolved(preset);
9571 config.cwd = deep.clone();
9572 let blob = assemble_project_instructions(&config);
9573
9574 let root_at = blob
9575 .find("REPO-ROOT-MARKER")
9576 .unwrap_or_else(|| panic!("{preset}: the ancestor repo root was not walked"));
9577 let near_at = blob
9578 .find("NEAREST-DIR-MARKER")
9579 .unwrap_or_else(|| panic!("{preset}: cwd's own file was not loaded"));
9580 assert!(
9581 root_at < near_at,
9582 "{preset}: nearest-to-cwd must win by appearing LAST (root→cwd)"
9583 );
9584 assert!(
9585 !blob.contains("ABOVE-THE-ROOT-MARKER"),
9586 "{preset}: the walk must stop at the `.git` root"
9587 );
9588 let _ = std::fs::remove_dir_all(&base);
9589 }
9590 }
9591
9592 /// The walk is bounded even with no root marker anywhere: it terminates
9593 /// at the filesystem root instead of looping.
9594 #[test]
9595 fn walk_terminates_without_a_root_marker() {
9596 let base = scratch("nomarker");
9597 let deep = base.join("a").join("b").join("c");
9598 std::fs::create_dir_all(&deep).unwrap();
9599 let mut config = resolved("cx-parity");
9600 config.cwd = deep.clone();
9601 let roots = instruction_walk_roots(&config);
9602 assert!(roots.len() <= MAX_INSTRUCTION_WALK_DEPTH);
9603 assert_eq!(roots.last().unwrap(), &deep, "cwd is the LAST root");
9604 let _ = std::fs::remove_dir_all(&base);
9605 }
9606
9607 /// Row `instruction-file-hygiene-controls`, cx half: `cx-parity` sets
9608 /// the documented 32 KiB `project_doc_max_bytes`, and it binds per file
9609 /// AND over the aggregate, each with its own notice.
9610 #[test]
9611 fn cx_parity_enforces_the_documented_instruction_byte_cap() {
9612 let config = resolved("cx-parity");
9613 assert_eq!(
9614 config.project_doc_max_bytes,
9615 Some(32_768),
9616 "cx-parity must arm cx§2's documented 32 KiB cap"
9617 );
9618
9619 let base = scratch("cap");
9620 std::fs::create_dir_all(base.join(".git")).unwrap();
9621 std::fs::write(base.join("CLAUDE.md"), "x".repeat(40_000)).unwrap();
9622 std::fs::write(base.join("AGENTS.md"), "y".repeat(40_000)).unwrap();
9623 let mut config = config;
9624 config.cwd = base.clone();
9625 let blob = assemble_project_instructions(&config);
9626 assert!(
9627 blob.contains("[supercode: file truncated at core.project_doc_max_bytes]"),
9628 "the per-file cap must fire with a notice"
9629 );
9630 assert!(
9631 blob.contains(
9632 "[supercode: instruction content truncated at core.project_doc_max_bytes]"
9633 ),
9634 "the aggregate cap must fire with a notice"
9635 );
9636 assert!(blob.len() < 33_200, "aggregate blob stayed over the cap");
9637 let _ = std::fs::remove_dir_all(&base);
9638 }
9639
9640 /// Row `instruction-file-hygiene-controls`, cc half: `cc-parity` arms
9641 /// CC's own two levers — HTML-comment stripping and `claudeMdExcludes`
9642 /// — and arms NO byte cap, because CC documents none.
9643 #[test]
9644 fn cc_parity_strips_html_comments_and_honours_excludes() {
9645 let mut config = resolved("cc-parity");
9646 assert!(config.project_doc_strip_comments, "cc strips `<!-- … -->`");
9647 assert_eq!(
9648 config.project_doc_max_bytes, None,
9649 "`project_doc_max_bytes = 0` is §3.1's spelling for uncapped"
9650 );
9651
9652 let base = scratch("hygiene");
9653 std::fs::create_dir_all(base.join(".git")).unwrap();
9654 std::fs::write(
9655 base.join("CLAUDE.md"),
9656 "KEEP-THIS<!-- MAINTAINER-NOTE -->AND-THIS",
9657 )
9658 .unwrap();
9659 std::fs::write(base.join("AGENTS.md"), "EXCLUDED-FILE-MARKER").unwrap();
9660 config.cwd = base.clone();
9661 config.project_doc_excludes = vec!["AGENTS.md".to_string()];
9662 let blob = assemble_project_instructions(&config);
9663 assert!(blob.contains("KEEP-THIS") && blob.contains("AND-THIS"));
9664 assert!(
9665 !blob.contains("MAINTAINER-NOTE"),
9666 "block HTML comments must be stripped before injection"
9667 );
9668 assert!(
9669 !blob.contains("EXCLUDED-FILE-MARKER"),
9670 "an excluded instruction file must never be read into the prompt"
9671 );
9672 let _ = std::fs::remove_dir_all(&base);
9673 }
9674
9675 /// A project layer must not be able to suppress the user's own global
9676 /// instruction files by adding an exclude pattern (§3.3 trust boundary).
9677 #[test]
9678 fn project_layer_cannot_set_instruction_excludes() {
9679 let hc = crate::configfile::HarnessConfig::from_toml_str(
9680 "schema_version = 1\n[core]\nproject_doc_excludes = [\"CLAUDE.md\"]\n",
9681 )
9682 .unwrap();
9683 let (sanitized, dropped) = crate::configfile::sanitize_for_project(&hc);
9684 assert!(sanitized.core.project_doc_excludes.is_none());
9685 assert!(dropped.iter().any(|d| d == "core.project_doc_excludes"));
9686 }
9687
9688 /// Row `environment-context-block`: both presets emit the policy line
9689 /// the row's semantics name, and the block is RE-EMITTED when the thing
9690 /// it describes moves (cx§2 "re-emitted on change").
9691 #[test]
9692 fn env_context_block_carries_policy_and_re_emits_on_change() {
9693 for preset in ["cc-parity", "cx-parity"] {
9694 let base = scratch("env");
9695 // A SIBLING, not a child: `contains` assertions below must not
9696 // be satisfiable by a path prefix.
9697 let here = base.join("here");
9698 let other = base.join("elsewhere");
9699 std::fs::create_dir_all(&here).unwrap();
9700 std::fs::create_dir_all(&other).unwrap();
9701
9702 let mut config = resolved(preset);
9703 config.cwd = here.clone();
9704 assert!(config.env_context, "{preset} must set core.env_context");
9705 let expected_policy = format!(
9706 "approval policy: {} · sandbox: {}",
9707 approval_policy_label(config.approval),
9708 sandbox_policy_label(config.sandbox),
9709 );
9710
9711 let mut agent = Agent::with_provider(config, Box::new(NoProvider));
9712 let system = agent.history[0].content.clone().unwrap_or_default();
9713 assert!(
9714 system.contains(&expected_policy),
9715 "{preset}: the environment block must state the approval/sandbox policy — {system}"
9716 );
9717 assert!(system.contains(&format!("cwd: {}", here.display())));
9718
9719 // Nothing moved ⇒ no churn (the prompt cache is not busted for
9720 // free).
9721 assert!(!agent.refresh_env_context(), "{preset}: spurious re-emit");
9722
9723 // cwd + policy move mid-session.
9724 agent.config.cwd = other.clone();
9725 agent.config.approval = crate::config::ApprovalPolicy::Untrusted;
9726 assert!(
9727 agent.refresh_env_context(),
9728 "{preset}: change not re-emitted"
9729 );
9730 let system = agent.history[0].content.clone().unwrap_or_default();
9731 assert!(
9732 system.contains(&format!("cwd: {}", other.display())),
9733 "{preset}: the fresh cwd must reach the model"
9734 );
9735 assert!(system.contains("approval policy: untrusted"));
9736 assert!(
9737 !system.contains(&format!("cwd: {}", here.display())),
9738 "{preset}: the stale block must be REPLACED, not duplicated"
9739 );
9740 assert_eq!(
9741 system.matches("# Environment").count(),
9742 1,
9743 "{preset}: exactly one environment block"
9744 );
9745 let _ = std::fs::remove_dir_all(&base);
9746 }
9747 }
9748
9749 /// Row `synthetic-context-injection-blocks`: both presets arm the
9750 /// registry, the built-in blocks reach the assembled system prompt, and
9751 /// a block spliced mid-session reaches it too.
9752 #[test]
9753 fn presets_splice_builtin_and_runtime_context_blocks() {
9754 for preset in ["cc-parity", "cx-parity"] {
9755 let config = resolved(preset);
9756 assert!(
9757 config.context_injections,
9758 "{preset} must set core.context_injections"
9759 );
9760 let mut agent = Agent::with_provider(config, Box::new(NoProvider));
9761 let system = agent.history[0].content.clone().unwrap_or_default();
9762 assert!(
9763 system.contains("# Task list"),
9764 "{preset}: a built-in ambient block must reach the prompt"
9765 );
9766
9767 assert!(agent.inject_context_block("Mid session", "SPLICED-BODY-MARKER"));
9768 let system = agent.history[0].content.clone().unwrap_or_default();
9769 assert!(
9770 system.contains("# Mid session") && system.contains("SPLICED-BODY-MARKER"),
9771 "{preset}: a runtime splice must reach the prompt"
9772 );
9773 assert_eq!(agent.spliced_context_blocks().len(), 1);
9774 }
9775 }
9776
9777 /// A deterministic stand-in for the CLI's real provider-backed
9778 /// summarizer: it records exactly what it was asked to summarize, so the
9779 /// test can prove the `/compact <focus>` text reached the summarizer's
9780 /// INPUT and not only the marker.
9781 #[derive(Debug, Default)]
9782 struct RecordingSummarizer {
9783 seen: std::sync::Mutex<Vec<String>>,
9784 }
9785
9786 impl reduce::summarize::SpanSummarizer for RecordingSummarizer {
9787 fn summarize(&self, span_text: &str) -> reduce::Result<String> {
9788 self.seen
9789 .lock()
9790 .unwrap_or_else(std::sync::PoisonError::into_inner)
9791 .push(span_text.to_string());
9792 Ok("MODEL-WRITTEN-SUMMARY".to_string())
9793 }
9794
9795 fn model_id(&self) -> &str {
9796 "test-summarizer"
9797 }
9798 }
9799
9800 fn stuffed_agent(preset: &str) -> Agent {
9801 let config = resolved(preset);
9802 let mut agent = Agent::with_provider(config, Box::new(NoProvider));
9803 for i in 0..40 {
9804 agent.history.push(ChatMessage::user(format!("turn {i}")));
9805 agent
9806 .history
9807 .push(ChatMessage::assistant(format!("reply {i}")));
9808 }
9809 agent
9810 }
9811
9812 /// Row `manual-compact-with-focus-instructions`: `/compact <focus>`
9813 /// compacts on demand (no trigger needed) and the focus text lands in
9814 /// BOTH the summarizer's input and the marker.
9815 #[test]
9816 fn presets_manual_compact_carries_focus_into_the_summarizer_and_the_marker() {
9817 for preset in ["cc-parity", "cx-parity"] {
9818 let config = resolved(preset);
9819 assert!(
9820 config.compaction_focus_instructions.is_some(),
9821 "{preset} must state core.compaction.focus_instructions"
9822 );
9823 let mut agent = stuffed_agent(preset);
9824 let summarizer = std::sync::Arc::new(RecordingSummarizer::default());
9825 agent.set_span_summarizer_arc(summarizer.clone());
9826
9827 let before = agent.history().len();
9828 assert!(
9829 agent.compact_now(Some("keep the migration steps")),
9830 "{preset}: /compact must compact on demand"
9831 );
9832 assert!(agent.history().len() < before, "{preset}: nothing dropped");
9833
9834 let marker = agent
9835 .history()
9836 .iter()
9837 .find_map(|m| m.content.as_deref())
9838 .filter(|c| c.contains("earlier conversation compacted"))
9839 .or_else(|| {
9840 agent
9841 .history()
9842 .iter()
9843 .filter_map(|m| m.content.as_deref())
9844 .find(|c| c.contains("earlier conversation compacted"))
9845 })
9846 .unwrap_or_else(|| panic!("{preset}: no compaction marker"))
9847 .to_string();
9848 assert!(
9849 marker.contains("Focus: keep the migration steps"),
9850 "{preset}: {marker}"
9851 );
9852
9853 let seen = summarizer
9854 .seen
9855 .lock()
9856 .unwrap_or_else(std::sync::PoisonError::into_inner);
9857 assert_eq!(seen.len(), 1, "{preset}: exactly one side-call");
9858 assert!(
9859 seen[0].contains("keep the migration steps"),
9860 "{preset}: the focus must reach the summarizer INPUT — {}",
9861 &seen[0][..seen[0].len().min(200)]
9862 );
9863 }
9864 }
9865
9866 /// Row `llm-summaries-of-cleared-spans`: the model-written summary is
9867 /// produced under the presets WITHOUT `capabilities.reduction` — design
9868 /// §1.5 puts "an LLM summary of the compacted span" in core obligation
9869 /// 5, knob `[core.compaction] summarize`.
9870 #[test]
9871 fn presets_summarize_the_cleared_span_without_the_reduction_module() {
9872 for preset in ["cc-parity", "cx-parity"] {
9873 let toml = crate::presets::lookup(preset).unwrap();
9874 let r = resolve(toml, None, &ResolveOptions { strict: true }).unwrap();
9875 assert_eq!(
9876 r.modules.get("reduction"),
9877 Some(&false),
9878 "{preset}: this row must hold with the reduction module OFF"
9879 );
9880 assert!(r.config.compaction_summarize);
9881
9882 let mut agent = stuffed_agent(preset);
9883 agent.set_span_summarizer_arc(std::sync::Arc::new(RecordingSummarizer::default()));
9884 assert!(agent.compact_now(None));
9885 let marker = agent
9886 .history()
9887 .iter()
9888 .filter_map(|m| m.content.as_deref())
9889 .find(|c| c.contains("earlier conversation compacted"))
9890 .unwrap_or_else(|| panic!("{preset}: no compaction marker"));
9891 assert!(
9892 marker.contains("MODEL-WRITTEN-SUMMARY"),
9893 "{preset}: the marker must carry the model-written summary — {marker}"
9894 );
9895 }
9896 }
9897
9898 /// BP-11 (catalog "Lifecycle hooks, config-registered"): the compaction
9899 /// boundary is observable — `pre_compact` fires once the compaction is
9900 /// decided (with the manual/auto trigger named) and `post_compact` once
9901 /// the window has been rewritten, under both parity presets.
9902 #[test]
9903 fn compaction_fires_pre_and_post_lifecycle_events_under_both_presets() {
9904 use crate::config::LifecycleEvent;
9905 for preset in ["cc-parity", "cx-parity"] {
9906 let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
9907 let mut agent = stuffed_agent(preset);
9908 let sink = seen.clone();
9909 agent.set_lifecycle_hook(Box::new(move |event| {
9910 sink.lock().unwrap().push(event.clone());
9911 }));
9912 let before = agent.history().len();
9913 assert!(agent.compact_now(Some("keep the plan")), "{preset}");
9914 let after = agent.history().len();
9915 let seen = seen.lock().unwrap();
9916 assert_eq!(seen.len(), 2, "{preset}: exactly pre + post — {seen:?}");
9917 match &seen[0] {
9918 LifecycleEvent::PreCompact {
9919 messages,
9920 dropped,
9921 manual,
9922 } => {
9923 assert_eq!(*messages, before, "{preset}");
9924 assert!(*dropped > 0, "{preset}");
9925 assert!(*manual, "{preset}: /compact is the manual trigger");
9926 }
9927 other => panic!("{preset}: first event must be PreCompact, got {other:?}"),
9928 }
9929 match &seen[1] {
9930 LifecycleEvent::PostCompact { messages, dropped } => {
9931 assert_eq!(*messages, after, "{preset}");
9932 assert_eq!(
9933 *dropped,
9934 before - after + 1,
9935 "{preset}: dropped span + 1 marker"
9936 );
9937 }
9938 other => panic!("{preset}: second event must be PostCompact, got {other:?}"),
9939 }
9940 }
9941 }
9942
9943 /// The automatic trigger reports itself as such, and a window too small
9944 /// to compact fires nothing at all (no pre without a post).
9945 #[test]
9946 fn automatic_compaction_reports_the_auto_trigger_and_a_no_op_fires_nothing() {
9947 use crate::config::LifecycleEvent;
9948 let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
9949 let mut agent = stuffed_agent("cc-parity");
9950 let sink = seen.clone();
9951 agent.set_lifecycle_hook(Box::new(move |event| {
9952 sink.lock().unwrap().push(event.clone());
9953 }));
9954 agent.config.compact_after_messages = Some(10);
9955 assert!(agent.maybe_compact());
9956 assert!(matches!(
9957 seen.lock().unwrap()[0],
9958 LifecycleEvent::PreCompact { manual: false, .. }
9959 ));
9960 seen.lock().unwrap().clear();
9961 let mut small = Agent::with_provider(resolved("cc-parity"), Box::new(NoProvider));
9962 let sink = seen.clone();
9963 small.set_lifecycle_hook(Box::new(move |event| {
9964 sink.lock().unwrap().push(event.clone());
9965 }));
9966 assert!(!small.compact_now(None));
9967 assert!(seen.lock().unwrap().is_empty());
9968 }
9969
9970 /// With no summarizer installed the marker degrades to the count-only
9971 /// form — the side-call never blocks or fails compaction.
9972 #[test]
9973 fn compaction_without_a_summarizer_keeps_the_count_only_marker() {
9974 let mut agent = stuffed_agent("cc-parity");
9975 assert!(agent.compact_now(None));
9976 let marker = agent
9977 .history()
9978 .iter()
9979 .filter_map(|m| m.content.as_deref())
9980 .find(|c| c.contains("earlier conversation compacted"))
9981 .unwrap();
9982 assert!(!marker.contains("Summary of the compacted span"));
9983 }
9984
9985 /// Row `compaction-markers-persisted-in-transcript`: under the presets
9986 /// (reduction OFF) the boundary marker is written to the session
9987 /// sidecar, and says where the originals went.
9988 #[test]
9989 fn presets_persist_the_compaction_marker_to_the_transcript() {
9990 for preset in ["cc-parity", "cx-parity"] {
9991 let dir = scratch("marker");
9992 let path = dir.join("session.jsonl");
9993 let empty = crate::session::Session::from_claude_code_str("").unwrap();
9994 let writer = crate::sidecar::SidecarWriter::create(&path, &empty).unwrap();
9995 let mut agent = stuffed_agent(preset);
9996 agent.set_recorder(writer);
9997 assert!(agent.reduction_policy().is_none(), "{preset}");
9998
9999 assert!(agent.compact_now(None));
10000 let on_disk = std::fs::read_to_string(&path).unwrap();
10001 assert!(
10002 on_disk.contains("earlier conversation compacted"),
10003 "{preset}: the marker must reach the transcript on disk"
10004 );
10005 assert!(
10006 on_disk.contains("remain in this session's transcript sidecar"),
10007 "{preset}: the marker must say where the originals went"
10008 );
10009 let _ = std::fs::remove_dir_all(&dir);
10010 }
10011 }
10012
10013 /// Row `handoff-fresh-objective-curated-keep-set`: an in-session
10014 /// `new_context` — fresh objective, curated recent keep-set, persisted
10015 /// marker — under cx-parity, where `capabilities.reduction` is off.
10016 #[test]
10017 fn cx_parity_handoff_seeds_a_fresh_objective_with_a_curated_keep_set() {
10018 let mut agent = stuffed_agent("cx-parity");
10019 assert!(!agent.config().handoff_enabled, "reduction handoff is off");
10020 agent.history.push(ChatMessage::user("LAST-USER-TURN"));
10021 let before = agent.history().len();
10022
10023 let dropped = agent.new_context("ship the migration", Some(3));
10024 assert!(dropped > 0, "messages must be set aside");
10025 assert!(agent.history().len() < before);
10026 let system_prompt = agent.history()[0].content.clone().unwrap_or_default();
10027 assert!(
10028 system_prompt.contains("supercode") || !system_prompt.is_empty(),
10029 "the system prompt survives a handoff"
10030 );
10031 let marker = agent
10032 .history()
10033 .iter()
10034 .filter_map(|m| m.content.as_deref())
10035 .find(|c| c.contains("[handoff:"))
10036 .expect("handoff marker");
10037 assert!(marker.contains("Objective: ship the migration"));
10038 assert!(
10039 agent
10040 .history()
10041 .iter()
10042 .any(|m| m.content.as_deref() == Some("LAST-USER-TURN")),
10043 "the curated keep-set must carry the most recent turns"
10044 );
10045 }
10046
10047 /// Row `context-usage-introspection`: a live breakdown, from the same
10048 /// estimator the context guard enforces, without sending anything.
10049 #[test]
10050 fn presets_report_live_context_usage() {
10051 for preset in ["cc-parity", "cx-parity"] {
10052 let agent = stuffed_agent(preset);
10053 let usage = agent.context_usage();
10054 assert_eq!(usage.messages, agent.history().len(), "{preset}");
10055 assert!(usage.message_tokens > 0, "{preset}");
10056 assert_eq!(
10057 usage.request_tokens,
10058 usage.message_tokens + usage.tool_schema_tokens,
10059 "{preset}: the breakdown must add up"
10060 );
10061 assert!(usage.projected_tokens >= usage.request_tokens, "{preset}");
10062 assert!(usage.context_limit.is_some(), "{preset}: window known");
10063 assert!(usage.fits, "{preset}");
10064 let line = usage.summary_line();
10065 assert!(line.contains('%') && line.contains(&usage.model), "{line}");
10066
10067 // Pure: asking must not change what the next request carries.
10068 let again = agent.context_usage();
10069 assert_eq!(usage, again, "{preset}");
10070 }
10071 }
10072}
10073
10074#[cfg(test)]
10075#[path = "agent_bp10_tests.rs"]
10076mod bp10_permissions_tests;