1use crate::event::RiskLevel;
8use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value};
10use std::collections::{BTreeMap, BTreeSet};
11use thiserror::Error;
12
13#[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#[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#[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 #[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#[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 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 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#[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#[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}