Skip to main content

aios_protocol/
state.rs

1//! Canonical state and patch types for the Agent OS.
2//!
3//! This module defines the protocol-level state model used for replay, UI sync,
4//! and deterministic patch application, plus homeostasis types still used by
5//! existing runtime components.
6
7use crate::event::RiskLevel;
8use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value};
10use std::collections::{BTreeMap, BTreeSet};
11use thiserror::Error;
12
13// -----------------------------------------------------------------------------
14// Canonical state + patch model
15// -----------------------------------------------------------------------------
16
17/// Reference to content-addressed blob payloads stored out-of-line.
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19pub struct BlobRef {
20    pub blob_id: String,
21    pub content_type: String,
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub codec: Option<String>,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub meta: Option<Value>,
26}
27
28/// Memory namespace. Stores projection pointers, not full payloads.
29#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
30#[serde(default)]
31pub struct MemoryNamespace {
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub summary_ref: Option<BlobRef>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub decisions_ref: Option<BlobRef>,
36    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
37    pub projections: BTreeMap<String, BlobRef>,
38}
39
40/// Canonical state model: one object with four namespaces.
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
42#[serde(default)]
43pub struct CanonicalState {
44    pub session: Value,
45    pub agent: Value,
46    pub os: Value,
47    pub memory: MemoryNamespace,
48    /// Tombstones prevent accidental resurrection of forgotten paths.
49    #[serde(skip)]
50    tombstones: BTreeSet<String>,
51}
52
53impl Default for CanonicalState {
54    fn default() -> Self {
55        Self {
56            session: Value::Object(Map::new()),
57            agent: Value::Object(Map::new()),
58            os: Value::Object(Map::new()),
59            memory: MemoryNamespace::default(),
60            tombstones: BTreeSet::new(),
61        }
62    }
63}
64
65/// Versioned state used by deterministic reducers.
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
67pub struct VersionedCanonicalState {
68    pub version: u64,
69    #[serde(default)]
70    pub state: CanonicalState,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
74#[serde(tag = "type", rename_all = "snake_case")]
75pub enum ProvenanceRef {
76    Event { event_id: String },
77    Blob { blob_id: String },
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
81#[serde(tag = "op", rename_all = "snake_case")]
82pub enum PatchOp {
83    Set {
84        path: String,
85        value: Value,
86    },
87    Merge {
88        path: String,
89        object: Value,
90    },
91    Append {
92        path: String,
93        values: Vec<Value>,
94    },
95    Tombstone {
96        path: String,
97        reason: String,
98        #[serde(default, skip_serializing_if = "Option::is_none")]
99        replaced_by: Option<String>,
100    },
101    SetRef {
102        path: String,
103        blob_ref: BlobRef,
104    },
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
108pub struct StatePatch {
109    pub base_version: u64,
110    #[serde(default)]
111    pub ops: Vec<PatchOp>,
112    #[serde(default)]
113    pub provenance: Vec<ProvenanceRef>,
114}
115
116#[derive(Debug, Error, PartialEq)]
117pub enum PatchApplyError {
118    #[error("base version mismatch: expected {expected}, got {actual}")]
119    BaseVersionMismatch { expected: u64, actual: u64 },
120    #[error("invalid patch path: {0}")]
121    InvalidPath(String),
122    #[error("path is tombstoned and cannot be mutated: {0}")]
123    Tombstoned(String),
124    #[error("type conflict at path {path}: expected {expected}")]
125    TypeConflict {
126        path: String,
127        expected: &'static str,
128    },
129}
130
131impl VersionedCanonicalState {
132    /// Apply a patch using deterministic reducer semantics.
133    pub fn apply_patch(&mut self, patch: &StatePatch) -> Result<(), PatchApplyError> {
134        if patch.base_version != self.version {
135            return Err(PatchApplyError::BaseVersionMismatch {
136                expected: self.version,
137                actual: patch.base_version,
138            });
139        }
140
141        for op in &patch.ops {
142            self.state.apply_op(op)?;
143        }
144
145        self.version = self.version.saturating_add(1);
146        Ok(())
147    }
148}
149
150impl CanonicalState {
151    fn apply_op(&mut self, op: &PatchOp) -> Result<(), PatchApplyError> {
152        match op {
153            PatchOp::Set { path, value } => {
154                self.ensure_not_tombstoned(path)?;
155                set_at_pointer(self, path, value.clone())
156            }
157            PatchOp::Merge { path, object } => {
158                self.ensure_not_tombstoned(path)?;
159                merge_at_pointer(self, path, object)
160            }
161            PatchOp::Append { path, values } => {
162                self.ensure_not_tombstoned(path)?;
163                append_at_pointer(self, path, values)
164            }
165            PatchOp::Tombstone {
166                path,
167                reason: _,
168                replaced_by,
169            } => {
170                self.tombstones.insert(path.clone());
171                if let Some(new_path) = replaced_by {
172                    self.tombstones.insert(new_path.clone());
173                }
174                // Keep journal as source of truth; projection hides forgotten data.
175                Ok(())
176            }
177            PatchOp::SetRef { path, blob_ref } => {
178                self.ensure_not_tombstoned(path)?;
179                set_at_pointer(
180                    self,
181                    path,
182                    serde_json::to_value(blob_ref)
183                        .map_err(|_| PatchApplyError::InvalidPath(path.clone()))?,
184                )
185            }
186        }
187    }
188
189    fn ensure_not_tombstoned(&self, path: &str) -> Result<(), PatchApplyError> {
190        if self
191            .tombstones
192            .iter()
193            .any(|t| path == t || path.starts_with(&(t.to_string() + "/")))
194        {
195            return Err(PatchApplyError::Tombstoned(path.to_owned()));
196        }
197        Ok(())
198    }
199}
200
201fn set_at_pointer(
202    state: &mut CanonicalState,
203    path: &str,
204    value: Value,
205) -> Result<(), PatchApplyError> {
206    let mut root = serde_json::to_value(state.clone())
207        .map_err(|_| PatchApplyError::InvalidPath(path.to_owned()))?;
208
209    let parent_path = parent_pointer(path)?;
210    let key = leaf_key(path)?;
211    ensure_object_path(&mut root, parent_path)?;
212
213    let parent = root
214        .pointer_mut(parent_path)
215        .ok_or_else(|| PatchApplyError::InvalidPath(path.to_owned()))?;
216
217    match parent {
218        Value::Object(map) => {
219            map.insert(key.to_owned(), value);
220        }
221        _ => {
222            return Err(PatchApplyError::TypeConflict {
223                path: parent_path.to_owned(),
224                expected: "object",
225            });
226        }
227    }
228
229    let tombstones = state.tombstones.clone();
230    *state =
231        serde_json::from_value(root).map_err(|_| PatchApplyError::InvalidPath(path.to_owned()))?;
232    state.tombstones = tombstones;
233    Ok(())
234}
235
236fn merge_at_pointer(
237    state: &mut CanonicalState,
238    path: &str,
239    object: &Value,
240) -> Result<(), PatchApplyError> {
241    let mut root = serde_json::to_value(state.clone())
242        .map_err(|_| PatchApplyError::InvalidPath(path.to_owned()))?;
243    ensure_object_path(&mut root, path)?;
244
245    let target = root
246        .pointer_mut(path)
247        .ok_or_else(|| PatchApplyError::InvalidPath(path.to_owned()))?;
248
249    let patch_obj = object
250        .as_object()
251        .ok_or_else(|| PatchApplyError::TypeConflict {
252            path: path.to_owned(),
253            expected: "object",
254        })?;
255
256    let target_obj = target
257        .as_object_mut()
258        .ok_or_else(|| PatchApplyError::TypeConflict {
259            path: path.to_owned(),
260            expected: "object",
261        })?;
262
263    for (k, v) in patch_obj {
264        target_obj.insert(k.clone(), v.clone());
265    }
266
267    let tombstones = state.tombstones.clone();
268    *state =
269        serde_json::from_value(root).map_err(|_| PatchApplyError::InvalidPath(path.to_owned()))?;
270    state.tombstones = tombstones;
271    Ok(())
272}
273
274fn append_at_pointer(
275    state: &mut CanonicalState,
276    path: &str,
277    values: &[Value],
278) -> Result<(), PatchApplyError> {
279    let mut root = serde_json::to_value(state.clone())
280        .map_err(|_| PatchApplyError::InvalidPath(path.to_owned()))?;
281    ensure_array_path(&mut root, path)?;
282
283    let target = root
284        .pointer_mut(path)
285        .ok_or_else(|| PatchApplyError::InvalidPath(path.to_owned()))?;
286
287    let target_arr = target
288        .as_array_mut()
289        .ok_or_else(|| PatchApplyError::TypeConflict {
290            path: path.to_owned(),
291            expected: "array",
292        })?;
293
294    target_arr.extend(values.iter().cloned());
295
296    let tombstones = state.tombstones.clone();
297    *state =
298        serde_json::from_value(root).map_err(|_| PatchApplyError::InvalidPath(path.to_owned()))?;
299    state.tombstones = tombstones;
300    Ok(())
301}
302
303fn ensure_object_path(root: &mut Value, path: &str) -> Result<(), PatchApplyError> {
304    if path == "/" {
305        return match root {
306            Value::Object(_) => Ok(()),
307            _ => Err(PatchApplyError::TypeConflict {
308                path: "/".to_owned(),
309                expected: "object",
310            }),
311        };
312    }
313
314    if !path.starts_with('/') {
315        return Err(PatchApplyError::InvalidPath(path.to_owned()));
316    }
317
318    let mut current = root;
319    for seg in path.trim_start_matches('/').split('/') {
320        if seg.is_empty() {
321            continue;
322        }
323        match current {
324            Value::Object(map) => {
325                current = map
326                    .entry(seg.to_owned())
327                    .or_insert_with(|| Value::Object(Map::new()));
328            }
329            _ => {
330                return Err(PatchApplyError::TypeConflict {
331                    path: path.to_owned(),
332                    expected: "object",
333                });
334            }
335        }
336    }
337
338    match current {
339        Value::Object(_) => Ok(()),
340        _ => Err(PatchApplyError::TypeConflict {
341            path: path.to_owned(),
342            expected: "object",
343        }),
344    }
345}
346
347fn ensure_array_path(root: &mut Value, path: &str) -> Result<(), PatchApplyError> {
348    if !path.starts_with('/') {
349        return Err(PatchApplyError::InvalidPath(path.to_owned()));
350    }
351    if root.pointer(path).is_some() {
352        return Ok(());
353    }
354
355    let parent_path = parent_pointer(path)?;
356    let key = leaf_key(path)?;
357    ensure_object_path(root, parent_path)?;
358    let parent = root
359        .pointer_mut(parent_path)
360        .ok_or_else(|| PatchApplyError::InvalidPath(path.to_owned()))?;
361    match parent {
362        Value::Object(map) => {
363            map.entry(key.to_owned())
364                .or_insert_with(|| Value::Array(Vec::new()));
365            Ok(())
366        }
367        _ => Err(PatchApplyError::TypeConflict {
368            path: parent_path.to_owned(),
369            expected: "object",
370        }),
371    }
372}
373
374fn parent_pointer(path: &str) -> Result<&str, PatchApplyError> {
375    if !path.starts_with('/') || path == "/" {
376        return Err(PatchApplyError::InvalidPath(path.to_owned()));
377    }
378    path.rsplit_once('/')
379        .map(|(p, _)| if p.is_empty() { "/" } else { p })
380        .ok_or_else(|| PatchApplyError::InvalidPath(path.to_owned()))
381}
382
383fn leaf_key(path: &str) -> Result<&str, PatchApplyError> {
384    if !path.starts_with('/') || path == "/" {
385        return Err(PatchApplyError::InvalidPath(path.to_owned()));
386    }
387    path.rsplit('/')
388        .next()
389        .filter(|k| !k.is_empty())
390        .ok_or_else(|| PatchApplyError::InvalidPath(path.to_owned()))
391}
392
393// -----------------------------------------------------------------------------
394// Homeostasis model (kept for runtime compatibility)
395// -----------------------------------------------------------------------------
396
397/// The agent's internal health and resource state vector.
398#[derive(Debug, Clone, Serialize, Deserialize)]
399pub struct AgentStateVector {
400    pub progress: f32,
401    pub uncertainty: f32,
402    pub risk_level: RiskLevel,
403    pub budget: BudgetState,
404    pub error_streak: u32,
405    pub context_pressure: f32,
406    pub side_effect_pressure: f32,
407    pub human_dependency: f32,
408}
409
410impl Default for AgentStateVector {
411    fn default() -> Self {
412        Self {
413            progress: 0.0,
414            uncertainty: 0.7,
415            risk_level: RiskLevel::Low,
416            budget: BudgetState::default(),
417            error_streak: 0,
418            context_pressure: 0.1,
419            side_effect_pressure: 0.0,
420            human_dependency: 0.0,
421        }
422    }
423}
424
425/// Resource budget tracking.
426#[derive(Debug, Clone, Serialize, Deserialize)]
427pub struct BudgetState {
428    pub tokens_remaining: u64,
429    pub time_remaining_ms: u64,
430    pub cost_remaining_usd: f64,
431    pub tool_calls_remaining: u32,
432    pub error_budget_remaining: u32,
433}
434
435impl Default for BudgetState {
436    fn default() -> Self {
437        Self {
438            tokens_remaining: 120_000,
439            time_remaining_ms: 300_000,
440            cost_remaining_usd: 5.0,
441            tool_calls_remaining: 48,
442            error_budget_remaining: 8,
443        }
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use serde_json::json;
451
452    #[test]
453    fn state_vector_default() {
454        let sv = AgentStateVector::default();
455        assert_eq!(sv.progress, 0.0);
456        assert_eq!(sv.uncertainty, 0.7);
457        assert_eq!(sv.error_streak, 0);
458    }
459
460    #[test]
461    fn budget_default() {
462        let b = BudgetState::default();
463        assert_eq!(b.tokens_remaining, 120_000);
464        assert_eq!(b.tool_calls_remaining, 48);
465    }
466
467    #[test]
468    fn canonical_state_set_and_append() {
469        let mut vs = VersionedCanonicalState::default();
470        let patch = StatePatch {
471            base_version: 0,
472            ops: vec![
473                PatchOp::Set {
474                    path: "/session/files".to_owned(),
475                    value: json!(["README.md"]),
476                },
477                PatchOp::Append {
478                    path: "/session/files".to_owned(),
479                    values: vec![json!("Cargo.toml")],
480                },
481            ],
482            provenance: vec![ProvenanceRef::Event {
483                event_id: "evt-1".to_owned(),
484            }],
485        };
486
487        vs.apply_patch(&patch).unwrap();
488        assert_eq!(vs.version, 1);
489        assert_eq!(
490            vs.state.session["files"],
491            json!(["README.md", "Cargo.toml"])
492        );
493    }
494
495    #[test]
496    fn tombstone_blocks_resurrection() {
497        let mut vs = VersionedCanonicalState::default();
498        let first = StatePatch {
499            base_version: 0,
500            ops: vec![PatchOp::Tombstone {
501                path: "/memory/projections/old".to_owned(),
502                reason: "expired".to_owned(),
503                replaced_by: None,
504            }],
505            provenance: vec![],
506        };
507        vs.apply_patch(&first).unwrap();
508
509        let second = StatePatch {
510            base_version: 1,
511            ops: vec![PatchOp::Set {
512                path: "/memory/projections/old".to_owned(),
513                value: json!({"foo": "bar"}),
514            }],
515            provenance: vec![],
516        };
517        let err = vs.apply_patch(&second).unwrap_err();
518        assert!(matches!(err, PatchApplyError::Tombstoned(_)));
519    }
520}