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}