cel_contracts/canonical.rs
1//! Canonical agent contract — the boundary types every caller (CLI, MCP server,
2//! eval harness, benchmarks) speaks.
3//!
4//! These types live at the runner/planner boundary so neither side has to
5//! depend on the other.
6//!
7//! Deliberately minimal. Each new field would be a new knob that the
8//! CLI/eval/MCP paths could drift on. Add one only when a caller genuinely
9//! needs it, and add it in one place.
10
11use serde::{Deserialize, Serialize};
12
13use crate::actions::PlannedAction;
14
15/// What the reactive planner decides to do next, given the current
16/// state.
17///
18/// Each turn of the agent loop is: observe → ask planner for the next
19/// move → execute it → observe again. The planner never commits past
20/// the next batch; if the first step of a batch reveals something
21/// surprising the planner will see it on the next call and pivot.
22///
23/// The old "upfront Plan with a fixed list of SubGoals" is gone — it
24/// forced the LLM to commit to a structure before it had perception,
25/// which meant Numbers-on-launch-shows-Open-dialog and similar
26/// surprises broke whole runs.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(tag = "kind", rename_all = "snake_case")]
29pub enum NextMove {
30 /// Execute this batch of steps one after another, then ask the
31 /// planner again. A batch is "commit to this short sequence" —
32 /// typically 1–5 steps. Larger commitments are a smell; use
33 /// smaller batches and trust the loop to re-plan.
34 Batch {
35 /// Short natural-language description of what this batch is
36 /// trying to accomplish. Surfaced in logs and failure reports.
37 purpose: String,
38 steps: Vec<Step>,
39 },
40 /// Goal achieved — return success.
41 Done {
42 summary: String,
43 #[serde(default)]
44 extracted_data: serde_json::Value,
45 },
46 /// Give up — goal can't be completed given the state.
47 Fail { reason: String },
48 /// Refuse to act — the goal is too ambiguous or destructive to
49 /// attempt safely, and the planner is asking the user for
50 /// clarification instead of guessing.
51 ///
52 /// Terminal like `Fail`, but distinct in intent: the agent isn't
53 /// stuck, it's deliberately declining to act on insufficient
54 /// information. Surfaced as `GoalOutcome::Refused` with `question`
55 /// in the summary so the eval harness, CLI, and MCP server can
56 /// display it back to the user without rendering it as an error.
57 ///
58 /// Use cases (see `NEXT_MOVE_SYSTEM_PROMPT` for the prompt rule):
59 /// - "Delete it" with no clear referent on screen.
60 /// - "Send the email" with no recipient identified.
61 /// - Destructive prompts (delete / overwrite / send / pay) without
62 /// explicit confirmation in the goal text.
63 Clarify { question: String },
64}
65
66/// Snapshot of what tools / channels the runtime has wired up this
67/// turn. Handed to the planner on every `decide_next` call so the
68/// LLM picks actions that actually go somewhere — e.g. emitting
69/// `cdp_eval` only when a CDP client is bound, and steering away
70/// from Safari when our bound browser is Chrome.
71///
72/// All fields are optional / best-effort; a runtime that doesn't know its
73/// capabilities returns [`RuntimeCaps::default()`] and the planner just runs
74/// with less context.
75#[derive(Debug, Clone, Default, Serialize, Deserialize)]
76pub struct RuntimeCaps {
77 /// True when the runtime has a CDP client bound (i.e. `cdp_eval` /
78 /// `navigate` will actually dispatch). When false the planner
79 /// should not emit those actions.
80 pub cdp_bound: bool,
81 /// Human-readable name of the CDP-controlled browser (e.g.
82 /// "Google Chrome"). Rendered to the planner so it can prefer
83 /// this browser over any other running one.
84 pub cdp_browser: Option<String>,
85 /// Current URL of the CDP-controlled page, if known. Helps the
86 /// planner decide between "already on the right page, extract
87 /// now" vs "navigate first".
88 pub cdp_url: Option<String>,
89 /// True when native-input dispatch is unlocked (mouse, keyboard,
90 /// ax_action, activate_app). When false the planner must stick
91 /// to browser-only actions.
92 pub native_input: bool,
93 /// Steps already consumed on this run. Zero on the first call.
94 /// Rendered to the planner so it can pace itself — e.g. stop
95 /// polishing extraction once most of the budget is spent and
96 /// commit to the terminal phase of the goal.
97 pub steps_used: u32,
98 /// Hard cap on total steps for this run. `steps_remaining =
99 /// max_steps - steps_used`. Rendered alongside `steps_used` so
100 /// the planner knows how much runway it has.
101 pub max_steps: u32,
102}
103
104/// What happened when an earlier step ran. The planner sees the full
105/// history on every decide_next call so it can reason about what's
106/// already been tried.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct AttemptRecord {
109 /// Short description of what the step was trying to do (taken
110 /// from Step.purpose so the planner sees its own intent).
111 pub step_purpose: String,
112 /// Serialized action that was dispatched.
113 pub action: PlannedAction,
114 /// True = executor reported success; false = reported failure.
115 pub succeeded: bool,
116 /// Error message on failure, Ok data on success (truncated).
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub error: Option<String>,
119 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
120 pub data: serde_json::Value,
121 /// Categorical recovery hint promoted from the verify_done grader
122 /// (or other runtime checks) up into the AttemptRecord so the
123 /// planner sees it as a top-level field rather than buried in the
124 /// `error` string. None for ordinary action-failure attempts;
125 /// populated for `verify_done`-rejected Done attempts and similar
126 /// runtime-grader rejections. See [`crate::NextActionHint`] for
127 /// the variants and their planner-side contract.
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub next_action_hint: Option<crate::NextActionHint>,
130}
131
132/// One executable unit inside a batch.
133///
134/// A step wraps exactly one [`PlannedAction`] plus the metadata the
135/// agent loop needs: why we're doing it (for failure reports) and
136/// whether the LLM is required to execute it.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct Step {
139 /// Natural-language description of what this step accomplishes.
140 /// Used in `FailureReport.failing_step` and in LLM-assisted retry
141 /// prompts as "you are trying to: {purpose}".
142 pub purpose: String,
143
144 /// Tells the executor whether it can run the step without an LLM
145 /// call at all. `Deterministic` steps (e.g. a navigate to a known
146 /// URL, a wait, a shell-style `activate_app`) skip the LLM. That is
147 /// the biggest latency win — every deterministic step saves a full
148 /// plan round-trip.
149 ///
150 /// Tolerant to LLM drift: if the model emits an unknown value
151 /// (e.g. confuses the step-level `kind` with the action-level
152 /// `type` and writes `"kind": "cdp_eval"`), we default to
153 /// `Deterministic` rather than failing the whole batch parse.
154 /// That's right almost always — the action itself carries the
155 /// real semantics — and it's robust to prompt drift.
156 #[serde(default, deserialize_with = "deserialize_step_kind_lenient")]
157 pub kind: StepKind,
158
159 /// The action to execute.
160 pub action: PlannedAction,
161}
162
163/// Whether a step needs the LLM to execute it.
164///
165/// Mostly advisory today — the executor can fall back to an LLM call
166/// if a "deterministic" step errors in a way that needs reasoning.
167#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(rename_all = "snake_case")]
169pub enum StepKind {
170 /// The action is fully specified; run it as-is. Example:
171 /// `Navigate { url: "https://finance.yahoo.com/..." }` or `Wait`.
172 #[default]
173 Deterministic,
174
175 /// The action's parameters need to be filled in at execute time,
176 /// given the live perception and the history of prior attempts.
177 /// Example: an `Extract` whose selector depends on what the page
178 /// currently renders.
179 LlmAssisted,
180}
181
182fn deserialize_step_kind_lenient<'de, D>(deserializer: D) -> Result<StepKind, D::Error>
183where
184 D: serde::Deserializer<'de>,
185{
186 let raw: Option<String> = Option::deserialize(deserializer)?;
187 Ok(match raw.as_deref() {
188 Some("llm_assisted") | Some("LlmAssisted") => StepKind::LlmAssisted,
189 _ => StepKind::Deterministic,
190 })
191}
192
193/// Outcome of a single step attempt.
194///
195/// On failure, the agent loop will retry up to 3 times per step before
196/// producing a [`FailureReport`].
197#[derive(Debug, Clone, Serialize, Deserialize)]
198#[serde(tag = "status", rename_all = "snake_case")]
199pub enum StepResult {
200 /// The step completed. `data` is a free-form JSON blob that the
201 /// agent writes into the plan's `shared_memory`. `discovered_sub_goal`
202 /// is how mid-execution surprises (consent walls, auth prompts) get
203 /// injected into the current sub-goal's remaining steps.
204 Ok {
205 #[serde(default)]
206 data: serde_json::Value,
207 /// Reserved: legacy hook for mid-execution sub-goal injection.
208 /// Kept as untyped JSON in cel-contracts so the planner-side
209 /// `SubGoal` type stays out of the boundary surface.
210 #[serde(default)]
211 discovered_sub_goal: Option<serde_json::Value>,
212 },
213
214 /// The step failed. `recoverable=true` means the agent may retry;
215 /// `recoverable=false` means retry would be pointless (e.g. the
216 /// LLM refused, or an invariant was violated) and the 3-strike
217 /// budget should skip straight to the failure report.
218 Err {
219 message: String,
220 #[serde(default = "default_recoverable")]
221 recoverable: bool,
222 },
223}
224
225fn default_recoverable() -> bool {
226 true
227}
228
229/// Terminal outcome of a whole agent run.
230///
231/// Every caller — CLI, MCP, eval harness — consumes this exact shape.
232/// There is no "timeout" or "max steps" variant: those are budget
233/// limits, and exhausting one produces a [`FailureReport`] whose
234/// `attempts` describe *why* the agent used up the budget.
235#[derive(Debug, Clone, Serialize, Deserialize)]
236#[serde(tag = "status", rename_all = "snake_case")]
237pub enum GoalOutcome {
238 /// The agent reached a satisfying end state. `summary` is the
239 /// natural-language recap (what the CLI prints after `=== Result ===`,
240 /// what the MCP response shows). `extracted_data` is the plan's
241 /// final `shared_memory` — prices, URLs, confirmation numbers, etc.
242 Succeeded {
243 summary: String,
244 #[serde(default)]
245 extracted_data: serde_json::Value,
246 },
247
248 /// The agent gave up. `report` names which sub-goal and step died,
249 /// and carries the last three error messages.
250 Failed(FailureReport),
251
252 /// The agent refused to act because the goal was too ambiguous or
253 /// destructive to attempt safely, and asked the user for
254 /// clarification instead.
255 ///
256 /// Terminal like `Failed` but semantically distinct: the agent
257 /// isn't broken, the goal is. `summary` carries the clarification
258 /// question — verbatim what the planner emitted in
259 /// [`NextMove::Clarify`]. Callers (CLI, MCP server, eval harness)
260 /// render it back to the user as a prompt, not an error.
261 Refused {
262 /// The clarification question (or refusal explanation) the
263 /// planner produced. Free-form text; the runner does not
264 /// re-format it. Callers may choose to wrap it in a "the agent
265 /// asked: …" frame.
266 summary: String,
267 },
268}
269
270/// Structured explanation for why the agent stopped before success.
271///
272/// Always surfaced verbatim to the caller — the CLI prints it, the
273/// eval harness matches on it, the MCP response includes it. No lossy
274/// formatting.
275#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct FailureReport {
277 /// Natural-language purpose of the sub-goal that was active when we
278 /// gave up. Matches `SubGoal.purpose` exactly.
279 pub failing_sub_goal: String,
280
281 /// Natural-language purpose of the specific step that exhausted
282 /// its retry budget. Matches `Step.purpose` exactly.
283 pub failing_step: String,
284
285 /// Error messages from each attempt, oldest first. Capped at the
286 /// configured step-retry budget (default 3). Each entry is the
287 /// `StepResult::Err { message }` from one attempt.
288 pub attempts: Vec<String>,
289}
290
291/// Budget limits for a single agent run.
292///
293/// These are the *only* knobs the agent loop itself consumes. Callers
294/// that used to set `enable_vision` / `enable_decomposition` / `self_heal`
295/// / `enable_notebook` are expected to stop — those behaviors are now
296/// implicit in the canonical agent loop.
297#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct RunLimits {
299 /// Total step budget across all sub-goals. A step that retries 3
300 /// times still counts as 1 step.
301 pub max_steps: u32,
302
303 /// Wall-clock deadline in milliseconds. Measured from
304 /// `GoalRunner::run` start, not per sub-goal.
305 pub timeout_ms: u64,
306
307 /// Per-step retry cap. Default: 3. See the 3-strike rule in
308 /// `docs/canonical-agent-plan.md`.
309 pub max_step_retries: u32,
310
311 /// App name the goal should ultimately land its output in (e.g.
312 /// `"Numbers"` for a spreadsheet scenario). When set, the runner
313 /// enforces a phase-gate: once `steps_used >= max_steps / 2` with
314 /// no terminal-app work recorded (no `write_cells`/`save_document`
315 /// action, and frontmost app != `terminal_app`), it injects a
316 /// synthetic history record telling the planner its next batch
317 /// MUST begin with `activate_app(terminal_app)`. On a second
318 /// ignore the runner auto-dispatches the activation itself.
319 ///
320 /// `None` = no phase-gate enforcement (legacy behaviour).
321 #[serde(default)]
322 pub terminal_app: Option<String>,
323
324 /// PR2 opt-in: when set together with `memory_db_path`, the canonical
325 /// runner writes a final outcome memory under this `workflow_id`
326 /// after the run terminates (success or failure). Defaults to `None`
327 /// — no memory writes happen if the caller doesn't ask for them.
328 ///
329 /// Mirrors the `cel_perceive start { enable_memory, workflow_id }`
330 /// opt-in for sessions; here it's at the runner level so any caller
331 /// of `CanonicalGoalRunner::run` (CLI, MCP, eval harness, worker) can opt
332 /// in independently.
333 #[serde(default)]
334 pub workflow_id_for_memory: Option<String>,
335
336 /// PR2 opt-in: SQLite path the canonical runner uses to write the
337 /// final outcome memory. Required alongside `workflow_id_for_memory`
338 /// for the auto-write to fire.
339 #[serde(default)]
340 pub memory_db_path: Option<String>,
341}
342
343impl Default for RunLimits {
344 fn default() -> Self {
345 Self {
346 max_steps: 80,
347 timeout_ms: 900_000,
348 max_step_retries: 3,
349 terminal_app: None,
350 workflow_id_for_memory: None,
351 memory_db_path: None,
352 }
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 #[test]
361 fn run_limits_default_is_the_documented_default() {
362 // docs/canonical-agent-plan.md calls out: 3 attempts per step,
363 // 80 steps, 15 minute wall clock. Keep them wired to the same
364 // constants so the doc and code cannot silently disagree.
365 let l = RunLimits::default();
366 assert_eq!(l.max_step_retries, 3);
367 assert_eq!(l.max_steps, 80);
368 assert_eq!(l.timeout_ms, 900_000);
369 }
370
371 #[test]
372 fn step_result_tags_are_snake_case() {
373 // The LLM will emit these exact strings in its plan responses;
374 // lock them down so a serde rename does not silently break
375 // prompt compatibility.
376 let ok = StepResult::Ok {
377 data: serde_json::json!({"x": 1}),
378 discovered_sub_goal: None,
379 };
380 let err = StepResult::Err {
381 message: "selector returned null".into(),
382 recoverable: true,
383 };
384 assert!(serde_json::to_string(&ok)
385 .unwrap()
386 .contains("\"status\":\"ok\""));
387 assert!(serde_json::to_string(&err)
388 .unwrap()
389 .contains("\"status\":\"err\""));
390 }
391
392 #[test]
393 fn clarify_next_move_round_trips_through_json() {
394 // The planner emits `{"kind":"clarify","question":"..."}`; lock
395 // down the serde tag so a rename can't silently break the
396 // prompt contract.
397 let mv = NextMove::Clarify {
398 question: "What should I delete?".into(),
399 };
400 let json = serde_json::to_string(&mv).unwrap();
401 assert!(json.contains("\"kind\":\"clarify\""));
402 assert!(json.contains("\"question\":\"What should I delete?\""));
403 let back: NextMove = serde_json::from_str(&json).unwrap();
404 assert!(matches!(back, NextMove::Clarify { .. }));
405 }
406
407 #[test]
408 fn refused_outcome_serializes_with_summary() {
409 // Eval harness, CLI, and MCP server all consume the JSON tag —
410 // they discriminate on `"status":"refused"`. Lock it down.
411 let outcome = GoalOutcome::Refused {
412 summary: "Which item should I delete? Please clarify.".into(),
413 };
414 let json = serde_json::to_string(&outcome).unwrap();
415 assert!(json.contains("\"status\":\"refused\""));
416 assert!(json.contains("clarify"));
417 let back: GoalOutcome = serde_json::from_str(&json).unwrap();
418 assert!(matches!(back, GoalOutcome::Refused { .. }));
419 }
420
421 #[test]
422 fn failure_report_surfaces_last_three_attempts() {
423 // 3-strike rule from the plan doc: the report carries exactly
424 // the errors the caller needs to debug — no extra, no loss.
425 let report = FailureReport {
426 failing_sub_goal: "gather BTC price".into(),
427 failing_step: "extract price via innerText regex".into(),
428 attempts: vec![
429 "selector returned null".into(),
430 "consent wall blocked script".into(),
431 "network error".into(),
432 ],
433 };
434 let outcome = GoalOutcome::Failed(report.clone());
435 let json = serde_json::to_string(&outcome).unwrap();
436 assert!(json.contains("\"status\":\"failed\""));
437 assert!(json.contains("gather BTC price"));
438 assert!(json.contains("network error"));
439 }
440}