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