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