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