Skip to main content

cel_contracts/
view.rs

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