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}