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}