Skip to main content

io_harness/
contract.rs

1//! The task contract: what the agent is asked to do and how success is judged.
2
3use std::path::PathBuf;
4use std::time::Duration;
5
6use crate::context::ContextBudget;
7use crate::resilience::{RetryPolicy, StallPolicy};
8use crate::verify::Verification;
9
10/// A single unit of work handed to the harness.
11///
12/// The agent edits one file to meet [`Verification`], bounded by budgets. v0.2
13/// adds the time and cost (token) budgets and the retry limit; a 0.1.0 caller
14/// that only set `goal`, `file`, `verify`, and `max_steps` still compiles —
15/// the new bounds default to unbounded / two retries.
16///
17/// Every entry point in the crate takes one of these, so it is where a run's
18/// definition of done and its ceilings are decided — before the model is asked
19/// anything, and independently of which provider will serve it:
20///
21/// ```
22/// use io_harness::{TaskContract, Verification};
23/// use std::time::Duration;
24///
25/// let contract = TaskContract::workspace(
26///     "make `parse` return an error on empty input instead of panicking",
27///     "/path/to/repo",
28///     // The criterion is checked by running the project's own suite, so a
29///     // plausible-looking stub cannot satisfy it. This is the half of the
30///     // contract that decides whether the run *succeeded*, as opposed to merely
31///     // stopping.
32///     Verification::Command { argv: vec!["cargo".into(), "test".into()], expect_exit: 0 },
33/// )
34/// // And this is the half that decides when it stops regardless. All three are
35/// // independent stops with their own `RunOutcome`, so a run that ran out of
36/// // money is distinguishable afterwards from one that ran out of ideas.
37/// .with_max_steps(20)
38/// .with_time_budget(Duration::from_secs(900))
39/// .with_token_budget(200_000)
40/// // Surfaced to the model verbatim. A constraint is guidance, not a boundary —
41/// // what the agent may actually touch is the `Policy`'s job, because the model
42/// // can ignore a sentence and cannot ignore a refusal.
43/// .with_constraint("do not change the public signature of `parse`");
44///
45/// assert_eq!(contract.max_steps, 20);
46/// assert!(contract.root.is_some()); // workspace mode: grep, find, read, write
47/// ```
48///
49/// [`TaskContract::new`] is the other constructor: one file, one tool, and no
50/// policy enforcement — a policy passed to a single-file run is refused with
51/// [`Error::Config`](crate::Error::Config) rather than silently ignored. Reach
52/// for [`TaskContract::workspace`] for anything with a boundary.
53///
54/// The `with_*` builders that add *capability* rather than bounds —
55/// [`with_mcp`](TaskContract::with_mcp), [`with_tools`](TaskContract::with_tools),
56/// [`with_skills`](TaskContract::with_skills) — are workspace-mode only and are
57/// validated at run start, so a duplicate tool name or an unreadable skills
58/// directory fails the run before the first completion is billed.
59#[derive(Debug, Clone)]
60pub struct TaskContract {
61    /// Plain-language goal, e.g. "add a `hello` function that returns 42".
62    pub goal: String,
63    /// The one file the agent may read and write in single-file mode. In
64    /// workspace mode (`root` is `Some`) it is unused.
65    pub file: PathBuf,
66    /// Workspace root for multi-file mode. `None` (the 0.1/0.2 default) runs the
67    /// single-file loop over `file`. `Some(dir)` runs the workspace loop, where
68    /// the agent greps/finds/reads/writes several files under `dir`.
69    pub root: Option<PathBuf>,
70    /// Extra rules the agent must respect, surfaced to the model verbatim.
71    pub constraints: Vec<String>,
72    /// The checkable success criterion. The run succeeds when this passes.
73    pub verify: Verification,
74    /// Step budget: hard cap on loop iterations. The run stops when reached.
75    pub max_steps: u32,
76    /// Time budget: the run stops if it runs longer than this. `None` = unbounded.
77    pub max_duration: Option<Duration>,
78    /// Cost budget, measured in total tokens summed across completions (no
79    /// price telemetry exists, so cost is counted in tokens). `None` = unbounded.
80    pub max_tokens: Option<u64>,
81    /// How many times a failing provider/tool step is retried before the run
82    /// escalates the error. Defaults to 2.
83    pub max_retries: u32,
84    /// MCP servers to connect for this run. Their tools are offered to the model
85    /// beside the built-ins, namespaced `mcp__<server>__<tool>`.
86    ///
87    /// Empty by default, so a 0.7.0-era contract behaves exactly as before.
88    /// Workspace mode only: single-file mode has one tool and no tool layer to
89    /// extend.
90    #[allow(clippy::doc_markdown)]
91    pub mcp: Vec<crate::mcp::McpServer>,
92    /// Images handed to the agent alongside the goal, shown to the model on
93    /// every step of the run.
94    ///
95    /// This is the caller's half of the image capability: the task is *about*
96    /// these, so they persist for the whole run rather than being attached once.
97    /// The agent's own half — looking at an image already in the workspace — is
98    /// the `view_image` built-in, which is gated on the path the model names.
99    ///
100    /// Empty by default, so a 0.14.0-era contract behaves exactly as before. A
101    /// provider that does not accept images refuses a run carrying any, before
102    /// anything is sent; see [`crate::Provider::accepts_images`].
103    #[cfg(feature = "media")]
104    pub images: Vec<crate::provider::Media>,
105    /// Who a commit the agent makes is attributed to.
106    ///
107    /// Defaults to an agent identity at a domain reserved so it can never exist.
108    /// `git commit` fails outright with no `user.email` configured, so this
109    /// cannot be left to the machine, and inheriting the repository's identity
110    /// would attribute the agent's commit to whichever human configured that
111    /// checkout.
112    pub commit_identity: crate::tools::git::Identity,
113    /// Tools the embedding program supplies itself, offered to the model beside
114    /// the built-ins and governed by the same policy and trace.
115    ///
116    /// Empty by default, so a 0.8.1-era contract behaves exactly as before. In
117    /// process, unlike [`TaskContract::mcp`]: see [`crate::tools::Tool`] for what
118    /// registration does and does not authorize.
119    pub tools: crate::tools::Toolbox,
120    /// How long to wait between provider attempts, and how long a wait may grow.
121    ///
122    /// Applies only to a failure that
123    /// [`is_retryable`](crate::error::ProviderErrorKind::is_retryable); an
124    /// authentication failure escalates on its first occurrence however patient
125    /// this is.
126    pub retry: RetryPolicy,
127    /// When to decide the agent has stopped making progress, and how many times to
128    /// tell it. `StallPolicy { window: 0, .. }` switches detection off.
129    pub stall: StallPolicy,
130    /// How much of each request the observation log may occupy.
131    ///
132    /// Defaults to [`ContextBudget::default`]. Separate from
133    /// [`TaskContract::max_tokens`] because they bound different things: that is
134    /// what the whole run may *spend*, this is what one request may *carry* —
135    /// though the two are related, since the share is taken of what the spend
136    /// budget has left.
137    pub context: ContextBudget,
138    /// How long a command the agent runs with the `exec` tool may take before it
139    /// is killed and reported as a timeout.
140    ///
141    /// Defaults to [`DEFAULT_EXEC_TIMEOUT`](crate::DEFAULT_EXEC_TIMEOUT). Set it
142    /// with [`TaskContract::with_exec_timeout`]. Separate from
143    /// [`TaskContract::max_duration`] because they bound different things: that is
144    /// how long the whole run may take, this is how long any one command may hang
145    /// before the run gets its turn back — without it, a wedged command consumes
146    /// the run's whole time budget and the run reports a budget stop, which is
147    /// the wrong diagnosis for what happened.
148    pub exec_timeout: Duration,
149    /// Directory of skill files to offer the agent, or `None` (the default) for
150    /// no skills.
151    ///
152    /// The *path* is held rather than the discovered set, because reading a
153    /// directory is fallible and a builder method is not: discovery happens at
154    /// run start, so a directory that does not exist fails the run with
155    /// [`Error::Config`](crate::Error::Config) naming the path — the same point
156    /// and the same way [`TaskContract::tools`] is arbitrated.
157    pub skills: Option<PathBuf>,
158    /// Named agent definitions a spawn may ask for by name (0.21.0).
159    ///
160    /// Empty by default, which is exactly the spawn behaviour of every release
161    /// before 0.21.0. A definition can only ever *narrow* the child's boundary —
162    /// composed through [`Policy::contain`](crate::Policy::contain), which has
163    /// bounded every child since 0.5.0 — so registering a roster grants nothing.
164    pub agents: crate::agent::Agents,
165    /// Who answers the agent's questions about intent, in this process (0.21.0).
166    ///
167    /// `None` — the default — means nobody does, so a question persists and pauses
168    /// the run for a human, which is the honest default for unattended work.
169    ///
170    /// Carried on the contract rather than passed to every entry point, the way a
171    /// [`Toolbox`](crate::Toolbox) is: adding an argument to `run`, `run_with`,
172    /// `run_tree` and their observed and resume variants would break every existing
173    /// call site to add something almost all of them would pass `None` for.
174    ///
175    /// Behind an `Arc` so a whole tree shares one responder, exactly as it shares one
176    /// [`Approver`](crate::Approver).
177    pub responder: Option<std::sync::Arc<dyn crate::approve::Responder>>,
178}
179
180impl TaskContract {
181    /// Minimal contract: goal, target file, and a success criterion.
182    /// Defaults to 8 steps, no time/token budget, 2 retries, no constraints.
183    pub fn new(goal: impl Into<String>, file: impl Into<PathBuf>, verify: Verification) -> Self {
184        Self {
185            goal: goal.into(),
186            file: file.into(),
187            root: None,
188            constraints: Vec::new(),
189            verify,
190            max_steps: 8,
191            max_duration: None,
192            max_tokens: None,
193            max_retries: 2,
194            mcp: Vec::new(),
195            commit_identity: crate::tools::git::Identity::default(),
196            #[cfg(feature = "media")]
197            images: Vec::new(),
198            tools: crate::tools::Toolbox::new(),
199            context: ContextBudget::default(),
200            retry: RetryPolicy::default(),
201            stall: StallPolicy::default(),
202            exec_timeout: crate::tools::DEFAULT_EXEC_TIMEOUT,
203            skills: None,
204            agents: crate::agent::Agents::new(),
205            responder: None,
206        }
207    }
208
209    /// A workspace task: the agent may grep, find, read, and write several files
210    /// under `root`. `verify` should be a multi-file variant
211    /// ([`Verification::EachCompilesRust`], or a [`Verification::Command`] that runs
212    /// the project's own suite).
213    /// Defaults match [`TaskContract::new`] (12 steps here, since repo tasks take
214    /// more turns), no time/token budget, 2 retries.
215    pub fn workspace(
216        goal: impl Into<String>,
217        root: impl Into<PathBuf>,
218        verify: Verification,
219    ) -> Self {
220        let root = root.into();
221        Self {
222            goal: goal.into(),
223            file: root.clone(),
224            root: Some(root),
225            constraints: Vec::new(),
226            verify,
227            max_steps: 12,
228            max_duration: None,
229            max_tokens: None,
230            max_retries: 2,
231            mcp: Vec::new(),
232            commit_identity: crate::tools::git::Identity::default(),
233            #[cfg(feature = "media")]
234            images: Vec::new(),
235            tools: crate::tools::Toolbox::new(),
236            context: ContextBudget::default(),
237            retry: RetryPolicy::default(),
238            stall: StallPolicy::default(),
239            exec_timeout: crate::tools::DEFAULT_EXEC_TIMEOUT,
240            skills: None,
241            agents: crate::agent::Agents::new(),
242            responder: None,
243        }
244    }
245
246    /// Connect these MCP servers for the run and offer their tools to the model.
247    ///
248    /// Each server is authorized before it is reached — spawning a stdio server
249    /// is an exec check on its binary, dialling an HTTP one is a network check
250    /// on its host — so configuring a server here does not grant access to it.
251    pub fn with_mcp<I>(mut self, servers: I) -> Self
252    where
253        I: IntoIterator<Item = crate::mcp::McpServer>,
254    {
255        self.mcp = servers.into_iter().collect();
256        self
257    }
258
259    /// Hand the agent images to look at, alongside the goal.
260    ///
261    /// A new named method rather than a parameter on any of the sixteen entry
262    /// points: every one of them takes a `TaskContract`, so attaching here works
263    /// with all of them and changes no existing signature.
264    ///
265    /// Construct each [`crate::Media`] with [`crate::Media::image`], which
266    /// refuses a media type no provider documents and refuses an image over the
267    /// per-image size bound. The total carried by one request is bounded too —
268    /// see [`crate::provider::MAX_REQUEST_IMAGE_BYTES`].
269    #[cfg(feature = "media")]
270    #[must_use]
271    pub fn with_images<I>(mut self, images: I) -> Self
272    where
273        I: IntoIterator<Item = crate::provider::Media>,
274    {
275        self.images.extend(images);
276        self
277    }
278
279    /// Attribute the agent's commits to this name and address.
280    ///
281    /// Replaces the default agent identity. Neither may be empty or contain a
282    /// control character — both reach the commit object and the reflog.
283    #[must_use]
284    pub fn with_commit_identity(
285        mut self,
286        name: impl Into<String>,
287        email: impl Into<String>,
288    ) -> Self {
289        self.commit_identity = crate::tools::git::Identity {
290            name: name.into(),
291            email: email.into(),
292        };
293        self
294    }
295
296    /// Register in-process tools for the run and offer them to the model.
297    ///
298    /// Registration makes a tool available; it does not authorize it. Each call
299    /// is an [`Act::Exec`](crate::Act::Exec) check on the tool's name, and a
300    /// registered tool runs with the embedding program's own privileges — see
301    /// [`crate::tools::Tool`] for the full bound.
302    ///
303    /// A name that shadows a built-in, uses the `mcp__` prefix, or duplicates
304    /// another registered tool fails the run with [`Error::Config`](crate::Error::Config)
305    /// before the first completion.
306    ///
307    /// Workspace mode only, like [`TaskContract::with_mcp`]: single-file mode has
308    /// one tool and no tool layer to extend.
309    pub fn with_tools(mut self, tools: crate::tools::Toolbox) -> Self {
310        self.tools = tools;
311        self
312    }
313
314    /// Offer the agent the skills in `dir` — see [`crate::skills`] for the
315    /// layout.
316    ///
317    /// The directory is read at run start, not here, so a path that does not
318    /// exist, is not a directory, or holds more than
319    /// [`MAX_SKILLS`](crate::skills::MAX_SKILLS) skills fails the run with
320    /// [`Error::Config`](crate::Error::Config) naming it, before the first
321    /// completion.
322    ///
323    /// A skill is instructions the model may choose to read. Offering one grants
324    /// nothing: the read goes through the policy when it happens, and anything
325    /// the model then does is checked as it always is.
326    pub fn with_skills(mut self, dir: impl Into<PathBuf>) -> Self {
327        self.skills = Some(dir.into());
328        self
329    }
330
331    /// Register the named agent definitions a spawn may ask for (0.21.0).
332    ///
333    /// Offering a roster grants nothing, for the same reason offering a skill does
334    /// not: a definition's `deny_write`/`deny_net` are composed through
335    /// [`Policy::contain`](crate::Policy::contain), so it has no way to express an
336    /// allow. A definition silent about a path its parent denies still yields a
337    /// child that is refused it.
338    ///
339    /// Only the tree entry points ([`run_tree`](crate::run_tree) and friends) offer
340    /// the spawn tool at all, so a roster on a contract handed to
341    /// [`run_with`](crate::run_with) is inert rather than a hidden capability.
342    ///
343    /// ```
344    /// use io_harness::{AgentDef, Agents, TaskContract, Verification};
345    ///
346    /// let contract = TaskContract::workspace(
347    ///     "find the bug, then fix it",
348    ///     "/repo",
349    ///     Verification::None,
350    /// )
351    /// .with_agents(
352    ///     Agents::new()
353    ///         .with(AgentDef::new("searcher").with_model("cheap-model").deny_write())
354    ///         .with(AgentDef::new("author").with_model("strong-model")),
355    /// );
356    ///
357    /// assert_eq!(contract.agents.len(), 2);
358    /// assert!(contract.agents.get("searcher").unwrap().deny_write);
359    /// ```
360    pub fn with_agents(mut self, agents: crate::agent::Agents) -> Self {
361        self.agents = agents;
362        self
363    }
364
365    /// Register who answers the agent's questions about intent (0.21.0).
366    ///
367    /// Without one, a question is persisted and the run pauses with
368    /// [`RunOutcome::AwaitingAnswer`](crate::RunOutcome::AwaitingAnswer) for a human
369    /// to answer through [`resume_with_answer`](crate::resume_with_answer).
370    ///
371    /// An answer is text the model reads. It authorizes nothing: every tool call that
372    /// follows one is checked against the same [`Policy`](crate::Policy) by the same
373    /// code, which is the rule steering has followed since 0.20.0.
374    ///
375    /// ```
376    /// use io_harness::{FixedResponder, TaskContract, Verification};
377    /// use std::sync::Arc;
378    ///
379    /// let contract = TaskContract::workspace("port the parser", "/repo", Verification::None)
380    ///     .with_responder(Arc::new(FixedResponder::new("use io.local.toml")));
381    ///
382    /// assert!(contract.responder.is_some());
383    /// ```
384    pub fn with_responder(
385        mut self,
386        responder: std::sync::Arc<dyn crate::approve::Responder>,
387    ) -> Self {
388        self.responder = Some(responder);
389        self
390    }
391
392    /// Discover the configured skills. Called at run start by every entry point,
393    /// alongside [`Toolbox::validate`](crate::tools::Toolbox::validate).
394    pub(crate) fn discover_skills(&self) -> crate::Result<crate::skills::Skills> {
395        match &self.skills {
396            Some(dir) => crate::skills::Skills::discover(dir),
397            None => Ok(crate::skills::Skills::none()),
398        }
399    }
400
401    /// Override the step budget.
402    pub fn with_max_steps(mut self, max_steps: u32) -> Self {
403        self.max_steps = max_steps;
404        self
405    }
406
407    /// Set the time budget.
408    pub fn with_time_budget(mut self, max_duration: Duration) -> Self {
409        self.max_duration = Some(max_duration);
410        self
411    }
412
413    /// Set the cost budget, in total tokens across all completions.
414    pub fn with_token_budget(mut self, max_tokens: u64) -> Self {
415        self.max_tokens = Some(max_tokens);
416        self
417    }
418
419    /// Set how long a command the agent runs with `exec` may take.
420    ///
421    /// A new named builder rather than a parameter, like every other capability
422    /// this crate has added: every entry point takes a `TaskContract`, so setting
423    /// it here works with all of them and changes no existing signature.
424    ///
425    /// ```
426    /// use io_harness::{TaskContract, Verification, DEFAULT_EXEC_TIMEOUT};
427    /// use std::time::Duration;
428    ///
429    /// // A repository whose cold build is slower than the default ceiling. Raise
430    /// // it rather than watching honest work be killed as if it had hung.
431    /// let patient = TaskContract::workspace("build and test it", "/monorepo", Verification::None)
432    ///     .with_exec_timeout(Duration::from_secs(2400));
433    ///
434    /// // And the other direction: an unattended fleet job that would rather give
435    /// // up on a command than sit behind it.
436    /// let impatient = TaskContract::workspace("lint it", "/repo", Verification::None)
437    ///     .with_exec_timeout(Duration::from_secs(60));
438    ///
439    /// assert!(patient.exec_timeout > DEFAULT_EXEC_TIMEOUT);
440    /// assert!(impatient.exec_timeout < DEFAULT_EXEC_TIMEOUT);
441    /// ```
442    #[must_use]
443    pub fn with_exec_timeout(mut self, exec_timeout: Duration) -> Self {
444        self.exec_timeout = exec_timeout;
445        self
446    }
447
448    /// Set how much of each request the observation log may occupy.
449    ///
450    /// Sits beside [`TaskContract::with_token_budget`] because they are the two
451    /// halves of one thing: that bounds what the run may spend, this bounds what
452    /// any one request carries of what it has already observed.
453    pub fn with_context_budget(mut self, context: ContextBudget) -> Self {
454        self.context = context;
455        self
456    }
457
458    /// Set how long to wait between provider attempts.
459    pub fn with_retry_policy(mut self, retry: RetryPolicy) -> Self {
460        self.retry = retry;
461        self
462    }
463
464    /// Set when a run decides its agent has stalled, and how often to tell it.
465    ///
466    /// `StallPolicy { window: 0, .. }` disables detection, restoring 0.10.0
467    /// behaviour exactly.
468    ///
469    /// Applies to workspace and sub-agent runs. A single-file run
470    /// ([`TaskContract::new`]) ignores it: it has one tool and one file, so
471    /// "repeated a call without changing anything" describes its only move.
472    pub fn with_stall_policy(mut self, stall: StallPolicy) -> Self {
473        self.stall = stall;
474        self
475    }
476
477    /// Override the retry limit for failing provider/tool steps.
478    pub fn with_max_retries(mut self, max_retries: u32) -> Self {
479        self.max_retries = max_retries;
480        self
481    }
482
483    /// Add a constraint the agent must respect.
484    pub fn with_constraint(mut self, constraint: impl Into<String>) -> Self {
485        self.constraints.push(constraint.into());
486        self
487    }
488}