Skip to main content

cel_contracts/
view.rs

1//! `PlanningView` — the budgeted, agent-facing projection of CEL state.
2//!
3//! `MentalModel` (in cel-cortex) can be rich. `PlanningView` must be small.
4//! Planners receive this, not the full mental model, so prompts stay under
5//! token budgets and the same context contract works across every planner
6//! runtime (canonical Rust runner, LangGraph, Codex, in-house).
7//!
8//! See `COGNITION_LAYER_PLAN.md` for the principle: **store broadly, select
9//! narrowly**.
10//!
11//! Memory / knowledge / event refs are typed here but populated only by
12//! later PRs (memory store + memory-aware selection). PR1a only fills
13//! `screen`, `elements`, `adapter_facts`, `adapter_actions`,
14//! `capabilities`, `blockers`, `anomalies`, `evidence`,
15//! `omitted_counts`, `selection_rationale`.
16
17use serde::{Deserialize, Serialize};
18use std::collections::BTreeMap;
19
20// ─── Top-level view ──────────────────────────────────────────────────────────
21
22/// Compact, budgeted projection of CEL state for one planner call.
23///
24/// Built by `cel-cortex`'s planning-view builder from `MentalModel` plus
25/// active adapter facts. Consumed by every planner — built-in or external —
26/// instead of raw `ScreenContext`.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct PlanningView {
29    /// The natural-language goal the planner is working on.
30    pub goal: String,
31    /// The budget the caller asked the builder to respect.
32    pub budget: PlanningBudget,
33
34    /// Current screen / app / window summary.
35    pub screen: PlanningScreen,
36    /// Selected elements relevant to the goal, after budget compression.
37    pub elements: Vec<PlanningElement>,
38    /// Active adapter-backed facts (Numbers cells, browser DOM facts, etc.).
39    /// Empty until adapters expose typed facts; in PR1a builders may leave
40    /// this empty if no adapter contributed.
41    #[serde(default)]
42    pub adapter_facts: Vec<AdapterFactRef>,
43    /// Active adapter-backed actions available for this turn. This is the
44    /// structured, agent-agnostic contract; LLM planners may render it into
45    /// prompt text, while non-LLM agents can inspect it directly.
46    #[serde(default, skip_serializing_if = "Vec::is_empty")]
47    pub adapter_actions: Vec<AdapterActionRef>,
48    /// Capabilities currently wired up (CDP bound, native input enabled, …).
49    /// Folds in the boolean flags from the legacy `RuntimeCaps`.
50    #[serde(default)]
51    pub capabilities: Vec<CapabilityRef>,
52    /// Where in the run the planner is. Lets the planner pace itself —
53    /// e.g. stop polishing extraction once most of the budget is spent.
54    /// Folds in the legacy `RuntimeCaps::steps_used` / `max_steps`.
55    #[serde(default)]
56    pub run_progress: RunProgress,
57
58    /// Memories selected by the cognition layer (PR3).
59    #[serde(default)]
60    pub memories: Vec<MemoryRef>,
61    /// Knowledge records selected by the cognition layer (PR3).
62    #[serde(default)]
63    pub knowledge: Vec<KnowledgeRef>,
64    /// Recent events / actions / checkpoints relevant to the goal.
65    #[serde(default)]
66    pub recent_events: Vec<EventRef>,
67
68    /// Things actively blocking forward progress (consent walls, modal
69    /// dialogs, missing capability, etc.). Promoted to first-class so the
70    /// planner notices them even if elements are aggressively compressed.
71    #[serde(default)]
72    pub blockers: Vec<Blocker>,
73    /// Anomalies the cortex flagged (stale state, unexpected window, etc.).
74    #[serde(default)]
75    pub anomalies: Vec<AnomalyRef>,
76    /// References back to source records that explain why selection picked
77    /// what it picked. Filled when a memory / failure / prior outcome
78    /// influenced the view.
79    #[serde(default)]
80    pub evidence: Vec<EvidenceRef>,
81
82    /// One-sentence rationale explaining the selection. Optional —
83    /// deterministic selectors may leave this absent.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub selection_rationale: Option<String>,
86    /// What the builder dropped to fit the budget. Lets the planner know
87    /// the view is compressed and how aggressively.
88    pub omitted_counts: OmittedCounts,
89    /// Transitional pre-rendered "App-Specific Actions" prompt fragment listing the
90    /// `{"type": "custom", "adapter": "...", "action": "...", "params": {...}}`
91    /// shapes for every currently-active adapter. The cortex-side step
92    /// executor builds this once per turn from `Cortex::active_adapter_manifests()`
93    /// and the canonical runner stamps it onto the view post-build.
94    /// `None` means no adapter actions are available to the planner this
95    /// turn — empty equivalent. Prefer `adapter_actions` for new callers;
96    /// this field is retained while prompt-only clients migrate.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub adapter_actions_prompt: Option<String>,
99}
100
101// ─── Budget ──────────────────────────────────────────────────────────────────
102
103/// Caller-provided ceilings for one planning view.
104///
105/// Builders enforce these greedily — most-relevant items first, drop the
106/// rest, count what was dropped in `OmittedCounts`. Budgets are advisory,
107/// not contractual: a planner with a 128K context window can request a
108/// larger budget; a benchmark harness can request a smaller one.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct PlanningBudget {
111    /// Soft token ceiling for the serialized view as it would appear in an
112    /// LLM prompt. Builders make best-effort decisions; the absolute cap is
113    /// the per-category limits below.
114    pub max_tokens: u32,
115    /// Maximum number of `PlanningElement` entries.
116    pub max_elements: u32,
117    /// Maximum number of `MemoryRef` entries (PR3 selector).
118    pub max_memories: u32,
119    /// Maximum number of `AdapterFactRef` entries.
120    pub max_adapter_facts: u32,
121    /// Maximum number of `KnowledgeRef` entries (Tier A1 selector — pulled
122    /// from `knowledge_fts` via FTS5 + bm25 ranking, scoped by workflow).
123    /// `serde(default)` keeps backward compat with v1 callers that don't
124    /// know about this field.
125    #[serde(default = "default_max_knowledge")]
126    pub max_knowledge: u32,
127    /// Maximum number of `EventRef` entries (Tier A2 selector — pulled
128    /// from cortex `observations` table, ordered by priority then
129    /// recency, scoped by workflow). `serde(default)` keeps backward
130    /// compat with pre-A2 callers.
131    #[serde(default = "default_max_recent_events")]
132    pub max_recent_events: u32,
133}
134
135fn default_max_knowledge() -> u32 {
136    8
137}
138
139fn default_max_recent_events() -> u32 {
140    10
141}
142
143impl Default for PlanningBudget {
144    /// Defaults sized to keep prompts under common LLM context windows.
145    /// Planners override per-call when they know better.
146    fn default() -> Self {
147        Self {
148            max_tokens: 8000,
149            max_elements: 80,
150            max_memories: 8,
151            max_adapter_facts: 12,
152            max_knowledge: 8,
153            max_recent_events: 10,
154        }
155    }
156}
157
158// ─── Run progress ────────────────────────────────────────────────────────────
159
160/// How much of the run budget the planner has consumed so far.
161///
162/// Replaces the `steps_used` / `max_steps` fields of the legacy `RuntimeCaps`.
163/// Helps the planner pace itself toward the terminal phase of the goal.
164#[derive(Debug, Clone, Default, Serialize, Deserialize)]
165pub struct RunProgress {
166    /// Steps already consumed on this run (zero on the first call).
167    pub steps_used: u32,
168    /// Hard cap on total steps for this run.
169    pub max_steps: u32,
170}
171
172impl RunProgress {
173    pub fn steps_remaining(&self) -> u32 {
174        self.max_steps.saturating_sub(self.steps_used)
175    }
176}
177
178// ─── Screen summary ──────────────────────────────────────────────────────────
179
180/// Compact screen-level summary written by perception.
181#[derive(Debug, Clone, Default, Serialize, Deserialize)]
182pub struct PlanningScreen {
183    /// Frontmost application name (e.g. "Google Chrome", "Numbers").
184    pub active_app: String,
185    /// Active window title.
186    #[serde(default)]
187    pub window: String,
188    /// One-paragraph natural-language summary of what is on screen.
189    /// Optional — builders may leave empty for the deterministic path.
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub summary: Option<String>,
192    /// Current URL when the active app is a browser with CDP bound.
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub url: Option<String>,
195}
196
197// ─── Compact element representation ──────────────────────────────────────────
198
199/// Compressed element representation for the planner.
200///
201/// Strict subset of `cel_context::ContextElement` — drops bounds, parent_id,
202/// full action vectors, source provenance, properties HashMap. Keeps what
203/// the planner needs to identify and act on the element. Builders embed
204/// only goal-relevant elements (and nearby anchors) to fit the budget.
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct PlanningElement {
207    /// Stable element identifier the planner uses in `PlannedAction`.
208    pub id: String,
209    /// Element kind: "button", "input", "link", "checkbox", "row", etc.
210    pub element_type: String,
211    /// Human-readable label or visible text. Optional — labelless elements
212    /// may still be selected when nearby labels clarify them.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub label: Option<String>,
215    /// Current value for inputs / selects / cells.
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub value: Option<String>,
218    /// State anchors the planner needs to reason about (focused, selected,
219    /// enabled, checked, expanded). Compact subset of `ElementState`.
220    pub state: PlanningElementState,
221    /// Whether this element supports a click-equivalent action.
222    #[serde(default)]
223    pub clickable: bool,
224    /// Whether this element supports `set_value` (form fields).
225    #[serde(default)]
226    pub settable: bool,
227    /// For `<select>` / combobox elements: enumerated option values
228    /// so the planner can `set_value` with a real `value=` attribute
229    /// instead of guessing slugs. Populated from
230    /// `ContextElement.properties["select_options"]` when the source
231    /// adapter provides it (browser CDP / DOM extractor). Empty / None
232    /// for everything else.
233    ///
234    /// Format encoded by the browser adapter as
235    /// `"value|Label, value2|Label 2, ..."` — strings separated by
236    /// `", "`, each entry split on `"|"`. The planner-side rendering
237    /// re-parses this; older clients that don't know the format show
238    /// the raw string, which is still strictly more useful than the
239    /// pre-fix behaviour of showing nothing.
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub select_options: Option<String>,
242}
243
244/// State flags that matter for planning. Strict subset of `ElementState`.
245#[derive(Debug, Clone, Default, Serialize, Deserialize)]
246pub struct PlanningElementState {
247    pub focused: bool,
248    pub selected: bool,
249    pub enabled: bool,
250    pub checked: bool,
251    pub expanded: bool,
252}
253
254// ─── References to source records ────────────────────────────────────────────
255
256/// Reference to a memory record selected for this view.
257///
258/// Hydrated by the memory-aware selector (PR3). PR1a builders leave this
259/// list empty.
260#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct MemoryRef {
262    /// Stable id of the memory record in `cortex_memories`.
263    pub id: i64,
264    /// Memory kind: "outcome", "prior", "failure", "preference".
265    pub kind: String,
266    /// One-line summary suitable for embedding in a prompt.
267    pub summary: String,
268    /// Raw content payload (free text or structured JSON).
269    pub content: serde_json::Value,
270    /// When the memory was created (ISO-8601 string).
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub created_at: Option<String>,
273}
274
275/// Reference to a knowledge record selected for this view.
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct KnowledgeRef {
278    pub id: i64,
279    pub source: String,
280    pub content: String,
281    #[serde(default, skip_serializing_if = "Vec::is_empty")]
282    pub tags: Vec<String>,
283}
284
285/// Reference to an adapter-backed fact relevant to the goal.
286///
287/// Adapters expose typed facts (e.g. `{adapter: "numbers", kind: "table",
288/// payload: { sheet: "Sheet 1", rows: 12, cols: 8 }}`). Selectors include
289/// only facts about the active adapter for the focused app.
290#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct AdapterFactRef {
292    /// Optional stable id supplied by the adapter. When absent, CEL
293    /// synthesizes evidence ids from adapter/kind/payload.
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub id: Option<String>,
296    pub adapter: String,
297    pub kind: String,
298    pub payload: serde_json::Value,
299}
300
301/// Adapter-backed action currently available to a planner.
302///
303/// This is the structured form of "App-Specific Actions". It lets any
304/// planner/runtime discover app-specific operations without scraping prompt
305/// prose, while still leaving each planner free to render or reason over the
306/// action catalogue in its own way.
307#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct AdapterActionRef {
309    pub adapter: String,
310    pub action: String,
311    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
312    pub params_schema: BTreeMap<String, String>,
313    #[serde(default, skip_serializing_if = "String::is_empty")]
314    pub description: String,
315    #[serde(default)]
316    pub mutates_state: bool,
317    #[serde(default)]
318    pub requires_verification: bool,
319    #[serde(default)]
320    pub returns_data: bool,
321}
322
323/// Reference to a capability the runtime currently has wired up.
324#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct CapabilityRef {
326    /// Capability identifier: "cdp_bound", "native_input", "vision",
327    /// "numbers_write_cells", etc.
328    pub id: String,
329    /// Free-form details (browser name + URL for cdp_bound, etc.).
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub detail: Option<String>,
332}
333
334/// Reference to a recent event the planner should be aware of.
335///
336/// Examples: a checkpoint summary, a recent failed action, a window
337/// activation. The full event lives in the run transcript / store; the
338/// view carries only what's needed to prompt.
339#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct EventRef {
341    /// Stable identifier or hash for cross-referencing the source record.
342    pub id: String,
343    /// Event kind: "checkpoint", "action_failed", "focus_changed", …
344    pub kind: String,
345    /// One-line natural-language description.
346    pub summary: String,
347    /// Optional ISO-8601 timestamp.
348    #[serde(default, skip_serializing_if = "Option::is_none")]
349    pub at: Option<String>,
350}
351
352/// Reference to evidence that explains why selection picked something.
353///
354/// Lets a planner trace a memory or fact back to its source record without
355/// inflating the view itself.
356#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
357pub struct EvidenceRef {
358    /// Where this evidence came from: "memory", "knowledge", "transcript",
359    /// "adapter_fact".
360    pub source: String,
361    /// Stable id within that source.
362    pub id: String,
363    /// One-line description suitable for inclusion in prompts.
364    pub summary: String,
365}
366
367/// Reference to an anomaly the cortex flagged that the planner should see.
368#[derive(Debug, Clone, Serialize, Deserialize)]
369pub struct AnomalyRef {
370    /// Anomaly kind: "stale_state", "unexpected_window", "missing_target",
371    /// "low_confidence", …
372    pub kind: String,
373    /// Human-readable description.
374    pub description: String,
375}
376
377/// First-class blocker that should not be lost to compression.
378#[derive(Debug, Clone, Serialize, Deserialize)]
379pub struct Blocker {
380    /// Blocker kind: "consent_wall", "modal_dialog", "missing_capability",
381    /// "auth_required", …
382    pub kind: String,
383    /// Human-readable description.
384    pub description: String,
385    /// Optional element id that represents the blocker (e.g. the consent
386    /// banner's accept button).
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub element_id: Option<String>,
389}
390
391// ─── What was dropped ────────────────────────────────────────────────────────
392
393/// Counts of items the builder filtered out to fit the budget.
394///
395/// Lets the planner notice that the view is compressed and decide whether
396/// to act on what it sees, request a tier-up, or pivot.
397#[derive(Debug, Clone, Default, Serialize, Deserialize)]
398pub struct OmittedCounts {
399    pub elements: u32,
400    pub memories: u32,
401    pub knowledge: u32,
402    pub adapter_facts: u32,
403    pub recent_events: u32,
404}
405
406// ─── Tests ───────────────────────────────────────────────────────────────────
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn budget_default_keeps_typical_prompts_under_common_windows() {
414        // Defaults must not push a prompt over 8K tokens for typical use.
415        // Planners with bigger windows override per-call.
416        let b = PlanningBudget::default();
417        assert!(b.max_tokens <= 8000);
418        assert!(b.max_elements <= 100);
419        assert!(b.max_memories <= 16);
420    }
421
422    #[test]
423    fn empty_view_serializes_compactly() {
424        let view = PlanningView {
425            goal: "test".into(),
426            budget: PlanningBudget::default(),
427            screen: PlanningScreen {
428                active_app: "TestApp".into(),
429                window: "main".into(),
430                summary: None,
431                url: None,
432            },
433            elements: vec![],
434            adapter_facts: vec![],
435            adapter_actions: vec![],
436            capabilities: vec![],
437            memories: vec![],
438            knowledge: vec![],
439            recent_events: vec![],
440            blockers: vec![],
441            anomalies: vec![],
442            evidence: vec![],
443            selection_rationale: None,
444            omitted_counts: OmittedCounts::default(),
445            run_progress: RunProgress::default(),
446            adapter_actions_prompt: None,
447        };
448        let json = serde_json::to_string(&view).unwrap();
449        // Empty-list fields use `default` + `skip_serializing_if` where
450        // appropriate; sanity-check the size is small for an empty view.
451        assert!(
452            json.len() < 700,
453            "empty view should serialize compactly, got {} chars",
454            json.len()
455        );
456    }
457
458    #[test]
459    fn view_roundtrips_through_json() {
460        let view = PlanningView {
461            goal: "submit form".into(),
462            budget: PlanningBudget {
463                max_tokens: 4000,
464                max_elements: 40,
465                max_memories: 4,
466                max_adapter_facts: 6,
467                max_knowledge: 4,
468                max_recent_events: 5,
469            },
470            screen: PlanningScreen {
471                active_app: "Browser".into(),
472                window: "Concur — Expenses".into(),
473                summary: Some("Expense form, partially filled".into()),
474                url: Some("https://concur.example.com/expenses".into()),
475            },
476            elements: vec![PlanningElement {
477                id: "dom:submit".into(),
478                element_type: "button".into(),
479                label: Some("Submit for Approval".into()),
480                value: None,
481                state: PlanningElementState {
482                    focused: false,
483                    selected: false,
484                    enabled: true,
485                    checked: false,
486                    expanded: false,
487                },
488                clickable: true,
489                settable: false,
490                select_options: None,
491            }],
492            adapter_facts: vec![],
493            adapter_actions: vec![],
494            capabilities: vec![CapabilityRef {
495                id: "cdp_bound".into(),
496                detail: Some("Google Chrome — concur.example.com".into()),
497            }],
498            memories: vec![],
499            knowledge: vec![],
500            recent_events: vec![],
501            blockers: vec![],
502            anomalies: vec![],
503            evidence: vec![],
504            selection_rationale: Some("Submit-related element + cdp capability".into()),
505            omitted_counts: OmittedCounts {
506                elements: 431,
507                ..Default::default()
508            },
509            run_progress: RunProgress {
510                steps_used: 7,
511                max_steps: 80,
512            },
513            adapter_actions_prompt: None,
514        };
515        let json = serde_json::to_string(&view).unwrap();
516        let back: PlanningView = serde_json::from_str(&json).unwrap();
517        assert_eq!(back.goal, view.goal);
518        assert_eq!(back.elements.len(), 1);
519        assert_eq!(back.capabilities[0].id, "cdp_bound");
520        assert_eq!(back.omitted_counts.elements, 431);
521        assert_eq!(back.run_progress.steps_remaining(), 73);
522    }
523
524    #[test]
525    fn run_progress_remaining_saturates_at_zero() {
526        let p = RunProgress {
527            steps_used: 100,
528            max_steps: 80,
529        };
530        assert_eq!(p.steps_remaining(), 0);
531    }
532}