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