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}