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