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}
159
160impl TaskContract {
161 /// Minimal contract: goal, target file, and a success criterion.
162 /// Defaults to 8 steps, no time/token budget, 2 retries, no constraints.
163 pub fn new(goal: impl Into<String>, file: impl Into<PathBuf>, verify: Verification) -> Self {
164 Self {
165 goal: goal.into(),
166 file: file.into(),
167 root: None,
168 constraints: Vec::new(),
169 verify,
170 max_steps: 8,
171 max_duration: None,
172 max_tokens: None,
173 max_retries: 2,
174 mcp: Vec::new(),
175 commit_identity: crate::tools::git::Identity::default(),
176 #[cfg(feature = "media")]
177 images: Vec::new(),
178 tools: crate::tools::Toolbox::new(),
179 context: ContextBudget::default(),
180 retry: RetryPolicy::default(),
181 stall: StallPolicy::default(),
182 exec_timeout: crate::tools::DEFAULT_EXEC_TIMEOUT,
183 skills: None,
184 }
185 }
186
187 /// A workspace task: the agent may grep, find, read, and write several files
188 /// under `root`. `verify` should be a multi-file variant
189 /// ([`Verification::EachCompilesRust`], or a [`Verification::Command`] that runs
190 /// the project's own suite).
191 /// Defaults match [`TaskContract::new`] (12 steps here, since repo tasks take
192 /// more turns), no time/token budget, 2 retries.
193 pub fn workspace(
194 goal: impl Into<String>,
195 root: impl Into<PathBuf>,
196 verify: Verification,
197 ) -> Self {
198 let root = root.into();
199 Self {
200 goal: goal.into(),
201 file: root.clone(),
202 root: Some(root),
203 constraints: Vec::new(),
204 verify,
205 max_steps: 12,
206 max_duration: None,
207 max_tokens: None,
208 max_retries: 2,
209 mcp: Vec::new(),
210 commit_identity: crate::tools::git::Identity::default(),
211 #[cfg(feature = "media")]
212 images: Vec::new(),
213 tools: crate::tools::Toolbox::new(),
214 context: ContextBudget::default(),
215 retry: RetryPolicy::default(),
216 stall: StallPolicy::default(),
217 exec_timeout: crate::tools::DEFAULT_EXEC_TIMEOUT,
218 skills: None,
219 }
220 }
221
222 /// Connect these MCP servers for the run and offer their tools to the model.
223 ///
224 /// Each server is authorized before it is reached — spawning a stdio server
225 /// is an exec check on its binary, dialling an HTTP one is a network check
226 /// on its host — so configuring a server here does not grant access to it.
227 pub fn with_mcp<I>(mut self, servers: I) -> Self
228 where
229 I: IntoIterator<Item = crate::mcp::McpServer>,
230 {
231 self.mcp = servers.into_iter().collect();
232 self
233 }
234
235 /// Hand the agent images to look at, alongside the goal.
236 ///
237 /// A new named method rather than a parameter on any of the sixteen entry
238 /// points: every one of them takes a `TaskContract`, so attaching here works
239 /// with all of them and changes no existing signature.
240 ///
241 /// Construct each [`crate::Media`] with [`crate::Media::image`], which
242 /// refuses a media type no provider documents and refuses an image over the
243 /// per-image size bound. The total carried by one request is bounded too —
244 /// see [`crate::provider::MAX_REQUEST_IMAGE_BYTES`].
245 #[cfg(feature = "media")]
246 #[must_use]
247 pub fn with_images<I>(mut self, images: I) -> Self
248 where
249 I: IntoIterator<Item = crate::provider::Media>,
250 {
251 self.images.extend(images);
252 self
253 }
254
255 /// Attribute the agent's commits to this name and address.
256 ///
257 /// Replaces the default agent identity. Neither may be empty or contain a
258 /// control character — both reach the commit object and the reflog.
259 #[must_use]
260 pub fn with_commit_identity(
261 mut self,
262 name: impl Into<String>,
263 email: impl Into<String>,
264 ) -> Self {
265 self.commit_identity = crate::tools::git::Identity {
266 name: name.into(),
267 email: email.into(),
268 };
269 self
270 }
271
272 /// Register in-process tools for the run and offer them to the model.
273 ///
274 /// Registration makes a tool available; it does not authorize it. Each call
275 /// is an [`Act::Exec`](crate::Act::Exec) check on the tool's name, and a
276 /// registered tool runs with the embedding program's own privileges — see
277 /// [`crate::tools::Tool`] for the full bound.
278 ///
279 /// A name that shadows a built-in, uses the `mcp__` prefix, or duplicates
280 /// another registered tool fails the run with [`Error::Config`](crate::Error::Config)
281 /// before the first completion.
282 ///
283 /// Workspace mode only, like [`TaskContract::with_mcp`]: single-file mode has
284 /// one tool and no tool layer to extend.
285 pub fn with_tools(mut self, tools: crate::tools::Toolbox) -> Self {
286 self.tools = tools;
287 self
288 }
289
290 /// Offer the agent the skills in `dir` — see [`crate::skills`] for the
291 /// layout.
292 ///
293 /// The directory is read at run start, not here, so a path that does not
294 /// exist, is not a directory, or holds more than
295 /// [`MAX_SKILLS`](crate::skills::MAX_SKILLS) skills fails the run with
296 /// [`Error::Config`](crate::Error::Config) naming it, before the first
297 /// completion.
298 ///
299 /// A skill is instructions the model may choose to read. Offering one grants
300 /// nothing: the read goes through the policy when it happens, and anything
301 /// the model then does is checked as it always is.
302 pub fn with_skills(mut self, dir: impl Into<PathBuf>) -> Self {
303 self.skills = Some(dir.into());
304 self
305 }
306
307 /// Discover the configured skills. Called at run start by every entry point,
308 /// alongside [`Toolbox::validate`](crate::tools::Toolbox::validate).
309 pub(crate) fn discover_skills(&self) -> crate::Result<crate::skills::Skills> {
310 match &self.skills {
311 Some(dir) => crate::skills::Skills::discover(dir),
312 None => Ok(crate::skills::Skills::none()),
313 }
314 }
315
316 /// Override the step budget.
317 pub fn with_max_steps(mut self, max_steps: u32) -> Self {
318 self.max_steps = max_steps;
319 self
320 }
321
322 /// Set the time budget.
323 pub fn with_time_budget(mut self, max_duration: Duration) -> Self {
324 self.max_duration = Some(max_duration);
325 self
326 }
327
328 /// Set the cost budget, in total tokens across all completions.
329 pub fn with_token_budget(mut self, max_tokens: u64) -> Self {
330 self.max_tokens = Some(max_tokens);
331 self
332 }
333
334 /// Set how long a command the agent runs with `exec` may take.
335 ///
336 /// A new named builder rather than a parameter, like every other capability
337 /// this crate has added: every entry point takes a `TaskContract`, so setting
338 /// it here works with all of them and changes no existing signature.
339 ///
340 /// ```
341 /// use io_harness::{TaskContract, Verification, DEFAULT_EXEC_TIMEOUT};
342 /// use std::time::Duration;
343 ///
344 /// // A repository whose cold build is slower than the default ceiling. Raise
345 /// // it rather than watching honest work be killed as if it had hung.
346 /// let patient = TaskContract::workspace("build and test it", "/monorepo", Verification::None)
347 /// .with_exec_timeout(Duration::from_secs(2400));
348 ///
349 /// // And the other direction: an unattended fleet job that would rather give
350 /// // up on a command than sit behind it.
351 /// let impatient = TaskContract::workspace("lint it", "/repo", Verification::None)
352 /// .with_exec_timeout(Duration::from_secs(60));
353 ///
354 /// assert!(patient.exec_timeout > DEFAULT_EXEC_TIMEOUT);
355 /// assert!(impatient.exec_timeout < DEFAULT_EXEC_TIMEOUT);
356 /// ```
357 #[must_use]
358 pub fn with_exec_timeout(mut self, exec_timeout: Duration) -> Self {
359 self.exec_timeout = exec_timeout;
360 self
361 }
362
363 /// Set how much of each request the observation log may occupy.
364 ///
365 /// Sits beside [`TaskContract::with_token_budget`] because they are the two
366 /// halves of one thing: that bounds what the run may spend, this bounds what
367 /// any one request carries of what it has already observed.
368 pub fn with_context_budget(mut self, context: ContextBudget) -> Self {
369 self.context = context;
370 self
371 }
372
373 /// Set how long to wait between provider attempts.
374 pub fn with_retry_policy(mut self, retry: RetryPolicy) -> Self {
375 self.retry = retry;
376 self
377 }
378
379 /// Set when a run decides its agent has stalled, and how often to tell it.
380 ///
381 /// `StallPolicy { window: 0, .. }` disables detection, restoring 0.10.0
382 /// behaviour exactly.
383 ///
384 /// Applies to workspace and sub-agent runs. A single-file run
385 /// ([`TaskContract::new`]) ignores it: it has one tool and one file, so
386 /// "repeated a call without changing anything" describes its only move.
387 pub fn with_stall_policy(mut self, stall: StallPolicy) -> Self {
388 self.stall = stall;
389 self
390 }
391
392 /// Override the retry limit for failing provider/tool steps.
393 pub fn with_max_retries(mut self, max_retries: u32) -> Self {
394 self.max_retries = max_retries;
395 self
396 }
397
398 /// Add a constraint the agent must respect.
399 pub fn with_constraint(mut self, constraint: impl Into<String>) -> Self {
400 self.constraints.push(constraint.into());
401 self
402 }
403}