Skip to main content

io_harness/
run.rs

1//! The orchestration loop: observe, reason, act, verify, stop — bounded by
2//! budgets, resilient to transient step failures, and resumable.
3//!
4//! v0.2 adds three budgets (step, time, cost-in-tokens) each with its own stop
5//! outcome, per-step retry with escalation, a full trace written to the store,
6//! and [`resume`], which continues an interrupted run under its original id
7//! instead of restarting.
8
9use std::cell::Cell;
10use std::future::Future;
11use std::path::{Path, PathBuf};
12use std::pin::Pin;
13use std::sync::Arc;
14use std::time::Duration;
15
16use serde_json::json;
17use tracing::info;
18
19use crate::agent::{AgentDef, Agents};
20use crate::approve::{ApproveAll, Approver, Decision, Request};
21use crate::approve::{Question, Responder, ResponderNone};
22use crate::containment::{Containment, Draw, Ledger};
23use crate::context::{
24    assemble, bound, entry_cap_chars, Assembly, Ledger as ContextLedger, ObsKind, Observation,
25};
26use crate::contract::TaskContract;
27use crate::error::{Error, Result};
28use crate::mcp::McpSession;
29use crate::net::{self, NetGuard};
30use crate::observe::{EventKind, Ignore, Observer, RunEvent};
31use crate::policy::{Act, Effect, Policy, Rule};
32use crate::provider::{CompletionRequest, CompletionResponse, Provider, ToolCall, ToolSpec};
33use crate::resilience::{Progress, Progressing};
34use crate::skills::Skills;
35use crate::state::PolicyEvent;
36use crate::state::{AgentEvent, ContextEvent, RunStatus, StepRecord, Store, TodoItem, TodoState};
37use crate::toolchain::Toolchain;
38use crate::tools::exec::{Exec, ExecOutcome};
39use crate::tools::git::{Git, GitCmd, GitOutcome};
40#[cfg(feature = "barcode")]
41use crate::tools::BARCODE_DECODE_TOOL;
42#[cfg(feature = "pptx")]
43use crate::tools::PPTX_READ_TOOL;
44
45/// The path a git built-in names when it asks the policy about the repository
46/// itself. Reading history reads it; committing writes it. A run under a narrow
47/// write policy must allow it explicitly, which is stated where the tools are
48/// documented rather than left to be discovered by a refusal.
49const GIT_DIR: &str = ".git";
50#[cfg(feature = "media")]
51use crate::tools::VIEW_IMAGE_TOOL;
52use crate::tools::{
53    FsTool, Toolbox, Workspace, ASK_QUESTION_TOOL, EDIT_FILE_TOOL, EXEC_TOOL, FIND_TOOL, GREP_TOOL,
54    READ_FILE_TOOL, READ_SKILL_TOOL, REMEMBER_TOOL, TODO_WRITE_TOOL, WRITE_FILE_TOOL,
55};
56#[cfg(feature = "docx")]
57use crate::tools::{DOCX_READ_TOOL, DOCX_WRITE_TOOL};
58use crate::tools::{GIT_ADD_TOOL, GIT_COMMIT_TOOL, GIT_DIFF_TOOL, GIT_LOG_TOOL, GIT_STATUS_TOOL};
59#[cfg(feature = "pdf")]
60use crate::tools::{PDF_FILL_FORM_TOOL, PDF_READ_TOOL, PDF_WATERMARK_TOOL, PDF_WRITE_TOOL};
61#[cfg(feature = "xlsx")]
62use crate::tools::{XLSX_READ_TOOL, XLSX_SET_CELL_TOOL, XLSX_SHEETS_TOOL, XLSX_WRITE_TOOL};
63use crate::verify::{ExecGuard, Verification};
64
65/// The tool a parent agent calls to spawn a contained sub-agent.
66///
67/// It is the name the model sees, and the name that appears in the trace and in
68/// [`EventKind::ToolCall`](crate::EventKind::ToolCall) when an agent fans out —
69/// which is the reason to know it. A consumer watching a tree matches on it to
70/// tell composition apart from ordinary work:
71///
72/// ```
73/// use io_harness::{EventKind, Flow, Observer, RunEvent, SPAWN_TOOL};
74///
75/// struct TreeShape;
76///
77/// impl Observer for TreeShape {
78///     fn event(&self, event: &RunEvent) -> Flow {
79///         match &event.kind {
80///             // A parent asking for a child, before the child exists.
81///             EventKind::ToolCall { name, target } if name == SPAWN_TOOL => {
82///                 println!("{:indent$}spawning: {target}", "", indent = event.depth as usize * 2);
83///             }
84///             // The child that resulted, with its own run id to route on.
85///             EventKind::Spawned { child_run_id, goal } => {
86///                 println!("{:indent$}run {child_run_id}: {goal}", "",
87///                          indent = (event.depth as usize + 1) * 2);
88///             }
89///             _ => {}
90///         }
91///         Flow::Continue
92///     }
93/// }
94/// ```
95///
96/// Only [`run_tree`] offers it. [`run`] and [`run_with`] never put it in the
97/// tool list, so a contract cannot opt into sub-agents by accident.
98///
99/// It is deliberately *not* governed by the exec policy the way a registered
100/// tool's name is — a spawn is intercepted by the tree loop before dispatch, and
101/// its ceilings are [`Containment`]'s: total agents, concurrency, depth, and the
102/// shared token ledger. To forbid composition, use [`run_with`]; to bound it,
103/// lower those caps.
104pub const SPAWN_TOOL: &str = "spawn_agent";
105
106/// What the loop writes into an observation when the model called no tool.
107///
108/// Shared with [`crate::session`], which reads a turn's closing message back out
109/// of the observations: two literals would drift the first time one of them was
110/// reworded, and the symptom would be a session that silently stopped reporting
111/// replies.
112pub(crate) const NO_TOOL_CALL: &str = "(no tool call)";
113
114/// How many grep hits are folded into one observation. A relevance ceiling, not a
115/// size one — the size ceiling is the budget-derived per-entry cap on top of it.
116const OBS_GREP_CAP: usize = 50;
117
118/// Why a run stopped.
119///
120/// A run stopping is not a run failing — only one of these variants is success,
121/// and lumping the rest together as "it didn't work" loses the difference
122/// between a run that needs more budget, one that needs a human, and one that
123/// needs a different task. The match is what a caller writes around every entry
124/// point:
125///
126/// ```
127/// use io_harness::RunOutcome;
128///
129/// # fn next_step(outcome: RunOutcome) -> &'static str {
130/// match outcome {
131///     RunOutcome::Success { .. } => "verification passed; ship it",
132///
133///     // Paused, not finished. The pending action is persisted under
134///     // `request_id` and this process may exit; whoever decides later calls
135///     // `resume_with_decision` with that id. Never retry the run from scratch.
136///     RunOutcome::AwaitingApproval { request_id: _, .. } => "ask a human, then resume",
137///
138///     // (0.21.0) Also paused, and a different question: the agent asked what you
139///     // MEANT, not whether it may act. `resume_with_answer` continues it with the
140///     // answer, which reaches the model as text and authorizes nothing.
141///     RunOutcome::AwaitingAnswer { question_id: _, .. } => "answer it, then resume",
142///
143///     // Ceilings, and they mean different things. More steps or more time is a
144///     // knob; a token ceiling that keeps being hit is usually a task too big
145///     // for one contract.
146///     RunOutcome::StepCapReached { .. } | RunOutcome::TimeBudgetExceeded { .. } => "raise the bound and resume",
147///     RunOutcome::CostBudgetExceeded { .. } | RunOutcome::BudgetCeilingReached { .. } => "split the task",
148///
149///     // The agent is going in circles and was already told once. Resuming
150///     // spends the rest of the budget proving it again — change the goal.
151///     RunOutcome::Stalled { .. } => "rewrite the contract",
152///
153///     // Both are reported *after the fact*: the run that escalated or was
154///     // refused returned the `Err` itself, and a later `resume` reports this
155///     // instead of re-driving the loop.
156///     RunOutcome::Escalated { retryable: true, .. } => "transient provider failure; resume",
157///     RunOutcome::Escalated { .. } => "wrong key or bad request; fix it first",
158///     RunOutcome::Refused { .. } => "the provider's host was denied; widen the net policy",
159///
160///     RunOutcome::Denied { .. } => "a human said no; the action never happened",
161///     // An observer returned `Flow::Cancel`. Finished cleanly, and still resumable.
162///     RunOutcome::Cancelled { .. } => "resume when you want it to continue",
163///
164///     // Only a `Verification::None` run reaches this: it stopped because the
165///     // agent stopped, not because a ceiling did. Nothing checked the work —
166///     // read it, rather than shipping it the way a `Success` may be shipped.
167///     RunOutcome::Finished { .. } => "the agent is done; nothing verified it",
168/// }
169/// # }
170/// ```
171///
172/// Every variant carries `steps`, which is how many steps *completed* — so a
173/// `StepCapReached { steps: 12 }` and a `Success { steps: 12 }` cost the same
174/// and only one of them produced anything. For what the run actually spent, use
175/// [`RunResult::summary`].
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub enum RunOutcome {
178    /// Verification passed. `steps` is the step it passed on.
179    Success { steps: u32 },
180    /// The step budget was reached before verification passed.
181    StepCapReached { steps: u32 },
182    /// The time budget was exceeded. `steps` is how many steps completed.
183    TimeBudgetExceeded { steps: u32 },
184    /// The cost (token) budget was exceeded. `steps` is how many steps completed.
185    CostBudgetExceeded { steps: u32 },
186    /// A human denied a deferred action on resume, so the run stopped without
187    /// performing it. `steps` is how many steps completed.
188    Denied { steps: u32 },
189    /// An approver deferred a decision. The run is paused, not finished: the
190    /// pending action is persisted under `request_id` and survives this
191    /// process, so [`resume_with_decision`] can continue it once a human
192    /// decides. `steps` is how many steps completed.
193    AwaitingApproval { request_id: i64, steps: u32 },
194    /// (0.21.0) The agent asked the operator about *intent* and nothing in this
195    /// process would answer. The run is paused, not finished: the question is
196    /// persisted under `question_id` and survives this process, so
197    /// [`resume_with_answer`] continues it once a human answers. `steps` is how many
198    /// steps completed.
199    ///
200    /// Distinct from [`AwaitingApproval`](RunOutcome::AwaitingApproval) because the
201    /// two questions differ: that one asks whether an action is permitted, this one
202    /// asks what was wanted. An answer to this one authorizes nothing.
203    AwaitingAnswer { question_id: i64, steps: u32 },
204    /// The agent stopped making progress: for `StallPolicy::window` consecutive
205    /// steps it changed nothing in the workspace while repeating a tool call it had
206    /// already made, and it had already been told once. The run stops here rather
207    /// than spending the rest of its step budget proving it is stuck. `steps` is
208    /// how many steps completed.
209    Stalled { steps: u32 },
210    /// A provider failure exhausted its retries and the run was escalated to the
211    /// caller. `retryable` is whether the failure was one another attempt could have
212    /// survived — a rate limit or a 503 — as opposed to a wrong key or an
213    /// unacceptable request. Reached through [`resume`] after the fact: the run that
214    /// escalated returned the `Err` itself.
215    Escalated { steps: u32, retryable: bool },
216    /// (sub-agent trees) The tree's aggregate spend ceiling was crossed, so the
217    /// whole tree halts — not this one agent hitting its own budget. `steps` is
218    /// how many steps this agent completed before the tree-wide halt.
219    BudgetCeilingReached { steps: u32 },
220    /// The run never started, because reaching the provider needed network access
221    /// the policy asked about and a human denied. The authorization happens before
222    /// the run's first step, so `steps` is normally 0.
223    ///
224    /// Reached through [`resume`] after the fact: the run that was refused
225    /// returned the `Err` itself, exactly as an escalation does. Added in 0.12.0 —
226    /// `"refused"` was written to the store from 0.8.0 onward with no variant and
227    /// no mapping, so resuming a refused run fell back into the loop and asked
228    /// the human again.
229    Refused { steps: u32 },
230    /// An [`Observer`] asked the run to stop, and it stopped — at the next step
231    /// boundary rather than where the request landed, so no step was abandoned
232    /// half-done. `steps` is how many steps completed before it stopped.
233    ///
234    /// Added in 0.12.0 with [`Flow::Cancel`](crate::Flow::Cancel), which is the
235    /// first supported way to stop a run in flight: dropping the run's future
236    /// abandons it mid-step and leaves `runs.status` as `running` forever, which
237    /// nothing can tell apart from a process that crashed. A cancelled run is
238    /// finished rather than abandoned, and stays resumable — a resume reports this
239    /// outcome instead of re-driving the loop.
240    Cancelled { steps: u32 },
241    /// The agent finished. Only a [`Verification::None`] run reaches this: with
242    /// no criterion to pass, an assistant turn that calls no tool is the run
243    /// saying it is done, and the loop stops there.
244    ///
245    /// Distinct from every ceiling on purpose. An unattended run that completed
246    /// its work and one that ran out of steps both stop, and treating them alike
247    /// is how a fleet operator ends up re-driving finished work — or worse,
248    /// shipping the output of a run that never got there. `steps` is the step it
249    /// finished on.
250    ///
251    /// It is **not** a claim the work is correct. Nothing checked it; that is
252    /// what choosing [`Verification::None`] means. A run with a criterion reports
253    /// [`RunOutcome::Success`], and that one *is* a claim — bounded by what the
254    /// criterion checked and no wider.
255    ///
256    /// Added in 0.17.0 with [`Verification::None`].
257    ///
258    /// [`Verification::None`]: crate::Verification::None
259    Finished { steps: u32 },
260}
261
262/// The result of a run, including the persisted run id for audit.
263///
264/// Three things, and the `run_id` is the one that outlives the process. Every
265/// step, refusal, spawn and budget draw is in the store under it, so a run is
266/// still readable long after the program that drove it exited — and it is the
267/// handle every `resume*` entry point takes:
268///
269/// ```no_run
270/// use io_harness::{run_with, ApproveAll, OpenRouter, Policy, RunOutcome, Store, TaskContract};
271///
272/// # async fn demo(contract: &TaskContract, policy: &Policy) -> io_harness::Result<()> {
273/// let store = Store::open("runs.db")?;
274/// let result = run_with(contract, &OpenRouter::from_env()?, &store, policy, &ApproveAll).await?;
275///
276/// // What it cost and how long it took, read back from the same row an auditor
277/// // would read — so the caller and the audit cannot disagree. `None` while a
278/// // run is paused awaiting a human: there is no ending to summarise yet.
279/// if let Some(summary) = result.summary(&store)? {
280///     println!("{} tokens", summary.tokens);
281/// }
282///
283/// // Rules an approver asked to remember. The crate applied them for the rest of
284/// // this run and hands them back here; persisting them across runs is the
285/// // caller's decision, because config files are the application's to own.
286/// for rule in &result.remembered {
287///     println!("remember: {:?} {} {:?}", rule.act, rule.pattern, rule.effect);
288/// }
289///
290/// // Keep the id if the run is not finished — it is all a later process needs.
291/// if !matches!(result.outcome, RunOutcome::Success { .. }) {
292///     println!("resume run {}", result.run_id);
293/// }
294/// # Ok(()) }
295/// ```
296#[derive(Debug, Clone)]
297pub struct RunResult {
298    /// Why the run stopped.
299    pub outcome: RunOutcome,
300    /// The run's id in the [`Store`], for reading its trace back.
301    pub run_id: i64,
302    /// Rules an approver asked to remember during this run. The crate applies
303    /// them for the rest of the run and hands them back here; persisting them
304    /// across runs is the caller's decision, since config files are app-owned.
305    pub remembered: Vec<Rule>,
306}
307
308impl RunResult {
309    /// What this run cost and whether it worked, read back from the store.
310    ///
311    /// Added in 0.12.0. Before it, a caller holding a `RunResult` had an outcome
312    /// discriminant and a `run_id`: spend needed a follow-up query, and latency
313    /// was not recorded anywhere at all.
314    ///
315    /// `None` when the run has not finished — a run paused awaiting a human has
316    /// no ending to summarise yet.
317    ///
318    /// A method rather than a field, deliberately. A field would have to be
319    /// filled at every one of the entry points' return sites, including the ones
320    /// that return `Err` and never build a `RunResult`, so the two could drift.
321    /// Reading it from the store means the caller and an auditor are looking at
322    /// the same row by construction. It also keeps this struct's existing
323    /// exhaustive-pattern compatibility intact: no new field, no break.
324    pub fn summary(&self, store: &Store) -> Result<Option<crate::RunSummary>> {
325        store.run_summary(self.run_id)
326    }
327}
328
329impl RunResult {
330    fn new(outcome: RunOutcome, run_id: i64) -> Self {
331        Self {
332            outcome,
333            run_id,
334            remembered: Vec::new(),
335        }
336    }
337
338    fn with_remembered(mut self, remembered: Vec<Rule>) -> Self {
339        self.remembered = remembered;
340        self
341    }
342}
343
344/// Run a task contract to a verified result using `provider` and `store`.
345///
346/// Each iteration: read the file into context, ask the model (offering the
347/// `write_file` tool, retrying transient failures), apply any write, record the
348/// trace, then verify. Stops on the first passing verify, or when any budget —
349/// steps, time, or tokens — is reached.
350///
351/// The smallest thing that works, and the right entry point when the boundary is
352/// the *task* rather than a policy: one file, one tool, and a criterion the model
353/// cannot talk its way past.
354///
355/// ```no_run
356/// use io_harness::{run, OpenRouter, RunOutcome, Store, TaskContract, Verification};
357///
358/// # async fn demo() -> io_harness::Result<()> {
359/// let contract = TaskContract::new(
360///     "add a `hello` function returning 42",
361///     "src/hello.rs",
362///     // Execution-based: the project's own build has to succeed, so `fn hello`
363///     // written as a literal string fails — which is exactly what a model did to
364///     // the cheaper `FileContains` in the 0.1.0 live run.
365///     Verification::Command { argv: vec!["cargo".into(), "build".into()], expect_exit: 0 },
366/// )
367/// .with_max_steps(6);
368///
369/// let result = run(&contract, &OpenRouter::from_env()?, &Store::open("runs.db")?).await?;
370/// match result.outcome {
371///     RunOutcome::Success { steps } => println!("verified in {steps} steps"),
372///     // Keep the id: the file on disk is the run's state, so a resume continues
373///     // from it rather than starting over.
374///     other => println!("{other:?} — resume run {}", result.run_id),
375/// }
376/// # Ok(()) }
377/// ```
378///
379/// It applies [`Policy::permissive`] and approves everything, because there is no
380/// policy-aware tool layer in single-file mode — passing a real policy to
381/// [`run_with`] with a single-file contract is refused with
382/// [`Error::Config`](crate::Error::Config) rather than silently ignored. Use
383/// [`TaskContract::workspace`] and [`run_with`] as soon as a boundary matters.
384pub async fn run<P: Provider>(
385    contract: &TaskContract,
386    provider: &P,
387    store: &Store,
388) -> Result<RunResult> {
389    run_observed(contract, provider, store, &Ignore).await
390}
391
392/// [`run`], reporting to `observer` as it happens.
393///
394/// The observed twin of every entry point takes the [`Observer`] last and does
395/// exactly what its unobserved original does — the originals *are* these
396/// functions, called with [`Ignore`]. Adding a parameter to the seven existing
397/// signatures would have broken every caller of a 0.11.0 API to add something
398/// opt-in; a builder would have added a second way to start a run for the same
399/// reason.
400///
401/// Reach for it when a run is long enough that silence is a problem. Without an
402/// observer the only thing between "started" and "finished" is the SQLite trace,
403/// which nobody is watching while it happens:
404///
405/// ```no_run
406/// use io_harness::{run_observed, EventKind, Flow, Observer, OpenRouter, RunEvent,
407///                  Store, TaskContract};
408///
409/// /// A progress line per committed step — the boundary at which work is durable.
410/// struct Progress;
411///
412/// impl Observer for Progress {
413///     fn event(&self, event: &RunEvent) -> Flow {
414///         if let EventKind::Step { decision, tokens, changed, .. } = &event.kind {
415///             let mark = if *changed { "*" } else { " " };
416///             println!("{mark} step {} ({tokens} tokens): {decision}", event.step);
417///         }
418///         Flow::Continue
419///     }
420/// }
421///
422/// # async fn demo(contract: &TaskContract) -> io_harness::Result<()> {
423/// run_observed(contract, &OpenRouter::from_env()?, &Store::memory()?, &Progress).await?;
424/// # Ok(()) }
425/// ```
426///
427/// Events are delivered in order, on the run's own task, so an observer that
428/// blocks holds the run up. Anything slow belongs on a channel.
429pub async fn run_observed<P: Provider>(
430    contract: &TaskContract,
431    provider: &P,
432    store: &Store,
433    observer: &dyn Observer,
434) -> Result<RunResult> {
435    run_with_observed(
436        contract,
437        provider,
438        store,
439        &Policy::permissive(),
440        &ApproveAll,
441        observer,
442    )
443    .await
444}
445
446/// Run a task contract under a permission `policy`, routing anything the policy
447/// marks [`Effect::Ask`] to `approver` before it happens.
448///
449/// An action the policy *denies* is refused without consulting the approver and
450/// reported to the model as a tool result it can adapt to; the refusal consumes
451/// the step, so a model that keeps retrying a denied action reaches the step cap
452/// rather than looping forever.
453///
454/// This is the entry point most callers want: a workspace, a boundary, and a
455/// human only for the grey tier.
456///
457/// ```no_run
458/// use io_harness::{run_with, OpenRouter, Policy, StdinApprover, Store, TaskContract,
459///                  Verification};
460///
461/// # async fn demo() -> io_harness::Result<()> {
462/// let contract = TaskContract::workspace(
463///     "make the failing test in tests/parse.rs pass",
464///     "/path/to/repo",
465///     Verification::Command { argv: vec!["cargo".into(), "test".into()], expect_exit: 0 },
466/// );
467///
468/// // Three tiers, and the middle one is the only one anybody is asked about.
469/// // `Policy::default()` already denies `.env`, `*.pem` and the other secret
470/// // paths outright, so those never become a question at 3am.
471/// let policy = Policy::default()
472///     .layer("app")
473///     .allow_read("*")
474///     .allow_write("src/*")     // routine, proceeds silently
475///     .deny_write("src/main.rs"); // never, and the approver is not consulted
476///
477/// // Everything else the policy marks `Ask` — a write outside src/, say —
478/// // stops here and waits, for as long as it takes.
479/// let result = run_with(
480///     &contract, &OpenRouter::from_env()?, &Store::open("runs.db")?, &policy, &StdinApprover,
481/// )
482/// .await?;
483/// println!("{:?}", result.outcome);
484/// # Ok(()) }
485/// ```
486///
487/// The policy is recorded against the run, so a later [`resume_from_stored_policy`]
488/// can recover the boundary this run executed under without the caller having to
489/// reconstruct it.
490pub async fn run_with<P: Provider>(
491    contract: &TaskContract,
492    provider: &P,
493    store: &Store,
494    policy: &Policy,
495    approver: &dyn Approver,
496) -> Result<RunResult> {
497    run_with_observed(contract, provider, store, policy, approver, &Ignore).await
498}
499
500/// [`run_with`], reporting to `observer` as it happens. See [`run_observed`].
501///
502/// The events a *policed* run adds are the ones worth watching: a refusal names
503/// the rule and layer that made it, which turns "the agent kept failing" into
504/// "one line of the ops baseline is too tight".
505///
506/// ```no_run
507/// use io_harness::{run_with_observed, ApproveAll, EventKind, Flow, Observer, OpenRouter,
508///                  Policy, RunEvent, Store, TaskContract};
509/// use std::sync::Mutex;
510///
511/// /// Collects every refusal so the operator sees which rules the task ran into,
512/// /// rather than only that it ran out of steps.
513/// #[derive(Default)]
514/// struct Friction(Mutex<Vec<String>>);
515///
516/// impl Observer for Friction {
517///     fn event(&self, event: &RunEvent) -> Flow {
518///         if let EventKind::Refused { act, target, rule, layer } = &event.kind {
519///             self.0.lock().unwrap().push(format!(
520///                 "{act} {target} <- {} in {}",
521///                 rule.as_deref().unwrap_or("tier default"),
522///                 layer.as_deref().unwrap_or("-"),
523///             ));
524///         }
525///         Flow::Continue
526///     }
527/// }
528///
529/// # async fn demo(contract: &TaskContract, policy: &Policy) -> io_harness::Result<()> {
530/// let friction = Friction::default();
531/// run_with_observed(
532///     contract, &OpenRouter::from_env()?, &Store::memory()?, policy, &ApproveAll, &friction,
533/// )
534/// .await?;
535/// for line in friction.0.lock().unwrap().iter() {
536///     println!("refused: {line}");
537/// }
538/// # Ok(()) }
539/// ```
540///
541/// `&self`, not `&mut self`: one observer serves a whole tree, so state goes
542/// behind a `Mutex` as above.
543pub async fn run_with_observed<P: Provider>(
544    contract: &TaskContract,
545    provider: &P,
546    store: &Store,
547    policy: &Policy,
548    approver: &dyn Approver,
549    observer: &dyn Observer,
550) -> Result<RunResult> {
551    run_with_extras(
552        contract,
553        provider,
554        store,
555        policy,
556        approver,
557        observer,
558        &TurnExtras::default(),
559    )
560    .await
561}
562
563/// What a session turn adds to a run, and nothing else does.
564///
565/// Three fields, all inert by default, which is what makes 0.20.0's session layer
566/// a layer: every 0.19.0 entry point drives the loop with `TurnExtras::default()`
567/// and therefore behaves exactly as it did — no seeded conversation, no steer
568/// inbox to read, and `complete` rather than `complete_streaming`.
569#[derive(Default)]
570pub(crate) struct TurnExtras<'a> {
571    /// The conversation this turn continues, rendered one entry per prior turn on
572    /// the path from the tree's root to the head. Seeded into the observation
573    /// ledger before the first step, so it is compacted by the assembler that
574    /// already compacts a long run rather than by a second rule of its own.
575    pub seed: &'a [String],
576    /// Where an operator's mid-turn messages and an interrupt arrive. Drained at
577    /// the step boundary and nowhere else.
578    pub steer: Option<&'a crate::session::SteerInbox>,
579    /// Whether to ask the provider for deltas as they arrive.
580    pub stream: bool,
581    /// The conversation this run is a turn of, when it is one. Written as a
582    /// `session_turns` row immediately after the run row exists, so a turn whose
583    /// process dies mid-answer is in the tree with a run id a resume can continue
584    /// from.
585    pub turn: Option<crate::session::SessionTurn<'a>>,
586}
587
588/// [`run_with_observed`] with the session layer's extras. Crate-internal: the
589/// public surface gains named session methods, not a seventh parameter on the
590/// entry points every caller already uses.
591pub(crate) async fn run_with_extras<P: Provider>(
592    contract: &TaskContract,
593    provider: &P,
594    store: &Store,
595    policy: &Policy,
596    approver: &dyn Approver,
597    observer: &dyn Observer,
598    extras: &TurnExtras<'_>,
599) -> Result<RunResult> {
600    // Arbitration before anything else: a toolbox that cannot be dispatched
601    // unambiguously is a configuration mistake, and the caller should hear about
602    // it before a run row exists and before the provider is billed for a turn.
603    contract.tools.validate()?;
604    // Same reason, same point: a skills directory that cannot be read is a
605    // configuration mistake, and the caller hears the path before a run row
606    // exists rather than getting a silently empty catalogue mid-run.
607    let skills = contract.discover_skills()?;
608    let file_str = contract.file.display().to_string();
609    let run_id = store.start_run(&contract.goal, &file_str)?;
610    // A session turn joins the tree here: after the run exists, before the first
611    // completion is billed. The order matters — a turn row with no run to point at
612    // would be a conversation entry nothing can explain.
613    if let Some(turn) = &extras.turn {
614        store.record_turn(turn.session_id, turn.parent_turn_id, run_id, turn.prompt)?;
615    }
616    store.set_provider(run_id, provider.name())?;
617    // The caller's policy, before the provider layer below is merged into it —
618    // what the caller asked for, not what the harness added. Recorded so a later
619    // `resume` can tell a run that had a boundary from one that never did; see
620    // [`resume`], which refuses the first rather than resuming it permissively.
621    store.record_run_policy(run_id, policy)?;
622    // The run row exists and the provider is set, which is what `Started` reports:
623    // emitted before the network authorization below, so an observer watching a run
624    // that is refused before its first step still saw it begin.
625    let watch = &Watch::new(observer);
626    watch.emit(RunEvent::new(
627        run_id,
628        0,
629        EventKind::Started {
630            goal: contract.goal.clone(),
631            provider: provider.name().to_string(),
632        },
633    ));
634    // Decided against the *caller's* policy, before the provider layer is merged
635    // in: the harness adding a network layer of its own must not turn a
636    // permissive caller into a policy-bearing one and push it off the
637    // single-file path.
638    let caller_enforces = !policy.is_permissive();
639    let policy = &match authorize_provider(provider, policy, store, run_id, approver, watch).await?
640    {
641        ProviderAccess::Granted(p) => p,
642        ProviderAccess::Pending(request_id) => {
643            return Ok(RunResult::new(
644                RunOutcome::AwaitingApproval {
645                    request_id,
646                    steps: 0,
647                },
648                run_id,
649            ))
650        }
651    };
652    match contract.root.clone() {
653        Some(root) => {
654            let mcp = McpSession::connect(&contract.mcp, policy, store, run_id, watch).await?;
655            let result = run_workspace_from(
656                contract, provider, store, run_id, &root, 1, policy, approver, &mcp, &skills,
657                watch, extras,
658            )
659            .await;
660            mcp.shutdown(store, run_id, watch).await;
661            result
662        }
663        // Single-file mode has no policy-aware tool layer in 0.4.0. Silently
664        // ignoring a policy here would be worse than not supporting it: the
665        // caller would believe a boundary was enforced when nothing was
666        // checking. Refuse loudly instead.
667        None if caller_enforces => Err(crate::error::Error::Config(
668            "a permission policy requires workspace mode — build the contract \
669             with TaskContract::workspace(goal, root, verify). Single-file \
670             contracts are not policy-enforced in 0.4.0."
671                .into(),
672        )),
673        None => run_from(contract, provider, store, run_id, 1, watch).await,
674    }
675}
676
677/// Resume an interrupted run under its original `run_id`. Continues from the
678/// step after the last one recorded, reusing the file on disk as the current
679/// state — it does not restart from step one.
680///
681/// This is the resume for a run that had **no** permission boundary. It drives
682/// the loop permissively, and a run that *was* started under a policy is refused
683/// with [`Error::Resume`] rather than resumed without it — use [`resume_with`]
684/// and supply the policy. Through 0.12.0 this function substituted
685/// [`Policy::permissive`] for every workspace run it resumed, so a caller who
686/// ran under a deny-by-default policy and crashed came back with no boundary and
687/// nothing said so. Refusing is the only behaviour that cannot silently widen
688/// what an agent may do.
689///
690/// What it preserves: the run id, the step it reached, its token and wall-clock
691/// budgets, and — since 0.13.0 — the observation ledger it had assembled, so the
692/// resumed run asks the model what the interrupted one would have. What it does
693/// not: a permission policy, which it refuses to guess at rather than
694/// substituting one. A run with no recorded policy, which is every run
695/// checkpointed before 0.13.0, resumes exactly as it did then.
696///
697/// The shape a supervisor process wants: the run id is the only thing that has
698/// to survive the crash, and resuming twice is a no-op rather than a second run.
699///
700/// ```no_run
701/// use io_harness::{run, resume, OpenRouter, RunOutcome, Store, TaskContract};
702///
703/// # async fn demo(contract: &TaskContract) -> io_harness::Result<()> {
704/// let store = Store::open("runs.db")?;
705/// let provider = OpenRouter::from_env()?;
706///
707/// let first = run(contract, &provider, &store).await?;
708/// // ... the process is killed here, mid-step ...
709///
710/// // A crash leaves either a whole step or none of it — the trace, the budget
711/// // draw and the checkpoint commit in one transaction — so this continues from
712/// // the last committed step rather than restarting. Completed steps are
713/// // skipped, spend is restored from durable totals rather than reset, and the
714/// // time budget counts the downtime as elapsed.
715/// let again = resume(contract, &provider, &store, first.run_id).await?;
716///
717/// // Idempotent: a finished run reports its outcome instead of re-driving.
718/// if let RunOutcome::Success { steps } = again.outcome {
719///     println!("done at step {steps}");
720/// }
721/// # Ok(()) }
722/// ```
723///
724/// It refuses a run that had a boundary — that is the point of it. Use
725/// [`resume_with`] when you hold the policy, and [`resume_from_stored_policy`]
726/// when you do not and want the one the run actually executed under.
727pub async fn resume<P: Provider>(
728    contract: &TaskContract,
729    provider: &P,
730    store: &Store,
731    run_id: i64,
732) -> Result<RunResult> {
733    resume_observed(contract, provider, store, run_id, &Ignore).await
734}
735
736/// [`resume`], reporting to `observer` as it happens. See [`run_observed`].
737///
738/// A resume of an already-finished run is a no-op and reports no events: it drives
739/// nothing, so there is nothing to watch.
740///
741/// That silence is the useful signal, and it is the reason to observe a resume at
742/// all: an observer that hears nothing but `Started` and `Finished` is looking at
743/// a run that had already completed before the crash, not one that did work.
744///
745/// ```no_run
746/// use io_harness::{resume_observed, EventKind, Flow, Observer, OpenRouter, RunEvent,
747///                  Store, TaskContract};
748/// use std::sync::atomic::{AtomicU32, Ordering};
749///
750/// /// Counts only what *this* process drove. Steps committed before the crash
751/// /// are replayed from the store, not re-run, so they emit nothing.
752/// #[derive(Default)]
753/// struct DrivenHere(AtomicU32);
754///
755/// impl Observer for DrivenHere {
756///     fn event(&self, event: &RunEvent) -> Flow {
757///         if matches!(event.kind, EventKind::Step { .. }) {
758///             self.0.fetch_add(1, Ordering::Relaxed);
759///         }
760///         Flow::Continue
761///     }
762/// }
763///
764/// # async fn demo(contract: &TaskContract, run_id: i64) -> io_harness::Result<()> {
765/// let driven = DrivenHere::default();
766/// resume_observed(contract, &OpenRouter::from_env()?, &Store::open("runs.db")?, run_id, &driven)
767///     .await?;
768/// println!("{} new steps after the restart", driven.0.load(Ordering::Relaxed));
769/// # Ok(()) }
770/// ```
771pub async fn resume_observed<P: Provider>(
772    contract: &TaskContract,
773    provider: &P,
774    store: &Store,
775    run_id: i64,
776    observer: &dyn Observer,
777) -> Result<RunResult> {
778    // Refuse a store from a newer checkpoint format or a missing run with a
779    // typed error, rather than misreading it or panicking. Before the policy
780    // gate below, so an unknown run still reports as an unknown run.
781    store.check_resumable(run_id)?;
782    // Before the gate, because a finished run is a read and not a resume: it
783    // drives no loop, performs no action, and asks no provider, so there is no
784    // boundary to drop and refusing it would break the "report, don't re-drive"
785    // contract a refused or escalated run relies on.
786    if let Some(o) = finished_outcome(store, run_id)? {
787        return Ok(RunResult::new(o, run_id));
788    }
789    // The gate. `None` means nothing recorded a policy for this run — a run
790    // written by 0.12.0 or earlier — and is deliberately not read as "the caller
791    // chose permissive": it resumes as it always did, which is what 0.7.0's
792    // resume contract promised those runs. A recorded permissive policy is the
793    // same case, said explicitly. Anything else had a boundary, and this
794    // function cannot honour it.
795    if let Some(recorded) = store.run_policy(run_id)? {
796        if !recorded.is_permissive() {
797            return Err(crate::error::Error::Resume {
798                reason: format!(
799                    "run {run_id} was started under a permission policy; resume it with \
800                     resume_with (or resume_with_observed), supplying that policy — resuming \
801                     here would drop the boundary the run was executing under"
802                ),
803            });
804        }
805    }
806    resume_with_observed(
807        contract,
808        provider,
809        store,
810        run_id,
811        &Policy::permissive(),
812        &ApproveAll,
813        observer,
814    )
815    .await
816}
817
818/// Resume an interrupted run under `policy`, routing anything the policy marks
819/// [`Effect::Ask`] to `approver` — the resume twin of [`run_with`].
820///
821/// The policy given here is the one that governs the resumed run; it is recorded
822/// against the run, so the store answers what rules the run actually executed
823/// under rather than only what it started under. Supplying
824/// [`Policy::permissive`] deliberately downgrades a run that had a boundary,
825/// which is a caller's decision to make explicitly and is exactly what [`resume`]
826/// will not do on its behalf.
827///
828/// Use it when the policy is something your program *builds* — from config it
829/// still holds, or from config that has since changed and should now apply:
830///
831/// ```no_run
832/// use io_harness::{resume_with, OpenRouter, Policy, StdinApprover, Store, TaskContract};
833///
834/// # async fn demo(contract: &TaskContract, run_id: i64) -> io_harness::Result<()> {
835/// // The same policy the run started under, rebuilt from the same config — plus
836/// // one deny added since, which now applies to the rest of the run. A resume is
837/// // the natural place to tighten: nothing forces the resumed run to inherit a
838/// // boundary the operator has since decided was too wide.
839/// let policy = Policy::default()
840///     .layer("app")
841///     .allow_read("*")
842///     .allow_write("src/*")
843///     .layer("incident-2026-07")
844///     .deny_write("src/billing/*");
845///
846/// resume_with(
847///     contract, &OpenRouter::from_env()?, &Store::open("runs.db")?, run_id, &policy,
848///     &StdinApprover,
849/// )
850/// .await?;
851/// # Ok(()) }
852/// ```
853///
854/// The policy given here is recorded against the run, so the store keeps
855/// answering what the run actually executed under. If you cannot reconstruct one,
856/// do not pass [`Policy::permissive`] to get moving — use
857/// [`resume_from_stored_policy`], which reads back the real one.
858pub async fn resume_with<P: Provider>(
859    contract: &TaskContract,
860    provider: &P,
861    store: &Store,
862    run_id: i64,
863    policy: &Policy,
864    approver: &dyn Approver,
865) -> Result<RunResult> {
866    resume_with_observed(contract, provider, store, run_id, policy, approver, &Ignore).await
867}
868
869/// Continue a run that paused on a question, with the answer a human gave.
870///
871/// The counterpart to [`resume_with_decision`], for the other channel. `answer` is
872/// text the model reads and it authorizes **nothing**: every tool call it leads to is
873/// checked against the same [`Policy`] by the same code. A human answering "just write
874/// the file" does not make a denied write permitted.
875///
876/// This is deliberately thin, and the thinness is the design: recording the answer and
877/// resuming normally is enough, so there is no second resume path for the ledger, the
878/// checkpoint and the policy to be got wrong in.
879///
880/// The step that asked **was** committed — a paused step commits and the resume starts
881/// after it — so the `ask_question` call is not replayed and cannot be handed its own
882/// answer. The answer is delivered instead as an observation on the run's ledger, which
883/// is what puts it in the next assembled prompt. This is the same mechanism 0.20.0 uses
884/// to deliver a steer.
885///
886/// Answering a question that was already answered is an [`Error::Resume`] rather than a
887/// second run — see [`Store::answer_question`].
888///
889/// ```no_run
890/// use io_harness::{resume_with_answer, ApproveAll, OpenRouter, Policy, RunOutcome,
891///                  Store, TaskContract, Verification};
892///
893/// # async fn demo() -> io_harness::Result<()> {
894/// # let contract = TaskContract::workspace("port it", "/repo", Verification::None);
895/// let store = Store::open("runs.db")?;
896/// let provider = OpenRouter::from_env()?;
897/// let policy = Policy::permissive();
898///
899/// // Some earlier process left this run waiting on a question.
900/// let run_id = 7;
901/// let question_id = 3;
902/// let q = store.question(question_id)?.expect("asked earlier");
903/// println!("the agent asked: {}", q.question);
904///
905/// let result = resume_with_answer(
906///     &contract, &provider, &store, run_id, question_id,
907///     "io.local.toml — the committed one is the template",
908///     &policy, &ApproveAll,
909/// ).await?;
910/// # let _ = result;
911/// # Ok(())
912/// # }
913/// ```
914#[allow(clippy::too_many_arguments)]
915pub async fn resume_with_answer<P: Provider>(
916    contract: &TaskContract,
917    provider: &P,
918    store: &Store,
919    run_id: i64,
920    question_id: i64,
921    answer: &str,
922    policy: &Policy,
923    approver: &dyn Approver,
924) -> Result<RunResult> {
925    resume_with_answer_observed(
926        contract,
927        provider,
928        store,
929        run_id,
930        question_id,
931        answer,
932        policy,
933        approver,
934        &Ignore,
935    )
936    .await
937}
938
939/// [`resume_with_answer`] with an [`Observer`].
940///
941/// ```no_run
942/// use io_harness::{resume_with_answer_observed, ApproveAll, Ignore, OpenRouter, Policy,
943///                  Store, TaskContract, Verification};
944///
945/// # async fn demo() -> io_harness::Result<()> {
946/// # let contract = TaskContract::workspace("port it", "/repo", Verification::None);
947/// let result = resume_with_answer_observed(
948///     &contract, &OpenRouter::from_env()?, &Store::open("runs.db")?,
949///     7, 3, "io.local.toml", &Policy::permissive(), &ApproveAll, &Ignore,
950/// ).await?;
951/// # let _ = result;
952/// # Ok(())
953/// # }
954/// ```
955#[allow(clippy::too_many_arguments)]
956pub async fn resume_with_answer_observed<P: Provider>(
957    contract: &TaskContract,
958    provider: &P,
959    store: &Store,
960    run_id: i64,
961    question_id: i64,
962    answer: &str,
963    policy: &Policy,
964    approver: &dyn Approver,
965    observer: &dyn Observer,
966) -> Result<RunResult> {
967    record_answer(store, run_id, question_id, answer)?;
968    resume_with_observed(
969        contract, provider, store, run_id, policy, approver, observer,
970    )
971    .await
972}
973
974/// Continue a tree that paused on a child's question.
975///
976/// A child's question pauses the whole tree, exactly as a child's deferred approval
977/// does, so this is the tree's counterpart to [`resume_tree_with_decision`]. `run_id`
978/// is the ROOT's run id and `question_id` identifies the question, which may belong to
979/// any agent in the tree — the resume walks the tree and every agent continues from its
980/// own last committed step.
981///
982/// ```no_run
983/// use io_harness::{resume_tree_with_answer, ApproveAll, Containment, OpenRouter, Policy,
984///                  Store, TaskContract, Verification};
985///
986/// # async fn demo() -> io_harness::Result<()> {
987/// # let contract = TaskContract::workspace("port it", "/repo", Verification::None);
988/// let store = Store::open("runs.db")?;
989///
990/// // The question belongs to whichever agent asked it — often a child — but the run id
991/// // passed here is the root's, because what resumes is the tree.
992/// let root_run_id = 7;
993/// let question_id = 4;
994/// let asked_by = store.question(question_id)?.map(|q| q.run_id);
995/// println!("the question came from run {asked_by:?}");
996///
997/// let result = resume_tree_with_answer(
998///     &contract, &OpenRouter::from_env()?, &store, root_run_id, question_id,
999///     "keep the old column", &Policy::permissive(), &ApproveAll,
1000///     &Containment::new(10, 4, 3, 1_000_000),
1001/// ).await?;
1002/// # let _ = result;
1003/// # Ok(())
1004/// # }
1005/// ```
1006#[allow(clippy::too_many_arguments)]
1007pub async fn resume_tree_with_answer<P: Provider>(
1008    contract: &TaskContract,
1009    provider: &P,
1010    store: &Store,
1011    run_id: i64,
1012    question_id: i64,
1013    answer: &str,
1014    policy: &Policy,
1015    approver: &dyn Approver,
1016    containment: &Containment,
1017) -> Result<RunResult> {
1018    resume_tree_with_answer_observed(
1019        contract,
1020        provider,
1021        store,
1022        run_id,
1023        question_id,
1024        answer,
1025        policy,
1026        approver,
1027        containment,
1028        &Ignore,
1029    )
1030    .await
1031}
1032
1033/// [`resume_tree_with_answer`] with an [`Observer`].
1034///
1035/// ```no_run
1036/// use io_harness::{resume_tree_with_answer_observed, ApproveAll, Containment, Ignore,
1037///                  OpenRouter, Policy, Store, TaskContract, Verification};
1038///
1039/// # async fn demo() -> io_harness::Result<()> {
1040/// # let contract = TaskContract::workspace("port it", "/repo", Verification::None);
1041/// let result = resume_tree_with_answer_observed(
1042///     &contract, &OpenRouter::from_env()?, &Store::open("runs.db")?, 7, 4,
1043///     "keep the old column", &Policy::permissive(), &ApproveAll,
1044///     &Containment::new(10, 4, 3, 1_000_000), &Ignore,
1045/// ).await?;
1046/// # let _ = result;
1047/// # Ok(())
1048/// # }
1049/// ```
1050#[allow(clippy::too_many_arguments)]
1051pub async fn resume_tree_with_answer_observed<P: Provider>(
1052    contract: &TaskContract,
1053    provider: &P,
1054    store: &Store,
1055    run_id: i64,
1056    question_id: i64,
1057    answer: &str,
1058    policy: &Policy,
1059    approver: &dyn Approver,
1060    containment: &Containment,
1061    observer: &dyn Observer,
1062) -> Result<RunResult> {
1063    // The question may belong to a child, so it is resolved against its own run id
1064    // rather than the root's.
1065    let owner = store
1066        .question(question_id)?
1067        .map(|q| q.run_id)
1068        .unwrap_or(run_id);
1069    record_answer(store, owner, question_id, answer)?;
1070    resume_tree_observed(
1071        contract,
1072        provider,
1073        store,
1074        run_id,
1075        policy,
1076        approver,
1077        containment,
1078        observer,
1079    )
1080    .await
1081}
1082
1083/// Record a human's answer against the question that paused a run.
1084///
1085/// One place, so the four entry points above cannot drift in what they check. The
1086/// question must exist, must belong to this run, and must not already be answered —
1087/// resuming a run with an answer to somebody else's question would replay a step that
1088/// then asked again and paused again, which reads as a hang rather than an error.
1089fn record_answer(store: &Store, run_id: i64, question_id: i64, answer: &str) -> Result<()> {
1090    let question = store.question(question_id)?.ok_or_else(|| Error::Resume {
1091        reason: format!("no question {question_id} to answer"),
1092    })?;
1093    if question.run_id != run_id {
1094        return Err(Error::Resume {
1095            reason: format!(
1096                "question {question_id} belongs to run {}, not {run_id}",
1097                question.run_id
1098            ),
1099        });
1100    }
1101    store.answer_question(question_id, answer, "human")?;
1102
1103    // The answer has to reach the model, and an observation is the only thing that
1104    // does. The step that asked WAS committed — a paused step is committed and the
1105    // resume starts after it — so there is no replay of the `ask_question` call to
1106    // hand the answer back to. Appending it to the run's observation ledger is what
1107    // puts it in the next assembled prompt, which is exactly how 0.20.0 delivers a
1108    // steer.
1109    //
1110    // `ObsKind::Message` and no target, so nothing can supersede it away: an answer
1111    // is not an observation *of* anything, and the assembler must not stub it as
1112    // stale when a later read touches the same path.
1113    store.record_observations(
1114        run_id,
1115        &[Observation::new(
1116            question.step,
1117            ObsKind::Message,
1118            None,
1119            format!(
1120                "\n[answer] {answer}\n(This is what the operator wanted, in reply to: \
1121                 {}. It is not permission for anything.)\n",
1122                question.question
1123            ),
1124        )],
1125    )?;
1126    Ok(())
1127}
1128
1129/// Resume a run under the policy it was started with, read back from the store.
1130///
1131/// [`resume_with`] takes a policy because a resumed run must not silently lose
1132/// its boundary — that was 0.13.0's subject. But the caller still had to
1133/// reconstruct one, and a caller resuming after a crash in another process may
1134/// have nothing to reconstruct it from. The policy has been durable since
1135/// 0.13.0; this is the entry point that uses it.
1136///
1137/// It matters more from 0.15.0 on than it did when it was first noticed: this is
1138/// the first release in which a crashed run may already have taken an
1139/// irreversible action — a commit — under a policy the resuming caller cannot
1140/// name.
1141///
1142/// Fails with [`Error::Resume`] when the store holds no policy for the run,
1143/// rather than substituting a permissive one. A run whose boundary cannot be
1144/// recovered is not resumed under no boundary; that substitution is exactly the
1145/// defect 0.13.0 closed.
1146///
1147/// The entry point for a restart supervisor, which knows a run id and nothing
1148/// else — it did not build the policy and has no config to rebuild it from:
1149///
1150/// ```no_run
1151/// use io_harness::{resume_from_stored_policy, DenyAll, Error, OpenRouter, RunStatus,
1152///                  Store, TaskContract};
1153///
1154/// # async fn sweep(contract: &TaskContract) -> io_harness::Result<()> {
1155/// let store = Store::open("runs.db")?;
1156/// let provider = OpenRouter::from_env()?;
1157///
1158/// for run_id in [17_i64, 18, 19] {
1159///     if store.run_status(run_id)? != Some(RunStatus::Running) {
1160///         continue; // already finished, or paused on a human
1161///     }
1162///     // No policy argument, and that is the point: the boundary comes back from
1163///     // the store, so a supervisor cannot silently widen what an agent may do by
1164///     // being the process that happened to restart it.
1165///     match resume_from_stored_policy(contract, &provider, &store, run_id, &DenyAll).await {
1166///         Ok(result) => println!("run {run_id}: {:?}", result.outcome),
1167///         // Refused rather than resumed unbounded. A run whose boundary cannot
1168///         // be recovered stays stopped until a human names one.
1169///         Err(Error::Resume { reason }) => eprintln!("run {run_id} needs a human: {reason}"),
1170///         Err(e) => return Err(e),
1171///     }
1172/// }
1173/// # Ok(()) }
1174/// ```
1175///
1176/// Prefer it to [`resume_with`] whenever the resuming process is not the one that
1177/// wrote the policy — from 0.15.0 a crashed run may already have committed, so
1178/// the boundary it was working under is not a detail that can be approximated.
1179pub async fn resume_from_stored_policy<P: Provider>(
1180    contract: &TaskContract,
1181    provider: &P,
1182    store: &Store,
1183    run_id: i64,
1184    approver: &dyn Approver,
1185) -> Result<RunResult> {
1186    resume_from_stored_policy_observed(contract, provider, store, run_id, approver, &Ignore).await
1187}
1188
1189/// [`resume_from_stored_policy`], reporting to `observer` as it happens. See
1190/// [`run_observed`].
1191///
1192/// The one thing this combination shows that no other does: the recovered
1193/// boundary in action. The caller never names the policy, so the refusals the
1194/// observer reports — each attributed to its rule and layer — are the only
1195/// visible evidence of which boundary came back from the store.
1196///
1197/// ```no_run
1198/// use io_harness::{resume_from_stored_policy_observed, DenyAll, EventKind, Flow, Observer,
1199///                  OpenRouter, RunEvent, Store, TaskContract};
1200///
1201/// /// Logs the layers the recovered policy is actually enforcing.
1202/// struct RecoveredBoundary;
1203///
1204/// impl Observer for RecoveredBoundary {
1205///     fn event(&self, event: &RunEvent) -> Flow {
1206///         if let EventKind::Refused { act, target, layer, .. } = &event.kind {
1207///             println!("still enforcing {}: refused {act} {target}",
1208///                      layer.as_deref().unwrap_or("tier default"));
1209///         }
1210///         Flow::Continue
1211///     }
1212/// }
1213///
1214/// # async fn demo(contract: &TaskContract, run_id: i64) -> io_harness::Result<()> {
1215/// resume_from_stored_policy_observed(
1216///     contract, &OpenRouter::from_env()?, &Store::open("runs.db")?, run_id, &DenyAll,
1217///     &RecoveredBoundary,
1218/// )
1219/// .await?;
1220/// # Ok(()) }
1221/// ```
1222pub async fn resume_from_stored_policy_observed<P: Provider>(
1223    contract: &TaskContract,
1224    provider: &P,
1225    store: &Store,
1226    run_id: i64,
1227    approver: &dyn Approver,
1228    observer: &dyn Observer,
1229) -> Result<RunResult> {
1230    let Some(policy) = store.run_policy(run_id)? else {
1231        return Err(Error::Resume {
1232            reason: format!(
1233                "run {run_id} has no recorded policy, so the boundary it ran under cannot be \
1234                 recovered; pass one explicitly with `resume_with` if you know what it was"
1235            ),
1236        });
1237    };
1238    resume_with_observed(
1239        contract, provider, store, run_id, &policy, approver, observer,
1240    )
1241    .await
1242}
1243
1244/// [`resume_with`], reporting to `observer` as it happens. See [`run_observed`].
1245///
1246/// An observer can also *stop* a run, which is worth pairing with a resume
1247/// because the two are the same mechanism seen from both ends: cancelling
1248/// finishes a run cleanly at the next step boundary and leaves it resumable, so
1249/// "stop it for now" and "carry on later" are one loop.
1250///
1251/// ```no_run
1252/// use io_harness::{resume_with_observed, ApproveAll, EventKind, Flow, Observer, OpenRouter,
1253///                  Policy, RunEvent, Store, TaskContract};
1254/// use std::sync::atomic::{AtomicU64, Ordering};
1255///
1256/// /// Stops the run once it has spent more than the operator is willing to.
1257/// struct SpendCeiling { limit: u64, spent: AtomicU64 }
1258///
1259/// impl Observer for SpendCeiling {
1260///     fn event(&self, event: &RunEvent) -> Flow {
1261///         if let EventKind::Step { tokens, .. } = &event.kind {
1262///             if self.spent.fetch_add(*tokens, Ordering::Relaxed) + tokens > self.limit {
1263///                 // Honoured at the next step boundary, never mid-step: no tool
1264///                 // call is abandoned in flight and no file is left half-written.
1265///                 // The run records `cancelled` and stays resumable.
1266///                 return Flow::Cancel;
1267///             }
1268///         }
1269///         Flow::Continue
1270///     }
1271/// }
1272///
1273/// # async fn demo(contract: &TaskContract, policy: &Policy, run_id: i64)
1274/// #     -> io_harness::Result<()> {
1275/// let ceiling = SpendCeiling { limit: 50_000, spent: AtomicU64::new(0) };
1276/// let result = resume_with_observed(
1277///     contract, &OpenRouter::from_env()?, &Store::open("runs.db")?, run_id, policy,
1278///     &ApproveAll, &ceiling,
1279/// )
1280/// .await?;
1281/// // `RunOutcome::Cancelled` — finished, not abandoned. Dropping the future
1282/// // instead would leave `runs.status` as `running` forever, indistinguishable
1283/// // from a crashed process.
1284/// println!("{:?}", result.outcome);
1285/// # Ok(()) }
1286/// ```
1287#[allow(clippy::too_many_arguments)]
1288pub async fn resume_with_observed<P: Provider>(
1289    contract: &TaskContract,
1290    provider: &P,
1291    store: &Store,
1292    run_id: i64,
1293    policy: &Policy,
1294    approver: &dyn Approver,
1295    observer: &dyn Observer,
1296) -> Result<RunResult> {
1297    contract.tools.validate()?;
1298    let skills = contract.discover_skills()?;
1299    store.check_resumable(run_id)?;
1300
1301    if let Some(o) = finished_outcome(store, run_id)? {
1302        return Ok(RunResult::new(o, run_id));
1303    }
1304
1305    let caller_enforces = !policy.is_permissive();
1306    let start_step = record_resume_markers(store, run_id)?;
1307    store.set_provider(run_id, provider.name())?;
1308    store.record_run_policy(run_id, policy)?;
1309    let watch = &Watch::new(observer);
1310    watch.emit(RunEvent::new(
1311        run_id,
1312        start_step.saturating_sub(1),
1313        EventKind::Started {
1314            goal: contract.goal.clone(),
1315            provider: provider.name().to_string(),
1316        },
1317    ));
1318    match contract.root.clone() {
1319        Some(root) => {
1320            // Re-authorized on resume rather than trusted from the interrupted
1321            // run, for the reason [`resume_tree_observed`] gives: the policy
1322            // handed to the resume is the one that governs it, and a host allowed
1323            // before a crash may not be allowed after.
1324            let policy = &match authorize_provider(provider, policy, store, run_id, approver, watch)
1325                .await?
1326            {
1327                ProviderAccess::Granted(p) => p,
1328                ProviderAccess::Pending(request_id) => {
1329                    return Ok(RunResult::new(
1330                        RunOutcome::AwaitingApproval {
1331                            request_id,
1332                            steps: start_step.saturating_sub(1),
1333                        },
1334                        run_id,
1335                    ))
1336                }
1337            };
1338            let mcp = McpSession::connect(&contract.mcp, policy, store, run_id, watch).await?;
1339            let result = run_workspace_from(
1340                contract,
1341                provider,
1342                store,
1343                run_id,
1344                &root,
1345                start_step,
1346                policy,
1347                approver,
1348                &mcp,
1349                &skills,
1350                watch,
1351                &TurnExtras::default(),
1352            )
1353            .await;
1354            mcp.shutdown(store, run_id, watch).await;
1355            result
1356        }
1357        // The same refusal [`run_with_observed`] makes, for the same reason:
1358        // single-file mode has no policy-aware tool layer, and silently ignoring
1359        // a policy would leave the caller believing a boundary was enforced when
1360        // nothing was checking.
1361        None if caller_enforces => Err(crate::error::Error::Config(
1362            "a permission policy requires workspace mode — build the contract \
1363             with TaskContract::workspace(goal, root, verify). Single-file \
1364             contracts are not policy-enforced."
1365                .into(),
1366        )),
1367        None => run_from(contract, provider, store, run_id, start_step, watch).await,
1368    }
1369}
1370
1371/// Continue a run that stopped at [`RunOutcome::AwaitingApproval`], once a
1372/// human has decided about the pending action.
1373///
1374/// An approval performs exactly the action that was persisted — the same target
1375/// and the same content the human was shown — and then continues the run under
1376/// its original `run_id`. The decision is re-checked against the policy first,
1377/// so a deny that landed after the pause still holds. A denial closes the run
1378/// without performing the action.
1379///
1380/// Preserves the policy — it is an argument — and, since 0.13.0, the run's
1381/// observation ledger. It is for a run that *paused*, though: a run that crashed
1382/// has no `request_id` and no pending decision to supply, and wants
1383/// [`resume_with`].
1384///
1385/// This is the other half of [`Decision::Defer`], and it is what makes an
1386/// approval able to outlive the process that asked for it — a web app can show
1387/// the pending action, close the request, and continue the run when someone
1388/// clicks approve tomorrow:
1389///
1390/// ```no_run
1391/// use io_harness::{resume_with_decision, ApproveAll, Act, Decision, OpenRouter, Policy,
1392///                  Request, RunOutcome, Store, TaskContract};
1393///
1394/// # async fn on_click(contract: &TaskContract, policy: &Policy, request_id: i64, approved: bool)
1395/// #     -> io_harness::Result<()> {
1396/// let store = Store::open("runs.db")?;
1397/// let pending = store.pending(request_id)?.expect("a pending request");
1398///
1399/// let decision = if approved {
1400///     // Approving performs exactly what was persisted — the same target, the
1401///     // same bytes the human was shown. Hand back a `modified` request to
1402///     // perform something else instead; it is re-checked against the policy
1403///     // first, so an approver cannot rewrite an action across a deny.
1404///     Decision::Approve {
1405///         modified: Some(Request::new(Act::Write, "docs/NOTES.md")
1406///             .with_content(pending.content.clone().unwrap_or_default())),
1407///         remember: Vec::new(),
1408///     }
1409/// } else {
1410///     // The action never happens and the run closes as `RunOutcome::Denied`.
1411///     Decision::deny("rejected in review")
1412/// };
1413///
1414/// let result = resume_with_decision(
1415///     contract, &OpenRouter::from_env()?, &store, pending.run_id, request_id, decision,
1416///     policy, &ApproveAll,
1417/// )
1418/// .await?;
1419///
1420/// // Deferring again is legal and leaves it pending — the run stays paused.
1421/// if let RunOutcome::AwaitingApproval { .. } = result.outcome {
1422///     println!("still waiting");
1423/// }
1424/// # Ok(()) }
1425/// ```
1426///
1427/// For a paused *tree*, use [`resume_tree_with_decision`]: the pending action
1428/// often belongs to a child rather than the root, and only that function
1429/// validates the request against the whole tree.
1430#[allow(clippy::too_many_arguments)]
1431pub async fn resume_with_decision<P: Provider>(
1432    contract: &TaskContract,
1433    provider: &P,
1434    store: &Store,
1435    run_id: i64,
1436    request_id: i64,
1437    decision: Decision,
1438    policy: &Policy,
1439    approver: &dyn Approver,
1440) -> Result<RunResult> {
1441    resume_with_decision_observed(
1442        contract, provider, store, run_id, request_id, decision, policy, approver, &Ignore,
1443    )
1444    .await
1445}
1446
1447/// [`resume_with_decision`], reporting to `observer` as it happens. See
1448/// [`run_observed`].
1449///
1450/// The event worth listening for here is
1451/// [`EventKind::ApprovalDecided`](crate::EventKind::ApprovalDecided): it is the
1452/// audit record that the decision a human made was the decision the run
1453/// performed, which is the one claim an approval flow has to be able to prove.
1454///
1455/// ```no_run
1456/// use io_harness::{resume_with_decision_observed, ApproveAll, Decision, EventKind, Flow,
1457///                  Observer, OpenRouter, Policy, RunEvent, Store, TaskContract};
1458///
1459/// struct AuditTrail;
1460///
1461/// impl Observer for AuditTrail {
1462///     fn event(&self, event: &RunEvent) -> Flow {
1463///         if let EventKind::ApprovalDecided { act, target, decision } = &event.kind {
1464///             println!("run {} step {}: {decision} {act} {target}", event.run_id, event.step);
1465///         }
1466///         Flow::Continue
1467///     }
1468/// }
1469///
1470/// # async fn demo(contract: &TaskContract, policy: &Policy, run_id: i64, request_id: i64)
1471/// #     -> io_harness::Result<()> {
1472/// resume_with_decision_observed(
1473///     contract, &OpenRouter::from_env()?, &Store::open("runs.db")?, run_id, request_id,
1474///     Decision::approve(), policy, &ApproveAll, &AuditTrail,
1475/// )
1476/// .await?;
1477/// # Ok(()) }
1478/// ```
1479///
1480/// A re-check against the policy happens before the action, so a deny added
1481/// while the run was paused still wins — in which case the observer sees a
1482/// refusal rather than an approval, and the run closes as
1483/// [`RunOutcome::Denied`].
1484#[allow(clippy::too_many_arguments)]
1485pub async fn resume_with_decision_observed<P: Provider>(
1486    contract: &TaskContract,
1487    provider: &P,
1488    store: &Store,
1489    run_id: i64,
1490    request_id: i64,
1491    decision: Decision,
1492    policy: &Policy,
1493    approver: &dyn Approver,
1494    observer: &dyn Observer,
1495) -> Result<RunResult> {
1496    contract.tools.validate()?;
1497    let skills = contract.discover_skills()?;
1498    let pending = store
1499        .pending(request_id)?
1500        .ok_or_else(|| crate::error::Error::Config(format!("no pending request {request_id}")))?;
1501    if pending.run_id != run_id {
1502        return Err(crate::error::Error::Config(format!(
1503            "request {request_id} belongs to run {}, not {run_id}",
1504            pending.run_id
1505        )));
1506    }
1507
1508    let root = contract.root.clone().ok_or_else(|| {
1509        crate::error::Error::Config("resume_with_decision needs a workspace".into())
1510    })?;
1511    let step = pending.step;
1512    // The run row and its provider have existed since the run that paused, so
1513    // `Started` here says "this process is now driving it" — one per entry point,
1514    // never zero, so a `Finished` below is never the first thing an observer hears.
1515    let watch = &Watch::new(observer);
1516    watch.emit(RunEvent::new(
1517        run_id,
1518        step,
1519        EventKind::Started {
1520            goal: contract.goal.clone(),
1521            provider: provider.name().to_string(),
1522        },
1523    ));
1524
1525    match decision {
1526        // Deferring again leaves it pending and the run paused.
1527        Decision::Defer => Ok(RunResult::new(
1528            RunOutcome::AwaitingApproval {
1529                request_id,
1530                steps: step,
1531            },
1532            run_id,
1533        )),
1534        Decision::Deny { reason } => {
1535            store.resolve_pending(request_id, "deny")?;
1536            store.record_event(
1537                run_id,
1538                &PolicyEvent::decision(
1539                    step,
1540                    &pending.act,
1541                    &pending.target,
1542                    "deny",
1543                    format!("resumed:{request_id}"),
1544                ),
1545            )?;
1546            info!(run_id, request_id, %reason, "deferred action denied");
1547            finish(store, watch, run_id, 0, step, "denied")?;
1548            Ok(RunResult::new(RunOutcome::Denied { steps: step }, run_id))
1549        }
1550        // A deferred *network* action has no filesystem effect to replay: the
1551        // run paused before its first step, so approving it grants the host and
1552        // starts the loop. Routing it through the write path below would check
1553        // a host against the path policy and then try to create a file named
1554        // after it.
1555        Decision::Approve { ref remember, .. } if pending.act == "net" => {
1556            let effective = policy
1557                .clone()
1558                .merge(net::provider_layer(&pending.target))
1559                .merge(remembered_layer(remember));
1560            store.resolve_pending(request_id, "approve")?;
1561            store.record_event(
1562                run_id,
1563                &PolicyEvent::decision(
1564                    step,
1565                    "net",
1566                    &pending.target,
1567                    "approve",
1568                    format!("resumed:{request_id}"),
1569                ),
1570            )?;
1571            let remember = remember.clone();
1572            let mcp = McpSession::connect(&contract.mcp, &effective, store, run_id, watch).await?;
1573            let result = run_workspace_from(
1574                contract,
1575                provider,
1576                store,
1577                run_id,
1578                &root,
1579                step + 1,
1580                &effective,
1581                approver,
1582                &mcp,
1583                &skills,
1584                watch,
1585                &TurnExtras::default(),
1586            )
1587            .await;
1588            mcp.shutdown(store, run_id, watch).await;
1589            result.map(|r| r.with_remembered(remember))
1590        }
1591        Decision::Approve { modified, remember } => {
1592            let target = modified
1593                .as_ref()
1594                .map(|m| m.target.clone())
1595                .unwrap_or_else(|| pending.target.clone());
1596            let content = modified
1597                .as_ref()
1598                .and_then(|m| m.content.clone())
1599                .or_else(|| pending.content.clone());
1600
1601            let mut effective = policy.clone();
1602            if !remember.is_empty() {
1603                let mut layer = Policy::permissive().layer("remembered");
1604                for r in &remember {
1605                    layer = layer.rule(r.act, r.effect, r.pattern.clone());
1606                }
1607                effective = effective.merge(layer);
1608            }
1609            let ws = Workspace::with_policy(&root, effective.clone());
1610
1611            // The pause does not grant immunity: the policy still decides.
1612            let act = if pending.act == "read" {
1613                Act::Read
1614            } else {
1615                Act::Write
1616            };
1617            let recheck = ws.check_path(act, &target);
1618            if recheck.effect == Effect::Deny {
1619                let mut ev = PolicyEvent::refusal(step, &pending.act, &target);
1620                ev.rule = recheck.rule.clone();
1621                ev.layer = recheck.layer.clone();
1622                store.record_event(run_id, &ev)?;
1623                refused(watch, run_id, 0, &ev);
1624                store.resolve_pending(request_id, "deny")?;
1625                finish(store, watch, run_id, 0, step, "denied")?;
1626                return Ok(RunResult::new(RunOutcome::Denied { steps: step }, run_id));
1627            }
1628
1629            if act == Act::Write {
1630                ws.write_file(&target, content.as_deref().unwrap_or_default())?;
1631            }
1632            store.resolve_pending(request_id, "approve")?;
1633            let mut ev = PolicyEvent::decision(
1634                step,
1635                &pending.act,
1636                &pending.target,
1637                "approve",
1638                format!("resumed:{request_id}"),
1639            );
1640            if target != pending.target {
1641                ev = ev.with_performed(&target);
1642            }
1643            store.record_event(run_id, &ev)?;
1644
1645            // Continue the run under its original id, from the next step.
1646            let mcp = McpSession::connect(&contract.mcp, &effective, store, run_id, watch).await?;
1647            let result = run_workspace_from(
1648                contract,
1649                provider,
1650                store,
1651                run_id,
1652                &root,
1653                step + 1,
1654                &effective,
1655                approver,
1656                &mcp,
1657                &skills,
1658                watch,
1659                &TurnExtras::default(),
1660            )
1661            .await;
1662            mcp.shutdown(store, run_id, watch).await;
1663            result.map(|r| r.with_remembered(remember))
1664        }
1665    }
1666}
1667
1668/// Continue an agent *tree* that paused at [`RunOutcome::AwaitingApproval`],
1669/// once a human has decided — the tree counterpart of [`resume_with_decision`].
1670///
1671/// The pending action belongs to whichever agent in the tree deferred (often a
1672/// child, not the root), so the decision is validated against the whole tree,
1673/// not just the root run id. On approve the deferred action is performed once,
1674/// the pending is resolved, and the tree is resumed from the store exactly as
1675/// [`resume_tree`] does: the root replays its (deliberately uncommitted) pause
1676/// step, re-adopts the paused child, and the child continues past the
1677/// now-applied action. A denial stops the tree.
1678///
1679/// The trap this exists to avoid: the `run_id` you pass is the tree's **root**,
1680/// while `pending.run_id` is whichever agent actually asked — often three levels
1681/// down. Passing the child's id to [`resume_with_decision`] resumes that child
1682/// alone and orphans the tree around it.
1683///
1684/// ```no_run
1685/// use io_harness::{resume_tree_with_decision, ApproveAll, Containment, Decision, OpenRouter,
1686///                  Policy, RunOutcome, Store, TaskContract};
1687///
1688/// # async fn decide(contract: &TaskContract, policy: &Policy, paused: RunOutcome, root_run_id: i64)
1689/// #     -> io_harness::Result<()> {
1690/// let store = Store::open("runs.db")?;
1691/// let RunOutcome::AwaitingApproval { request_id, .. } = paused else { return Ok(()) };
1692///
1693/// // Show the human which agent in the tree is asking, not just what for.
1694/// let pending = store.pending(request_id)?.expect("a pending request");
1695/// println!("agent {} wants to {} {}", pending.run_id, pending.act, pending.target);
1696///
1697/// // The ROOT id, and the request id from anywhere in the tree. Containment is
1698/// // supplied again because the resumed tree draws against one continuous
1699/// // ceiling — it is restored from durable totals, never reset.
1700/// resume_tree_with_decision(
1701///     contract, &OpenRouter::from_env()?, &store, root_run_id, request_id,
1702///     Decision::approve(), policy, &ApproveAll, &Containment::new(8, 3, 2, 400_000),
1703/// )
1704/// .await?;
1705/// # Ok(()) }
1706/// ```
1707#[allow(clippy::too_many_arguments)]
1708pub async fn resume_tree_with_decision<P: Provider>(
1709    contract: &TaskContract,
1710    provider: &P,
1711    store: &Store,
1712    run_id: i64,
1713    request_id: i64,
1714    decision: Decision,
1715    policy: &Policy,
1716    approver: &dyn Approver,
1717    containment: &Containment,
1718) -> Result<RunResult> {
1719    resume_tree_with_decision_observed(
1720        contract,
1721        provider,
1722        store,
1723        run_id,
1724        request_id,
1725        decision,
1726        policy,
1727        approver,
1728        containment,
1729        &Ignore,
1730    )
1731    .await
1732}
1733
1734/// [`resume_tree_with_decision`], reporting to `observer` as it happens. See
1735/// [`run_observed`].
1736///
1737/// One observer watches the whole tree: every agent's events carry that agent's
1738/// own `run_id` and `depth`, so a consumer routes on those rather than being
1739/// handed one observer per child.
1740///
1741/// Which is what makes it usable here: after a tree-wide pause you want to see
1742/// the deferred action land and then watch the *right* agent carry on, out of
1743/// the several that resume at once.
1744///
1745/// ```no_run
1746/// use io_harness::{resume_tree_with_decision_observed, ApproveAll, Containment, Decision,
1747///                  EventKind, Flow, Observer, OpenRouter, Policy, RunEvent, Store, TaskContract};
1748///
1749/// /// Follows one agent out of a whole tree resuming around it.
1750/// struct FollowAgent { run_id: i64 }
1751///
1752/// impl Observer for FollowAgent {
1753///     fn event(&self, event: &RunEvent) -> Flow {
1754///         if event.run_id != self.run_id {
1755///             return Flow::Continue; // some other agent in the tree
1756///         }
1757///         match &event.kind {
1758///             EventKind::ApprovalDecided { decision, target, .. } => {
1759///                 println!("resumed on: {decision} {target}");
1760///             }
1761///             EventKind::Step { decision, .. } => println!("  step {}: {decision}", event.step),
1762///             _ => {}
1763///         }
1764///         Flow::Continue
1765///     }
1766/// }
1767///
1768/// # async fn demo(contract: &TaskContract, policy: &Policy, root_run_id: i64, request_id: i64)
1769/// #     -> io_harness::Result<()> {
1770/// let store = Store::open("runs.db")?;
1771/// let pending = store.pending(request_id)?.expect("a pending request");
1772/// let follow = FollowAgent { run_id: pending.run_id };
1773///
1774/// resume_tree_with_decision_observed(
1775///     contract, &OpenRouter::from_env()?, &store, root_run_id, request_id,
1776///     Decision::approve(), policy, &ApproveAll, &Containment::new(8, 3, 2, 400_000), &follow,
1777/// )
1778/// .await?;
1779/// # Ok(()) }
1780/// ```
1781///
1782/// A [`Flow::Cancel`](crate::Flow::Cancel) from any agent's event stops the whole
1783/// tree at the next boundary, not only the agent that emitted it — there is one
1784/// cancellation flag per tree, as there is one approver and one ledger.
1785#[allow(clippy::too_many_arguments)]
1786pub async fn resume_tree_with_decision_observed<P: Provider>(
1787    contract: &TaskContract,
1788    provider: &P,
1789    store: &Store,
1790    run_id: i64,
1791    request_id: i64,
1792    decision: Decision,
1793    policy: &Policy,
1794    approver: &dyn Approver,
1795    containment: &Containment,
1796    observer: &dyn Observer,
1797) -> Result<RunResult> {
1798    store.check_resumable(run_id)?;
1799    contract.tools.validate()?;
1800    let skills = contract.discover_skills()?;
1801    let pending = store
1802        .pending(request_id)?
1803        .ok_or_else(|| crate::error::Error::Config(format!("no pending request {request_id}")))?;
1804    // The pending may belong to any agent in this tree, not only the root.
1805    if !store.tree_run_ids(run_id)?.contains(&pending.run_id) {
1806        return Err(crate::error::Error::Config(format!(
1807            "request {request_id} belongs to run {}, which is not in the tree rooted at {run_id}",
1808            pending.run_id
1809        )));
1810    }
1811    let root = contract.root.clone().ok_or_else(|| {
1812        crate::error::Error::Config("resume_tree_with_decision needs a workspace".into())
1813    })?;
1814    let step = pending.step;
1815    let watch = &Watch::new(observer);
1816    watch.emit(RunEvent::new(
1817        run_id,
1818        step,
1819        EventKind::Started {
1820            goal: contract.goal.clone(),
1821            provider: provider.name().to_string(),
1822        },
1823    ));
1824
1825    match decision {
1826        Decision::Defer => Ok(RunResult::new(
1827            RunOutcome::AwaitingApproval {
1828                request_id,
1829                steps: step,
1830            },
1831            run_id,
1832        )),
1833        Decision::Deny { reason } => {
1834            store.resolve_pending(request_id, "deny")?;
1835            store.record_event(
1836                pending.run_id,
1837                &PolicyEvent::decision(
1838                    step,
1839                    &pending.act,
1840                    &pending.target,
1841                    "deny",
1842                    format!("resumed:{request_id}"),
1843                ),
1844            )?;
1845            info!(run_id, request_id, %reason, "deferred tree action denied; tree stops");
1846            finish(store, watch, run_id, 0, step, "denied")?;
1847            Ok(RunResult::new(RunOutcome::Denied { steps: step }, run_id))
1848        }
1849        // As in `resume_with_decision`: an approved network action grants the
1850        // host and starts the tree, with no filesystem effect to replay.
1851        Decision::Approve { ref remember, .. } if pending.act == "net" => {
1852            let effective = policy
1853                .clone()
1854                .merge(net::provider_layer(&pending.target))
1855                .merge(remembered_layer(remember));
1856            store.resolve_pending(request_id, "approve")?;
1857            store.record_event(
1858                pending.run_id,
1859                &PolicyEvent::decision(
1860                    step,
1861                    "net",
1862                    &pending.target,
1863                    "approve",
1864                    format!("resumed:{request_id}"),
1865                ),
1866            )?;
1867            let ledger = Arc::new(Ledger::from_state(
1868                containment,
1869                store.spent_tokens_tree(run_id)?,
1870                store.agent_count_tree(run_id)?,
1871            ));
1872            let start_step = record_resume_markers(store, run_id)?;
1873            store.set_provider(run_id, provider.name())?;
1874            let mcp = McpSession::connect(&contract.mcp, &effective, store, run_id, watch).await?;
1875            let tree = Tree {
1876                mcp: &mcp,
1877                tools: &contract.tools,
1878                skills: &skills,
1879                agents: &contract.agents,
1880                provider,
1881                store,
1882                approver,
1883                responder: responder_of(contract),
1884                watch,
1885                ledger,
1886                containment,
1887                root,
1888                root_run_id: run_id,
1889                web: contract.web.clone(),
1890            };
1891            let outcome = run_agent(&tree, contract, run_id, 0, &effective, start_step, None).await;
1892            mcp.shutdown(store, run_id, watch).await;
1893            Ok(RunResult::new(outcome?, run_id).with_remembered(remember.clone()))
1894        }
1895        Decision::Approve { modified, remember } => {
1896            let target = modified
1897                .as_ref()
1898                .map(|m| m.target.clone())
1899                .unwrap_or_else(|| pending.target.clone());
1900            let content = modified
1901                .as_ref()
1902                .and_then(|m| m.content.clone())
1903                .or_else(|| pending.content.clone());
1904
1905            // ponytail: the deferred write is re-checked against the tree policy,
1906            // not the child's narrowed policy (not reconstructed here). A denial
1907            // beneath still holds; a narrower child allow is not re-enforced on
1908            // this one performed action. Tighten if child-specific deny of an
1909            // approved action becomes a requirement.
1910            let ws = Workspace::with_policy(&root, policy.clone());
1911            let act = if pending.act == "read" {
1912                Act::Read
1913            } else {
1914                Act::Write
1915            };
1916            if ws.check_path(act, &target).effect == Effect::Deny {
1917                store.resolve_pending(request_id, "deny")?;
1918                finish(store, watch, run_id, 0, step, "denied")?;
1919                return Ok(RunResult::new(RunOutcome::Denied { steps: step }, run_id));
1920            }
1921            if act == Act::Write {
1922                ws.write_file(&target, content.as_deref().unwrap_or_default())?;
1923            }
1924            store.resolve_pending(request_id, "approve")?;
1925            store.record_event(
1926                pending.run_id,
1927                &PolicyEvent::decision(
1928                    step,
1929                    &pending.act,
1930                    &pending.target,
1931                    "approve",
1932                    format!("resumed:{request_id}"),
1933                ),
1934            )?;
1935
1936            // Resume the whole tree; the root replays its uncommitted pause step
1937            // and re-adopts the (now-unblocked) child.
1938            let mut effective = policy.clone();
1939            if !remember.is_empty() {
1940                let mut layer = Policy::permissive().layer("remembered");
1941                for r in &remember {
1942                    layer = layer.rule(r.act, r.effect, r.pattern.clone());
1943                }
1944                effective = effective.merge(layer);
1945            }
1946            let ledger = Arc::new(Ledger::from_state(
1947                containment,
1948                store.spent_tokens_tree(run_id)?,
1949                store.agent_count_tree(run_id)?,
1950            ));
1951            let start_step = record_resume_markers(store, run_id)?;
1952            store.set_provider(run_id, provider.name())?;
1953            let mcp = McpSession::connect(&contract.mcp, &effective, store, run_id, watch).await?;
1954            let tree = Tree {
1955                mcp: &mcp,
1956                tools: &contract.tools,
1957                skills: &skills,
1958                agents: &contract.agents,
1959                provider,
1960                store,
1961                approver,
1962                responder: responder_of(contract),
1963                watch,
1964                ledger,
1965                containment,
1966                root,
1967                root_run_id: run_id,
1968                web: contract.web.clone(),
1969            };
1970            let outcome = run_agent(&tree, contract, run_id, 0, &effective, start_step, None).await;
1971            mcp.shutdown(store, run_id, watch).await;
1972            Ok(RunResult::new(outcome?, run_id).with_remembered(remember))
1973        }
1974    }
1975}
1976
1977/// Reconstruct the *final* [`RunOutcome`] of a run that cannot be meaningfully
1978/// re-driven, so a resume of such a run is a faithful no-op. Only genuinely
1979/// final outcomes are returned: `success`, `denied` (a human's no), and a tree
1980/// `budget_ceiling_reached`. A run that merely ran out of step / token / time
1981/// budget is deliberately NOT final — a caller resumes it with a larger budget
1982/// to continue — so those return `None` and resume re-drives the loop (which is
1983/// itself idempotent: re-running with the same budget skips the exhausted loop
1984/// and reports the same outcome without spending anything). `awaiting_approval`
1985/// is `Paused`, not `Completed`, and resumes via [`resume_with_decision`].
1986/// Record the resume marker and one skipped marker per already-committed step,
1987/// so a multi-crash run's full history is reconstructable from the store alone.
1988/// Returns the step to resume from (last committed + 1).
1989/// Did this step end the run, because there is no gate and the agent stopped
1990/// acting?
1991///
1992/// Both halves are required. Without [`Verification::None`] an assistant turn
1993/// with no tool call is an ordinary unproductive step — the agent thinking aloud,
1994/// or asking a question the loop cannot answer — and ending the run there would
1995/// silently cap every existing contract at its first quiet turn. Without the
1996/// empty tool-call list there is no signal at all: no `done` tool is added, so an
1997/// unverified run has exactly the tool surface a verified one has, and a model
1998/// that has finished says so by saying something.
1999fn finished(contract: &TaskContract, response: &CompletionResponse) -> bool {
2000    matches!(contract.verify, Verification::None)
2001        && response.tool_calls.is_empty()
2002        // 0.22.0 — and the turn actually ended. A provider running a long web
2003        // search hands back what it has so far with a *paused* stop reason and no
2004        // tool call, which is indistinguishable from a finished answer by the two
2005        // conditions above. Ending there would stop an unverified run in the
2006        // middle of the search it was told to make.
2007        && !paused_turn(response)
2008}
2009
2010/// Did the provider pause this turn rather than end it?
2011///
2012/// Anthropic says `pause_turn` when a server-executed tool has been running long
2013/// enough that it would rather hand back control than hold the connection. It is
2014/// a continuation signal, not a finish: the loop takes another step, and the
2015/// partial text is an observation like any other.
2016///
2017/// What this crate does NOT do is echo the vendor's partial assistant blocks back
2018/// verbatim — the request has been one flattened user turn since 0.1.0 — so a
2019/// paused turn resumes as a fresh request and the provider may repeat a search it
2020/// already charged for. `docs/CONTRACT.md` says so.
2021fn paused_turn(response: &CompletionResponse) -> bool {
2022    response.finish_reason.as_deref() == Some("pause_turn")
2023}
2024
2025/// The note a failed provider-executed call leaves in the observation log, if
2026/// this response reported one.
2027///
2028/// A vendor reports a broken search inside an otherwise successful response, so
2029/// without this the model sees an answer with no results and concludes the web
2030/// had nothing to say. Naming the failure lets it retry or proceed knowingly.
2031fn web_failure_note(response: &CompletionResponse) -> Option<String> {
2032    let failed: Vec<String> = response
2033        .server_tools
2034        .iter()
2035        .filter(|c| !c.succeeded())
2036        .map(|c| {
2037            format!(
2038                "{} failed ({})",
2039                c.tool,
2040                c.error.as_deref().unwrap_or("no reason given")
2041            )
2042        })
2043        .collect();
2044    (!failed.is_empty()).then(|| failed.join("; "))
2045}
2046
2047fn record_resume_markers(store: &Store, run_id: i64) -> Result<u32> {
2048    let last = store.last_step(run_id)?;
2049    let start_step = last + 1;
2050    store.record_checkpoint_event(&crate::state::CheckpointEvent::resume(
2051        run_id,
2052        start_step,
2053        format!("resuming at step {start_step}, {last} committed step(s) skipped"),
2054    ))?;
2055    for s in 1..=last {
2056        store.record_checkpoint_event(&crate::state::CheckpointEvent::skipped(run_id, s))?;
2057    }
2058    Ok(start_step)
2059}
2060
2061/// A run's ledger as the store has it, with the count already durable.
2062///
2063/// The count is the watermark [`persist_ledger`] appends from: everything below
2064/// it is on disk, everything above it was observed since the last committed
2065/// step.
2066fn restore_ledger(store: &Store, run_id: i64) -> Result<(ContextLedger, usize)> {
2067    let mut ledger = ContextLedger::new();
2068    for obs in store.observations(run_id)? {
2069        ledger.push(obs);
2070    }
2071    let written = ledger.len();
2072    Ok((ledger, written))
2073}
2074
2075/// Append everything observed since the last committed step, and return the new
2076/// watermark.
2077///
2078/// Called at the step boundary that commits, so an observation belonging to a
2079/// step that never committed does not outlive it — the ledger stays consistent
2080/// with the trace rather than running ahead of it.
2081fn persist_ledger(
2082    store: &Store,
2083    run_id: i64,
2084    ledger: &ContextLedger,
2085    written: usize,
2086) -> Result<usize> {
2087    store.record_observations(run_id, &ledger.entries()[written..])?;
2088    Ok(ledger.len())
2089}
2090
2091/// The outcome of a run that is already over, if it is over.
2092///
2093/// Idempotence for every resume entry point: a run that already finished is
2094/// returned as-is, so resuming twice does not re-drive the loop or re-charge the
2095/// budget. A run still `Running` — its process died mid-loop — reads as `None`
2096/// here and is resumed from its last committed step.
2097fn finished_outcome(store: &Store, run_id: i64) -> Result<Option<RunOutcome>> {
2098    if store.run_status(run_id)? != Some(RunStatus::Completed) {
2099        return Ok(None);
2100    }
2101    terminal_outcome(store, run_id)
2102}
2103
2104fn terminal_outcome(store: &Store, run_id: i64) -> Result<Option<RunOutcome>> {
2105    let last = store.last_step(run_id)?;
2106    Ok(store.outcome(run_id)?.and_then(|o| match o.as_str() {
2107        "success" => Some(RunOutcome::Success { steps: last }),
2108        "denied" => Some(RunOutcome::Denied { steps: last }),
2109        "budget_ceiling_reached" => Some(RunOutcome::BudgetCeilingReached { steps: last }),
2110        "stalled" => Some(RunOutcome::Stalled { steps: last }),
2111        // Before 0.11.0 `"escalated"` was unmapped and `finish_run` reported it as a
2112        // plain completion, so resuming an escalated run fell straight back into the
2113        // loop and re-ran it. An unattended run that escalated at 3am was silently
2114        // restarted by the next resume.
2115        "escalated_retryable" => Some(RunOutcome::Escalated {
2116            steps: last,
2117            retryable: true,
2118        }),
2119        "escalated_terminal" | "escalated" => Some(RunOutcome::Escalated {
2120            steps: last,
2121            retryable: false,
2122        }),
2123        // 0.12.0: the same defect, found by the same kind of audit. `"refused"` is
2124        // written when a human denies the network access the provider needs
2125        // (`authorize_provider`), and it was unmapped — so resuming a refused run
2126        // re-entered the loop and asked the human the same question again. A human's
2127        // no is as final as a policy's, which is why `denied` above is final too.
2128        "refused" => Some(RunOutcome::Refused { steps: last }),
2129        // Also 0.12.0: a run its observer stopped. Final for the same reason a
2130        // human's `denied` is — the caller asked for it — so a resume reports it
2131        // rather than quietly starting the run up again.
2132        "cancelled" => Some(RunOutcome::Cancelled { steps: last }),
2133        // 0.17.0: a `Verification::None` run that ended on its own terms. Final —
2134        // there is no criterion left to re-check, so a resume reports it rather
2135        // than driving the loop again to watch the agent say nothing twice.
2136        "finished" => Some(RunOutcome::Finished { steps: last }),
2137        _ => None,
2138    }))
2139}
2140
2141/// The [`Observer`] a run reports to, plus the one bit of state it can set.
2142///
2143/// A wrapper rather than a bare `&dyn Observer` because a cancellation has to
2144/// outlive the `event()` call that asked for it: [`Flow::Cancel`](crate::Flow::Cancel)
2145/// is honoured at the next step boundary, not where it was returned, so the
2146/// request is remembered here — and one `Watch` shared by a whole tree means a
2147/// child's observer can stop the tree, not only itself.
2148///
2149/// A [`Cell`] is enough: [`Store`] is `!Sync` and `run_agent` returns a
2150/// non-`Send` future, so a run and every agent in its tree are driven on one
2151/// task. Nothing here needs a lock, and adding one would be the only `Sync`
2152/// requirement in the loop.
2153pub(crate) struct Watch<'a> {
2154    observer: &'a dyn Observer,
2155    cancelled: Cell<bool>,
2156}
2157
2158impl<'a> Watch<'a> {
2159    fn new(observer: &'a dyn Observer) -> Self {
2160        Self {
2161            observer,
2162            cancelled: Cell::new(false),
2163        }
2164    }
2165
2166    /// Report one event, remembering a cancellation for the next step boundary.
2167    pub(crate) fn emit(&self, event: RunEvent) {
2168        if self.observer.event(&event).is_cancel() {
2169            self.cancelled.set(true);
2170        }
2171    }
2172
2173    /// Whether stopping has been asked for. Read at a step boundary only.
2174    fn cancelled(&self) -> bool {
2175        self.cancelled.get()
2176    }
2177}
2178
2179/// Announce a refusal, reading the event straight off the row that records it.
2180///
2181/// Every `Refused` in the crate goes through here, and takes the `PolicyEvent`
2182/// rather than the four fields, so an event cannot carry a rule or a layer the
2183/// `policy_events` row does not. The two surfaces agree by construction instead
2184/// of by four call sites remembering to keep in step.
2185pub(crate) fn refused(watch: &Watch<'_>, run_id: i64, depth: u32, ev: &PolicyEvent) {
2186    watch.emit(RunEvent::at_depth(
2187        run_id,
2188        ev.step,
2189        depth,
2190        EventKind::Refused {
2191            act: ev.act.clone(),
2192            target: ev.target.clone(),
2193            rule: ev.rule.clone(),
2194            layer: ev.layer.clone(),
2195        },
2196    ));
2197}
2198
2199/// Announce a human's answer, from the row that records it. As [`refused`]: the
2200/// event's `decision` is the row's, never a second literal beside it.
2201fn decided(watch: &Watch<'_>, run_id: i64, depth: u32, ev: &PolicyEvent) {
2202    watch.emit(RunEvent::at_depth(
2203        run_id,
2204        ev.step,
2205        depth,
2206        EventKind::ApprovalDecided {
2207            act: ev.act.clone(),
2208            target: ev.target.clone(),
2209            decision: ev.decision.clone().unwrap_or_default(),
2210        },
2211    ));
2212}
2213
2214/// The one step boundary: commit the step, log it, and tell the observer.
2215///
2216/// Before 0.12.0 the single-file loop, the workspace loop and the sub-agent loop
2217/// each had their own copy of this — their own inline [`StepRecord`], their own
2218/// `checkpoint_step`, and their own differently-named `info!` ("loop step" /
2219/// "workspace step" / "agent step"). One boundary is what stops the three
2220/// drifting, and what makes [`EventKind::Step`] one fact about a committed step
2221/// rather than three approximations of one.
2222///
2223/// `commit` is `false` for exactly one case: the sub-agent loop's step that
2224/// paused because one of its CHILDREN deferred. That step is deliberately left
2225/// uncommitted so a resume replays it and re-adopts the paused child — only the
2226/// parent re-entering `spawn_child` can wait on that child again — and committing
2227/// it would skip the replay, which is the double-execution defect 0.7.0's
2228/// checkpointing exists to prevent. Nothing is committed and no
2229/// [`EventKind::Step`] is emitted, because there is no committed step to report:
2230/// what the caller hears about is the pause, through
2231/// [`RunOutcome::AwaitingApproval`].
2232fn commit_step(
2233    store: &Store,
2234    watch: &Watch<'_>,
2235    run_id: i64,
2236    depth: u32,
2237    record: StepRecord,
2238    changed: bool,
2239    commit: bool,
2240) -> Result<()> {
2241    if !commit {
2242        info!(
2243            run_id,
2244            depth,
2245            step = record.step,
2246            "tree paused for a child's approval (step left uncommitted for replay)"
2247        );
2248        return Ok(());
2249    }
2250    store.checkpoint_step(run_id, &record)?;
2251    info!(
2252        run_id,
2253        depth,
2254        step = record.step,
2255        decision = %record.decision,
2256        tokens = record.tokens,
2257        changed,
2258        "step"
2259    );
2260    // The record's own fields, moved rather than cloned: an event must report
2261    // exactly what was committed, and the unobserved path must not pay an
2262    // allocation per step for the privilege of being ignored.
2263    watch.emit(RunEvent::at_depth(
2264        run_id,
2265        record.step,
2266        depth,
2267        EventKind::Step {
2268            decision: record.decision,
2269            tool_call: record.tool_call,
2270            tokens: record.tokens,
2271            changed,
2272        },
2273    ));
2274    Ok(())
2275}
2276
2277/// Stop the run if the observer asked it to, recording `"cancelled"` as the
2278/// outcome. `None` means carry on.
2279///
2280/// Call this at a step boundary and nowhere else — that is the contract
2281/// [`Flow::Cancel`](crate::Flow::Cancel) states, and the whole reason the request
2282/// is remembered in [`Watch`] rather than acted on where it was returned. The run
2283/// is *finished*, not abandoned: `runs.status` stops being `running`, a summary is
2284/// written, and `terminal_outcome` maps the string back so a resume reports the
2285/// cancellation instead of re-driving the loop.
2286fn cancelled(
2287    store: &Store,
2288    watch: &Watch<'_>,
2289    run_id: i64,
2290    depth: u32,
2291    steps: u32,
2292) -> Result<Option<RunOutcome>> {
2293    if !watch.cancelled() {
2294        return Ok(None);
2295    }
2296    finish(store, watch, run_id, depth, steps, "cancelled")?;
2297    info!(run_id, depth, steps, "run cancelled by its observer");
2298    Ok(Some(RunOutcome::Cancelled { steps }))
2299}
2300
2301/// End a run: write the outcome and tell the observer, so no terminal path can do
2302/// one without the other. Every `finish_run` in this file goes through here.
2303///
2304/// `steps` is what the outcome reports, which is not always the step the loop was
2305/// on — a time-budget stop reports the last step that completed.
2306fn finish(
2307    store: &Store,
2308    watch: &Watch<'_>,
2309    run_id: i64,
2310    depth: u32,
2311    steps: u32,
2312    outcome: &str,
2313) -> Result<()> {
2314    store.finish_run(run_id, outcome)?;
2315    watch.emit(RunEvent::at_depth(
2316        run_id,
2317        steps,
2318        depth,
2319        EventKind::Finished {
2320            outcome: outcome.to_string(),
2321            steps,
2322            // Read back from the store rather than carried in a local: the store
2323            // is what an audit will read, and the two must agree.
2324            tokens: store.spent_tokens(run_id)?,
2325        },
2326    ));
2327    Ok(())
2328}
2329
2330async fn run_from<P: Provider>(
2331    contract: &TaskContract,
2332    provider: &P,
2333    store: &Store,
2334    run_id: i64,
2335    start_step: u32,
2336    watch: &Watch<'_>,
2337) -> Result<RunResult> {
2338    let fs = FsTool::new(&contract.file);
2339    let system = system_prompt();
2340    let tool = write_file_tool();
2341    // Durable budget: spend and elapsed time are restored from the store, so a
2342    // resume continues one continuous budget instead of restarting it at zero.
2343    let mut tokens_used: u64 = store.spent_tokens(run_id)?;
2344    // Single-file mode is not policy-enforced (0.4.0), but the verify gate is
2345    // still sandboxed (0.6.0). A permissive guard carries the trace so the
2346    // sandbox lifecycle is recorded for single-file runs too.
2347    let permissive = Policy::permissive();
2348
2349    for step in start_step..=contract.max_steps {
2350        // A cancellation is acted on here, at the boundary between two steps, and
2351        // nowhere else: the points inside a step are not safe to stop at — a tool
2352        // call is in flight, a file may be half-written — and stopping there is
2353        // what dropping the future already does badly. Checked before the budgets
2354        // because the caller asking to stop outranks a budget saying so.
2355        if let Some(o) = cancelled(store, watch, run_id, 0, step - 1)? {
2356            return Ok(RunResult::new(o, run_id));
2357        }
2358        // Time budget: checked before doing the step's work, against real
2359        // wall-clock elapsed since the run started (durable across a restart).
2360        if let Some(max) = contract.max_duration {
2361            if store.elapsed_secs(run_id)? > max.as_secs_f64() {
2362                finish(store, watch, run_id, 0, step - 1, "time_budget_exceeded")?;
2363                return Ok(RunResult::new(
2364                    RunOutcome::TimeBudgetExceeded { steps: step - 1 },
2365                    run_id,
2366                ));
2367            }
2368        }
2369
2370        let current = fs.read().await?;
2371        // The whole file goes back every turn, so it is bounded on the same terms
2372        // as any observation: one large file must not exhaust the request. The tail
2373        // is kept, because the end of a file is what a writer needs.
2374        let user =
2375            user_prompt(
2376                contract,
2377                &bound(
2378                    &current,
2379                    entry_cap_chars(contract.context.effective_tokens(
2380                        contract.max_tokens.map(|m| m.saturating_sub(tokens_used)),
2381                    )),
2382                    ObsKind::Read,
2383                ),
2384            );
2385        #[allow(clippy::needless_update)] // `media` is cfg'd out in the default build
2386        let request = CompletionRequest {
2387            system: system.clone(),
2388            user: user.clone(),
2389            tools: vec![tool.clone()],
2390            // 0.22.0 — what the provider may look up, as the contract declared it.
2391            // `None` for every contract that declared nothing, which is what the
2392            // three built-in providers read as "send the 0.21.0 body".
2393            web: contract.web.clone(),
2394            // Single-file mode has no `view_image` tool, so only the caller's
2395            // images are in play here.
2396            #[cfg(feature = "media")]
2397            media: attach_media(contract, &mut PendingMedia::default())?,
2398            ..Default::default()
2399        };
2400
2401        let response = complete_with_retry(
2402            provider, &request, contract, store, run_id, step, watch, 0, false,
2403        )
2404        .await?;
2405
2406        // Which provider answered, when that is not a foregone conclusion. A
2407        // `Fallback` that fell over served this step from its secondary, and a trace
2408        // reader has no other way to know.
2409        if let Some(served) = provider.last_served() {
2410            store.record_context_event(run_id, &ContextEvent::served(step, served.clone()))?;
2411            watch.emit(RunEvent::new(
2412                run_id,
2413                step,
2414                EventKind::FellBackTo { provider: served },
2415            ));
2416        }
2417        let step_tokens = response.usage.map(|u| u.total_tokens).unwrap_or(0);
2418        tokens_used += step_tokens;
2419
2420        let call = response
2421            .tool_calls
2422            .iter()
2423            .find(|c| c.name == WRITE_FILE_TOOL);
2424        let tool_call_json = call.map(|c| c.arguments.to_string()).unwrap_or_default();
2425        let write = call.and_then(|c| c.arguments.get("content").and_then(|v| v.as_str()));
2426
2427        let (decision, result_text) = match write {
2428            Some(content) => {
2429                fs.write(content).await?;
2430                ("wrote file", content.to_string())
2431            }
2432            None => ("no tool call", response.text.clone().unwrap_or_default()),
2433        };
2434        // The file write (if any) is already applied above, before this commit:
2435        // a crash between the write and the commit replays this step, and the
2436        // model re-observes the already-written file, so the edit lands exactly
2437        // once. The committed checkpoint is the step's completion marker.
2438        //
2439        // Single-file mode has no workspace-change signal of its own, so `changed`
2440        // is whether this step wrote the file at all — the nearest true statement
2441        // the mode can make.
2442        commit_step(
2443            store,
2444            watch,
2445            run_id,
2446            0,
2447            StepRecord::new(step, decision, result_text).with_trace(
2448                user,
2449                tool_call_json,
2450                step_tokens,
2451            ),
2452            write.is_some(),
2453            true,
2454        )?;
2455
2456        // Cost budget: checked after this step's tokens are counted.
2457        if let Some(max) = contract.max_tokens {
2458            if tokens_used > max {
2459                finish(store, watch, run_id, 0, step, "cost_budget_exceeded")?;
2460                return Ok(RunResult::new(
2461                    RunOutcome::CostBudgetExceeded { steps: step },
2462                    run_id,
2463                ));
2464            }
2465        }
2466
2467        // A run with no criterion ends when the agent stops calling tools. After
2468        // the budget checks, because a step that also crossed a ceiling crossed
2469        // it — and before the gate, which for this variant can never pass.
2470        if finished(contract, &response) {
2471            finish(store, watch, run_id, 0, step, "finished")?;
2472            return Ok(RunResult::new(RunOutcome::Finished { steps: step }, run_id));
2473        }
2474
2475        let contents = fs.read().await?;
2476        let guard = ExecGuard::new(&permissive)
2477            .tracing(store, run_id, step)
2478            .watching(watch, 0);
2479        if contract
2480            .verify
2481            .passes_guarded(&contract.file, &contents, &guard)
2482            .await?
2483        {
2484            finish(store, watch, run_id, 0, step, "success")?;
2485            return Ok(RunResult::new(RunOutcome::Success { steps: step }, run_id));
2486        }
2487    }
2488
2489    finish(
2490        store,
2491        watch,
2492        run_id,
2493        0,
2494        contract.max_steps,
2495        "step_cap_reached",
2496    )?;
2497    Ok(RunResult::new(
2498        RunOutcome::StepCapReached {
2499            steps: contract.max_steps,
2500        },
2501        run_id,
2502    ))
2503}
2504
2505/// The workspace loop (0.3 multi-file mode): the agent greps, finds, reads, and
2506/// writes several files under `root`, carrying its own working memory as an
2507/// observation log folded into each turn's prompt. Budgets, retry, trace, and
2508/// resume behave as in single-file mode; verification is multi-file
2509/// ([`Verification::passes_in`]).
2510#[allow(clippy::too_many_arguments)]
2511async fn run_workspace_from<P: Provider>(
2512    contract: &TaskContract,
2513    provider: &P,
2514    store: &Store,
2515    run_id: i64,
2516    root: &Path,
2517    start_step: u32,
2518    policy: &Policy,
2519    approver: &dyn Approver,
2520    mcp: &McpSession,
2521    skills: &Skills,
2522    watch: &Watch<'_>,
2523    extras: &TurnExtras<'_>,
2524) -> Result<RunResult> {
2525    // The effective policy grows as approvers remember rules; it is rebuilt as a
2526    // merge so a remembered allow can still never defeat a deny beneath it.
2527    let mut effective = policy.clone();
2528    let mut remembered: Vec<Rule> = Vec::new();
2529    let mut ws = Workspace::with_policy(root, effective.clone());
2530    // MCP tools sit beside the built-ins under their namespaced names, so the
2531    // model chooses between them the same way it chooses between grep and find.
2532    // Registered in-process tools and MCP tools sit beside the built-ins under
2533    // their own names, so the model chooses between them the same way it chooses
2534    // between grep and find.
2535    let mut extra = contract.tools.specs();
2536    extra.extend(mcp.tool_specs());
2537    extra.extend(skill_tool(skills));
2538    let system = with_skill_catalog(with_extra_tools(workspace_system_prompt(), &extra), skills);
2539    let mut tools = workspace_tools();
2540    tools.extend(extra);
2541    // Durable budget: restored from the store so a resume continues the same
2542    // token and wall-clock budget rather than restarting it at zero.
2543    let mut tokens_used: u64 = store.spent_tokens(run_id)?;
2544    // History, append-only. What the model sees of it is decided per turn by
2545    // `assemble`, under the contract's context budget — the log itself is never
2546    // trimmed, so the trace keeps everything.
2547    //
2548    // Restored from the store, so a resumed run continues with the context it had
2549    // rather than re-deriving one from the workspace and asking the model a
2550    // different question than the process before it would have. Empty for a fresh
2551    // run, and empty for a run checkpointed before 0.13.0, which is the same
2552    // re-derivation that binary did.
2553    let (mut ledger, mut written) = restore_ledger(store, run_id)?;
2554    // A session turn continues a conversation, and the conversation enters as
2555    // observations rather than as a longer goal: the assembler then bounds and
2556    // compacts it under the contract's context budget, which is the machinery that
2557    // already decides what a long run's history gets to say. Step 0, because no
2558    // step of THIS run produced them.
2559    //
2560    // Only when the ledger is empty, which is a fresh turn. A resumed turn already
2561    // has them from the store — the seed was persisted with the first step — and
2562    // seeding again would say everything twice.
2563    if ledger.is_empty() {
2564        for entry in extras.seed {
2565            ledger.push(Observation::new(0, ObsKind::Message, None, entry.clone()));
2566        }
2567    }
2568    // Is the agent getting anywhere? Restored from nothing on resume by design: a
2569    // resumed run has just been given a fresh chance, and condemning it for the
2570    // window it stalled in before the crash would be a poor welcome.
2571    let mut progress = Progress::new();
2572    // Detected once, before the first turn. The marker files do not change under
2573    // a run often enough to be worth a filesystem walk every step, and a run that
2574    // creates its own `package.json` is creating a project rather than working in
2575    // one.
2576    let toolchain = crate::toolchain::detect(root);
2577    let mem_key = memory_key(root);
2578    // Images the agent looked at last step, carried into this one's request and
2579    // dropped once shown. A viewed image is a tool result, not a permanent part
2580    // of the conversation: the model that wants it again asks again, and the
2581    // request stays bounded by what one step actually needed.
2582    let pending_media = &mut PendingMedia::default();
2583
2584    for step in start_step..=contract.max_steps {
2585        // The step boundary, where a cancellation is honoured (see `cancelled`).
2586        if let Some(o) = cancelled(store, watch, run_id, 0, step - 1)? {
2587            return Ok(RunResult::new(o, run_id).with_remembered(remembered));
2588        }
2589        // The same boundary is where an operator's steering lands, for the same
2590        // reason: the points in between are not safe to stop at or to change course
2591        // from — a tool call is in flight and a file may be half-written.
2592        if let Some(inbox) = extras.steer {
2593            let steered = inbox.drain();
2594            if steered.interrupted {
2595                // The cancel path, not a second one. An interrupted turn is
2596                // finished rather than abandoned: `runs.status` stops being
2597                // `running`, a summary is written, and a resume reports the
2598                // cancellation instead of re-driving the loop.
2599                finish(store, watch, run_id, 0, step - 1, "cancelled")?;
2600                info!(run_id, steps = step - 1, "turn interrupted by its operator");
2601                return Ok(
2602                    RunResult::new(RunOutcome::Cancelled { steps: step - 1 }, run_id)
2603                        .with_remembered(remembered),
2604                );
2605            }
2606            for message in steered.messages {
2607                // An observation like any other: bounded by the same budget,
2608                // recorded in the same trace, and — carrying no target — never
2609                // superseded away. It is text the model reads, not permission the
2610                // model has: every tool call it leads to is checked against the
2611                // same policy by the same code.
2612                info!(run_id, step, "operator steered the turn");
2613                store.record_context_event(
2614                    run_id,
2615                    &ContextEvent::steered(
2616                        step,
2617                        format!("operator message ({} chars)", message.len()),
2618                    ),
2619                )?;
2620                ledger.push(Observation::new(
2621                    step,
2622                    ObsKind::Message,
2623                    None,
2624                    format!("\n[operator, mid-turn] {message}\n"),
2625                ));
2626            }
2627        }
2628        if let Some(max) = contract.max_duration {
2629            if store.elapsed_secs(run_id)? > max.as_secs_f64() {
2630                finish(store, watch, run_id, 0, step - 1, "time_budget_exceeded")?;
2631                return Ok(RunResult::new(
2632                    RunOutcome::TimeBudgetExceeded { steps: step - 1 },
2633                    run_id,
2634                )
2635                .with_remembered(remembered));
2636            }
2637        }
2638
2639        // One budget, derived once per turn: it sets both this request's ceiling
2640        // and the per-observation cap the results of this step enter under.
2641        let budget_tokens = contract
2642            .context
2643            .effective_tokens(contract.max_tokens.map(|m| m.saturating_sub(tokens_used)));
2644        let entry_cap = entry_cap_chars(budget_tokens);
2645        // Re-read each turn rather than once at the start, so the notes the model
2646        // sees are the notes the store holds — including one written this run, and
2647        // not one the operator has since cleared.
2648        let notes = store.memory_list(&mem_key)?;
2649        let assembled = assemble(
2650            &ledger,
2651            budget_tokens,
2652            &notes,
2653            Assembly {
2654                ws: Some(&ws),
2655                policy: &effective,
2656                store,
2657                run_id,
2658                step,
2659            },
2660        )
2661        .await?;
2662        let user = workspace_user_prompt(contract, &assembled.text, toolchain.as_ref());
2663        #[allow(clippy::needless_update)] // `media` is cfg'd out in the default build
2664        let request = CompletionRequest {
2665            system: system.clone(),
2666            user: user.clone(),
2667            tools: tools.clone(),
2668            // 0.22.0 — the run's web declaration, unchanged per step.
2669            web: contract.web.clone(),
2670            #[cfg(feature = "media")]
2671            media: attach_media(contract, pending_media)?,
2672            ..Default::default()
2673        };
2674
2675        let response = complete_with_retry(
2676            provider,
2677            &request,
2678            contract,
2679            store,
2680            run_id,
2681            step,
2682            watch,
2683            0,
2684            extras.stream,
2685        )
2686        .await?;
2687
2688        // Which provider answered, when that is not a foregone conclusion. A
2689        // `Fallback` that fell over served this step from its secondary, and a trace
2690        // reader has no other way to know.
2691        if let Some(served) = provider.last_served() {
2692            store.record_context_event(run_id, &ContextEvent::served(step, served.clone()))?;
2693            watch.emit(RunEvent::new(
2694                run_id,
2695                step,
2696                EventKind::FellBackTo { provider: served },
2697            ));
2698        }
2699        let step_tokens = response.usage.map(|u| u.total_tokens).unwrap_or(0);
2700        tokens_used += step_tokens;
2701        // The provider's own number for the request `assemble` just built, beside
2702        // the estimate: the pair is what makes the estimator's drift auditable. A
2703        // silent provider leaves it null rather than recording a zero.
2704        if step_tokens > 0 {
2705            store.record_context_reported(run_id, step, step_tokens)?;
2706        }
2707
2708        // Dispatch every tool call the model made this step, in order, folding
2709        // each result into the observation log the next turn will see.
2710        let mut decisions: Vec<String> = Vec::new();
2711        let mut calls_json: Vec<String> = Vec::new();
2712        // Did this step move the workspace? Only a write that wrote something
2713        // different can, and it is the half of the stall signal that says the agent
2714        // is not merely repeating itself but achieving nothing.
2715        let mut step_changed = false;
2716        // 0.22.0 — a provider-executed search that broke reaches the model as an
2717        // observation, so it can retry or answer knowingly. Without it the model
2718        // sees an answer with no sources and concludes the web had nothing.
2719        if let Some(note) = web_failure_note(&response) {
2720            ledger.push(Observation::new(
2721                step,
2722                ObsKind::Message,
2723                None,
2724                bound(
2725                    &format!("\n[step {step}] provider web tool: {note}\n"),
2726                    entry_cap,
2727                    ObsKind::Message,
2728                ),
2729            ));
2730        }
2731        if response.tool_calls.is_empty() {
2732            let said = response.text.clone().unwrap_or_default();
2733            ledger.push(Observation::new(
2734                step,
2735                ObsKind::Message,
2736                None,
2737                bound(
2738                    &format!("\n[step {step}] {NO_TOOL_CALL} {said}\n"),
2739                    entry_cap,
2740                    ObsKind::Message,
2741                ),
2742            ));
2743            decisions.push("no tool call".into());
2744        }
2745        let mut paused: Option<i64> = None;
2746        // 0.21.0 — the other reason a step can stop short: a question nobody here
2747        // would answer. Kept separate from `paused` so the two pauses cannot be
2748        // confused for one another in the outcome.
2749        let mut asked: Option<i64> = None;
2750        let mut new_rules: Vec<Rule> = Vec::new();
2751        for call in &response.tool_calls {
2752            calls_json.push(format!("{}:{}", call.name, call.arguments));
2753            match dispatch(
2754                &ws,
2755                call,
2756                approver,
2757                responder_of(contract),
2758                store,
2759                run_id,
2760                step,
2761                mcp,
2762                &contract.tools,
2763                skills,
2764                entry_cap,
2765                &mem_key,
2766                watch,
2767                0,
2768                pending_media,
2769                &contract.commit_identity,
2770                contract.exec_timeout,
2771            )
2772            .await?
2773            {
2774                Dispatched::Continue {
2775                    decision,
2776                    obs,
2777                    kind,
2778                    target,
2779                    changed,
2780                    remember,
2781                } => {
2782                    step_changed |= changed;
2783                    ledger.push(Observation::new(step, kind, target, obs));
2784                    decisions.push(decision);
2785                    new_rules.extend(remember);
2786                }
2787                Dispatched::Pause { request_id } => {
2788                    decisions.push(format!("awaiting approval (request {request_id})"));
2789                    paused = Some(request_id);
2790                    break;
2791                }
2792                Dispatched::Ask { question_id } => {
2793                    decisions.push(format!("awaiting answer (question {question_id})"));
2794                    asked = Some(question_id);
2795                    break;
2796                }
2797            }
2798        }
2799
2800        // The trace gets this step's observations unelided, so concatenating the
2801        // rows in step order reproduces the whole log: bounding what the model
2802        // sees must not bound what an operator can audit. A delta rather than the
2803        // whole log per row, so the trace is linear in the step count and a
2804        // 24-hour run does not write the same text hundreds of times.
2805        // ponytail: each row repeats the whole log, so the column grows with the
2806        // square of the step count. Bounded in practice by the step budget times
2807        // the entry cap; write per-step deltas if a long run's store size matters.
2808        //
2809        // The assembly stats this line used to log (`carried`, `stubbed`,
2810        // `est_tokens`) are not lost with the loop's own `info!`: `assemble`
2811        // records them as the step's `"assembled"` context event, which is where a
2812        // reader could already find them.
2813        commit_step(
2814            store,
2815            watch,
2816            run_id,
2817            0,
2818            StepRecord::new(step, decisions.join("; "), ledger.text_for_step(step)).with_trace(
2819                user,
2820                calls_json.join(" | "),
2821                step_tokens,
2822            ),
2823            step_changed,
2824            true,
2825        )?;
2826        // The step is committed, so the observations behind it are safe to make
2827        // durable. After the commit rather than before: a ledger that ran ahead of
2828        // the trace would restore observations for a step the run never took.
2829        written = persist_ledger(store, run_id, &ledger, written)?;
2830
2831        // Did that step get anywhere? A stall needs both halves — nothing changed
2832        // in the workspace AND a tool call this window already saw — because a
2833        // legitimate exploration phase changes nothing either, and flagging that
2834        // would degrade healthy runs to add resilience.
2835        let signature = calls_json.join(" | ");
2836        match progress.step(contract.stall, step_changed, &signature) {
2837            Progressing::Fine => {}
2838            Progressing::Replan => {
2839                store.record_context_event(
2840                    run_id,
2841                    &ContextEvent::replan(
2842                        step,
2843                        format!(
2844                            "{} steps without progress; replanning",
2845                            contract.stall.window
2846                        ),
2847                    ),
2848                )?;
2849                // The directive is an observation like any other, so it is bounded
2850                // by the same budget and, carrying no target, can never be
2851                // superseded away.
2852                ledger.push(Observation::new(
2853                    step,
2854                    ObsKind::Message,
2855                    None,
2856                    bound(
2857                        &progress.replan_directive(contract.stall.window, &decisions),
2858                        entry_cap,
2859                        ObsKind::Message,
2860                    ),
2861                ));
2862                info!(run_id, step, "agent told to change approach");
2863                watch.emit(RunEvent::new(
2864                    run_id,
2865                    step,
2866                    EventKind::Replan {
2867                        window: contract.stall.window,
2868                    },
2869                ));
2870            }
2871            Progressing::Stalled => {
2872                store.record_context_event(
2873                    run_id,
2874                    &ContextEvent::stalled(step, "still no progress after replanning"),
2875                )?;
2876                info!(run_id, step, "run stopped: stalled");
2877                watch.emit(RunEvent::new(run_id, step, EventKind::Stalled));
2878                finish(store, watch, run_id, 0, step, "stalled")?;
2879                return Ok(RunResult::new(RunOutcome::Stalled { steps: step }, run_id)
2880                    .with_remembered(remembered));
2881            }
2882        }
2883
2884        // An approver deferred: persist nothing further, stop, and let the
2885        // caller resume once a human has decided.
2886        if let Some(question_id) = asked {
2887            finish(store, watch, run_id, 0, step, "awaiting_answer")?;
2888            return Ok(RunResult::new(
2889                RunOutcome::AwaitingAnswer {
2890                    question_id,
2891                    steps: step,
2892                },
2893                run_id,
2894            )
2895            .with_remembered(remembered));
2896        }
2897
2898        if let Some(request_id) = paused {
2899            finish(store, watch, run_id, 0, step, "awaiting_approval")?;
2900            return Ok(RunResult::new(
2901                RunOutcome::AwaitingApproval {
2902                    request_id,
2903                    steps: step,
2904                },
2905                run_id,
2906            )
2907            .with_remembered(remembered));
2908        }
2909
2910        // Rules an approver asked to remember apply as a top layer for the rest
2911        // of the run. Merging (rather than editing) is what keeps a remembered
2912        // allow from overriding a deny beneath it.
2913        if !new_rules.is_empty() {
2914            let mut layer = Policy::permissive().layer("remembered");
2915            for r in &new_rules {
2916                layer = layer.rule(r.act, r.effect, r.pattern.clone());
2917            }
2918            effective = effective.merge(layer);
2919            ws = Workspace::with_policy(root, effective.clone());
2920            remembered.extend(new_rules);
2921        }
2922
2923        if let Some(max) = contract.max_tokens {
2924            if tokens_used > max {
2925                finish(store, watch, run_id, 0, step, "cost_budget_exceeded")?;
2926                return Ok(
2927                    RunResult::new(RunOutcome::CostBudgetExceeded { steps: step }, run_id)
2928                        .with_remembered(remembered),
2929                );
2930            }
2931        }
2932
2933        // A run with no criterion ends when the agent stops calling tools. Checked
2934        // after the budgets, so a step that also crossed a ceiling reports the
2935        // ceiling, and before the gate, which for this variant can never pass.
2936        if finished(contract, &response) {
2937            finish(store, watch, run_id, 0, step, "finished")?;
2938            return Ok(RunResult::new(RunOutcome::Finished { steps: step }, run_id)
2939                .with_remembered(remembered));
2940        }
2941
2942        if contract
2943            .verify
2944            .passes_in_guarded(
2945                root,
2946                &ExecGuard::new(&effective)
2947                    .tracing(store, run_id, step)
2948                    .watching(watch, 0),
2949            )
2950            .await?
2951        {
2952            finish(store, watch, run_id, 0, step, "success")?;
2953            return Ok(RunResult::new(RunOutcome::Success { steps: step }, run_id)
2954                .with_remembered(remembered));
2955        }
2956    }
2957
2958    finish(
2959        store,
2960        watch,
2961        run_id,
2962        0,
2963        contract.max_steps,
2964        "step_cap_reached",
2965    )?;
2966    Ok(RunResult::new(
2967        RunOutcome::StepCapReached {
2968            steps: contract.max_steps,
2969        },
2970        run_id,
2971    ))
2972}
2973
2974/// Shared context for one agent tree: everything every agent in the tree
2975/// draws on — the provider, the store, the one approver, the shared spend
2976/// ledger, the containment caps, and the workspace root.
2977struct Tree<'a, P: Provider> {
2978    /// One MCP session for the whole tree. A server is a stateful process, so
2979    /// 100 concurrent agents get 100 views of one connection, not 100 of their
2980    /// own — the same reason the ledger and the store are shared here.
2981    mcp: &'a McpSession,
2982    /// The caller's registered tools, shared by the whole tree. A child is
2983    /// offered exactly what its parent was: inheritance grants the tool, and the
2984    /// child's own narrowed policy still decides each call. Carried here rather
2985    /// than read from each agent's contract so a spawned child — whose contract
2986    /// the *model* writes — cannot register a tool its parent never had.
2987    tools: &'a Toolbox,
2988    /// The catalogue discovered from the ROOT contract, shared by the whole tree
2989    /// for the same reason `tools` is: a child contract the *model* wrote must
2990    /// not be able to conjure skills its parent was never offered.
2991    skills: &'a Skills,
2992    /// The roster a spawn may name (0.21.0). Shared tree-wide for the same reason
2993    /// `skills` is: a definition is the operator's, so a child cannot conjure one
2994    /// its parent's contract never registered.
2995    agents: &'a Agents,
2996    provider: &'a P,
2997    store: &'a Store,
2998    approver: &'a dyn Approver,
2999    /// Who answers a question about intent, for the whole tree (0.21.0) — one
3000    /// responder, exactly as there is one approver.
3001    responder: &'a dyn Responder,
3002    /// One observer for the whole tree, exactly as there is one approver: every
3003    /// event carries the agent's own `run_id` and `depth`, so a consumer routes on
3004    /// those rather than being handed an observer per child. It also carries the
3005    /// tree's single cancellation flag, so a `Flow::Cancel` from any agent's event
3006    /// stops the tree at the next boundary rather than only that agent.
3007    watch: &'a Watch<'a>,
3008    ledger: Arc<Ledger>,
3009    containment: &'a Containment,
3010    root: PathBuf,
3011    /// The tree root's run id, so `Containment::max_total_duration` can be
3012    /// measured against when the TREE started rather than when this agent did.
3013    /// A child spawned twenty hours into a run has its own young `started_at`;
3014    /// the ceiling is about the whole tree, so the root's stamp is the only
3015    /// correct clock. Held here because [`Ledger`] has no store access.
3016    root_run_id: i64,
3017    /// The ROOT contract's web declaration (0.22.0), shared tree-wide for exactly
3018    /// the reason `tools`, `skills` and `agents` are: it is the operator's, and a
3019    /// child contract the *model* writes must not be able to conjure web access
3020    /// its parent was never given — nor to lose the one its parent had, which
3021    /// would leave a sub-agent answering from memory on a task that needs the
3022    /// current answer.
3023    web: Option<crate::web::WebAccess>,
3024}
3025
3026/// Run a workspace contract as the root of an agent tree under `containment`.
3027///
3028/// The root agent runs the workspace loop with one extra tool, [`SPAWN_TOOL`],
3029/// which launches a contained sub-agent. A child inherits the parent policy and
3030/// can only narrow it ([`Policy::contain`]); the whole tree draws its token
3031/// spend from one shared ledger no child contract can raise; and every spawn,
3032/// refusal, and budget draw is recorded so the tree is a reconstructable graph.
3033///
3034/// Sub-agents are opt-in: this is the only entry point that offers the spawn
3035/// tool. [`run_with`] and [`run`] are unchanged and never expose it.
3036///
3037/// Reach for it when a task decomposes into parts that do not have to share one
3038/// agent's context — and note that the [`Containment`], not the contract, is
3039/// what actually bounds the result:
3040///
3041/// ```no_run
3042/// use io_harness::{run_tree, Containment, OpenRouter, Policy, StdinApprover, Store,
3043///                  TaskContract, Verification};
3044/// use std::time::Duration;
3045///
3046/// # async fn demo() -> io_harness::Result<()> {
3047/// let contract = TaskContract::workspace(
3048///     "document every public module under docs/, one file per module",
3049///     "/path/to/repo",
3050///     Verification::WorkspaceFileContains { file: "docs/index.md".into(), needle: "##".into() },
3051/// );
3052///
3053/// // The root's boundary, and therefore the ceiling for the entire tree: a child
3054/// // inherits it through `Policy::contain` and may only narrow it, so no
3055/// // descendant at any depth can write outside docs/ however its goal is worded.
3056/// let policy = Policy::default()
3057///     .layer("app")
3058///     .allow_read("*")
3059///     .allow_write("docs/*");
3060///
3061/// // The spend ceiling belongs here rather than on the contract, because a
3062/// // spawned child's contract is written by the *model* — anything it could set
3063/// // is something it could raise.
3064/// let containment = Containment {
3065///     max_total_agents: 12,
3066///     max_concurrent: 4,   // the fan-out bound
3067///     max_depth: 2,
3068///     max_total_tokens: 500_000, // drawn down by the whole tree together
3069///     max_total_cost: None,      // reserved and inert; bound money in tokens
3070///     max_total_duration: Some(Duration::from_secs(3600)),
3071/// };
3072///
3073/// let result = run_tree(
3074///     &contract, &OpenRouter::from_env()?, &Store::open("runs.db")?, &policy,
3075///     &StdinApprover, &containment,
3076/// )
3077/// .await?;
3078/// # Ok(()) }
3079/// ```
3080///
3081/// Every spawn, refusal and budget draw is recorded against the tree, so
3082/// `Store::agent_events` reconstructs who spawned whom and what each drew long
3083/// after the process exited.
3084pub async fn run_tree<P: Provider>(
3085    contract: &TaskContract,
3086    provider: &P,
3087    store: &Store,
3088    policy: &Policy,
3089    approver: &dyn Approver,
3090    containment: &Containment,
3091) -> Result<RunResult> {
3092    run_tree_observed(
3093        contract,
3094        provider,
3095        store,
3096        policy,
3097        approver,
3098        containment,
3099        &Ignore,
3100    )
3101    .await
3102}
3103
3104/// [`run_tree`], reporting to `observer` as it happens. See [`run_observed`].
3105///
3106/// One observer watches the whole tree: a child's events carry that child's own
3107/// `run_id` and its non-zero `depth`.
3108///
3109/// A tree is where an observer stops being a nicety. Children run concurrently
3110/// and their output interleaves, so `depth` and `run_id` are what turn a stream
3111/// of events back into a shape a person can read:
3112///
3113/// ```no_run
3114/// use io_harness::{run_tree_observed, Containment, EventKind, Flow, Observer, OpenRouter,
3115///                  Policy, RunEvent, StdinApprover, Store, TaskContract};
3116///
3117/// /// Indents by depth, so concurrent children are legible rather than interleaved.
3118/// struct TreeLog;
3119///
3120/// impl Observer for TreeLog {
3121///     fn event(&self, event: &RunEvent) -> Flow {
3122///         let pad = "  ".repeat(event.depth as usize);
3123///         match &event.kind {
3124///             EventKind::Spawned { child_run_id, goal } => {
3125///                 println!("{pad}+ run {child_run_id}: {goal}");
3126///             }
3127///             // Containment refused the spawn — the tree hit `max_total_agents`,
3128///             // `max_depth` or `max_concurrent`. The parent adapts; nothing fails.
3129///             EventKind::SpawnRefused { cap } => println!("{pad}! spawn refused: {cap} cap"),
3130///             // What the tree has left of its ONE shared ceiling, after this draw.
3131///             EventKind::SpendDraw { remaining, .. } => {
3132///                 println!("{pad}  budget left: {remaining:?}");
3133///             }
3134///             EventKind::Step { decision, .. } => println!("{pad}  {decision}"),
3135///             _ => {}
3136///         }
3137///         Flow::Continue
3138///     }
3139/// }
3140///
3141/// # async fn demo(contract: &TaskContract, policy: &Policy) -> io_harness::Result<()> {
3142/// run_tree_observed(
3143///     contract, &OpenRouter::from_env()?, &Store::memory()?, policy, &StdinApprover,
3144///     &Containment::new(12, 4, 2, 500_000), &TreeLog,
3145/// )
3146/// .await?;
3147/// # Ok(()) }
3148/// ```
3149///
3150/// Events arrive on the run's own task and children share it, so a slow observer
3151/// slows every agent in the tree, not just one.
3152#[allow(clippy::too_many_arguments)]
3153pub async fn run_tree_observed<P: Provider>(
3154    contract: &TaskContract,
3155    provider: &P,
3156    store: &Store,
3157    policy: &Policy,
3158    approver: &dyn Approver,
3159    containment: &Containment,
3160    observer: &dyn Observer,
3161) -> Result<RunResult> {
3162    contract.tools.validate()?;
3163    let skills = contract.discover_skills()?;
3164    let root = contract.root.clone().ok_or_else(|| {
3165        crate::error::Error::Config(
3166            "run_tree needs a workspace contract — build it with TaskContract::workspace".into(),
3167        )
3168    })?;
3169    let ledger = Arc::new(Ledger::new(containment));
3170    let run_id = store.start_run(&contract.goal, &root.display().to_string())?;
3171    store.set_provider(run_id, provider.name())?;
3172    // As in [`run_with_observed`]: the caller's policy, recorded before the
3173    // provider layer is merged in. A tree's own resume already takes a policy,
3174    // so this is for the audit rather than for a gate.
3175    store.record_run_policy(run_id, policy)?;
3176    let watch = &Watch::new(observer);
3177    watch.emit(RunEvent::new(
3178        run_id,
3179        0,
3180        EventKind::Started {
3181            goal: contract.goal.clone(),
3182            provider: provider.name().to_string(),
3183        },
3184    ));
3185    // Authorized once at the root. Children inherit the root's policy through
3186    // `Policy::contain`, so the provider layer flows down the tree and no child
3187    // needs (or gets) its own chance to widen network access.
3188    let policy = &match authorize_provider(provider, policy, store, run_id, approver, watch).await?
3189    {
3190        ProviderAccess::Granted(p) => p,
3191        ProviderAccess::Pending(request_id) => {
3192            return Ok(RunResult::new(
3193                RunOutcome::AwaitingApproval {
3194                    request_id,
3195                    steps: 0,
3196                },
3197                run_id,
3198            ))
3199        }
3200    };
3201    let mcp = McpSession::connect(&contract.mcp, policy, store, run_id, watch).await?;
3202    let tree = Tree {
3203        mcp: &mcp,
3204        tools: &contract.tools,
3205        skills: &skills,
3206        agents: &contract.agents,
3207        provider,
3208        store,
3209        approver,
3210        responder: responder_of(contract),
3211        watch,
3212        ledger,
3213        containment,
3214        root,
3215        root_run_id: run_id,
3216        web: contract.web.clone(),
3217    };
3218    let outcome = run_agent(&tree, contract, run_id, 0, policy, 1, None).await;
3219    mcp.shutdown(store, run_id, watch).await;
3220    Ok(RunResult::new(outcome?, run_id))
3221}
3222
3223/// Resume a crashed agent tree under its original root `run_id`. Reconstructs
3224/// the whole 0.5.0 tree from the store: the shared spend ledger is restored from
3225/// the tree's durable total spend and agent count (so the resumed tree draws
3226/// against one continuous ceiling, never a reset one), and the root agent
3227/// resumes from its last committed step. As it replays its crashed step it
3228/// adopts the children it had already spawned and resumes each from that child's
3229/// own checkpoint (see `spawn_child`), so every agent in the tree continues
3230/// where it stopped rather than restarting.
3231///
3232/// Additive to [`run_tree`], mirroring how [`resume_with`] complements
3233/// [`run_with`]. Takes the policy and the approver, so a tree's boundary was
3234/// never at risk of being dropped across a resume the way the flat workspace
3235/// loop's was; since 0.13.0 every agent in the tree also restores its own
3236/// observation ledger.
3237///
3238/// One call, whole tree — you never resume a child yourself, and the containment
3239/// you pass is what the restored ledger is measured against:
3240///
3241/// ```no_run
3242/// use io_harness::{resume_tree, Containment, OpenRouter, Policy, StdinApprover, Store,
3243///                  TaskContract};
3244///
3245/// # async fn after_crash(contract: &TaskContract, policy: &Policy, root_run_id: i64)
3246/// #     -> io_harness::Result<()> {
3247/// // The SAME ceiling the tree started under. The ledger is rebuilt from the
3248/// // tree's durable total spend, so a tree that had already used 400k of 500k
3249/// // resumes with 100k left — pass a fresh, larger number and you have raised the
3250/// // ceiling, not restored it.
3251/// let containment = Containment::new(12, 4, 2, 500_000);
3252///
3253/// // The root's run id. Children are re-adopted from the store as the root
3254/// // replays its crashed step, each continuing from its own checkpoint.
3255/// let result = resume_tree(
3256///     contract, &OpenRouter::from_env()?, &Store::open("runs.db")?, root_run_id, policy,
3257///     &StdinApprover, &containment,
3258/// )
3259/// .await?;
3260/// println!("{:?}", result.outcome);
3261/// # Ok(()) }
3262/// ```
3263///
3264/// One caveat worth knowing before you choose this over
3265/// [`resume_tree_from_stored_policy`]: `run_tree` and the two flat loops record
3266/// the caller's policy against the run, and this function does not — so a tree
3267/// resumed here under a widened policy leaves an audit that understates what was
3268/// permitted.
3269#[allow(clippy::too_many_arguments)]
3270pub async fn resume_tree<P: Provider>(
3271    contract: &TaskContract,
3272    provider: &P,
3273    store: &Store,
3274    run_id: i64,
3275    policy: &Policy,
3276    approver: &dyn Approver,
3277    containment: &Containment,
3278) -> Result<RunResult> {
3279    resume_tree_observed(
3280        contract,
3281        provider,
3282        store,
3283        run_id,
3284        policy,
3285        approver,
3286        containment,
3287        &Ignore,
3288    )
3289    .await
3290}
3291
3292/// [`resume_tree`], reporting to `observer` as it happens. See [`run_observed`].
3293///
3294/// What this shows that a fresh [`run_tree_observed`] cannot: how much of the
3295/// tree was already done. Adopted children emit nothing for the steps they
3296/// committed before the crash, so the events that do arrive are exactly the work
3297/// this process is driving.
3298///
3299/// ```no_run
3300/// use io_harness::{resume_tree_observed, ApproveAll, Containment, EventKind, Flow, Observer,
3301///                  OpenRouter, Policy, RunEvent, Store, TaskContract};
3302/// use std::collections::BTreeSet;
3303/// use std::sync::Mutex;
3304///
3305/// /// Which agents in the tree still had work left after the restart.
3306/// #[derive(Default)]
3307/// struct StillWorking(Mutex<BTreeSet<i64>>);
3308///
3309/// impl Observer for StillWorking {
3310///     fn event(&self, event: &RunEvent) -> Flow {
3311///         if matches!(event.kind, EventKind::Step { .. }) {
3312///             self.0.lock().unwrap().insert(event.run_id);
3313///         }
3314///         Flow::Continue
3315///     }
3316/// }
3317///
3318/// # async fn demo(contract: &TaskContract, policy: &Policy, root_run_id: i64)
3319/// #     -> io_harness::Result<()> {
3320/// let working = StillWorking::default();
3321/// resume_tree_observed(
3322///     contract, &OpenRouter::from_env()?, &Store::open("runs.db")?, root_run_id, policy,
3323///     &ApproveAll, &Containment::new(12, 4, 2, 500_000), &working,
3324/// )
3325/// .await?;
3326/// println!("{:?} had steps left", working.0.lock().unwrap());
3327/// # Ok(()) }
3328/// ```
3329#[allow(clippy::too_many_arguments)]
3330pub async fn resume_tree_observed<P: Provider>(
3331    contract: &TaskContract,
3332    provider: &P,
3333    store: &Store,
3334    run_id: i64,
3335    policy: &Policy,
3336    approver: &dyn Approver,
3337    containment: &Containment,
3338    observer: &dyn Observer,
3339) -> Result<RunResult> {
3340    contract.tools.validate()?;
3341    let skills = contract.discover_skills()?;
3342    store.check_resumable(run_id)?;
3343
3344    // A finished tree is returned as-is — resume is idempotent for the whole tree.
3345    if store.run_status(run_id)? == Some(RunStatus::Completed) {
3346        if let Some(o) = terminal_outcome(store, run_id)? {
3347            return Ok(RunResult::new(o, run_id));
3348        }
3349    }
3350
3351    let root = contract.root.clone().ok_or_else(|| {
3352        crate::error::Error::Config(
3353            "resume_tree needs a workspace contract — build it with TaskContract::workspace".into(),
3354        )
3355    })?;
3356
3357    // Restore the shared ledger from durable tree-wide totals, so the budget is
3358    // continuous across the crash rather than reset to zero.
3359    let ledger = Arc::new(Ledger::from_state(
3360        containment,
3361        store.spent_tokens_tree(run_id)?,
3362        store.agent_count_tree(run_id)?,
3363    ));
3364    let start_step = record_resume_markers(store, run_id)?;
3365    store.set_provider(run_id, provider.name())?;
3366    let watch = &Watch::new(observer);
3367    watch.emit(RunEvent::new(
3368        run_id,
3369        start_step.saturating_sub(1),
3370        EventKind::Started {
3371            goal: contract.goal.clone(),
3372            provider: provider.name().to_string(),
3373        },
3374    ));
3375    // Re-authorized on resume rather than trusted from the crashed run: the
3376    // policy handed to the resume is the one that governs it, and a host allowed
3377    // before a crash may not be allowed after.
3378    let policy = &match authorize_provider(provider, policy, store, run_id, approver, watch).await?
3379    {
3380        ProviderAccess::Granted(p) => p,
3381        ProviderAccess::Pending(request_id) => {
3382            return Ok(RunResult::new(
3383                RunOutcome::AwaitingApproval {
3384                    request_id,
3385                    steps: start_step.saturating_sub(1),
3386                },
3387                run_id,
3388            ))
3389        }
3390    };
3391    let mcp = McpSession::connect(&contract.mcp, policy, store, run_id, watch).await?;
3392    let tree = Tree {
3393        mcp: &mcp,
3394        tools: &contract.tools,
3395        skills: &skills,
3396        agents: &contract.agents,
3397        provider,
3398        store,
3399        approver,
3400        responder: responder_of(contract),
3401        watch,
3402        ledger,
3403        containment,
3404        root,
3405        root_run_id: run_id,
3406        web: contract.web.clone(),
3407    };
3408    let outcome = run_agent(&tree, contract, run_id, 0, policy, start_step, None).await;
3409    mcp.shutdown(store, run_id, watch).await;
3410    Ok(RunResult::new(outcome?, run_id))
3411}
3412
3413/// Resume a crashed agent tree under the policy it was started with, read back
3414/// from the store — [`resume_from_stored_policy`] for the 0.5.0 tree loop.
3415///
3416/// [`resume_tree`] takes a policy, so a tree's boundary was never at risk of
3417/// being silently dropped the way the flat workspace loop's was. But the caller
3418/// still had to have one to hand, and a process that comes up after a crash in
3419/// another process may have nothing to reconstruct it from. The policy has been
3420/// durable since 0.13.0 and the single-file and workspace loops have resumed from
3421/// it since then; the tree loop had no such entry point until 0.16.0, so the
3422/// three resume paths disagreed about whether a restart preserves the boundary —
3423/// a contradiction a release documenting the public contract would otherwise have
3424/// had to write down as a caveat.
3425///
3426/// Fails with [`Error::Resume`] when the store holds no policy for the run,
3427/// rather than substituting a permissive one. That substitution is the exact
3428/// defect 0.13.0 closed in the other two loops, and it is sharper for a tree:
3429/// every child inherits the root's policy through [`Policy::contain`], so a
3430/// guessed-at root boundary is guessed at for the whole tree, which may already
3431/// have taken an irreversible action under the real one.
3432///
3433/// Prefer it to [`resume_tree`] whenever the boundary matters, and not only
3434/// because you might get the policy wrong: it is also the only tree resume that
3435/// leaves an accurate audit. `resume_tree` does not call `record_run_policy`, so
3436/// a tree resumed there under a different policy keeps reporting the one it
3437/// started with; this one reads that row back rather than writing over it.
3438///
3439/// ```no_run
3440/// use io_harness::{resume_tree_from_stored_policy, Containment, DenyAll, Error, OpenRouter,
3441///                  Store, TaskContract};
3442///
3443/// # async fn supervisor(contract: &TaskContract, root_run_id: i64) -> io_harness::Result<()> {
3444/// // No policy argument. A process that comes up after a crash in another
3445/// // process has nothing to reconstruct one from, and guessing here would guess
3446/// // for every agent in the tree at once.
3447/// let resumed = resume_tree_from_stored_policy(
3448///     contract, &OpenRouter::from_env()?, &Store::open("runs.db")?, root_run_id, &DenyAll,
3449///     &Containment::new(12, 4, 2, 500_000),
3450/// )
3451/// .await;
3452///
3453/// match resumed {
3454///     Ok(result) => println!("{:?}", result.outcome),
3455///     // No recorded policy — a tree checkpointed by 0.12.0 or earlier. It stays
3456///     // stopped rather than resuming unbounded; a human names the boundary and
3457///     // uses `resume_tree`.
3458///     Err(Error::Resume { reason }) => eprintln!("cannot recover the boundary: {reason}"),
3459///     Err(e) => return Err(e),
3460/// }
3461/// # Ok(()) }
3462/// ```
3463pub async fn resume_tree_from_stored_policy<P: Provider>(
3464    contract: &TaskContract,
3465    provider: &P,
3466    store: &Store,
3467    run_id: i64,
3468    approver: &dyn Approver,
3469    containment: &Containment,
3470) -> Result<RunResult> {
3471    resume_tree_from_stored_policy_observed(
3472        contract,
3473        provider,
3474        store,
3475        run_id,
3476        approver,
3477        containment,
3478        &Ignore,
3479    )
3480    .await
3481}
3482
3483/// [`resume_tree_from_stored_policy`], reporting to `observer` as it happens. See
3484/// [`run_observed`].
3485///
3486/// The combination an unattended supervisor actually wants: recover the boundary
3487/// from the store, resume the whole tree, and keep a live handle on it — because
3488/// a tree resumed by a process nobody is watching should still be stoppable.
3489///
3490/// ```no_run
3491/// use io_harness::{resume_tree_from_stored_policy_observed, Containment, DenyAll, EventKind,
3492///                  Flow, Observer, OpenRouter, RunEvent, Store, TaskContract};
3493/// use std::sync::atomic::{AtomicBool, Ordering};
3494///
3495/// /// Logs the recovered boundary doing its job, and stops the tree on request.
3496/// struct Supervised { stop: AtomicBool }
3497///
3498/// impl Observer for Supervised {
3499///     fn event(&self, event: &RunEvent) -> Flow {
3500///         if let EventKind::Refused { act, target, layer, .. } = &event.kind {
3501///             println!("agent {} refused {act} {target} ({})",
3502///                      event.run_id, layer.as_deref().unwrap_or("tier default"));
3503///         }
3504///         // One flag for the whole tree: cancelling from any agent's event stops
3505///         // every agent at its next step boundary, and the tree stays resumable.
3506///         if self.stop.load(Ordering::Relaxed) { Flow::Cancel } else { Flow::Continue }
3507///     }
3508/// }
3509///
3510/// # async fn demo(contract: &TaskContract, root_run_id: i64) -> io_harness::Result<()> {
3511/// let supervised = Supervised { stop: AtomicBool::new(false) };
3512/// resume_tree_from_stored_policy_observed(
3513///     contract, &OpenRouter::from_env()?, &Store::open("runs.db")?, root_run_id, &DenyAll,
3514///     &Containment::new(12, 4, 2, 500_000), &supervised,
3515/// )
3516/// .await?;
3517/// # Ok(()) }
3518/// ```
3519#[allow(clippy::too_many_arguments)]
3520pub async fn resume_tree_from_stored_policy_observed<P: Provider>(
3521    contract: &TaskContract,
3522    provider: &P,
3523    store: &Store,
3524    run_id: i64,
3525    approver: &dyn Approver,
3526    containment: &Containment,
3527    observer: &dyn Observer,
3528) -> Result<RunResult> {
3529    let Some(policy) = store.run_policy(run_id)? else {
3530        return Err(Error::Resume {
3531            reason: format!(
3532                "tree {run_id} has no recorded policy, so the boundary it ran under cannot be \
3533                 recovered; pass one explicitly with `resume_tree` if you know what it was"
3534            ),
3535        });
3536    };
3537    resume_tree_observed(
3538        contract,
3539        provider,
3540        store,
3541        run_id,
3542        &policy,
3543        approver,
3544        containment,
3545        observer,
3546    )
3547    .await
3548}
3549
3550/// One agent's loop, reused for the root and every child. Identical to the
3551/// workspace loop, plus: it may spawn children (recursively, via [`SPAWN_TOOL`]),
3552/// and its token spend is drawn from the tree's shared ledger rather than only
3553/// its own contract budget.
3554///
3555/// `depth` is 0 at the root; a child's depth is its parent's + 1. Returns the
3556/// agent's [`RunOutcome`]; a tree-wide budget halt propagates up as
3557/// [`RunOutcome::BudgetCeilingReached`].
3558#[allow(clippy::too_many_arguments)]
3559fn run_agent<'f, P: Provider>(
3560    tree: &'f Tree<'_, P>,
3561    contract: &'f TaskContract,
3562    run_id: i64,
3563    depth: u32,
3564    policy: &'f Policy,
3565    start_step: u32,
3566    // 0.21.0 — the definition this agent was spawned from, if it was spawned from
3567    // one. Crate-internal rather than a `TaskContract` field: a run-level model
3568    // override is a separate decision about whether a conversation may change models
3569    // mid-thread, and 0.20.0 deliberately left that shut.
3570    identity: Option<&'f AgentDef>,
3571) -> Pin<Box<dyn Future<Output = Result<RunOutcome>> + 'f>> {
3572    // Boxed so the loop can recurse into itself when an agent spawns a child.
3573    Box::pin(async move {
3574        let ws = Workspace::with_policy(&tree.root, policy.clone());
3575        // The tree shares one MCP session, so every agent in it — root or child —
3576        // is offered the same server tools beside its built-ins. Connecting a
3577        // session and then not offering its tools would leave the model unable to
3578        // call something the run had already paid to set up.
3579        let mut extra = tree.tools.specs();
3580        extra.extend(tree.mcp.tool_specs());
3581        extra.extend(skill_tool(tree.skills));
3582        let system =
3583            with_skill_catalog(with_extra_tools(tree_system_prompt(), &extra), tree.skills);
3584        // A role is PREPENDED, never a replacement: the tree prompt is what tells an
3585        // agent how to use its tools and that its result composes back into its
3586        // parent, and a role that replaced it would produce an agent that did not
3587        // know how to be one.
3588        let system = match identity.and_then(|d| d.role.as_deref()) {
3589            Some(role) => format!("{}\n\n{system}", role.trim()),
3590            None => system,
3591        };
3592        let mut tools = tree_tools(tree.agents);
3593        tools.extend(extra);
3594        // The budget this agent runs under is the smaller of what its contract
3595        // asked for and what the tree has left — a contract cannot raise it.
3596        let token_cap = tree.ledger.effective_token_budget(contract.max_tokens);
3597        // Durable per-agent budget, restored across a restart.
3598        let mut tokens_used: u64 = tree.store.spent_tokens(run_id)?;
3599        // Same ledger and same per-turn assembly as the workspace loop: a tree of
3600        // 100 children each re-sending its own unbounded log is the multiplied
3601        // version of the problem 0.10.0 exists to fix — and, since 0.13.0, the
3602        // same restore, keyed on this agent's own run id. A child that is resumed
3603        // is the same child, at whatever depth it sits.
3604        let (mut ledger, mut written) = restore_ledger(tree.store, run_id)?;
3605        let mut progress = Progress::new();
3606        // Children share their parent's workspace, so they share its detection too.
3607        let toolchain = crate::toolchain::detect(&tree.root);
3608        // Children share their parent's workspace, so they share its memory: one
3609        // note store per workspace, every entry attributed to the run that wrote it.
3610        let mem_key = memory_key(&tree.root);
3611        // See the workspace loop: viewed images ride one step and are dropped.
3612        let pending_media = &mut PendingMedia::default();
3613
3614        for step in start_step..=contract.max_steps {
3615            // The step boundary, where a cancellation is honoured (see `cancelled`).
3616            // One flag for the whole tree, so a cancel asked for while a sibling was
3617            // mid-flight stops this agent too.
3618            if let Some(o) = cancelled(tree.store, tree.watch, run_id, depth, step - 1)? {
3619                return Ok(o);
3620            }
3621            if let Some(max) = contract.max_duration {
3622                if tree.store.elapsed_secs(run_id)? > max.as_secs_f64() {
3623                    finish(
3624                        tree.store,
3625                        tree.watch,
3626                        run_id,
3627                        depth,
3628                        step - 1,
3629                        "time_budget_exceeded",
3630                    )?;
3631                    return Ok(RunOutcome::TimeBudgetExceeded { steps: step - 1 });
3632                }
3633            }
3634
3635            let budget_tokens = contract
3636                .context
3637                .effective_tokens(Some(token_cap.saturating_sub(tokens_used)));
3638            let entry_cap = entry_cap_chars(budget_tokens);
3639            let notes = tree.store.memory_list(&mem_key)?;
3640            let assembled = assemble(
3641                &ledger,
3642                budget_tokens,
3643                &notes,
3644                Assembly {
3645                    ws: Some(&ws),
3646                    policy,
3647                    store: tree.store,
3648                    run_id,
3649                    step,
3650                },
3651            )
3652            .await?;
3653            let user = workspace_user_prompt(contract, &assembled.text, toolchain.as_ref());
3654            #[allow(clippy::needless_update)] // `media` is cfg'd out in the default build
3655            let request = CompletionRequest {
3656                system: system.clone(),
3657                user: user.clone(),
3658                tools: tools.clone(),
3659                // 0.21.0 — a named agent's model. `None` for the root and for any
3660                // child spawned without a definition, which is what every provider
3661                // reads as "the model you were built with".
3662                model: identity.and_then(|d| d.model.clone()),
3663                // 0.22.0 — this agent's declaration, which for a child is the
3664                // root's, copied in by `spawn_child` rather than taken from the
3665                // spawn arguments.
3666                web: contract.web.clone(),
3667                #[cfg(feature = "media")]
3668                media: attach_media(contract, pending_media)?,
3669                ..Default::default()
3670            };
3671            let response = complete_with_retry(
3672                tree.provider,
3673                &request,
3674                contract,
3675                tree.store,
3676                run_id,
3677                step,
3678                tree.watch,
3679                depth,
3680                false,
3681            )
3682            .await?;
3683
3684            // Which provider answered, when that is not a foregone conclusion. A
3685            // `Fallback` that fell over served this step from its secondary, and a
3686            // trace reader has no other way to know.
3687            if let Some(served) = tree.provider.last_served() {
3688                tree.store
3689                    .record_context_event(run_id, &ContextEvent::served(step, served.clone()))?;
3690                tree.watch.emit(RunEvent::at_depth(
3691                    run_id,
3692                    step,
3693                    depth,
3694                    EventKind::FellBackTo { provider: served },
3695                ));
3696            }
3697            let step_tokens = response.usage.map(|u| u.total_tokens).unwrap_or(0);
3698            tokens_used += step_tokens;
3699            if step_tokens > 0 {
3700                tree.store
3701                    .record_context_reported(run_id, step, step_tokens)?;
3702            }
3703
3704            let mut decisions: Vec<String> = Vec::new();
3705            let mut calls_json: Vec<String> = Vec::new();
3706            let mut step_changed = false;
3707            // The same note as the flat loop: a broken provider-executed search is
3708            // an observation the child can act on, not silence.
3709            if let Some(note) = web_failure_note(&response) {
3710                ledger.push(Observation::new(
3711                    step,
3712                    ObsKind::Message,
3713                    None,
3714                    bound(
3715                        &format!("\n[step {step}] provider web tool: {note}\n"),
3716                        entry_cap,
3717                        ObsKind::Message,
3718                    ),
3719                ));
3720            }
3721            if response.tool_calls.is_empty() {
3722                let said = response.text.clone().unwrap_or_default();
3723                ledger.push(Observation::new(
3724                    step,
3725                    ObsKind::Message,
3726                    None,
3727                    bound(
3728                        &format!("\n[step {step}] {NO_TOOL_CALL} {said}\n"),
3729                        entry_cap,
3730                        ObsKind::Message,
3731                    ),
3732                ));
3733                decisions.push("no tool call".into());
3734            }
3735            // Non-spawn tools mutate the workspace and the observation log, so
3736            // they run in order. Spawn calls are independent sub-agents, so they
3737            // fan out concurrently, bounded by the tree's `max_concurrent`.
3738            let mut paused: Option<i64> = None;
3739            // 0.21.0 — the other reason a step can stop short: a question nobody here
3740            // would answer. Kept separate from `paused` so the two pauses cannot be
3741            // confused for one another in the outcome.
3742            let mut asked: Option<i64> = None;
3743            let mut paused_by_child = false;
3744            let mut spawn_calls: Vec<&ToolCall> = Vec::new();
3745            for call in &response.tool_calls {
3746                calls_json.push(format!("{}:{}", call.name, call.arguments));
3747                if call.name == SPAWN_TOOL {
3748                    spawn_calls.push(call);
3749                    continue;
3750                }
3751                match dispatch(
3752                    &ws,
3753                    call,
3754                    tree.approver,
3755                    tree.responder,
3756                    tree.store,
3757                    run_id,
3758                    step,
3759                    tree.mcp,
3760                    tree.tools,
3761                    tree.skills,
3762                    entry_cap,
3763                    &mem_key,
3764                    tree.watch,
3765                    depth,
3766                    pending_media,
3767                    &contract.commit_identity,
3768                    contract.exec_timeout,
3769                )
3770                .await?
3771                {
3772                    Dispatched::Continue {
3773                        decision,
3774                        obs,
3775                        kind,
3776                        target,
3777                        changed,
3778                        ..
3779                    } => {
3780                        step_changed |= changed;
3781                        ledger.push(Observation::new(step, kind, target, obs));
3782                        decisions.push(decision);
3783                    }
3784                    Dispatched::Pause { request_id } => {
3785                        decisions.push(format!("awaiting approval (request {request_id})"));
3786                        paused = Some(request_id);
3787                        break;
3788                    }
3789                    Dispatched::Ask { question_id } => {
3790                        decisions.push(format!("awaiting answer (question {question_id})"));
3791                        asked = Some(question_id);
3792                        break;
3793                    }
3794                }
3795            }
3796            if paused.is_none() && !spawn_calls.is_empty() {
3797                use futures_util::stream::{self, StreamExt};
3798                let max_c = tree.containment.max_concurrent.max(1) as usize;
3799                // `buffered`, not `buffer_unordered`: up to `max_c` children still
3800                // run at once, but their results are collected in the order the
3801                // model asked for them rather than the order they happen to finish.
3802                //
3803                // Until 0.12.0 this was `buffer_unordered`, which made a tree run
3804                // non-reproducible: the composed child observations and the
3805                // `decisions` list — both of which become the `steps.result` and
3806                // `steps.decision` columns — came back in completion order, so the
3807                // same task over the same workspace produced a different trace and
3808                // a different next prompt depending on which child won a race.
3809                // Deterministic replay cannot be built on that.
3810                //
3811                // The cost is that a child which finishes early has its result held
3812                // until the children before it are done. That is bounded by `max_c`
3813                // and changes when a result is *read*, never when the work runs.
3814                let results: Vec<Result<SpawnResult>> = stream::iter(
3815                    spawn_calls
3816                        .into_iter()
3817                        .map(|c| spawn_child(tree, c, run_id, depth, policy, step)),
3818                )
3819                .buffered(max_c)
3820                .collect()
3821                .await;
3822                for r in results {
3823                    match r? {
3824                        SpawnResult::Composed { decision, obs } => {
3825                            // A child's composed result is an observation like any
3826                            // other, and is bounded like any other.
3827                            ledger.push(Observation::new(
3828                                step,
3829                                ObsKind::Child,
3830                                None,
3831                                bound(&obs, entry_cap, ObsKind::Child),
3832                            ));
3833                            decisions.push(decision);
3834                            // A child that ran did work the parent did not have to.
3835                            // Whether it changed the workspace is the child's own
3836                            // stall problem, tracked in the child's own loop.
3837                            step_changed = true;
3838                        }
3839                        // A child deferred; pause the tree with its request_id.
3840                        SpawnResult::Paused { request_id } => {
3841                            decisions
3842                                .push(format!("child awaiting approval (request {request_id})"));
3843                            paused = Some(request_id);
3844                            paused_by_child = true;
3845                        }
3846                        // A child asked the operator something; pause the tree with its
3847                        // question_id, the same way.
3848                        SpawnResult::Asked { question_id } => {
3849                            decisions
3850                                .push(format!("child awaiting answer (question {question_id})"));
3851                            asked = Some(question_id);
3852                            paused_by_child = true;
3853                        }
3854                    }
3855                }
3856            }
3857
3858            // An agent paused because one of its CHILDREN deferred does NOT commit
3859            // this step: on resume it must replay it to re-adopt and resume that
3860            // paused child (only the parent re-entering `spawn_child` can wait on
3861            // the child again). An agent paused by its OWN gate commits normally —
3862            // it resumes from the step after, past the now-approved action.
3863            //
3864            // The condition is passed to the one boundary rather than branching
3865            // around it, so the uncommitted case is a stated argument at the single
3866            // commit point instead of a second, quieter commit path that a later
3867            // change could forget about. `commit_step` emits no `EventKind::Step`
3868            // for it either: there is no committed step to report, and a resume is
3869            // going to run this step again.
3870            // 0.21.0 — `asked` counts the same as `paused` here. A parent step whose
3871            // child stopped for a human must be replayed on resume so the parent
3872            // re-adopts that child; committing it would leave the child stranded and
3873            // the parent believing the spawn was done.
3874            let committed = !((paused.is_some() || asked.is_some()) && paused_by_child);
3875            commit_step(
3876                tree.store,
3877                tree.watch,
3878                run_id,
3879                depth,
3880                StepRecord::new(step, decisions.join("; "), ledger.text_for_step(step)).with_trace(
3881                    user,
3882                    calls_json.join(" | "),
3883                    step_tokens,
3884                ),
3885                step_changed,
3886                committed,
3887            )?;
3888            // Only when the step actually committed. A step paused by a child is
3889            // deliberately left uncommitted so the resume replays it (0.7.0's
3890            // fix for double execution); persisting its observations would mean
3891            // the replay observed everything twice.
3892            if committed {
3893                written = persist_ledger(tree.store, run_id, &ledger, written)?;
3894            }
3895
3896            // 0.5.0 spawns up to a hundred of these, so an agent burning its whole
3897            // step budget going nowhere is the multiplied version of the problem.
3898            let signature = calls_json.join(" | ");
3899            match progress.step(contract.stall, step_changed, &signature) {
3900                Progressing::Fine => {}
3901                Progressing::Replan => {
3902                    tree.store.record_context_event(
3903                        run_id,
3904                        &ContextEvent::replan(
3905                            step,
3906                            format!(
3907                                "{} steps without progress; replanning",
3908                                contract.stall.window
3909                            ),
3910                        ),
3911                    )?;
3912                    ledger.push(Observation::new(
3913                        step,
3914                        ObsKind::Message,
3915                        None,
3916                        bound(
3917                            &progress.replan_directive(contract.stall.window, &decisions),
3918                            entry_cap,
3919                            ObsKind::Message,
3920                        ),
3921                    ));
3922                    info!(run_id, depth, step, "agent told to change approach");
3923                    tree.watch.emit(RunEvent::at_depth(
3924                        run_id,
3925                        step,
3926                        depth,
3927                        EventKind::Replan {
3928                            window: contract.stall.window,
3929                        },
3930                    ));
3931                }
3932                Progressing::Stalled => {
3933                    tree.store.record_context_event(
3934                        run_id,
3935                        &ContextEvent::stalled(step, "still no progress after replanning"),
3936                    )?;
3937                    info!(run_id, depth, step, "agent stopped: stalled");
3938                    tree.watch
3939                        .emit(RunEvent::at_depth(run_id, step, depth, EventKind::Stalled));
3940                    finish(tree.store, tree.watch, run_id, depth, step, "stalled")?;
3941                    return Ok(RunOutcome::Stalled { steps: step });
3942                }
3943            }
3944
3945            if let Some(question_id) = asked {
3946                finish(
3947                    tree.store,
3948                    tree.watch,
3949                    run_id,
3950                    depth,
3951                    step,
3952                    "awaiting_answer",
3953                )?;
3954                return Ok(RunOutcome::AwaitingAnswer {
3955                    question_id,
3956                    steps: step,
3957                });
3958            }
3959
3960            if let Some(request_id) = paused {
3961                finish(
3962                    tree.store,
3963                    tree.watch,
3964                    run_id,
3965                    depth,
3966                    step,
3967                    "awaiting_approval",
3968                )?;
3969                return Ok(RunOutcome::AwaitingApproval {
3970                    request_id,
3971                    steps: step,
3972                });
3973            }
3974
3975            // Draw this step's tokens against the tree. The draw is recorded even
3976            // when it crosses the ceiling — the tokens were already spent — and a
3977            // crossing halts the whole tree, not just this agent.
3978            let draw = tree.ledger.draw_tokens(step_tokens);
3979            let remaining = tree.ledger.remaining_tokens();
3980            tree.store.record_agent_event(&AgentEvent::budget_draw(
3981                run_id,
3982                step,
3983                step_tokens,
3984                remaining,
3985            ))?;
3986            tree.watch.emit(RunEvent::at_depth(
3987                run_id,
3988                step,
3989                depth,
3990                EventKind::SpendDraw {
3991                    tokens: step_tokens,
3992                    // A tree always has a ceiling, so there is always a number here;
3993                    // the field is optional for a future draw against no ceiling.
3994                    remaining: Some(remaining),
3995                },
3996            ));
3997            if draw == Draw::Halted {
3998                finish(
3999                    tree.store,
4000                    tree.watch,
4001                    run_id,
4002                    depth,
4003                    step,
4004                    "budget_ceiling_reached",
4005                )?;
4006                return Ok(RunOutcome::BudgetCeilingReached { steps: step });
4007            }
4008            // The tree's wall-clock ceiling, measured from the ROOT's `started_at`
4009            // rather than this agent's: a child spawned twenty hours in has a young
4010            // stamp of its own, and the ceiling is about the whole tree. Checked
4011            // beside the token draw because both are the same kind of limit — one a
4012            // contract cannot raise, crossing which halts the tree rather than the
4013            // agent that noticed.
4014            //
4015            // `max_total_duration` has existed on `Containment` since 0.5.0 and was
4016            // never read, so a caller could set a ceiling on a 24-hour tree and have
4017            // it silently ignored. Enforced in 0.12.0. Its sibling `max_total_cost`
4018            // still cannot be: there is no price telemetry to compare against.
4019            if let Some(max) = tree.containment.max_total_duration {
4020                if tree.store.elapsed_secs(tree.root_run_id)? > max.as_secs_f64() {
4021                    finish(
4022                        tree.store,
4023                        tree.watch,
4024                        run_id,
4025                        depth,
4026                        step,
4027                        "budget_ceiling_reached",
4028                    )?;
4029                    info!(run_id, depth, step, "tree stopped: duration ceiling");
4030                    return Ok(RunOutcome::BudgetCeilingReached { steps: step });
4031                }
4032            }
4033            // This agent's own contract budget (never looser than the tree's).
4034            if tokens_used > token_cap {
4035                finish(
4036                    tree.store,
4037                    tree.watch,
4038                    run_id,
4039                    depth,
4040                    step,
4041                    "cost_budget_exceeded",
4042                )?;
4043                return Ok(RunOutcome::CostBudgetExceeded { steps: step });
4044            }
4045
4046            // As in the workspace loop: no criterion means the agent's own quiet
4047            // turn ends it. A child composes back into its parent carrying this
4048            // outcome, so a parent can tell a child that finished from one that
4049            // ran out of steps.
4050            if finished(contract, &response) {
4051                finish(tree.store, tree.watch, run_id, depth, step, "finished")?;
4052                return Ok(RunOutcome::Finished { steps: step });
4053            }
4054
4055            if contract
4056                .verify
4057                .passes_in_guarded(
4058                    &tree.root,
4059                    &ExecGuard::new(policy)
4060                        .tracing(tree.store, run_id, step)
4061                        .watching(tree.watch, depth),
4062                )
4063                .await?
4064            {
4065                finish(tree.store, tree.watch, run_id, depth, step, "success")?;
4066                return Ok(RunOutcome::Success { steps: step });
4067            }
4068        }
4069
4070        finish(
4071            tree.store,
4072            tree.watch,
4073            run_id,
4074            depth,
4075            contract.max_steps,
4076            "step_cap_reached",
4077        )?;
4078        Ok(RunOutcome::StepCapReached {
4079            steps: contract.max_steps,
4080        })
4081    })
4082}
4083
4084/// The result of one [`SPAWN_TOOL`] call.
4085enum SpawnResult {
4086    /// The child finished; fold its composed result into the parent's log.
4087    Composed { decision: String, obs: String },
4088    /// The child deferred a sensitive action to a human. The pending action is
4089    /// persisted under `request_id`; the whole tree pauses so the caller can
4090    /// resume it with [`resume_with_decision`], exactly as a single run does.
4091    Paused { request_id: i64 },
4092    /// (0.21.0) The child asked the operator about intent and nobody in this process
4093    /// answered. The question is persisted under `question_id`; the whole tree pauses
4094    /// so the caller can resume it with [`resume_tree_with_answer`], exactly as a
4095    /// deferred approval does.
4096    Asked { question_id: i64 },
4097}
4098
4099/// Handle one [`SPAWN_TOOL`] call: enforce the containment caps, derive the
4100/// child's narrowed policy, run it, and compose its result back for the parent's
4101/// next turn. A refused spawn is a typed observation the parent can adapt to,
4102/// never a failure of the parent run; a child that defers propagates the pause
4103/// up so the caller can resume the child once a human decides.
4104async fn spawn_child<P: Provider>(
4105    tree: &Tree<'_, P>,
4106    call: &ToolCall,
4107    parent_run_id: i64,
4108    depth: u32,
4109    parent_policy: &Policy,
4110    step: u32,
4111) -> Result<SpawnResult> {
4112    let a = &call.arguments;
4113    let goal = a.get("goal").and_then(|v| v.as_str()).unwrap_or_default();
4114    let file = a
4115        .get("verify_file")
4116        .and_then(|v| v.as_str())
4117        .unwrap_or_default();
4118    let needle = a
4119        .get("verify_contains")
4120        .and_then(|v| v.as_str())
4121        .unwrap_or_default();
4122    if goal.is_empty() || file.is_empty() {
4123        return Ok(SpawnResult::Composed {
4124            decision: "spawn missing fields".into(),
4125            obs: "\n[spawn error] spawn_agent needs \"goal\" and \"verify_file\"\n".into(),
4126        });
4127    }
4128
4129    let child_depth = depth + 1;
4130
4131    // 0.21.0 — an optional named definition. Unknown is an error observation and no
4132    // child: a spawn that silently became an unnarrowed agent because its definition
4133    // was misspelled is exactly the failure a roster must not have.
4134    let named = a
4135        .get("agent")
4136        .and_then(|v| v.as_str())
4137        .filter(|n| !n.is_empty());
4138    let def = match named {
4139        Some(name) => match tree.agents.get(name) {
4140            Some(def) => Some(def),
4141            None => {
4142                let known = tree.agents.names().join(", ");
4143                return Ok(SpawnResult::Composed {
4144                    decision: format!("unknown agent {name}"),
4145                    obs: format!(
4146                        "\n[spawn error] no agent named `{name}`. Available: {}\n",
4147                        if known.is_empty() { "none" } else { &known }
4148                    ),
4149                });
4150            }
4151        },
4152        None => None,
4153    };
4154
4155    // A child inherits the parent policy and may only narrow it. Optional
4156    // `deny_write` globs let the parent tighten the child further, and a definition
4157    // narrows it again — both through `Policy::contain`, which is why neither can
4158    // widen anything. There is no path here that adds an allow.
4159    let mut overlay = Policy::permissive().layer("child");
4160    if let Some(def) = def {
4161        if def.deny_write {
4162            overlay = overlay.deny_write("*");
4163        }
4164        if def.deny_net {
4165            overlay = overlay.deny_net("*");
4166        }
4167    }
4168    if let Some(denies) = a.get("deny_write").and_then(|v| v.as_array()) {
4169        for d in denies.iter().filter_map(|v| v.as_str()) {
4170            overlay = overlay.deny_write(d);
4171        }
4172    }
4173    if let Some(denies) = a.get("deny_net").and_then(|v| v.as_array()) {
4174        for d in denies.iter().filter_map(|v| v.as_str()) {
4175            overlay = overlay.deny_net(d);
4176        }
4177    }
4178    let child_policy = parent_policy.contain(&overlay);
4179
4180    let verify = Verification::WorkspaceFileContains {
4181        file: file.into(),
4182        needle: needle.into(),
4183    };
4184    let mut child_contract = TaskContract::workspace(goal, &tree.root, verify);
4185    // 0.22.0 — the tree's web declaration, not one the model asked for. A child
4186    // inherits exactly what the root was given and has no way to widen it: the
4187    // spawn arguments are never read for this, so "give the sub-agent web access"
4188    // is unwritable in the JSON the model controls.
4189    child_contract.web = tree.web.clone();
4190    if let Some(n) = a.get("max_steps").and_then(|v| v.as_u64()) {
4191        child_contract = child_contract.with_max_steps(n as u32);
4192    }
4193    // A definition's cap is the operator's and outranks the model's own request.
4194    if let Some(n) = def.and_then(|d| d.max_steps) {
4195        child_contract = child_contract.with_max_steps(n);
4196    }
4197
4198    // Spawn-or-adopt. On a fresh run this spawn has no persisted record, so a new
4199    // child is created. On a tree resume the parent replays the same spawn step
4200    // and finds the child it already spawned (keyed by parent+step+goal): it
4201    // adopts that child and resumes it from its OWN last committed step instead
4202    // of creating a duplicate or restarting it. This is what lets every agent in
4203    // a crashed tree continue from its own checkpoint.
4204    let (child_run, child_start) = match tree.store.find_spawn(parent_run_id, step, goal)? {
4205        Some(row) => {
4206            // Adopted: already counted in the reconstructed ledger, so do NOT
4207            // register it again. A finished child is composed from its recorded
4208            // outcome without re-running; a mid-flight child resumes from its
4209            // next step.
4210            if let Some(o) = terminal_outcome(tree.store, row.child_run_id)? {
4211                return Ok(compose_child(row.child_run_id, goal, o));
4212            }
4213            (
4214                row.child_run_id,
4215                tree.store.last_step(row.child_run_id)? + 1,
4216            )
4217        }
4218        None => {
4219            // Fresh: the containment boundary decides whether it may exist, and
4220            // its contract is persisted so a later resume can adopt it.
4221            if let Err(refusal) = tree.ledger.register_agent(child_depth) {
4222                tree.store.record_agent_event(&AgentEvent::spawn_refused(
4223                    parent_run_id,
4224                    step,
4225                    refusal.cap(),
4226                ))?;
4227                // The parent's event, at the parent's depth: no child exists to
4228                // attribute it to, which is the point of the refusal.
4229                tree.watch.emit(RunEvent::at_depth(
4230                    parent_run_id,
4231                    step,
4232                    depth,
4233                    EventKind::SpawnRefused {
4234                        cap: refusal.cap().to_string(),
4235                    },
4236                ));
4237                return Ok(SpawnResult::Composed {
4238                    decision: format!("spawn refused ({})", refusal.cap()),
4239                    obs: format!(
4240                        "\n[spawn refused] {refusal} — adapt or finish with what you have\n"
4241                    ),
4242                });
4243            }
4244            let child_run = tree.store.start_child_run(
4245                goal,
4246                &tree.root.display().to_string(),
4247                parent_run_id,
4248                child_depth,
4249            )?;
4250            // `detail` is free-form and documented as the child's goal; a child
4251            // spawned from a definition records that too, so the trace says which
4252            // role ran without the `spawns` table gaining a column (and this
4253            // release alters no table).
4254            tree.store.record_agent_event(&AgentEvent::spawn(
4255                parent_run_id,
4256                step,
4257                child_run,
4258                match def {
4259                    Some(d) => format!("as {}: {goal}", d.name),
4260                    None => goal.to_string(),
4261                },
4262            ))?;
4263            // Attributed to the PARENT's run and depth: the parent is what spawned
4264            // it, and the child's own events (which carry `child_depth`) start
4265            // arriving next.
4266            tree.watch.emit(RunEvent::at_depth(
4267                parent_run_id,
4268                step,
4269                depth,
4270                EventKind::Spawned {
4271                    child_run_id: child_run,
4272                    goal: goal.to_string(),
4273                },
4274            ));
4275            let deny_json = a
4276                .get("deny_write")
4277                .map(|v| v.to_string())
4278                .unwrap_or_else(|| "[]".into());
4279            tree.store.record_spawn(
4280                parent_run_id,
4281                step,
4282                child_run,
4283                goal,
4284                file,
4285                needle,
4286                a.get("max_steps")
4287                    .and_then(|v| v.as_u64())
4288                    .map(|n| n as u32),
4289                &deny_json,
4290            )?;
4291            (child_run, 1)
4292        }
4293    };
4294
4295    let outcome = run_agent(
4296        tree,
4297        &child_contract,
4298        child_run,
4299        child_depth,
4300        &child_policy,
4301        child_start,
4302        def,
4303    )
4304    .await?;
4305
4306    // A child that deferred pauses the whole tree, surfacing its request_id so
4307    // the caller can resume that child once a human decides.
4308    if let RunOutcome::AwaitingApproval { request_id, .. } = outcome {
4309        return Ok(SpawnResult::Paused { request_id });
4310    }
4311
4312    // And a child that asked the operator something pauses it the same way. Without
4313    // this the child's `AwaitingAnswer` would fall through to `compose_child` and read
4314    // as a child that had finished, so the tree would carry on having never heard the
4315    // question — which is how this was found.
4316    if let RunOutcome::AwaitingAnswer { question_id, .. } = outcome {
4317        return Ok(SpawnResult::Asked { question_id });
4318    }
4319
4320    Ok(compose_child(child_run, goal, outcome))
4321}
4322
4323/// Fold one child's finished result back into the parent's observation log.
4324fn compose_child(child_run: i64, goal: &str, outcome: RunOutcome) -> SpawnResult {
4325    SpawnResult::Composed {
4326        decision: format!("spawned child {child_run}: {outcome:?}"),
4327        obs: format!("\n[child {child_run} \"{goal}\" -> {outcome:?}]\n"),
4328    }
4329}
4330
4331/// The rules an approver asked to remember, as a mergeable top layer.
4332///
4333/// A layer rather than an edit: merging is what keeps a remembered allow from
4334/// defeating a deny beneath it, since deny is absolute across the stack.
4335fn remembered_layer(rules: &[Rule]) -> Policy {
4336    let mut layer = Policy::permissive().layer("remembered");
4337    for r in rules {
4338        layer = layer.rule(r.act, r.effect, r.pattern.clone());
4339    }
4340    layer
4341}
4342
4343/// The outcome of authorizing the provider's own endpoint, before a run makes
4344/// its first outbound call.
4345enum ProviderAccess {
4346    /// Cleared to run, under the policy the provider layer has been merged into.
4347    Granted(Policy),
4348    /// An approver deferred; the pending decision is persisted under this id and
4349    /// the run stops before it ever dials.
4350    Pending(i64),
4351}
4352
4353/// Authorize the provider's endpoint once, before the first completion.
4354///
4355/// Once per run rather than once per step: a provider's endpoint is fixed for
4356/// the life of the provider, so re-asking each step would be the same question
4357/// with the same answer — and asking a human it repeatedly would train them to
4358/// wave it through.
4359///
4360/// The provider layer is merged *before* the check, not consulted after it. That
4361/// ordering is what makes a network-deny base usable: the `net` default denies,
4362/// the provider layer's allow rule beats a default, and a caller's explicit
4363/// `deny_net` still beats the allow because deny is absolute across layers. So
4364/// "deny everything but the model" needs no host list from the caller, while
4365/// "deny even the model" remains expressible — and fails fast as a refusal
4366/// rather than hanging on a call that is never made.
4367async fn authorize_provider<P: Provider>(
4368    provider: &P,
4369    policy: &Policy,
4370    store: &Store,
4371    run_id: i64,
4372    approver: &dyn Approver,
4373    watch: &Watch<'_>,
4374) -> Result<ProviderAccess> {
4375    // A provider that opens no connection (the mock providers tests drive the
4376    // loop with) has no endpoint to authorize.
4377    // Every host in the chain, not just the first: a `Fallback` can dial its
4378    // secondary, and a host the policy never checked would be a hole in an egress
4379    // model that is deny-by-default everywhere else.
4380    let urls = provider.endpoints();
4381    if urls.is_empty() {
4382        // A provider that opens no connection (the mock providers the tests drive
4383        // the loop with) has no endpoint to authorize.
4384        return Ok(ProviderAccess::Granted(policy.clone()));
4385    }
4386
4387    let mut effective = policy.clone();
4388    let mut ask: Option<String> = None;
4389    for url in urls {
4390        let Some(target) = net::target(url) else {
4391            return Err(crate::error::Error::Refused {
4392                act: "net".into(),
4393                target: url.to_string(),
4394                rule: None,
4395                layer: None,
4396            });
4397        };
4398        effective = effective.merge(net::provider_layer(&target));
4399        // Step 0: the authorization happens before the run's first step.
4400        let verdict = NetGuard::new(&effective)
4401            .tracing(store, run_id, 0)
4402            .watching(watch, 0)
4403            .check_target(&target)?;
4404        if verdict.effect == Effect::Ask {
4405            // One human decision covers the run; the first host that needs asking
4406            // is the one asked about.
4407            ask = Some(target.clone());
4408        }
4409    }
4410
4411    let Some(target) = ask else {
4412        return Ok(ProviderAccess::Granted(effective));
4413    };
4414
4415    // The run is now waiting on a human, before its first step. Step 0 for the
4416    // same reason the rows below are: the authorization precedes step 1.
4417    watch.emit(RunEvent::new(
4418        run_id,
4419        0,
4420        EventKind::ApprovalRequested {
4421            act: "net".into(),
4422            target: target.clone(),
4423        },
4424    ));
4425    match approver.decide(&Request::new(Act::Net, &target)).await {
4426        Decision::Approve { .. } => {
4427            let ev = PolicyEvent::decision(0, "net", &target, "approve", "approver");
4428            store.record_event(run_id, &ev)?;
4429            decided(watch, run_id, 0, &ev);
4430            Ok(ProviderAccess::Granted(effective))
4431        }
4432        Decision::Deny { reason } => {
4433            let ev = PolicyEvent::decision(0, "net", &target, "deny", "approver");
4434            store.record_event(run_id, &ev)?;
4435            decided(watch, run_id, 0, &ev);
4436            // Step 0: the run never started, so it finished having taken no steps.
4437            finish(store, watch, run_id, 0, 0, "refused")?;
4438            Err(crate::error::Error::Refused {
4439                act: "net".into(),
4440                target: format!("{target} — {reason}"),
4441                // The approver denied it, so the refusal is the human's, not a
4442                // rule's: there is no rule to name.
4443                rule: None,
4444                layer: None,
4445            })
4446        }
4447        Decision::Defer => {
4448            let ev = PolicyEvent::decision(0, "net", &target, "defer", "approver");
4449            store.record_event(run_id, &ev)?;
4450            decided(watch, run_id, 0, &ev);
4451            let request_id = store.put_pending(run_id, 0, "net", &target, None)?;
4452            finish(store, watch, run_id, 0, 0, "awaiting_approval")?;
4453            Ok(ProviderAccess::Pending(request_id))
4454        }
4455    }
4456}
4457
4458/// The responder a contract carries, or one that answers nothing.
4459///
4460/// A `static` rather than a local, so the "nobody answers" case is a reference to one
4461/// value instead of a temporary every call site has to keep alive. Answering nothing
4462/// is the default and the honest one for unattended work: the question persists and
4463/// the run pauses, so waiting costs nothing.
4464static NO_RESPONDER: ResponderNone = ResponderNone;
4465
4466fn responder_of(contract: &TaskContract) -> &dyn Responder {
4467    match &contract.responder {
4468        Some(r) => r.as_ref(),
4469        None => &NO_RESPONDER,
4470    }
4471}
4472
4473/// The result of dispatching one tool call.
4474enum Dispatched {
4475    /// The call resolved; fold `obs` into the observation log and carry any
4476    /// rules an approver asked to remember.
4477    ///
4478    /// `kind` and `target` travel with `obs` because assembly reasons about them:
4479    /// what a later observation supersedes, and which read a write invalidates,
4480    /// is a question about the tool and its subject — not something to recover by
4481    /// re-parsing the text afterwards.
4482    Continue {
4483        decision: String,
4484        obs: String,
4485        kind: ObsKind,
4486        target: Option<String>,
4487        /// Whether this call moved the workspace. Only a write can, and only a
4488        /// write that wrote something different — the signal stall detection reads.
4489        changed: bool,
4490        remember: Vec<Rule>,
4491    },
4492    /// An approver deferred; the action is persisted under `request_id` and the
4493    /// run stops until a human decides.
4494    Pause { request_id: i64 },
4495    /// (0.21.0) The agent asked the operator about intent and no `Responder` in this
4496    /// process would answer. The question is persisted under `question_id` and the
4497    /// run stops until a human answers it.
4498    Ask { question_id: i64 },
4499}
4500
4501impl Dispatched {
4502    /// A tool result: what it was, and the subject it names (if any).
4503    fn seen(
4504        decision: impl Into<String>,
4505        obs: impl Into<String>,
4506        kind: ObsKind,
4507        target: Option<String>,
4508    ) -> Self {
4509        Dispatched::Continue {
4510            decision: decision.into(),
4511            obs: obs.into(),
4512            kind,
4513            target,
4514            changed: false,
4515            remember: Vec::new(),
4516        }
4517    }
4518
4519    /// A failure or a refusal. Kept subject-less on purpose: an error about a
4520    /// path is not an observation *of* that path, so it must never supersede one.
4521    fn go(decision: impl Into<String>, obs: impl Into<String>) -> Self {
4522        Self::seen(decision, obs, ObsKind::Error, None)
4523    }
4524}
4525
4526/// Execute one tool call against the workspace, enforcing the policy and
4527/// consulting `approver` for anything it marks [`Effect::Ask`].
4528///
4529/// Tool-level failures (bad regex, path escape, a policy refusal) become
4530/// The images one request carries: the caller's, which are the task's subject and
4531/// ride every step, plus whatever the agent looked at last step, which rides one.
4532///
4533/// Bounded here rather than at either source, because neither can see the total.
4534/// Over the bound the oldest viewed images are dropped first and the model is not
4535/// told a lie about it — the drop is reported in the trace by the caller. The
4536/// caller's own images are never dropped: a task about an image that silently
4537/// stops carrying it is the failure this whole boundary exists to prevent, so an
4538/// over-budget contract is an error at the first step instead.
4539#[cfg(feature = "media")]
4540fn attach_media(
4541    contract: &TaskContract,
4542    pending: &mut PendingMedia,
4543) -> Result<Vec<crate::provider::Media>> {
4544    use crate::provider::MAX_REQUEST_IMAGE_BYTES;
4545    let fixed: usize = contract.images.iter().map(|m| m.byte_len()).sum();
4546    if fixed > MAX_REQUEST_IMAGE_BYTES {
4547        return Err(Error::Config(format!(
4548            "the contract's images total {fixed} bytes, over the \
4549             {MAX_REQUEST_IMAGE_BYTES}-byte per-request bound"
4550        )));
4551    }
4552    let mut out = contract.images.clone();
4553    let mut used = fixed;
4554    for m in pending.drain(..) {
4555        if used + m.byte_len() > MAX_REQUEST_IMAGE_BYTES {
4556            continue;
4557        }
4558        used += m.byte_len();
4559        out.push(m);
4560    }
4561    Ok(out)
4562}
4563
4564/// Images the agent looked at this step, waiting to be attached to the next
4565/// request.
4566///
4567/// An alias rather than a `cfg` on the parameter itself, so the two call sites
4568/// and the signature read the same in both feature states. Without the feature
4569/// it is `()`: there is nothing to carry and nothing to bound.
4570#[cfg(feature = "media")]
4571pub(crate) type PendingMedia = Vec<crate::provider::Media>;
4572/// See the `media` form above.
4573#[cfg(not(feature = "media"))]
4574pub(crate) type PendingMedia = ();
4575
4576/// Read a `todo_write` argument object into a plan, or say what was wrong with it.
4577///
4578/// Strict on purpose, and the error goes back to the model as an observation rather
4579/// than out of the run: an item whose state the crate does not understand is an item
4580/// whose state nobody knows, and guessing `pending` would show an operator a plan the
4581/// agent did not write. The message names the three legal states, so the correction
4582/// costs one step and needs no documentation.
4583fn parse_todo_items(args: &serde_json::Value) -> std::result::Result<Vec<TodoItem>, String> {
4584    let list = args
4585        .get("items")
4586        .ok_or_else(|| "`items` is required: send the whole plan as a list".to_string())?
4587        .as_array()
4588        .ok_or_else(|| {
4589            "`items` must be a list of {text, state} objects, and the whole plan is sent \
4590             every time"
4591                .to_string()
4592        })?;
4593    let mut out = Vec::with_capacity(list.len());
4594    for (i, raw) in list.iter().enumerate() {
4595        let text = raw
4596            .get("text")
4597            .and_then(|v| v.as_str())
4598            .map(str::trim)
4599            .filter(|t| !t.is_empty())
4600            .ok_or_else(|| format!("item {} needs a non-empty `text`", i + 1))?;
4601        let state = raw
4602            .get("state")
4603            .and_then(|v| v.as_str())
4604            .ok_or_else(|| format!("item {} (`{text}`) needs a `state`", i + 1))?;
4605        let state = TodoState::parse(state).ok_or_else(|| {
4606            format!(
4607                "item {} (`{text}`) has state `{state}`; use pending, active or done",
4608                i + 1
4609            )
4610        })?;
4611        out.push(TodoItem::new(text, state));
4612    }
4613    Ok(out)
4614}
4615
4616/// observations the agent can recover from rather than failing the run — only
4617/// the model can decide what to do about them.
4618#[allow(clippy::too_many_arguments)]
4619// `pending_media` is `()` without the feature, and nothing reads it there.
4620#[cfg_attr(not(feature = "media"), allow(unused_variables))]
4621async fn dispatch(
4622    ws: &Workspace,
4623    call: &ToolCall,
4624    approver: &dyn Approver,
4625    // 0.21.0 — who answers a question about intent. Separate from `approver` on
4626    // purpose: one decides whether an act is permitted, the other what was wanted.
4627    responder: &dyn Responder,
4628    store: &Store,
4629    run_id: i64,
4630    step: u32,
4631    mcp: &McpSession,
4632    custom: &Toolbox,
4633    skills: &Skills,
4634    cap: usize,
4635    memory_key: &str,
4636    watch: &Watch<'_>,
4637    depth: u32,
4638    pending_media: &mut PendingMedia,
4639    identity: &crate::tools::git::Identity,
4640    exec_timeout: Duration,
4641) -> Result<Dispatched> {
4642    let a = &call.arguments;
4643    let s = |k: &str| a.get(k).and_then(|v| v.as_str());
4644    // Announced before the call is made, so a watcher sees what the run is about
4645    // to do rather than only what it did. The subject is whichever of the
4646    // conventional argument names this tool uses; a tool that names none of them
4647    // is its own subject, which is what an MCP or registered tool call is.
4648    watch.emit(RunEvent::at_depth(
4649        run_id,
4650        step,
4651        depth,
4652        EventKind::ToolCall {
4653            name: call.name.clone(),
4654            target: ["path", "pattern", "name_glob", "glob", "key", "name"]
4655                .into_iter()
4656                .find_map(s)
4657                .unwrap_or(&call.name)
4658                .to_string(),
4659        },
4660    ));
4661    let name = call.name.as_str();
4662    Ok(match name {
4663        GREP_TOOL => {
4664            let pattern = s("pattern").unwrap_or_default();
4665            match ws.grep(pattern, s("path_glob")) {
4666                Ok(hits) => {
4667                    let shown: Vec<String> = hits
4668                        .iter()
4669                        .take(OBS_GREP_CAP)
4670                        .map(|m| format!("{}:{}: {}", m.path, m.line, m.text))
4671                        .collect();
4672                    Dispatched::seen(
4673                        format!("grep {pattern:?} ({} hits)", hits.len()),
4674                        bound(
4675                            &format!("\n[grep {pattern:?}]\n{}\n", shown.join("\n")),
4676                            cap,
4677                            ObsKind::Grep,
4678                        ),
4679                        ObsKind::Grep,
4680                        Some(pattern.to_string()),
4681                    )
4682                }
4683                Err(e) => Dispatched::go("grep error", format!("\n[grep error] {e}\n")),
4684            }
4685        }
4686        FIND_TOOL => {
4687            let glob = s("name_glob").or_else(|| s("glob")).unwrap_or_default();
4688            match ws.find(glob) {
4689                Ok(paths) => Dispatched::seen(
4690                    format!("find {glob:?} ({} paths)", paths.len()),
4691                    bound(
4692                        &format!("\n[find {glob:?}]\n{}\n", paths.join("\n")),
4693                        cap,
4694                        ObsKind::Find,
4695                    ),
4696                    ObsKind::Find,
4697                    Some(glob.to_string()),
4698                ),
4699                Err(e) => Dispatched::go("find error", format!("\n[find error] {e}\n")),
4700            }
4701        }
4702        REMEMBER_TOOL => {
4703            let key = s("key").unwrap_or_default();
4704            let value = s("value").unwrap_or_default();
4705            if key.is_empty() || value.is_empty() {
4706                return Ok(Dispatched::go(
4707                    "remember error",
4708                    "\n[remember error] both key and value are required\n",
4709                ));
4710            }
4711            // The store bounds the entry and evicts oldest-first to hold the caps;
4712            // it writes no trace rows of its own, so the write and every eviction
4713            // are recorded here, where the run_id and step are known.
4714            let evicted = store.memory_put(memory_key, key, value, run_id, step)?;
4715            store.record_context_event(
4716                run_id,
4717                &ContextEvent::memory_write(
4718                    step,
4719                    format!("{key} ({} chars)", value.chars().count()),
4720                ),
4721            )?;
4722            // The key only: the row's detail carries a character count too, but
4723            // that is prose about the write, not the note's identity.
4724            watch.emit(RunEvent::at_depth(
4725                run_id,
4726                step,
4727                depth,
4728                EventKind::MemoryWrote {
4729                    key: key.to_string(),
4730                },
4731            ));
4732            for gone in &evicted {
4733                store.record_context_event(
4734                    run_id,
4735                    &ContextEvent::memory_evict(step, format!("{gone} (evicted to hold the cap)")),
4736                )?;
4737            }
4738            info!(run_id, step, key, evicted = evicted.len(), "remembered");
4739            // No target: two notes under one key are the store's business, and a
4740            // remember is not an observation OF anything that could go stale.
4741            Dispatched::seen(
4742                format!("remembered {key}"),
4743                format!("\n[remember {key}]\n"),
4744                ObsKind::Tool,
4745                None,
4746            )
4747        }
4748        TODO_WRITE_TOOL => {
4749            // Not gated, and deliberately so: this writes into the harness's own
4750            // store, not into the workspace, the network or a binary, so there is no
4751            // `Act` it could be checked against. Inventing one would put a permission
4752            // rule in front of the agent stating its intentions. See the plan section
4753            // of `docs/CONTRACT.md`.
4754            let items = match parse_todo_items(a) {
4755                Ok(items) => items,
4756                Err(why) => {
4757                    return Ok(Dispatched::go(
4758                        "todo error",
4759                        format!("\n[todo error] {why}\n"),
4760                    ))
4761                }
4762            };
4763            // The store caps the list and reports what it dropped; it writes no trace
4764            // row of its own, so both are recorded here, where the step is known.
4765            let dropped = store.write_todos(run_id, &items)?;
4766            let done = items
4767                .iter()
4768                .filter(|i| i.state == crate::state::TodoState::Done)
4769                .count();
4770            store.record_context_event(
4771                run_id,
4772                &ContextEvent::todo_write(step, format!("{} items, {done} done", items.len())),
4773            )?;
4774            watch.emit(RunEvent::at_depth(
4775                run_id,
4776                step,
4777                depth,
4778                EventKind::TodoWrote {
4779                    items: items.clone(),
4780                },
4781            ));
4782            info!(run_id, step, items = items.len(), done, dropped, "plan");
4783            // The plan back in one line, so the model sees what was actually recorded
4784            // rather than assuming its write landed verbatim — the cap is visible.
4785            let mut obs = format!("\n[plan {} items, {done} done]\n", items.len());
4786            for item in &items {
4787                obs.push_str(&format!("- [{}] {}\n", item.state.as_str(), item.text));
4788            }
4789            if dropped > 0 {
4790                obs.push_str(&format!(
4791                    "({dropped} item(s) past the {} the plan holds were dropped)\n",
4792                    crate::state::TODO_MAX_ITEMS
4793                ));
4794            }
4795            // No target: a plan supersedes nothing and cannot go stale — the next
4796            // write replaces it wholesale.
4797            Dispatched::seen(
4798                format!("plan: {} items, {done} done", items.len()),
4799                obs,
4800                ObsKind::Tool,
4801                None,
4802            )
4803        }
4804        ASK_QUESTION_TOOL => {
4805            // Not gated, and for a sharper reason than the todo tool's: this asks a
4806            // human something. Putting a permission rule in front of the channel
4807            // whose whole purpose is to ask would be a category error, and there is
4808            // no `Act` that means "ask about intent" — see `docs/CONTRACT.md`.
4809            let Some(text) = s("question").map(str::trim).filter(|q| !q.is_empty()) else {
4810                return Ok(Dispatched::go(
4811                    "question error",
4812                    "\n[question error] `question` is required and must not be empty\n",
4813                ));
4814            };
4815            let mut question = Question::new(text);
4816            if let Some(context) = s("context").map(str::trim).filter(|c| !c.is_empty()) {
4817                question = question.with_context(context);
4818            }
4819            if let Some(choices) = a.get("choices").and_then(|v| v.as_array()) {
4820                question = question.with_choices(
4821                    choices
4822                        .iter()
4823                        .filter_map(|v| v.as_str())
4824                        .map(str::to_string)
4825                        .collect::<Vec<_>>(),
4826                );
4827            }
4828            watch.emit(RunEvent::at_depth(
4829                run_id,
4830                step,
4831                depth,
4832                EventKind::QuestionAsked {
4833                    question: question.question.clone(),
4834                    choices: question.choices.clone(),
4835                },
4836            ));
4837            store.record_context_event(
4838                run_id,
4839                &ContextEvent::question_asked(step, question.question.clone()),
4840            )?;
4841
4842            // Only the in-process responder is consulted here. A human's answer does
4843            // NOT arrive through this path: the step that asks is committed before the
4844            // run pauses, so a resume starts at the step *after* it and this call is
4845            // never replayed. `resume_with_answer` therefore delivers the answer as an
4846            // observation instead, the way a steer is delivered.
4847            let answered = match responder.answer(&question).await {
4848                Some(answer) => {
4849                    // Recorded even though nothing paused: "the machine decided" is a
4850                    // fact about the run worth keeping.
4851                    let id = store.put_question(run_id, step, &question)?;
4852                    store.answer_question(id, &answer, "responder")?;
4853                    Some((answer, "responder".to_string()))
4854                }
4855                None => None,
4856            };
4857
4858            match answered {
4859                Some((answer, by)) => {
4860                    watch.emit(RunEvent::at_depth(
4861                        run_id,
4862                        step,
4863                        depth,
4864                        EventKind::QuestionAnswered {
4865                            answer: answer.clone(),
4866                            by: by.clone(),
4867                        },
4868                    ));
4869                    store.record_context_event(
4870                        run_id,
4871                        &ContextEvent::question_answered(step, format!("{by}: {answer}")),
4872                    )?;
4873                    info!(run_id, step, %by, "question answered");
4874                    // The answer is an observation. It is text the model reads, and it
4875                    // authorizes nothing: every tool call it leads to is checked
4876                    // against the same policy by the same code.
4877                    Dispatched::seen(
4878                        format!("asked, answered by {by}"),
4879                        format!(
4880                            "\n[answer] {answer}\n(This is what the operator wanted. It is not \
4881                             permission for anything.)\n"
4882                        ),
4883                        ObsKind::Tool,
4884                        None,
4885                    )
4886                }
4887                None => {
4888                    // Nobody here can answer. Persist it and pause: a run that had to
4889                    // guess would spend its budget pursuing something nobody asked for.
4890                    let question_id = store.put_question(run_id, step, &question)?;
4891                    info!(run_id, step, question_id, "run paused for an answer");
4892                    Dispatched::Ask { question_id }
4893                }
4894            }
4895        }
4896        READ_FILE_TOOL => {
4897            let path = s("path").unwrap_or_default();
4898            match gate(
4899                ws,
4900                approver,
4901                store,
4902                run_id,
4903                step,
4904                Act::Read,
4905                path,
4906                None,
4907                watch,
4908                depth,
4909            )
4910            .await?
4911            {
4912                Gated::Refused { decision, obs } => Dispatched::go(decision, obs),
4913                Gated::Paused { request_id } => Dispatched::Pause { request_id },
4914                Gated::Go {
4915                    target, remember, ..
4916                } => match ws.read_file(&target) {
4917                    Ok(c) => Dispatched::Continue {
4918                        decision: format!("read {target}"),
4919                        obs: format!("\n[read {target}]\n{}\n", bound(&c, cap, ObsKind::Read)),
4920                        kind: ObsKind::Read,
4921                        target: Some(target.clone()),
4922                        changed: false,
4923                        remember,
4924                    },
4925                    Err(e) => Dispatched::go("read error", format!("\n[read error] {e}\n")),
4926                },
4927            }
4928        }
4929        #[cfg(feature = "media")]
4930        VIEW_IMAGE_TOOL => {
4931            let path = s("path").unwrap_or_default();
4932            // The extension decides the media type, and an unknown one is
4933            // reported rather than guessed. Checked before the gate only because
4934            // it costs nothing: the gate still runs for every path that could
4935            // actually be read, so this cannot be used to probe for a file's
4936            // existence outside the policy.
4937            let Some(media_type) = crate::provider::Media::media_type_for(path) else {
4938                return Ok(Dispatched::go(
4939                    "view_image unsupported type",
4940                    format!(
4941                        "\n[view_image error] {path} is not an image this crate can send. \
4942                         Supported: {}\n",
4943                        crate::provider::IMAGE_MEDIA_TYPES.join(", ")
4944                    ),
4945                ));
4946            };
4947            match gate(
4948                ws,
4949                approver,
4950                store,
4951                run_id,
4952                step,
4953                Act::Read,
4954                path,
4955                None,
4956                watch,
4957                depth,
4958            )
4959            .await?
4960            {
4961                Gated::Refused { decision, obs } => Dispatched::go(decision, obs),
4962                Gated::Paused { request_id } => Dispatched::Pause { request_id },
4963                Gated::Go {
4964                    target, remember, ..
4965                } => match ws
4966                    .read_bytes(&target)
4967                    .map_err(|e| e.to_string())
4968                    .and_then(|bytes| {
4969                        crate::provider::Media::image(media_type, &bytes).map_err(|e| e.to_string())
4970                    }) {
4971                    Ok(media) => {
4972                        // The observation records what was sent, not the image:
4973                        // a digest, a size and a type. A trace that held the
4974                        // bytes would grow by megabytes a step in exactly the
4975                        // long unattended runs this crate exists for.
4976                        let obs = format!(
4977                            "\n[view_image {target}] attached to the next request \
4978                             ({media_type}, {} bytes, digest {})\n",
4979                            media.byte_len(),
4980                            media.digest()
4981                        );
4982                        pending_media.push(media);
4983                        Dispatched::Continue {
4984                            decision: format!("viewed {target}"),
4985                            obs,
4986                            kind: ObsKind::Read,
4987                            target: Some(target.clone()),
4988                            changed: false,
4989                            remember,
4990                        }
4991                    }
4992                    Err(e) => {
4993                        Dispatched::go("view_image error", format!("\n[view_image error] {e}\n"))
4994                    }
4995                },
4996            }
4997        }
4998        WRITE_FILE_TOOL => {
4999            let path = s("path").unwrap_or_default();
5000            let content = s("content").unwrap_or_default();
5001            if path.is_empty() {
5002                return Ok(Dispatched::go(
5003                    "write missing path",
5004                    "\n[write error] write_file needs a \"path\" in workspace mode\n",
5005                ));
5006            }
5007            match gate(
5008                ws,
5009                approver,
5010                store,
5011                run_id,
5012                step,
5013                Act::Write,
5014                path,
5015                Some(content),
5016                watch,
5017                depth,
5018            )
5019            .await?
5020            {
5021                Gated::Refused { decision, obs } => Dispatched::go(decision, obs),
5022                Gated::Paused { request_id } => Dispatched::Pause { request_id },
5023                Gated::Go {
5024                    target,
5025                    content,
5026                    remember,
5027                } => {
5028                    let body = content.unwrap_or_default();
5029                    // What is there now, read before the write so the line counts
5030                    // below have something to compare against. The write gate has
5031                    // already passed at this point; this is measurement, and the
5032                    // content never reaches the model.
5033                    let before = ws
5034                        .resolve(&target)
5035                        .ok()
5036                        .and_then(|abs| std::fs::read_to_string(abs).ok())
5037                        .unwrap_or_default();
5038                    match ws.write_file(&target, &body) {
5039                        Ok(wrote) => {
5040                            record_edit(
5041                                store,
5042                                run_id,
5043                                step,
5044                                WRITE_FILE_TOOL,
5045                                &target,
5046                                &before,
5047                                &body,
5048                            );
5049                            Dispatched::Continue {
5050                                decision: format!("wrote {target}"),
5051                                // A write that changed nothing says so, to the model as
5052                                // well as to the trace: an agent rewriting a file with
5053                                // what it already held is the shape of a stall, and it
5054                                // cannot correct for what it is not told.
5055                                obs: bound(
5056                                    &format!(
5057                                        "\n[wrote {target}] ({} chars{})\n",
5058                                        body.chars().count(),
5059                                        if wrote.moved_the_workspace() {
5060                                            ""
5061                                        } else {
5062                                            ", identical to what was already there — the \
5063                                         workspace did not change"
5064                                        }
5065                                    ),
5066                                    cap,
5067                                    ObsKind::Write,
5068                                ),
5069                                kind: ObsKind::Write,
5070                                target: Some(target.clone()),
5071                                changed: wrote.moved_the_workspace(),
5072                                remember,
5073                            }
5074                        }
5075                        Err(e) => Dispatched::go("write error", format!("\n[write error] {e}\n")),
5076                    }
5077                }
5078            }
5079        }
5080        EDIT_FILE_TOOL => {
5081            let path = s("path").unwrap_or_default();
5082            let search = s("search").unwrap_or_default();
5083            let replacement = s("replace").unwrap_or_default();
5084            if path.is_empty() || search.is_empty() {
5085                return Ok(Dispatched::go(
5086                    "edit missing arguments",
5087                    "\n[edit error] edit_file needs a \"path\" and a non-empty \"search\"\n",
5088                ));
5089            }
5090            // The same act as `write_file`, so the same gate on the same path — a
5091            // partial edit is not a lesser write, and a policy that refuses one
5092            // refuses the other. The replacement text is offered as the content so
5093            // a human deciding an `Ask` sees what is going in rather than only
5094            // where.
5095            match gate(
5096                ws,
5097                approver,
5098                store,
5099                run_id,
5100                step,
5101                Act::Write,
5102                path,
5103                Some(replacement),
5104                watch,
5105                depth,
5106            )
5107            .await?
5108            {
5109                Gated::Refused { decision, obs } => Dispatched::go(decision, obs),
5110                Gated::Paused { request_id } => Dispatched::Pause { request_id },
5111                Gated::Go {
5112                    target,
5113                    content,
5114                    remember,
5115                } => {
5116                    let replacement = content.unwrap_or_default();
5117                    match ws.edit_file(&target, search, &replacement) {
5118                        Ok(wrote) => {
5119                            // The replaced text against the text that replaced it.
5120                            // Everything outside the match is byte-identical by
5121                            // construction, so this is the same answer comparing the
5122                            // whole file would give, without reading it twice.
5123                            record_edit(
5124                                store,
5125                                run_id,
5126                                step,
5127                                EDIT_FILE_TOOL,
5128                                &target,
5129                                search,
5130                                &replacement,
5131                            );
5132                            Dispatched::Continue {
5133                                decision: format!("edited {target}"),
5134                                obs: bound(
5135                                    &format!(
5136                                        "\n[edited {target}] replaced {} chars with {}{}\n",
5137                                        search.chars().count(),
5138                                        replacement.chars().count(),
5139                                        if wrote.moved_the_workspace() {
5140                                            ""
5141                                        } else {
5142                                            " — the replacement is identical to what was there, so \
5143                                         the workspace did not change"
5144                                        }
5145                                    ),
5146                                    cap,
5147                                    ObsKind::Write,
5148                                ),
5149                                kind: ObsKind::Write,
5150                                target: Some(target.clone()),
5151                                changed: wrote.moved_the_workspace(),
5152                                remember,
5153                            }
5154                        }
5155                        // A miss or an ambiguity is the model's to fix and says how:
5156                        // an edit that guessed which of three occurrences was meant
5157                        // is the failure this tool exists to make impossible.
5158                        Err(e) => Dispatched::go("edit error", format!("\n[edit error] {e}\n")),
5159                    }
5160                }
5161            }
5162        }
5163        EXEC_TOOL => {
5164            let argv: Vec<String> = a
5165                .get("argv")
5166                .and_then(|v| v.as_array())
5167                .map(|v| {
5168                    v.iter()
5169                        .filter_map(|x| x.as_str().map(str::to_string))
5170                        .collect()
5171                })
5172                .unwrap_or_default();
5173            let Some(program) = argv.first().filter(|p| !p.trim().is_empty()).cloned() else {
5174                return Ok(Dispatched::go(
5175                    "exec missing argv",
5176                    "\n[exec error] exec needs a non-empty \"argv\" array whose first element is \
5177                     the program\n",
5178                ));
5179            };
5180            // Two checks, and the second is the one that makes a useful rule
5181            // writable. The program alone is what `deny_exec(\"rm\")` names, and it
5182            // holds whatever the arguments are. The whole argv is what
5183            // `allow_exec(\"git log*\")` and `deny_exec(\"git push*\")` name — a
5184            // check on the program could not tell those two apart, which is the
5185            // weakness the git built-ins were built to route around and the reason
5186            // they still exist.
5187            let joined = argv.join(" ");
5188            let mut remembered: Vec<Rule> = Vec::new();
5189            let mut targets = vec![program.clone()];
5190            if joined != program {
5191                targets.push(joined.clone());
5192            }
5193            for target in targets {
5194                match gate(
5195                    ws,
5196                    approver,
5197                    store,
5198                    run_id,
5199                    step,
5200                    Act::Exec,
5201                    &target,
5202                    None,
5203                    watch,
5204                    depth,
5205                )
5206                .await?
5207                {
5208                    Gated::Refused { decision, obs } => return Ok(Dispatched::go(decision, obs)),
5209                    Gated::Paused { request_id } => return Ok(Dispatched::Pause { request_id }),
5210                    Gated::Go { remember, .. } => remembered.extend(remember),
5211                }
5212            }
5213
5214            let outcome = Exec::new(ws.root(), exec_timeout, cap).run(&argv).await?;
5215            let (decision, obs) = match &outcome {
5216                ExecOutcome::Unavailable { reason } => (
5217                    format!("{program} unavailable"),
5218                    format!(
5219                        "\n[exec unavailable] {reason}. This machine cannot run that command; \
5220                         try another, or carry on without it.\n"
5221                    ),
5222                ),
5223                ExecOutcome::TimedOut { after } => (
5224                    format!("{program} timed out"),
5225                    format!(
5226                        "\n[exec timed out] `{joined}` was killed after {}s without finishing. \
5227                         Nothing it printed was captured. Run something narrower, or expect this \
5228                         command to need longer than this run allows.\n",
5229                        after.as_secs()
5230                    ),
5231                ),
5232                ExecOutcome::Ran {
5233                    code,
5234                    stdout,
5235                    stderr,
5236                    elided,
5237                } => {
5238                    let body = crate::verify::joined_streams(stdout, stderr);
5239                    (
5240                        format!(
5241                            "exec {program} {}",
5242                            code.map_or("(signal)".to_string(), |c| format!("exit {c}"))
5243                        ),
5244                        format!(
5245                            "\n[exec `{joined}` {}]{}\n{}\n",
5246                            code.map_or("killed by a signal".to_string(), |c| format!("exit {c}")),
5247                            if *elided > 0 {
5248                                format!(
5249                                    " ({elided} characters of output elided from the middle; the \
5250                                     start and the end are both here)"
5251                                )
5252                            } else {
5253                                String::new()
5254                            },
5255                            if body.trim().is_empty() {
5256                                "(no output)"
5257                            } else {
5258                                body.trim_end()
5259                            }
5260                        ),
5261                    )
5262                }
5263            };
5264            Dispatched::Continue {
5265                decision,
5266                obs,
5267                kind: ObsKind::Tool,
5268                target: None,
5269                // Deliberately not `changed`, even for a command that plainly
5270                // wrote files. The stall signal asks whether the agent is getting
5271                // anywhere, and running the same build a fourth time without
5272                // editing anything in between is the shape of an agent that is
5273                // not — the argv is part of the call signature, so a *different*
5274                // command is never mistaken for a repeat.
5275                changed: false,
5276                remember: remembered,
5277            }
5278        }
5279        // Loading one skill's body. Offered only when skills are configured, so
5280        // the name is not special otherwise: a run without skills falls through
5281        // to the unknown-tool arm like any other name.
5282        //
5283        // The body is read through the policy at the moment it is asked for,
5284        // against the skill file's ABSOLUTE path — a skills directory usually
5285        // sits outside the workspace root, so this is a policy check, not a
5286        // workspace-relative one (see `gate`).
5287        READ_SKILL_TOOL if !skills.is_empty() => {
5288            let name = s("name").unwrap_or_default();
5289            let Some(skill) = skills.get(name) else {
5290                // Not an error and not a failed run: the model asked for
5291                // something that does not exist, so it is told what does.
5292                return Ok(Dispatched::go(
5293                    format!("unknown skill {name}"),
5294                    format!(
5295                        "\n[read_skill] there is no skill named {name:?}. Available: {}\n",
5296                        skills.names().join(", ")
5297                    ),
5298                ));
5299            };
5300            let path = skill.path.display().to_string();
5301            match gate(
5302                ws,
5303                approver,
5304                store,
5305                run_id,
5306                step,
5307                Act::Read,
5308                &path,
5309                None,
5310                watch,
5311                depth,
5312            )
5313            .await?
5314            {
5315                Gated::Refused { decision, obs } => Dispatched::go(decision, obs),
5316                Gated::Paused { request_id } => Dispatched::Pause { request_id },
5317                Gated::Go {
5318                    target, remember, ..
5319                } => match std::fs::read_to_string(&target) {
5320                    Ok(body) => {
5321                        // Capped where it enters the context, like every other
5322                        // tool result, under the same budget-derived cap.
5323                        let (body, truncated) = crate::tools::cap_result(body, cap);
5324                        info!(run_id, step, skill = name, truncated, "skill read");
5325                        Dispatched::Continue {
5326                            decision: format!("read skill {name}"),
5327                            obs: format!("\n[skill {name}]\n{body}\n"),
5328                            kind: ObsKind::Skill,
5329                            target: Some(name.to_string()),
5330                            changed: false,
5331                            remember,
5332                        }
5333                    }
5334                    Err(e) => Dispatched::go(
5335                        format!("skill {name} read error"),
5336                        format!("\n[skill {name} error] {e}\n"),
5337                    ),
5338                },
5339            }
5340        }
5341        // Spreadsheet built-ins (0.14.0). Each one gates on the path the model
5342        // named, with `Act::Read` or `Act::Write`, through the same `gate` the
5343        // file built-ins use — so a refusal names the workbook rather than the
5344        // tool, a child's narrowed policy applies to documents exactly as it
5345        // applies to source, and the underlying module reaches the file only
5346        // through `Workspace`'s policy-checked byte IO.
5347        //
5348        // This is why they are built-ins and not registered `Tool`s: a registered
5349        // tool is authorised once by an exec check on its name and then does
5350        // whatever it likes to the filesystem, which for a capability whose whole
5351        // job is reading and writing the user's files is the wrong boundary.
5352        #[cfg(feature = "xlsx")]
5353        XLSX_SHEETS_TOOL | XLSX_READ_TOOL => {
5354            let path = s("path").unwrap_or_default();
5355            let sheet = s("sheet");
5356            let listing = name == XLSX_SHEETS_TOOL;
5357            match gate(
5358                ws,
5359                approver,
5360                store,
5361                run_id,
5362                step,
5363                Act::Read,
5364                path,
5365                None,
5366                watch,
5367                depth,
5368            )
5369            .await?
5370            {
5371                Gated::Refused { decision, obs } => Dispatched::go(decision, obs),
5372                Gated::Paused { request_id } => Dispatched::Pause { request_id },
5373                Gated::Go {
5374                    target, remember, ..
5375                } => {
5376                    let read = if listing {
5377                        crate::tools::documents::xlsx::sheet_names(ws, &target)
5378                            .map(|names| names.join("\n"))
5379                    } else {
5380                        crate::tools::documents::xlsx::read_sheet(ws, &target, sheet)
5381                    };
5382                    match read {
5383                        Ok(text) => Dispatched::Continue {
5384                            decision: format!("read {target}"),
5385                            obs: format!(
5386                                "\n[{name} {target}]\n{}\n",
5387                                bound(&text, cap, ObsKind::Read)
5388                            ),
5389                            kind: ObsKind::Read,
5390                            target: Some(target.clone()),
5391                            changed: false,
5392                            remember,
5393                        },
5394                        // A corrupt or non-xlsx file is the model's problem to
5395                        // route around, not the run's to die on.
5396                        Err(e) => Dispatched::go(
5397                            "spreadsheet read error",
5398                            format!("\n[{name} error] {e}\n"),
5399                        ),
5400                    }
5401                }
5402            }
5403        }
5404        #[cfg(feature = "xlsx")]
5405        XLSX_WRITE_TOOL | XLSX_SET_CELL_TOOL => {
5406            let path = s("path").unwrap_or_default();
5407            if path.is_empty() {
5408                return Ok(Dispatched::go(
5409                    "spreadsheet missing path",
5410                    format!("\n[{name} error] needs a \"path\" relative to the workspace root\n"),
5411                ));
5412            }
5413            let sheet = s("sheet").unwrap_or_default().to_string();
5414            let cell = s("cell").unwrap_or_default().to_string();
5415            let value = s("value").unwrap_or_default().to_string();
5416            let rows: Vec<Vec<String>> = call
5417                .arguments
5418                .get("rows")
5419                .and_then(|r| serde_json::from_value(r.clone()).ok())
5420                .unwrap_or_default();
5421            let creating = name == XLSX_WRITE_TOOL;
5422            // The approval preview is the change being asked for, not the file's
5423            // bytes: a human deciding on a spreadsheet write needs to see what it
5424            // does, and a workbook's raw bytes tell them nothing.
5425            let preview = if creating {
5426                format!(
5427                    "create workbook {path} sheet {sheet} with {} row(s)",
5428                    rows.len()
5429                )
5430            } else {
5431                format!("set {sheet}!{cell} to {value:?} in {path}")
5432            };
5433            match gate(
5434                ws,
5435                approver,
5436                store,
5437                run_id,
5438                step,
5439                Act::Write,
5440                path,
5441                Some(&preview),
5442                watch,
5443                depth,
5444            )
5445            .await?
5446            {
5447                Gated::Refused { decision, obs } => Dispatched::go(decision, obs),
5448                Gated::Paused { request_id } => Dispatched::Pause { request_id },
5449                Gated::Go {
5450                    target, remember, ..
5451                } => {
5452                    let wrote = if creating {
5453                        crate::tools::documents::xlsx::write_new(ws, &target, &sheet, &rows)
5454                    } else {
5455                        crate::tools::documents::xlsx::set_cell(ws, &target, &sheet, &cell, &value)
5456                    };
5457                    match wrote {
5458                        Ok(w) => Dispatched::Continue {
5459                            decision: format!("wrote {target}"),
5460                            obs: format!(
5461                                "\n[{name} {target}] {}{}\n",
5462                                preview,
5463                                if w.moved_the_workspace() {
5464                                    ""
5465                                } else {
5466                                    " — identical to what was already there, the \
5467                                     workspace did not change"
5468                                }
5469                            ),
5470                            kind: ObsKind::Write,
5471                            target: Some(target.clone()),
5472                            changed: w.moved_the_workspace(),
5473                            remember,
5474                        },
5475                        Err(e) => Dispatched::go(
5476                            "spreadsheet write error",
5477                            format!("\n[{name} error] {e}\n"),
5478                        ),
5479                    }
5480                }
5481            }
5482        }
5483        // The remaining document readers. Same gate, same reason as the
5484        // spreadsheet arms above: `Act::Read` on the path the model named.
5485        #[cfg(any(
5486            feature = "docx",
5487            feature = "pptx",
5488            feature = "pdf",
5489            feature = "barcode"
5490        ))]
5491        n if is_document_read(n) => {
5492            let path = s("path").unwrap_or_default();
5493            match gate(
5494                ws,
5495                approver,
5496                store,
5497                run_id,
5498                step,
5499                Act::Read,
5500                path,
5501                None,
5502                watch,
5503                depth,
5504            )
5505            .await?
5506            {
5507                Gated::Refused { decision, obs } => Dispatched::go(decision, obs),
5508                Gated::Paused { request_id } => Dispatched::Pause { request_id },
5509                Gated::Go {
5510                    target, remember, ..
5511                } => match read_document(ws, name, &target) {
5512                    Ok(text) => Dispatched::Continue {
5513                        decision: format!("read {target}"),
5514                        obs: format!(
5515                            "\n[{name} {target}]\n{}\n",
5516                            bound(&text, cap, ObsKind::Read)
5517                        ),
5518                        kind: ObsKind::Read,
5519                        target: Some(target.clone()),
5520                        changed: false,
5521                        remember,
5522                    },
5523                    Err(e) => {
5524                        Dispatched::go("document read error", format!("\n[{name} error] {e}\n"))
5525                    }
5526                },
5527            }
5528        }
5529        // The remaining document writers.
5530        #[cfg(any(feature = "docx", feature = "pdf"))]
5531        n if is_document_write(n) => {
5532            let path = s("path").unwrap_or_default();
5533            if path.is_empty() {
5534                return Ok(Dispatched::go(
5535                    "document missing path",
5536                    format!("\n[{name} error] needs a \"path\" relative to the workspace root\n"),
5537                ));
5538            }
5539            let preview = describe_document_write(name, &call.arguments);
5540            match gate(
5541                ws,
5542                approver,
5543                store,
5544                run_id,
5545                step,
5546                Act::Write,
5547                path,
5548                Some(&preview),
5549                watch,
5550                depth,
5551            )
5552            .await?
5553            {
5554                Gated::Refused { decision, obs } => Dispatched::go(decision, obs),
5555                Gated::Paused { request_id } => Dispatched::Pause { request_id },
5556                Gated::Go {
5557                    target, remember, ..
5558                } => match write_document(ws, name, &target, &call.arguments) {
5559                    Ok(w) => Dispatched::Continue {
5560                        decision: format!("wrote {target}"),
5561                        obs: format!(
5562                            "\n[{name} {target}] {preview}{}\n",
5563                            if w.moved_the_workspace() {
5564                                ""
5565                            } else {
5566                                " — identical to what was already there, the workspace \
5567                                 did not change"
5568                            }
5569                        ),
5570                        kind: ObsKind::Write,
5571                        target: Some(target.clone()),
5572                        changed: w.moved_the_workspace(),
5573                        remember,
5574                    },
5575                    Err(e) => {
5576                        Dispatched::go("document write error", format!("\n[{name} error] {e}\n"))
5577                    }
5578                },
5579            }
5580        }
5581        // A tool the embedding program registered. Registration made it
5582        // available; this check is what authorizes the call, on exactly the terms
5583        // an MCP tool gets — an exec check on the name the model used. Deciding
5584        // it here rather than at registration is what lets one policy layer hand
5585        // over a toolbox and another refuse a single tool in it.
5586        //
5587        // Registered tools are matched before the MCP arm and after the
5588        // built-ins, and `Toolbox::validate` has already guaranteed the three
5589        // sets are disjoint, so the order is documentation rather than a
5590        // tie-break.
5591        GIT_LOG_TOOL | GIT_STATUS_TOOL | GIT_DIFF_TOOL | GIT_ADD_TOOL | GIT_COMMIT_TOOL => {
5592            // Paths the model named, if any. Every one of them is data: `argv`
5593            // puts them after `--` and refuses a leading `-`.
5594            let paths: Vec<String> = a
5595                .get("paths")
5596                .and_then(|v| v.as_array())
5597                .map(|v| {
5598                    v.iter()
5599                        .filter_map(|x| x.as_str().map(str::to_string))
5600                        .collect()
5601                })
5602                .unwrap_or_default();
5603
5604            // What the policy is asked, per tool. Reading history reads `.git`.
5605            // Staging copies a file's bytes into the object store, so it needs
5606            // `Act::Read` on that file — which is what stops a path the policy
5607            // denies from reaching a commit. Committing writes `.git`.
5608            let (repo_act, path_act) = match name {
5609                GIT_ADD_TOOL => (Act::Write, Some(Act::Read)),
5610                GIT_COMMIT_TOOL => (Act::Write, None),
5611                _ => (Act::Read, Some(Act::Read)),
5612            };
5613
5614            let mut remembered: Vec<Rule> = Vec::new();
5615            let mut targets: Vec<(Act, String)> = vec![(repo_act, GIT_DIR.to_string())];
5616            if let Some(act) = path_act {
5617                targets.extend(paths.iter().map(|p| (act, p.clone())));
5618            }
5619            let mut refused: Option<Dispatched> = None;
5620            for (act, target) in targets {
5621                match gate(
5622                    ws, approver, store, run_id, step, act, &target, None, watch, depth,
5623                )
5624                .await?
5625                {
5626                    Gated::Refused { decision, obs } => {
5627                        refused = Some(Dispatched::go(decision, obs));
5628                        break;
5629                    }
5630                    Gated::Paused { request_id } => {
5631                        refused = Some(Dispatched::Pause { request_id });
5632                        break;
5633                    }
5634                    Gated::Go { remember, .. } => remembered.extend(remember),
5635                }
5636            }
5637            if let Some(d) = refused {
5638                return Ok(d);
5639            }
5640
5641            let cmd = match name {
5642                GIT_STATUS_TOOL => GitCmd::Status { paths },
5643                GIT_DIFF_TOOL => GitCmd::Diff {
5644                    staged: a.get("staged").and_then(serde_json::Value::as_bool) == Some(true),
5645                    paths,
5646                },
5647                GIT_LOG_TOOL => GitCmd::Log {
5648                    // Clamped rather than trusted: a model asking for the whole
5649                    // history of a large repository would blow the observation
5650                    // cap and learn nothing the first twenty commits do not say.
5651                    max_count: a
5652                        .get("max_count")
5653                        .and_then(serde_json::Value::as_u64)
5654                        .unwrap_or(20)
5655                        .clamp(1, 200) as u32,
5656                    paths,
5657                },
5658                GIT_ADD_TOOL => {
5659                    if paths.is_empty() {
5660                        return Ok(Dispatched::go(
5661                            "git_add missing paths",
5662                            "\n[git error] git_add needs a non-empty \"paths\" array\n".to_string(),
5663                        ));
5664                    }
5665                    GitCmd::Add { paths }
5666                }
5667                _ => {
5668                    let Some(message) = s("message").filter(|m| !m.trim().is_empty()) else {
5669                        return Ok(Dispatched::go(
5670                            "git_commit missing message",
5671                            "\n[git error] git_commit needs a non-empty \"message\"\n".to_string(),
5672                        ));
5673                    };
5674                    GitCmd::Commit {
5675                        message: message.to_string(),
5676                        identity: identity.clone(),
5677                    }
5678                }
5679            };
5680
5681            let git = Git::new(ws.policy(), ws.root(), cap);
5682            // 0.21.0 — a refused git built-in costs a step, not the run.
5683            //
5684            // Until here, `Git::run`'s refusal left the loop as `Error::Refused`, so
5685            // one speculative `git status` under a policy denying `Act::Exec` for
5686            // `git` escalated the whole run. Found while running the 0.20.0 live
5687            // session and recorded in `docs/CONTRACT.md`; not fixed there because
5688            // that release touched no tool. Every other refusal in this crate is an
5689            // observation the model reads and adapts to, and this is now one too.
5690            //
5691            // Both of `Git::run`'s refusals land here — the policy denying the `git`
5692            // program, and a path that would be read as an option — and the row is
5693            // written exactly as `gate` writes one, so a reader cannot tell a git
5694            // refusal from any other and does not have to.
5695            let outcome = match git.run(&cmd).await {
5696                Ok(out) => out,
5697                Err(Error::Refused {
5698                    act,
5699                    target,
5700                    rule,
5701                    layer,
5702                }) => {
5703                    let mut ev = PolicyEvent::refusal(step, act.clone(), target.clone());
5704                    ev.rule = rule.clone();
5705                    ev.layer = layer.clone();
5706                    store.record_event(run_id, &ev)?;
5707                    // Qualified: this arm has a local `refused` holding the gate's
5708                    // verdict for the paths, which shadows the function.
5709                    crate::run::refused(watch, run_id, depth, &ev);
5710                    let why = rule
5711                        .as_deref()
5712                        .map(|r| format!(" (rule {r})"))
5713                        .unwrap_or_default();
5714                    return Ok(Dispatched::go(
5715                        format!("{name} refused"),
5716                        format!(
5717                            "\n[{act} refused] {target}{why} — the policy forbids this; carry on \
5718                             without git\n"
5719                        ),
5720                    ));
5721                }
5722                Err(e) => return Err(e),
5723            };
5724            match outcome {
5725                GitOutcome::Unavailable { reason } => Dispatched::go(
5726                    "git unavailable",
5727                    format!(
5728                        "\n[git unavailable] {reason}. This workspace cannot be worked as a git \
5729                         repository; carry on without it.\n"
5730                    ),
5731                ),
5732                out @ GitOutcome::Ran { .. } => {
5733                    let GitOutcome::Ran {
5734                        code,
5735                        stdout,
5736                        stderr,
5737                    } = &out
5738                    else {
5739                        unreachable!()
5740                    };
5741                    let ok = out.ok();
5742                    let body = if stdout.trim().is_empty() && !ok {
5743                        stderr.clone()
5744                    } else {
5745                        stdout.clone()
5746                    };
5747                    // A git that ran and failed is an observation, not a run
5748                    // failure — the same treatment a malformed regex gets from
5749                    // `grep`. The model reads the message and adapts.
5750                    Dispatched::Continue {
5751                        decision: format!(
5752                            "{name} {}",
5753                            if ok {
5754                                "ok".to_string()
5755                            } else {
5756                                format!("exit {}", code.map_or("signal".into(), |c| c.to_string()))
5757                            }
5758                        ),
5759                        obs: format!(
5760                            "\n[{name}{}]\n{}\n",
5761                            if ok {
5762                                String::new()
5763                            } else {
5764                                " failed".to_string()
5765                            },
5766                            if body.trim().is_empty() {
5767                                "(no output)"
5768                            } else {
5769                                body.trim_end()
5770                            }
5771                        ),
5772                        kind: ObsKind::Tool,
5773                        target: None,
5774                        changed: matches!(cmd, GitCmd::Add { .. } | GitCmd::Commit { .. }) && ok,
5775                        remember: remembered,
5776                    }
5777                }
5778            }
5779        }
5780        name if custom.owns(name) => {
5781            match gate(
5782                ws,
5783                approver,
5784                store,
5785                run_id,
5786                step,
5787                Act::Exec,
5788                name,
5789                None,
5790                watch,
5791                depth,
5792            )
5793            .await?
5794            {
5795                Gated::Refused { decision, obs } => Dispatched::go(decision, obs),
5796                Gated::Paused { request_id } => Dispatched::Pause { request_id },
5797                Gated::Go { remember, .. } => {
5798                    // `validate` ran at run start, so the lookup cannot miss.
5799                    let tool = custom.get(name).expect("owns() and get() agree");
5800                    match tool.invoke(&call.arguments).await {
5801                        Ok(out) => {
5802                            let (out, truncated) = crate::tools::cap_result(out, cap);
5803                            info!(run_id, step, tool = name, truncated, "registered tool call");
5804                            Dispatched::Continue {
5805                                decision: format!("called {name}"),
5806                                obs: format!("\n[{name}]\n{out}\n"),
5807                                kind: ObsKind::Tool,
5808                                target: Some(name.to_string()),
5809                                changed: false,
5810                                remember,
5811                            }
5812                        }
5813                        // A tool's own failure is the model's problem to route
5814                        // around, not the run's to die on — the same treatment a
5815                        // bad regex gets from grep.
5816                        Err(e) => {
5817                            info!(run_id, step, tool = name, error = %e, "registered tool failed");
5818                            Dispatched::Continue {
5819                                decision: format!("{name} failed"),
5820                                obs: format!("\n[{name} error] {e}\n"),
5821                                kind: ObsKind::Error,
5822                                target: None,
5823                                changed: false,
5824                                remember,
5825                            }
5826                        }
5827                    }
5828                }
5829            }
5830        }
5831        // An MCP tool. Invoking it is an exec check on its namespaced name, so a
5832        // policy can allow a server generally and still deny one of its tools.
5833        name if mcp.owns(name) => {
5834            let verdict = ws.policy().check(Act::Exec, name);
5835            if verdict.effect != Effect::Allow {
5836                let mut ev = PolicyEvent::refusal(step, "exec", name);
5837                ev.rule = verdict.rule.clone();
5838                ev.layer = verdict.layer.clone();
5839                store.record_event(run_id, &ev)?;
5840                refused(watch, run_id, depth, &ev);
5841                let why = verdict
5842                    .rule
5843                    .as_deref()
5844                    .map(|r| format!(" (rule {r})"))
5845                    .unwrap_or_default();
5846                return Ok(Dispatched::go(
5847                    format!("{name} refused"),
5848                    format!("\n[{name} refused]{why} — the policy forbids calling this tool\n"),
5849                ));
5850            }
5851            let out = mcp
5852                .call_media(
5853                    name,
5854                    &call.arguments,
5855                    store,
5856                    run_id,
5857                    step,
5858                    cap,
5859                    watch,
5860                    depth,
5861                    pending_media,
5862                )
5863                .await?;
5864            Dispatched::seen(
5865                format!("called {name}"),
5866                format!("\n[{name}]\n{out}\n"),
5867                ObsKind::Mcp,
5868                Some(name.to_string()),
5869            )
5870        }
5871        other => Dispatched::go(
5872            format!("unknown tool {other}"),
5873            format!("\n[unknown tool {other}]\n"),
5874        ),
5875    })
5876}
5877
5878/// What the policy and the approver together decided about one action.
5879enum Gated {
5880    /// Perform it, possibly in the form an approver rewrote it into.
5881    Go {
5882        target: String,
5883        content: Option<String>,
5884        remember: Vec<Rule>,
5885    },
5886    /// Do not perform it; `obs` is what the model is told.
5887    Refused { decision: String, obs: String },
5888    /// An approver deferred the decision.
5889    Paused { request_id: i64 },
5890}
5891
5892/// Evaluate one action against the policy, consulting `approver` only for the
5893/// sensitive-but-permitted tier.
5894///
5895/// A denied action never reaches the approver — refusal and approval are
5896/// different things. An approver that rewrites the action has the rewritten
5897/// form re-evaluated here, so it can narrow or redirect within the policy but
5898/// cannot move an action across a deny.
5899#[allow(clippy::too_many_arguments)]
5900async fn gate(
5901    ws: &Workspace,
5902    approver: &dyn Approver,
5903    store: &Store,
5904    run_id: i64,
5905    step: u32,
5906    act: Act,
5907    target: &str,
5908    content: Option<&str>,
5909    watch: &Watch<'_>,
5910    depth: u32,
5911) -> Result<Gated> {
5912    let kind = format!("{act:?}").to_lowercase();
5913    // Read and write targets are workspace paths, and are resolved so a symlink
5914    // cannot smuggle one outside the root. Exec and net targets are *names* — a
5915    // binary, an MCP tool, a registered tool, a host — and must not be resolved
5916    // against the root, or a file that happens to share a tool's name would
5917    // change what the policy said about calling it.
5918    //
5919    // An ABSOLUTE read/write target is not a workspace path at all — a skill
5920    // file normally lives outside the root — so it is decided by the policy
5921    // directly. `check_path` would resolve it against the root and deny it
5922    // unconditionally, which would make `read_skill` refusable only by accident.
5923    // This relaxes what the *gate* says, not what the workspace does:
5924    // `Workspace::resolve` rejects absolute paths outright and both `read_file`
5925    // and `write_file` go through it, so an absolute path still cannot leave the
5926    // root (asserted in tests/skills.rs).
5927    let check = |act: Act, target: &str| match act {
5928        Act::Exec | Act::Net => ws.policy().check(act, target),
5929        Act::Read | Act::Write if Path::new(target).is_absolute() => ws.policy().check(act, target),
5930        Act::Read | Act::Write => ws.check_path(act, target),
5931    };
5932    let verdict = check(act, target);
5933
5934    match verdict.effect {
5935        Effect::Deny => {
5936            let mut ev = PolicyEvent::refusal(step, &kind, target);
5937            if let (Some(rule), layer) = (verdict.rule.clone(), verdict.layer.clone()) {
5938                ev.rule = Some(rule);
5939                ev.layer = layer;
5940            }
5941            store.record_event(run_id, &ev)?;
5942            refused(watch, run_id, depth, &ev);
5943            let why = verdict
5944                .rule
5945                .as_deref()
5946                .map(|r| format!(" (rule {r})"))
5947                .unwrap_or_default();
5948            Ok(Gated::Refused {
5949                decision: format!("{kind} refused"),
5950                obs: format!("\n[{kind} refused] {target}{why} — the policy forbids this; try another path\n"),
5951            })
5952        }
5953        Effect::Allow => Ok(Gated::Go {
5954            target: target.to_string(),
5955            content: content.map(str::to_string),
5956            remember: Vec::new(),
5957        }),
5958        Effect::Ask => {
5959            let mut request = Request::new(act, target);
5960            if let Some(c) = content {
5961                request = request.with_content(c);
5962            }
5963            watch.emit(RunEvent::at_depth(
5964                run_id,
5965                step,
5966                depth,
5967                EventKind::ApprovalRequested {
5968                    act: kind.clone(),
5969                    target: target.to_string(),
5970                },
5971            ));
5972            match approver.decide(&request).await {
5973                Decision::Approve { modified, remember } => {
5974                    let performed = modified.unwrap_or_else(|| request.clone());
5975                    // The rewritten action gets the same scrutiny as the original.
5976                    let recheck = check(act, &performed.target);
5977                    if recheck.effect == Effect::Deny {
5978                        let mut ev = PolicyEvent::refusal(step, &kind, &performed.target);
5979                        ev.rule = recheck.rule.clone();
5980                        ev.layer = recheck.layer.clone();
5981                        store.record_event(run_id, &ev)?;
5982                        // A refusal, not a decision: the row is a refusal too, and
5983                        // the approval it overrode never took effect.
5984                        refused(watch, run_id, depth, &ev);
5985                        return Ok(Gated::Refused {
5986                            decision: format!("{kind} refused after approval"),
5987                            obs: format!(
5988                                "\n[{kind} refused] {} — an approved change may not cross a deny\n",
5989                                performed.target
5990                            ),
5991                        });
5992                    }
5993                    let mut ev = PolicyEvent::decision(step, &kind, target, "approve", "approver");
5994                    if performed.target != target {
5995                        ev = ev.with_performed(&performed.target);
5996                    }
5997                    store.record_event(run_id, &ev)?;
5998                    decided(watch, run_id, depth, &ev);
5999                    Ok(Gated::Go {
6000                        target: performed.target,
6001                        content: performed.content,
6002                        remember,
6003                    })
6004                }
6005                Decision::Deny { reason } => {
6006                    let ev = PolicyEvent::decision(step, &kind, target, "deny", "approver");
6007                    store.record_event(run_id, &ev)?;
6008                    decided(watch, run_id, depth, &ev);
6009                    Ok(Gated::Refused {
6010                        decision: format!("{kind} denied"),
6011                        obs: format!("\n[{kind} denied] {target} — {reason}\n"),
6012                    })
6013                }
6014                Decision::Defer => {
6015                    let ev = PolicyEvent::decision(step, &kind, target, "defer", "approver");
6016                    store.record_event(run_id, &ev)?;
6017                    decided(watch, run_id, depth, &ev);
6018                    let request_id = store.put_pending(run_id, step, &kind, target, content)?;
6019                    Ok(Gated::Paused { request_id })
6020                }
6021            }
6022        }
6023    }
6024}
6025
6026/// The key one workspace's durable memory is stored under.
6027///
6028/// Canonicalised, so the same directory reached by two different paths is one
6029/// workspace rather than two. The path as given is the fallback: a root that cannot
6030/// be canonicalised yet should still have memory rather than none.
6031fn memory_key(root: &Path) -> String {
6032    std::fs::canonicalize(root)
6033        .unwrap_or_else(|_| root.to_path_buf())
6034        .to_string_lossy()
6035        .into_owned()
6036}
6037
6038/// Call the provider, retrying a failing call up to `max_retries` times. Each
6039/// failed attempt is recorded in the trace. After the limit the error is
6040/// escalated (recorded, the run marked `escalated`, and returned).
6041#[allow(clippy::too_many_arguments)]
6042async fn complete_with_retry<P: Provider>(
6043    provider: &P,
6044    request: &CompletionRequest,
6045    contract: &TaskContract,
6046    store: &Store,
6047    run_id: i64,
6048    step: u32,
6049    watch: &Watch<'_>,
6050    depth: u32,
6051    stream: bool,
6052) -> Result<CompletionResponse> {
6053    // The general media boundary. Every completion in every loop goes through
6054    // here, so this covers an out-of-tree `Provider` as well as the three built
6055    // in — and it runs before the first attempt, so a refused request costs no
6056    // retry, no token and no wall clock. The built-in providers check again
6057    // inside their own `complete`, which is what stops a caller reaching one
6058    // directly from bypassing it.
6059    #[cfg(feature = "media")]
6060    crate::provider::ensure_media_accepted(provider.name(), provider.accepts_images(), request)?;
6061    let max_retries = contract.max_retries;
6062    let retry = contract.retry;
6063    let max_duration = contract.max_duration;
6064    let mut attempt = 0;
6065    loop {
6066        // 0.18.0: one `provider_calls` row per attempt, written here because this
6067        // is the only place that knows an attempt happened — a `Fallback` is one
6068        // `complete` call from the outside, and `steps.tokens` collapses a
6069        // retried step into a single integer attributed to nothing.
6070        //
6071        // The clock is the harness's own and brackets `complete`, so it includes
6072        // this crate's request building and stream consumption as well as the
6073        // provider's part. `CONTRACT.md` says so rather than implying the figure
6074        // is the vendor's.
6075        let started = std::time::Instant::now();
6076        // A streamed attempt and a plain one are the same attempt: the same clock,
6077        // the same `provider_calls` row, the same retry classification. The only
6078        // difference is that the deltas of a streamed one reach the observer while
6079        // it is still in flight instead of being accumulated in silence.
6080        let outcome = if stream {
6081            stream_completion(provider, request, watch, run_id, step, depth).await
6082        } else {
6083            provider.complete(request.clone()).await
6084        };
6085        let latency_ms = started.elapsed().as_millis() as u64;
6086        record_provider_call(
6087            store,
6088            run_id,
6089            step,
6090            attempt,
6091            provider.name(),
6092            latency_ms,
6093            &outcome,
6094        );
6095        // 0.22.0 — and what the provider looked up while serving it: the sources
6096        // it cited and the server-side calls it ran, including the ones that
6097        // failed inside an otherwise successful response.
6098        record_web_activity(store, watch, run_id, step, depth, &outcome);
6099        match outcome {
6100            Ok(response) => return Ok(response),
6101            // Only ask again if asking again could answer differently. Before
6102            // 0.11.0 every error was retried identically — including a 401 and a
6103            // missing API key, which cost three calls each to learn nothing, while
6104            // the one failure worth waiting for got no wait at all.
6105            Err(e) if attempt < max_retries && retryable(&e) => {
6106                attempt += 1;
6107                let wait = retry.wait(attempt, retry_after(&e));
6108                // A wait is not a way to escape the time budget: if the run's
6109                // deadline falls inside this sleep, stop now rather than after it.
6110                if let Some(max) = max_duration {
6111                    let elapsed = store.elapsed_secs(run_id)?;
6112                    if elapsed + wait.as_secs_f64() > max.as_secs_f64() {
6113                        store.record(
6114                            run_id,
6115                            &StepRecord::new(
6116                                step,
6117                                // Same "escalated after <kind>" prefix as every
6118                                // other escalation, so a trace reader grepping for
6119                                // escalations does not miss this class of them.
6120                                format!(
6121                                    "escalated after {} (a retry would outlast the time budget)",
6122                                    kind_of(&e)
6123                                ),
6124                                e.to_string(),
6125                            ),
6126                        )?;
6127                        // The step that failed never completed, so the count comes from the
6128                        // trace itself — which is also what `terminal_outcome` reports
6129                        // when this run is later resumed, so the two cannot disagree.
6130                        let steps = store.last_step(run_id)?;
6131                        finish(store, watch, run_id, depth, steps, escalation_outcome(&e))?;
6132                        return Err(e);
6133                    }
6134                }
6135                store.record(
6136                    run_id,
6137                    &StepRecord::new(
6138                        step,
6139                        format!("retry {attempt} after {} in {:?}", kind_of(&e), wait),
6140                        e.to_string(),
6141                    ),
6142                )?;
6143                // The same `kind_of` string the row above records, so the event and
6144                // the trace name the failure identically rather than nearly so.
6145                watch.emit(RunEvent::at_depth(
6146                    run_id,
6147                    step,
6148                    depth,
6149                    EventKind::Retry {
6150                        kind: kind_of(&e),
6151                        attempt,
6152                        delay_ms: wait.as_millis() as u64,
6153                    },
6154                ));
6155                if !wait.is_zero() {
6156                    tokio::time::sleep(wait).await;
6157                }
6158            }
6159            Err(e) => {
6160                store.record(
6161                    run_id,
6162                    &StepRecord::new(
6163                        step,
6164                        format!("escalated after {}", kind_of(&e)),
6165                        e.to_string(),
6166                    ),
6167                )?;
6168                // The step that failed never completed, so the count comes from the
6169                // trace itself — which is also what `terminal_outcome` reports
6170                // when this run is later resumed, so the two cannot disagree.
6171                let steps = store.last_step(run_id)?;
6172                finish(store, watch, run_id, depth, steps, escalation_outcome(&e))?;
6173                return Err(e);
6174            }
6175        }
6176    }
6177}
6178
6179/// One completion whose text deltas reach the observer while the model is still
6180/// producing them.
6181///
6182/// The provider's sink has to be `Send + Sync` — its future is `Send` and a
6183/// built-in provider's is driven inside `reqwest`'s stream — and [`Watch`] is
6184/// neither: it holds a `Cell` so a cancellation can outlive the `event()` call
6185/// that asked for it, and `Store` is `!Sync` besides. A channel is the seam
6186/// between the two halves: the sink is a closure over an `mpsc` sender, which is
6187/// `Send + Sync`, and the emitting happens here, on the loop's own task, where
6188/// `Watch` already lives.
6189///
6190/// Which also means an observer that returns [`Flow::Cancel`](crate::Flow::Cancel)
6191/// from a `Token` event is honoured exactly where every other cancellation is —
6192/// at the next step boundary — rather than tearing down a request mid-body.
6193async fn stream_completion<P: Provider>(
6194    provider: &P,
6195    request: &CompletionRequest,
6196    watch: &Watch<'_>,
6197    run_id: i64,
6198    step: u32,
6199    depth: u32,
6200) -> Result<CompletionResponse> {
6201    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
6202    // Unbounded deliberately: a bounded sender would either block the provider's
6203    // stream on a slow observer — turning rendering latency into wire latency — or
6204    // drop a delta, and a stream missing one delta reads like a complete answer
6205    // and is not.
6206    let sink = move |text: &str| {
6207        // A closed receiver means this function has already returned, which cannot
6208        // happen while the future it awaits is still alive. Ignored rather than
6209        // escalated: a completion must not fail because nobody was listening.
6210        let _ = tx.send(text.to_string());
6211    };
6212    let completion = provider.complete_streaming(request.clone(), &sink);
6213    tokio::pin!(completion);
6214    let outcome = loop {
6215        tokio::select! {
6216            // Deltas first, so a burst that lands with the final chunk is emitted
6217            // in order rather than after the response it belongs to.
6218            biased;
6219            Some(text) = rx.recv() => {
6220                watch.emit(RunEvent::at_depth(run_id, step, depth, EventKind::Token { text }));
6221            }
6222            done = &mut completion => break done,
6223        }
6224    };
6225    // Whatever the provider sent between the last poll and its return. Without this
6226    // the last delta of every stream is lost, which is the failure mode a
6227    // concatenation assertion exists to catch.
6228    while let Ok(text) = rx.try_recv() {
6229        watch.emit(RunEvent::at_depth(
6230            run_id,
6231            step,
6232            depth,
6233            EventKind::Token { text },
6234        ));
6235    }
6236    outcome
6237}
6238
6239/// Record one file change and the lines it added and removed (0.18.0).
6240///
6241/// Swallowed on a store failure for the same reason a provider call is: an edit
6242/// that reached the disk is not undone by failing to write its bookkeeping row,
6243/// and turning the run into an error here would lose the work as well as the
6244/// row.
6245fn record_edit(
6246    store: &Store,
6247    run_id: i64,
6248    step: u32,
6249    tool: &str,
6250    path: &str,
6251    before: &str,
6252    after: &str,
6253) {
6254    let edit = crate::state::Edit::measure(step, tool, path, before, after);
6255    if let Err(e) = store.record_edit(run_id, &edit) {
6256        tracing::warn!("could not record the edit to {path} at step {step}: {e}");
6257    }
6258}
6259
6260/// Record one provider call, answered or failed (0.18.0).
6261///
6262/// A failed attempt is recorded too, and deliberately: a model that produced
6263/// tokens and then hit a broken connection was still billed for them, so a trace
6264/// that kept only the winning attempt would understate the money.
6265///
6266/// A store that cannot take the row is logged and swallowed. The alternative is
6267/// failing a run that the provider answered because the accounting could not be
6268/// written, which trades a real answer for a bookkeeping entry.
6269fn record_provider_call(
6270    store: &Store,
6271    run_id: i64,
6272    step: u32,
6273    attempt: u32,
6274    provider: &str,
6275    latency_ms: u64,
6276    outcome: &Result<CompletionResponse>,
6277) {
6278    let call = crate::state::ProviderCall {
6279        step,
6280        attempt,
6281        provider: provider.to_string(),
6282        model: outcome.as_ref().ok().and_then(|r| r.model.clone()),
6283        usage: outcome.as_ref().ok().and_then(|r| r.usage),
6284        latency_ms,
6285        ttft_ms: outcome.as_ref().ok().and_then(|r| r.ttft_ms),
6286        finish_reason: outcome.as_ref().ok().and_then(|r| r.finish_reason.clone()),
6287        // The same short name the retry and escalation rows use, so the two
6288        // surfaces name one failure identically rather than nearly so.
6289        failure: outcome.as_ref().err().map(kind_of),
6290    };
6291    if let Err(e) = store.record_provider_call(run_id, &call) {
6292        tracing::warn!("could not record the provider call for step {step}: {e}");
6293    }
6294}
6295
6296/// Record what the provider looked up while serving one call (0.22.0).
6297///
6298/// Citations and server-tool rows are written here, beside the `provider_calls`
6299/// row, because this is the only place that knows which attempt produced them —
6300/// and because a failed attempt that still ran a search was still billed for it.
6301///
6302/// A store that cannot take a row is logged and swallowed, exactly as the
6303/// accounting row is: failing a run the provider answered because a citation
6304/// could not be written trades a real answer for a bookkeeping entry.
6305fn record_web_activity(
6306    store: &Store,
6307    watch: &Watch<'_>,
6308    run_id: i64,
6309    step: u32,
6310    depth: u32,
6311    outcome: &Result<CompletionResponse>,
6312) {
6313    let Ok(response) = outcome else { return };
6314    if !response.citations.is_empty() {
6315        if let Err(e) = store.record_citations(run_id, step, &response.citations) {
6316            tracing::warn!("could not record the citations for step {step}: {e}");
6317        }
6318    }
6319    if response.server_tools.is_empty() {
6320        return;
6321    }
6322    if let Err(e) = store.record_server_tool_calls(run_id, step, &response.server_tools) {
6323        tracing::warn!("could not record the server-tool calls for step {step}: {e}");
6324    }
6325    for call in &response.server_tools {
6326        watch.emit(RunEvent::at_depth(
6327            run_id,
6328            step,
6329            depth,
6330            EventKind::ServerToolUsed {
6331                provider: call.provider.clone(),
6332                tool: call.tool.clone(),
6333                ok: call.succeeded(),
6334            },
6335        ));
6336    }
6337}
6338
6339/// Whether this failure is worth another attempt. A non-provider error — a bad
6340/// configuration, an IO failure — is not: it will fail the same way next time.
6341fn retryable(e: &Error) -> bool {
6342    matches!(e, Error::Provider { kind, .. } if kind.is_retryable())
6343}
6344
6345/// What the server asked us to wait, if it asked.
6346fn retry_after(e: &Error) -> Option<std::time::Duration> {
6347    match e {
6348        Error::Provider { retry_after, .. } => *retry_after,
6349        _ => None,
6350    }
6351}
6352
6353/// A short name for the trace row, so a reader can tell a wait from a hammer.
6354fn kind_of(e: &Error) -> String {
6355    match e {
6356        Error::Provider { kind, status, .. } => match status {
6357            Some(s) => format!("{kind:?} (HTTP {s})"),
6358            None => format!("{kind:?}"),
6359        },
6360        other => format!("{other}"),
6361    }
6362}
6363
6364/// The outcome string an escalation records, carrying whether the failure was one
6365/// another attempt could have survived.
6366///
6367/// Two strings rather than one because a resumed run and a trace reader have to
6368/// reach the same conclusion the caller did, and the caller's `Error` does not
6369/// survive into the store.
6370fn escalation_outcome(e: &Error) -> &'static str {
6371    if retryable(e) {
6372        "escalated_retryable"
6373    } else {
6374        "escalated_terminal"
6375    }
6376}
6377
6378fn system_prompt() -> String {
6379    "You are an agent that edits exactly one file to meet a stated specification. \
6380     Call the `write_file` tool with the file's full new contents. Do not explain; \
6381     make the edit. The file will be checked against the success criterion after \
6382     each write."
6383        .to_string()
6384}
6385
6386fn user_prompt(contract: &TaskContract, current: &str) -> String {
6387    let constraints = if contract.constraints.is_empty() {
6388        "(none)".to_string()
6389    } else {
6390        contract.constraints.join("; ")
6391    };
6392    format!(
6393        "Goal: {goal}\nConstraints: {constraints}\nSuccess criterion: {criterion}\n\n\
6394         Current file contents:\n---\n{current}\n---\n\n\
6395         Call write_file with the full new contents that satisfy the success criterion.",
6396        goal = contract.goal,
6397        criterion = contract.verify.describe(),
6398    )
6399}
6400
6401fn write_file_tool() -> ToolSpec {
6402    ToolSpec {
6403        name: WRITE_FILE_TOOL.to_string(),
6404        description: "Write the full new contents of the target file.".to_string(),
6405        parameters: json!({
6406            "type": "object",
6407            "properties": {
6408                "content": { "type": "string", "description": "Full new file contents." }
6409            },
6410            "required": ["content"]
6411        }),
6412    }
6413}
6414
6415fn workspace_system_prompt() -> String {
6416    "You are an agent working across a repository to meet a stated specification. \
6417     Use `grep` to search file contents and `find` to locate files by name, then \
6418     `read_file` to inspect a file before changing it, and `write_file` with the \
6419     file's path and full new contents to edit it. You may edit several files. \
6420     Work in small steps; after each of your steps the whole set is checked \
6421     against the success criterion. Do not explain; call tools."
6422        .to_string()
6423}
6424
6425/// Tell the model about tools the built-in prompt does not enumerate.
6426///
6427/// Without this the system prompt describes a world of exactly four tools while
6428/// the request carries more, and a model that trusts the prose over the schema
6429/// either ignores an MCP tool or, worse, calls one repeatedly without noticing
6430/// it already answered. Naming them — and saying plainly that a result lands in
6431/// the observations — is what turns a discovered tool into a usable one.
6432fn with_extra_tools(base: String, extra: &[ToolSpec]) -> String {
6433    if extra.is_empty() {
6434        return base;
6435    }
6436    let names: Vec<&str> = extra.iter().map(|t| t.name.as_str()).collect();
6437    format!(
6438        "{base} These extra tools are also available and work the same way: {}. \
6439         Each tool's result appears in the observations below; once a tool has \
6440         returned what you asked for, move on rather than calling it again.",
6441        names.join(", ")
6442    )
6443}
6444
6445/// [`READ_SKILL_TOOL`], offered only when the contract configures skills — a
6446/// tool that could do nothing but fail would cost a slot in every request of
6447/// every other run. Same rule MCP tools get: they appear when servers do.
6448fn skill_tool(skills: &Skills) -> Option<ToolSpec> {
6449    if skills.is_empty() {
6450        return None;
6451    }
6452    Some(ToolSpec {
6453        name: READ_SKILL_TOOL.to_string(),
6454        description: "Load one skill's full instructions into your observations, by the name it \
6455                      is listed under. Read a skill when its description says it covers what you \
6456                      are about to do."
6457            .to_string(),
6458        parameters: json!({
6459            "type": "object",
6460            "properties": {
6461                "name": { "type": "string", "description": "The skill's name, as listed in the system prompt." }
6462            },
6463            "required": ["name"]
6464        }),
6465    })
6466}
6467
6468/// Name the available skills in the system prompt: one line each, name and
6469/// description. A body is never here — that is what [`READ_SKILL_TOOL`] loads,
6470/// once, on demand, so a caller with twenty skills does not pay for twenty
6471/// bodies on every turn.
6472fn with_skill_catalog(base: String, skills: &Skills) -> String {
6473    if skills.is_empty() {
6474        return base;
6475    }
6476    format!(
6477        "{base}\n\nSkills available to you — instructions written for this repository. Only each \
6478         skill's name and description is shown; call `{READ_SKILL_TOOL}` with a name to read that \
6479         skill's full text when its description matches what you are doing.\n{}",
6480        skills.catalog()
6481    )
6482}
6483
6484fn workspace_user_prompt(
6485    contract: &TaskContract,
6486    observations: &str,
6487    toolchain: Option<&Toolchain>,
6488) -> String {
6489    let constraints = if contract.constraints.is_empty() {
6490        "(none)".to_string()
6491    } else {
6492        contract.constraints.join("; ")
6493    };
6494    let obs = if observations.is_empty() {
6495        "(nothing yet — start by grepping or finding)".to_string()
6496    } else {
6497        observations.to_string()
6498    };
6499    // Every turn, not only the first. An agent forty steps into a run has had the
6500    // first turn compacted out from under it by `ContextBudget`, and the project's
6501    // build command is exactly the fact it would then have to rediscover — which
6502    // is what this exists to stop it paying for twice.
6503    let project = match toolchain {
6504        Some(t) => format!("Project: {}\n", t.describe()),
6505        None => String::new(),
6506    };
6507    format!(
6508        "Goal: {goal}\nConstraints: {constraints}\nSuccess criterion: {criterion}\n\
6509         {project}\n\
6510         Observations so far (results of your tool calls):\n{obs}\n\n\
6511         Call a tool to make progress toward the success criterion.",
6512        goal = contract.goal,
6513        criterion = contract.verify.describe(),
6514    )
6515}
6516
6517fn tree_system_prompt() -> String {
6518    "You are an agent working across a repository to meet a stated specification. \
6519     Use `grep`, `find`, `read_file`, and `write_file` as in a normal run. You may \
6520     also decompose the work: call `spawn_agent` to launch a sub-agent that pursues \
6521     a smaller goal over the same workspace, and its result is reported back to you. \
6522     A sub-agent inherits your permissions and can only be more restricted, never \
6523     less. Prefer spawning when parts of the task are independent. Work in small \
6524     steps; the whole set is checked against the success criterion after each. Do \
6525     not explain; call tools."
6526        .to_string()
6527}
6528
6529/// Workspace tools plus [`SPAWN_TOOL`] — offered only inside an agent tree.
6530fn tree_tools(agents: &Agents) -> Vec<ToolSpec> {
6531    let mut tools = workspace_tools();
6532    // 0.21.0 — the roster is named in the description rather than as a schema `enum`,
6533    // because a model that asks for a name nobody registered gets a plain error
6534    // observation naming what IS available, and that recovers in one step. An `enum`
6535    // would instead make the whole call malformed at the provider.
6536    let roster = if agents.is_empty() {
6537        String::new()
6538    } else {
6539        format!(
6540            " Named agents you may spawn with \"agent\", each with its own role, model and \
6541             (possibly narrower) permissions:\n{}",
6542            agents.catalog()
6543        )
6544    };
6545    tools.push(ToolSpec {
6546        name: SPAWN_TOOL.to_string(),
6547        description: format!(
6548            "Spawn a contained sub-agent to pursue a smaller goal over the same \
6549             workspace. The sub-agent inherits your permissions (it can only be \
6550             further restricted) and its outcome is reported back to you.{roster}"
6551        ),
6552        parameters: json!({
6553            "type": "object",
6554            "properties": {
6555                "goal": { "type": "string", "description": "The sub-agent's goal." },
6556                "agent": { "type": "string", "description": "Optional name of a configured agent to spawn, which gives the sub-agent that agent's role, model and permissions." },
6557                "verify_file": { "type": "string", "description": "File (relative to the workspace root) whose contents decide the sub-agent's success." },
6558                "verify_contains": { "type": "string", "description": "Text that file must contain for the sub-agent to succeed." },
6559                "deny_write": { "type": "array", "items": { "type": "string" }, "description": "Optional globs the sub-agent must not write — tightens its inherited policy." },
6560                "deny_net": { "type": "array", "items": { "type": "string" }, "description": "Optional host globs (host or host:port) the sub-agent must not reach — tightens its inherited policy." },
6561                "max_steps": { "type": "integer", "description": "Optional step budget for the sub-agent." }
6562            },
6563            "required": ["goal", "verify_file", "verify_contains"]
6564        }),
6565    });
6566    tools
6567}
6568
6569/// Whether `name` is a document tool that only reads.
6570///
6571/// A free function rather than a match arm per format: the arms differ only in
6572/// which module they call, and four near-identical arms would drift.
6573#[cfg(any(
6574    feature = "docx",
6575    feature = "pptx",
6576    feature = "pdf",
6577    feature = "barcode"
6578))]
6579fn is_document_read(name: &str) -> bool {
6580    #[cfg(feature = "docx")]
6581    if name == DOCX_READ_TOOL {
6582        return true;
6583    }
6584    #[cfg(feature = "pptx")]
6585    if name == PPTX_READ_TOOL {
6586        return true;
6587    }
6588    #[cfg(feature = "pdf")]
6589    if name == PDF_READ_TOOL {
6590        return true;
6591    }
6592    #[cfg(feature = "barcode")]
6593    if name == BARCODE_DECODE_TOOL {
6594        return true;
6595    }
6596    false
6597}
6598
6599/// Whether `name` is a document tool that writes.
6600#[cfg(any(feature = "docx", feature = "pdf"))]
6601fn is_document_write(name: &str) -> bool {
6602    #[cfg(feature = "docx")]
6603    if name == DOCX_WRITE_TOOL {
6604        return true;
6605    }
6606    #[cfg(feature = "pdf")]
6607    if name == PDF_WRITE_TOOL || name == PDF_WATERMARK_TOOL || name == PDF_FILL_FORM_TOOL {
6608        return true;
6609    }
6610    false
6611}
6612
6613/// Read one document, choosing the reader by the tool the model called rather
6614/// than by the file's extension: the model named the format it believes it is
6615/// dealing with, and letting the extension decide would silently read something
6616/// else than what was asked for.
6617#[cfg(any(
6618    feature = "docx",
6619    feature = "pptx",
6620    feature = "pdf",
6621    feature = "barcode"
6622))]
6623fn read_document(ws: &Workspace, name: &str, target: &str) -> Result<String> {
6624    use crate::tools::documents;
6625    match name {
6626        #[cfg(feature = "docx")]
6627        n if n == DOCX_READ_TOOL => documents::docx::read_text(ws, target),
6628        #[cfg(feature = "pptx")]
6629        n if n == PPTX_READ_TOOL => documents::pptx::read_text(ws, target),
6630        #[cfg(feature = "pdf")]
6631        n if n == PDF_READ_TOOL => documents::pdf::read_text(ws, target),
6632        #[cfg(feature = "barcode")]
6633        n if n == BARCODE_DECODE_TOOL => documents::barcode::decode(ws, target).map(|found| {
6634            if found.is_empty() {
6635                // Not an error: "I looked and there was nothing there" is a fact
6636                // the model can act on, and the run continues.
6637                "no barcode or QR code found in this image".to_string()
6638            } else {
6639                found
6640                    .iter()
6641                    .map(|d| format!("{}: {}", d.format, d.text))
6642                    .collect::<Vec<_>>()
6643                    .join("\n")
6644            }
6645        }),
6646        other => Err(crate::error::Error::Config(format!(
6647            "not a document read tool: {other}"
6648        ))),
6649    }
6650}
6651
6652/// What a document write is about to do, for the approval preview and the trace.
6653/// The change, never the bytes — a human deciding on a document write cannot
6654/// decide on a blob.
6655#[cfg(any(feature = "docx", feature = "pdf"))]
6656fn describe_document_write(name: &str, args: &serde_json::Value) -> String {
6657    let count = |key: &str| {
6658        args.get(key)
6659            .and_then(|v| v.as_array())
6660            .map(|a| a.len())
6661            .unwrap_or(0)
6662    };
6663    match name {
6664        #[cfg(feature = "docx")]
6665        n if n == DOCX_WRITE_TOOL => {
6666            format!("create a document of {} paragraph(s)", count("paragraphs"))
6667        }
6668        #[cfg(feature = "pdf")]
6669        n if n == PDF_WRITE_TOOL => format!("create a PDF of {} page(s)", count("pages")),
6670        #[cfg(feature = "pdf")]
6671        n if n == PDF_WATERMARK_TOOL => format!(
6672            "watermark every page with {:?}",
6673            args.get("text")
6674                .and_then(|v| v.as_str())
6675                .unwrap_or_default()
6676        ),
6677        #[cfg(feature = "pdf")]
6678        n if n == PDF_FILL_FORM_TOOL => format!("fill {} form field(s)", count("fields")),
6679        other => format!("write via {other}"),
6680    }
6681}
6682
6683/// Perform one document write, chosen by the tool the model called.
6684#[cfg(any(feature = "docx", feature = "pdf"))]
6685fn write_document(
6686    ws: &Workspace,
6687    name: &str,
6688    target: &str,
6689    args: &serde_json::Value,
6690) -> Result<crate::tools::workspace::Wrote> {
6691    use crate::tools::documents;
6692    #[allow(unused_variables)]
6693    let strings = |key: &str| -> Vec<String> {
6694        args.get(key)
6695            .and_then(|v| serde_json::from_value(v.clone()).ok())
6696            .unwrap_or_default()
6697    };
6698    match name {
6699        #[cfg(feature = "docx")]
6700        n if n == DOCX_WRITE_TOOL => documents::docx::write_new(ws, target, &strings("paragraphs")),
6701        #[cfg(feature = "pdf")]
6702        n if n == PDF_WRITE_TOOL => documents::pdf::write_new(ws, target, &strings("pages")),
6703        #[cfg(feature = "pdf")]
6704        n if n == PDF_WATERMARK_TOOL => documents::pdf::watermark(
6705            ws,
6706            target,
6707            args.get("text")
6708                .and_then(|v| v.as_str())
6709                .unwrap_or_default(),
6710        ),
6711        #[cfg(feature = "pdf")]
6712        n if n == PDF_FILL_FORM_TOOL => {
6713            let fields: Vec<(String, String)> = args
6714                .get("fields")
6715                .and_then(|v| v.as_object().cloned())
6716                .map(|m| {
6717                    m.into_iter()
6718                        .map(|(k, v)| (k, v.as_str().unwrap_or_default().to_string()))
6719                        .collect()
6720                })
6721                .unwrap_or_default();
6722            documents::pdf::fill_form(ws, target, &fields)
6723        }
6724        other => Err(crate::error::Error::Config(format!(
6725            "not a document write tool: {other}"
6726        ))),
6727    }
6728}
6729
6730fn workspace_tools() -> Vec<ToolSpec> {
6731    #[allow(unused_mut)]
6732    let mut v = vec![
6733        ToolSpec {
6734            name: GREP_TOOL.to_string(),
6735            description: "Search file contents by regex (a plain substring is valid). Returns file:line: matches.".to_string(),
6736            parameters: json!({
6737                "type": "object",
6738                "properties": {
6739                    "pattern": { "type": "string", "description": "Regex or substring to search for." },
6740                    "path_glob": { "type": "string", "description": "Optional glob limiting which files are searched, e.g. src/*.rs." }
6741                },
6742                "required": ["pattern"]
6743            }),
6744        },
6745        ToolSpec {
6746            name: FIND_TOOL.to_string(),
6747            description: "List files whose name or relative path matches a glob (* and ?).".to_string(),
6748            parameters: json!({
6749                "type": "object",
6750                "properties": {
6751                    "name_glob": { "type": "string", "description": "Glob to match, e.g. *.rs or src/*.rs." }
6752                },
6753                "required": ["name_glob"]
6754            }),
6755        },
6756        ToolSpec {
6757            name: READ_FILE_TOOL.to_string(),
6758            description: "Read a file (path relative to the workspace root) into context.".to_string(),
6759            parameters: json!({
6760                "type": "object",
6761                "properties": {
6762                    "path": { "type": "string", "description": "File path relative to the workspace root." }
6763                },
6764                "required": ["path"]
6765            }),
6766        },
6767        ToolSpec {
6768            name: GIT_STATUS_TOOL.to_string(),
6769            description: "Show what has changed in the git repository at the workspace root: \
6770                          modified, staged and untracked files."
6771                .to_string(),
6772            parameters: json!({
6773                "type": "object",
6774                "properties": {
6775                    "paths": { "type": "array", "items": { "type": "string" }, "description": "Optional paths to limit the report to." }
6776                }
6777            }),
6778        },
6779        ToolSpec {
6780            name: GIT_DIFF_TOOL.to_string(),
6781            description: "Show the diff of the working tree, or of what is staged.".to_string(),
6782            parameters: json!({
6783                "type": "object",
6784                "properties": {
6785                    "staged": { "type": "boolean", "description": "Diff what is staged instead of the working tree." },
6786                    "paths": { "type": "array", "items": { "type": "string" }, "description": "Optional paths to limit the diff to." }
6787                }
6788            }),
6789        },
6790        ToolSpec {
6791            name: GIT_LOG_TOOL.to_string(),
6792            description: "Read the repository's recent commit history, newest first.".to_string(),
6793            parameters: json!({
6794                "type": "object",
6795                "properties": {
6796                    "max_count": { "type": "integer", "description": "How many commits to show (1-200, default 20)." },
6797                    "paths": { "type": "array", "items": { "type": "string" }, "description": "Optional paths to limit the history to." }
6798                }
6799            }),
6800        },
6801        ToolSpec {
6802            name: GIT_ADD_TOOL.to_string(),
6803            description: "Stage the named files for the next commit. Honours .gitignore; an \
6804                          ignored file is reported rather than staged."
6805                .to_string(),
6806            parameters: json!({
6807                "type": "object",
6808                "properties": {
6809                    "paths": { "type": "array", "items": { "type": "string" }, "description": "Files to stage, relative to the workspace root." }
6810                },
6811                "required": ["paths"]
6812            }),
6813        },
6814        ToolSpec {
6815            name: GIT_COMMIT_TOOL.to_string(),
6816            description: "Commit what you have staged, on the branch that is checked out. There \
6817                          is no push, no branch switching and no history rewriting: your work \
6818                          stays local for a human to review."
6819                .to_string(),
6820            parameters: json!({
6821                "type": "object",
6822                "properties": {
6823                    "message": { "type": "string", "description": "The commit message." }
6824                },
6825                "required": ["message"]
6826            }),
6827        },
6828        #[cfg(feature = "media")]
6829        ToolSpec {
6830            name: VIEW_IMAGE_TOOL.to_string(),
6831            description: "Look at an image in the workspace. The image is attached to your next \
6832                          message, so you see it on the following step rather than in this tool's \
6833                          result. Costs a step; the file must be a jpeg, png, gif or webp."
6834                .to_string(),
6835            parameters: json!({
6836                "type": "object",
6837                "properties": {
6838                    "path": { "type": "string", "description": "Image path relative to the workspace root." }
6839                },
6840                "required": ["path"]
6841            }),
6842        },
6843        ToolSpec {
6844            name: REMEMBER_TOOL.to_string(),
6845            description: "Record a short fact or decision worth keeping for a later run over this \
6846                          workspace — a build command, a layout you had to discover, a decision and \
6847                          why. Notes are yours, not instructions, and are recalled at the start of \
6848                          later runs so you do not rediscover the same thing twice."
6849                .to_string(),
6850            parameters: json!({
6851                "type": "object",
6852                "properties": {
6853                    "key": { "type": "string", "description": "Short name to recall it by; writing the same key again replaces it." },
6854                    "value": { "type": "string", "description": "The fact, in one or two sentences." }
6855                },
6856                "required": ["key", "value"]
6857            }),
6858        },
6859        ToolSpec {
6860            name: TODO_WRITE_TOOL.to_string(),
6861            description: "Write down your plan so the operator can see where you are. Send the \
6862                          WHOLE list every time — it replaces the previous one, so include the items \
6863                          already done with state \"done\". Keep one item \"active\". This is for the \
6864                          human watching; nothing here is checked, and writing a plan does not do \
6865                          any of the work in it."
6866                .to_string(),
6867            parameters: json!({
6868                "type": "object",
6869                "properties": {
6870                    "items": {
6871                        "type": "array",
6872                        "description": "The whole plan, in order. Replaces any previous plan.",
6873                        "items": {
6874                            "type": "object",
6875                            "properties": {
6876                                "text": { "type": "string", "description": "One step of the plan, in a short phrase." },
6877                                "state": { "type": "string", "enum": ["pending", "active", "done"], "description": "Where this step has got to." }
6878                            },
6879                            "required": ["text", "state"]
6880                        }
6881                    }
6882                },
6883                "required": ["items"]
6884            }),
6885        },
6886        ToolSpec {
6887            name: ASK_QUESTION_TOOL.to_string(),
6888            description: "Ask the operator what they actually want, when the task is genuinely \
6889                          ambiguous and guessing would waste the run. This is NOT for permission — \
6890                          you never need permission, the policy decides that and will refuse you if \
6891                          the answer is no. Use it for intent: which of two files they meant, \
6892                          whether to keep or drop something, which behaviour is correct. Offer \
6893                          choices when you have them. Do not ask what you could find out by \
6894                          looking."
6895                .to_string(),
6896            parameters: json!({
6897                "type": "object",
6898                "properties": {
6899                    "question": { "type": "string", "description": "The question, in one sentence." },
6900                    "context": { "type": "string", "description": "Optional: what you already established, so they can answer without re-deriving it." },
6901                    "choices": { "type": "array", "items": { "type": "string" }, "description": "Optional options you are offering. The answer need not be one of them." }
6902                },
6903                "required": ["question"]
6904            }),
6905        },
6906        ToolSpec {
6907            name: WRITE_FILE_TOOL.to_string(),
6908            description: "Write the full new contents of a file (path relative to the workspace root); creates it if absent.".to_string(),
6909            parameters: json!({
6910                "type": "object",
6911                "properties": {
6912                    "path": { "type": "string", "description": "File path relative to the workspace root." },
6913                    "content": { "type": "string", "description": "Full new file contents." }
6914                },
6915                "required": ["path", "content"]
6916            }),
6917        },
6918        ToolSpec {
6919            name: EDIT_FILE_TOOL.to_string(),
6920            description: "Change part of an existing file, leaving the rest of it exactly as it \
6921                          was. Prefer this to write_file for anything but a new file. The search \
6922                          text must appear EXACTLY ONCE: if it appears zero times or more than \
6923                          once the edit is refused and nothing changes, so include enough \
6924                          surrounding lines to make it unique."
6925                .to_string(),
6926            parameters: json!({
6927                "type": "object",
6928                "properties": {
6929                    "path": { "type": "string", "description": "File path relative to the workspace root." },
6930                    "search": { "type": "string", "description": "The exact text to replace, copied from the file including its whitespace. Must occur exactly once." },
6931                    "replace": { "type": "string", "description": "What to put in its place. An empty string deletes the searched text." }
6932                },
6933                "required": ["path", "search", "replace"]
6934            }),
6935        },
6936        ToolSpec {
6937            name: EXEC_TOOL.to_string(),
6938            description: "Run a command in the workspace root and read back its exit status and \
6939                          its output — the project's own build, tests, linter, formatter or \
6940                          package manager. There is NO shell: give the command as an array of \
6941                          strings, one element per argument. Pipes, redirection, `&&`, `;` and \
6942                          `$(...)` are not interpreted; they are ordinary characters inside \
6943                          whichever argument contains them. Run one command per call. A command \
6944                          that runs too long is killed and reported as a timeout, and very long \
6945                          output keeps its start and its end with the middle elided."
6946                .to_string(),
6947            parameters: json!({
6948                "type": "object",
6949                "properties": {
6950                    "argv": {
6951                        "type": "array",
6952                        "description": "The command, one array element per argument, program first — e.g. [\"npm\", \"test\"] or [\"cargo\", \"test\", \"--all-features\"].",
6953                        "items": { "type": "string" }
6954                    }
6955                },
6956                "required": ["argv"]
6957            }),
6958        },
6959    ];
6960    #[cfg(feature = "xlsx")]
6961    // Offered only when the feature is on. A model is told about a tool it can
6962    // actually call, never about one the build does not contain.
6963    v.extend([
6964        ToolSpec {
6965            name: XLSX_SHEETS_TOOL.to_string(),
6966            description: "List the sheet names of an .xlsx workbook in the workspace.".to_string(),
6967            parameters: json!({
6968                "type": "object",
6969                "properties": {
6970                    "path": { "type": "string", "description": "Workbook path relative to the workspace root." }
6971                },
6972                "required": ["path"]
6973            }),
6974        },
6975        ToolSpec {
6976            name: XLSX_READ_TOOL.to_string(),
6977            description: "Read one sheet of an .xlsx workbook as text. Omit \"sheet\" for the first sheet.".to_string(),
6978            parameters: json!({
6979                "type": "object",
6980                "properties": {
6981                    "path": { "type": "string", "description": "Workbook path relative to the workspace root." },
6982                    "sheet": { "type": "string", "description": "Sheet name; the first sheet if omitted." }
6983                },
6984                "required": ["path"]
6985            }),
6986        },
6987        ToolSpec {
6988            name: XLSX_WRITE_TOOL.to_string(),
6989            description: "Create a NEW .xlsx workbook with one sheet of rows. Replaces the file if it exists; to change one cell of an existing workbook use xlsx_set_cell instead, which keeps the rest of it.".to_string(),
6990            parameters: json!({
6991                "type": "object",
6992                "properties": {
6993                    "path": { "type": "string", "description": "Workbook path relative to the workspace root." },
6994                    "sheet": { "type": "string", "description": "Name for the sheet." },
6995                    "rows": {
6996                        "type": "array",
6997                        "description": "Rows, each an array of cell values as strings.",
6998                        "items": { "type": "array", "items": { "type": "string" } }
6999                    }
7000                },
7001                "required": ["path", "sheet", "rows"]
7002            }),
7003        },
7004        ToolSpec {
7005            name: XLSX_SET_CELL_TOOL.to_string(),
7006            description: "Set one cell of an EXISTING .xlsx workbook, keeping every other sheet, cell and format as it was.".to_string(),
7007            parameters: json!({
7008                "type": "object",
7009                "properties": {
7010                    "path": { "type": "string", "description": "Workbook path relative to the workspace root." },
7011                    "sheet": { "type": "string", "description": "Sheet name." },
7012                    "cell": { "type": "string", "description": "A1-style cell reference, e.g. B7." },
7013                    "value": { "type": "string", "description": "New cell value." }
7014                },
7015                "required": ["path", "sheet", "cell", "value"]
7016            }),
7017        },
7018    ]);
7019    #[cfg(feature = "docx")]
7020    v.extend([
7021        ToolSpec {
7022            name: DOCX_READ_TOOL.to_string(),
7023            description: "Read the text of a .docx Word document in the workspace.".to_string(),
7024            parameters: json!({
7025                "type": "object",
7026                "properties": { "path": { "type": "string", "description": "Document path relative to the workspace root." } },
7027                "required": ["path"]
7028            }),
7029        },
7030        ToolSpec {
7031            name: DOCX_WRITE_TOOL.to_string(),
7032            description: "Create a NEW .docx Word document from paragraphs. There is no in-place edit for Word: to change an existing document, read it and write a new one, accepting that formatting this crate does not model is not carried over.".to_string(),
7033            parameters: json!({
7034                "type": "object",
7035                "properties": {
7036                    "path": { "type": "string", "description": "Document path relative to the workspace root." },
7037                    "paragraphs": { "type": "array", "description": "Paragraphs, in order.", "items": { "type": "string" } }
7038                },
7039                "required": ["path", "paragraphs"]
7040            }),
7041        },
7042    ]);
7043    #[cfg(feature = "pptx")]
7044    v.push(ToolSpec {
7045        name: PPTX_READ_TOOL.to_string(),
7046        description: "Read the text of a .pptx slide deck, slide by slide. Reading only — this crate cannot write PowerPoint.".to_string(),
7047        parameters: json!({
7048            "type": "object",
7049            "properties": { "path": { "type": "string", "description": "Deck path relative to the workspace root." } },
7050            "required": ["path"]
7051        }),
7052    });
7053    #[cfg(feature = "pdf")]
7054    v.extend([
7055        ToolSpec {
7056            name: PDF_READ_TOOL.to_string(),
7057            description: "Extract the text of a PDF. Best effort: reading order across columns and tables is not guaranteed.".to_string(),
7058            parameters: json!({
7059                "type": "object",
7060                "properties": { "path": { "type": "string", "description": "PDF path relative to the workspace root." } },
7061                "required": ["path"]
7062            }),
7063        },
7064        ToolSpec {
7065            name: PDF_WRITE_TOOL.to_string(),
7066            description: "Create a NEW PDF, one page per string.".to_string(),
7067            parameters: json!({
7068                "type": "object",
7069                "properties": {
7070                    "path": { "type": "string", "description": "PDF path relative to the workspace root." },
7071                    "pages": { "type": "array", "description": "Page text, one entry per page.", "items": { "type": "string" } }
7072                },
7073                "required": ["path", "pages"]
7074            }),
7075        },
7076        ToolSpec {
7077            name: PDF_WATERMARK_TOOL.to_string(),
7078            description: "Stamp text across every page of an existing PDF, keeping its content.".to_string(),
7079            parameters: json!({
7080                "type": "object",
7081                "properties": {
7082                    "path": { "type": "string", "description": "PDF path relative to the workspace root." },
7083                    "text": { "type": "string", "description": "Watermark text." }
7084                },
7085                "required": ["path", "text"]
7086            }),
7087        },
7088        ToolSpec {
7089            name: PDF_FILL_FORM_TOOL.to_string(),
7090            description: "Fill the form fields of an existing PDF, by field name.".to_string(),
7091            parameters: json!({
7092                "type": "object",
7093                "properties": {
7094                    "path": { "type": "string", "description": "PDF path relative to the workspace root." },
7095                    "fields": { "type": "object", "description": "Field name to value.", "additionalProperties": { "type": "string" } }
7096                },
7097                "required": ["path", "fields"]
7098            }),
7099        },
7100    ]);
7101    #[cfg(feature = "barcode")]
7102    v.push(ToolSpec {
7103        name: BARCODE_DECODE_TOOL.to_string(),
7104        description: "Decode barcodes and QR codes from a PNG or JPEG in the workspace. Reports plainly when the image contains none.".to_string(),
7105        parameters: json!({
7106            "type": "object",
7107            "properties": { "path": { "type": "string", "description": "Image path relative to the workspace root." } },
7108            "required": ["path"]
7109        }),
7110    });
7111    v
7112}