Skip to main content

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}