Skip to main content

open_agent_profile/
delta.rs

1use std::{
2    cmp::Ordering,
3    fs::{self, OpenOptions},
4    io::Write,
5    path::Path,
6};
7
8use chrono::{DateTime, SecondsFormat, Utc};
9use serde_json::{Map, Value, json};
10use thiserror::Error;
11
12use crate::{AgentProfile, AgentStateDelta, DeltaApplication, Document, object, profile_digest};
13
14/// Failure while validating or applying an agent state delta.
15#[derive(Debug, Error)]
16pub enum ApplyError {
17    /// Optimistic-concurrency target data did not match the profile.
18    #[error(transparent)]
19    Conflict(#[from] ConflictError),
20    /// The delta was invalid or could not be applied or persisted.
21    #[error("{0}")]
22    Message(String),
23}
24
25/// Optimistic-concurrency conflict raised before any mutation is committed.
26#[derive(Debug, Error)]
27#[error("{0}")]
28pub struct ConflictError(pub String);
29
30/// Controls approval, attribution, and time for delta application.
31#[derive(Default)]
32pub struct ApplyOptions<'a> {
33    /// Whether a `propose` writeback policy has explicit human approval.
34    pub approved: bool,
35    /// Actor recorded in the appended history entry.
36    pub actor: Option<&'a str>,
37    /// Deterministic timestamp override; current UTC time is used when absent.
38    pub now: Option<DateTime<Utc>>,
39}
40
41fn pointer_tokens(pointer: &str) -> Vec<String> {
42    pointer
43        .trim_start_matches('/')
44        .split('/')
45        .filter(|part| !part.is_empty())
46        .map(|part| part.replace("~1", "/").replace("~0", "~"))
47        .collect()
48}
49
50fn modify(
51    current: &mut Value,
52    tokens: &[String],
53    kind: &str,
54    value: Option<&Value>,
55) -> Result<bool, ApplyError> {
56    if tokens.is_empty() {
57        *current = value.cloned().unwrap_or(Value::Null);
58        return Ok(false);
59    }
60    let token = &tokens[0];
61    let last = tokens.len() == 1;
62    match current {
63        Value::Object(map) => {
64            if last {
65                match kind {
66                    "remove" => return Ok(map.remove(token).is_none()),
67                    "add" | "replace" => {
68                        map.insert(token.clone(), value.cloned().unwrap_or(Value::Null));
69                        return Ok(false);
70                    }
71                    _ => return Err(ApplyError::Message(format!("unknown operation {kind:?}"))),
72                }
73            }
74            if !map.contains_key(token) {
75                map.insert(token.clone(), Value::Object(Map::new()));
76            }
77            modify(map.get_mut(token).unwrap(), &tokens[1..], kind, value)
78        }
79        Value::Array(items) => {
80            let index = if let Some(id) = token.strip_prefix("id:") {
81                items.iter().position(|item| {
82                    object(Some(item)).get("id").and_then(Value::as_str) == Some(id)
83                })
84            } else if token == "-" {
85                Some(items.len())
86            } else {
87                token.parse::<usize>().ok()
88            };
89            let Some(index) = index else {
90                return Ok(true);
91            };
92            if last {
93                match kind {
94                    "add" if index <= items.len() => {
95                        items.insert(index, value.cloned().unwrap_or(Value::Null));
96                        Ok(false)
97                    }
98                    "remove" if index < items.len() => {
99                        items.remove(index);
100                        Ok(false)
101                    }
102                    "replace" if index < items.len() => {
103                        items[index] = value.cloned().unwrap_or(Value::Null);
104                        Ok(false)
105                    }
106                    "add" | "remove" | "replace" => Ok(true),
107                    _ => Err(ApplyError::Message(format!("unknown operation {kind:?}"))),
108                }
109            } else if index < items.len() {
110                modify(&mut items[index], &tokens[1..], kind, value)
111            } else {
112                Ok(true)
113            }
114        }
115        _ => Ok(true),
116    }
117}
118
119fn apply_operation(
120    document: &mut Document,
121    operation: &Document,
122    warnings: &mut Vec<String>,
123) -> Result<(), ApplyError> {
124    let kind = operation
125        .get("op")
126        .and_then(Value::as_str)
127        .unwrap_or_default();
128    let pointer = operation
129        .get("path")
130        .and_then(Value::as_str)
131        .unwrap_or_default();
132    if pointer != "/state" && !pointer.starts_with("/state/") {
133        return Err(ApplyError::Message(format!(
134            "operation path {pointer:?} is outside /state"
135        )));
136    }
137    let mut root = Value::Object(std::mem::take(document));
138    let missing = modify(
139        &mut root,
140        &pointer_tokens(pointer),
141        kind,
142        operation.get("value"),
143    )?;
144    *document = root.as_object().cloned().unwrap_or_default();
145    if missing {
146        if kind == "remove" {
147            warnings.push(format!("remove on missing path {pointer:?} ignored"));
148            Ok(())
149        } else {
150            Err(ApplyError::Message(format!(
151                "path {pointer:?} does not resolve"
152            )))
153        }
154    } else {
155        Ok(())
156    }
157}
158
159/// Validates and atomically applies state operations to a cloned profile.
160///
161/// Capability-widening proposals are returned separately and never applied.
162pub fn apply_delta(
163    profile: &AgentProfile,
164    delta: &AgentStateDelta,
165    options: ApplyOptions<'_>,
166) -> Result<DeltaApplication, ApplyError> {
167    let metadata = object(profile.get("metadata"));
168    let current = metadata
169        .get("revision")
170        .and_then(Value::as_u64)
171        .unwrap_or(1);
172    let target = object(delta.get("target"));
173    if target.get("name").and_then(Value::as_str) != metadata.get("name").and_then(Value::as_str) {
174        return Err(ApplyError::Message(format!(
175            "delta targets {:?} but profile is {:?}",
176            target.get("name"),
177            metadata.get("name")
178        )));
179    }
180    if target.get("revision").and_then(Value::as_u64) != Some(current) {
181        return Err(ConflictError(format!(
182            "delta targets revision {:?} but profile is at {current}",
183            target.get("revision")
184        ))
185        .into());
186    }
187    if let Some(pinned) = target.get("digest").and_then(Value::as_str) {
188        if profile_digest(profile).map_err(|error| ApplyError::Message(error.to_string()))?
189            != pinned
190        {
191            return Err(ApplyError::Message(
192                "target.digest does not match profile".into(),
193            ));
194        }
195    }
196    let lifecycle = object(object(profile.get("spec")).get("lifecycle"));
197    let writeback = lifecycle
198        .get("writeback")
199        .and_then(Value::as_str)
200        .unwrap_or("propose");
201    if writeback == "off" {
202        return Err(ApplyError::Message("lifecycle.writeback is 'off'".into()));
203    }
204    if writeback == "propose" && !options.approved {
205        return Err(ApplyError::Message(
206            "lifecycle.writeback is 'propose'; explicit approval is required".into(),
207        ));
208    }
209    let mut working = profile.clone();
210    working
211        .entry("state")
212        .or_insert_with(|| Value::Object(Map::new()));
213    let mut warnings = vec![];
214    let operations = delta
215        .get("operations")
216        .and_then(Value::as_array)
217        .cloned()
218        .unwrap_or_default();
219    for (index, raw) in operations.iter().enumerate() {
220        apply_operation(
221            &mut working,
222            &raw.as_object().cloned().unwrap_or_default(),
223            &mut warnings,
224        )
225        .map_err(|error| ApplyError::Message(format!("operation {index}: {error}")))?;
226    }
227    let stamp = options
228        .now
229        .unwrap_or_else(Utc::now)
230        .to_rfc3339_opts(SecondsFormat::Secs, true);
231    enforce_retention(&mut working, &mut warnings, &stamp);
232    if let Some(metadata) = working.get_mut("metadata").and_then(Value::as_object_mut) {
233        metadata.insert("revision".into(), (current + 1).into());
234        metadata.insert("updated_at".into(), stamp.clone().into());
235    }
236    if !operations.is_empty() {
237        if let Some(state) = working.get_mut("state").and_then(Value::as_object_mut) {
238            let revision = state.get("revision").and_then(Value::as_u64).unwrap_or(0) + 1;
239            state.insert("revision".into(), revision.into());
240            state.insert("updated_at".into(), stamp.clone().into());
241        }
242    }
243    let session = object(delta.get("session"));
244    let actor = options.actor.unwrap_or("oap-rust");
245    let by = session.get("id").and_then(Value::as_str).unwrap_or(actor);
246    let mut entry = Map::from_iter([
247        ("revision".into(), (current + 1).into()),
248        ("at".into(), stamp.clone().into()),
249        ("by".into(), by.into()),
250        (
251            "change".into(),
252            delta
253                .get("summary")
254                .cloned()
255                .unwrap_or_else(|| format!("{} state operations", operations.len()).into()),
256        ),
257        ("sections".into(), json!(["state"])),
258    ]);
259    if let Some(id) = session.get("id") {
260        entry.insert("session_id".into(), id.clone());
261    }
262    if let Some(harness) = session.get("harness") {
263        entry.insert("harness".into(), harness.clone());
264    }
265    if options.approved {
266        entry.insert("approved_by".into(), actor.into());
267    }
268    working
269        .entry("history")
270        .or_insert_with(|| Value::Array(vec![]))
271        .as_array_mut()
272        .unwrap()
273        .push(Value::Object(entry));
274    enforce_retention(&mut working, &mut warnings, &stamp);
275    let pending_proposals = delta
276        .get("proposals")
277        .and_then(Value::as_array)
278        .into_iter()
279        .flatten()
280        .filter_map(|raw| raw.as_object().cloned())
281        .map(|mut proposal| {
282            let path = proposal
283                .get("path")
284                .and_then(Value::as_str)
285                .unwrap_or_default();
286            if [
287                "/spec/tools",
288                "/spec/permissions",
289                "/spec/memory",
290                "/spec/runtime/subagents",
291            ]
292            .iter()
293            .any(|prefix| path.starts_with(prefix))
294            {
295                proposal.insert("risk".into(), "high".into());
296            }
297            proposal
298        })
299        .collect();
300    Ok(DeltaApplication {
301        profile: working,
302        warnings,
303        pending_proposals,
304    })
305}
306
307fn sort_value(entry: &Value, strategy: &str) -> Value {
308    let object = object(Some(entry));
309    if strategy == "least_confident" {
310        object
311            .get("confidence")
312            .cloned()
313            .unwrap_or_else(|| json!(1.0))
314    } else if strategy == "oldest" {
315        object
316            .get("learned_at")
317            .or_else(|| object.get("opened_at"))
318            .cloned()
319            .unwrap_or_default()
320    } else {
321        ["last_used_at", "updated_at", "learned_at"]
322            .iter()
323            .find_map(|key| object.get(*key))
324            .cloned()
325            .unwrap_or_default()
326    }
327}
328fn compare(left: &Value, right: &Value) -> Ordering {
329    match (left.as_f64(), right.as_f64()) {
330        (Some(a), Some(b)) => a.partial_cmp(&b).unwrap_or(Ordering::Equal),
331        _ => left
332            .as_str()
333            .unwrap_or_default()
334            .cmp(right.as_str().unwrap_or_default()),
335    }
336}
337
338fn enforce_retention(profile: &mut AgentProfile, warnings: &mut Vec<String>, now: &str) {
339    let retention =
340        object(object(object(profile.get("spec")).get("lifecycle")).get("retention")).clone();
341    let strategy = retention
342        .get("eviction")
343        .and_then(Value::as_str)
344        .unwrap_or("least_recently_used");
345    if let Some(state) = profile.get_mut("state").and_then(Value::as_object_mut) {
346        for collection in ["facts", "preferences"] {
347            let Some(entries) = state.get_mut(collection).and_then(Value::as_array_mut) else {
348                continue;
349            };
350            entries.retain(|raw| {
351                let entry = object(Some(raw));
352                let expired = retention.contains_key("fact_ttl_days")
353                    && entry
354                        .get("expires_at")
355                        .and_then(Value::as_str)
356                        .is_some_and(|expiry| expiry < now)
357                    && entry.get("pinned") != Some(&Value::Bool(true));
358                if expired {
359                    warnings.push(format!(
360                        "evicted expired {collection} entry {:?}",
361                        entry.get("id").and_then(Value::as_str).unwrap_or_default()
362                    ));
363                }
364                !expired
365            });
366            if collection == "facts" {
367                if let Some(cap) = retention
368                    .get("max_facts")
369                    .and_then(Value::as_u64)
370                    .map(|value| value as usize)
371                {
372                    if entries.len() > cap {
373                        let mut candidates: Vec<_> = entries
374                            .iter()
375                            .filter(|entry| {
376                                object(Some(entry)).get("pinned") != Some(&Value::Bool(true))
377                            })
378                            .cloned()
379                            .collect();
380                        candidates.sort_by(|a, b| {
381                            compare(&sort_value(a, strategy), &sort_value(b, strategy))
382                        });
383                        let drop_count = entries.len().saturating_sub(cap).min(candidates.len());
384                        let dropped: BTreeIds = candidates
385                            .into_iter()
386                            .take(drop_count)
387                            .filter_map(|entry| {
388                                object(Some(&entry))
389                                    .get("id")
390                                    .and_then(Value::as_str)
391                                    .map(str::to_owned)
392                            })
393                            .collect();
394                        for id in &dropped {
395                            warnings
396                                .push(format!("evicted {collection} entry {id:?} ({strategy})"));
397                        }
398                        entries.retain(|entry| {
399                            !object(Some(entry))
400                                .get("id")
401                                .and_then(Value::as_str)
402                                .is_some_and(|id| dropped.contains(id))
403                        });
404                    }
405                }
406            }
407        }
408        if let Some(cap) = retention
409            .get("max_open_threads")
410            .and_then(Value::as_u64)
411            .map(|value| value as usize)
412        {
413            if let Some(threads) = state.get_mut("open_threads").and_then(Value::as_array_mut) {
414                if threads.len() > cap {
415                    let mut closed: Vec<_> = threads
416                        .iter()
417                        .filter(|thread| {
418                            matches!(
419                                object(Some(thread)).get("status").and_then(Value::as_str),
420                                Some("done" | "abandoned")
421                            )
422                        })
423                        .cloned()
424                        .collect();
425                    closed.sort_by_key(|thread| {
426                        object(Some(thread))
427                            .get("updated_at")
428                            .and_then(Value::as_str)
429                            .unwrap_or_default()
430                            .to_owned()
431                    });
432                    let dropped: BTreeIds = closed
433                        .into_iter()
434                        .take((threads.len() - cap).min(threads.len()))
435                        .filter_map(|entry| {
436                            object(Some(&entry))
437                                .get("id")
438                                .and_then(Value::as_str)
439                                .map(str::to_owned)
440                        })
441                        .collect();
442                    for id in &dropped {
443                        warnings.push(format!("evicted closed thread {id:?}"));
444                    }
445                    threads.retain(|entry| {
446                        !object(Some(entry))
447                            .get("id")
448                            .and_then(Value::as_str)
449                            .is_some_and(|id| dropped.contains(id))
450                    });
451                    if threads.len() > cap {
452                        let remove = threads.len() - cap;
453                        threads.drain(..remove);
454                        warnings.push(
455                            "open_threads still over cap after evicting closed threads".into(),
456                        );
457                    }
458                }
459            }
460        }
461    }
462    let history_cap = retention
463        .get("max_history")
464        .and_then(Value::as_u64)
465        .unwrap_or(50) as usize;
466    if let Some(history) = profile.get_mut("history").and_then(Value::as_array_mut) {
467        if history.len() > history_cap {
468            let remove = history.len() - history_cap;
469            history.drain(..remove);
470        }
471    }
472}
473
474type BTreeIds = std::collections::BTreeSet<String>;
475
476/// Serializes an OAP document as YAML, JSON, or Markdown.
477pub fn serialize(document: &Document, format: crate::OapFormat) -> Result<String, ApplyError> {
478    match format {
479        crate::OapFormat::Json => serde_json::to_string_pretty(document)
480            .map(|text| text + "\n")
481            .map_err(|error| ApplyError::Message(error.to_string())),
482        crate::OapFormat::Yaml => serde_yaml_ng::to_string(document)
483            .map_err(|error| ApplyError::Message(error.to_string())),
484        crate::OapFormat::Markdown => {
485            let mut copy = document.clone();
486            let instructions = copy
487                .get_mut("spec")
488                .and_then(Value::as_object_mut)
489                .and_then(|spec| spec.get_mut("role"))
490                .and_then(Value::as_object_mut)
491                .and_then(|role| role.remove("instructions"))
492                .and_then(|value| value.as_str().map(str::to_owned))
493                .unwrap_or_default();
494            let yaml = serde_yaml_ng::to_string(&copy)
495                .map_err(|error| ApplyError::Message(error.to_string()))?;
496            Ok(format!(
497                "---\n{}---\n{}\n",
498                yaml.trim_start_matches("---\n"),
499                instructions.trim_end()
500            ))
501        }
502    }
503}
504
505/// Replaces a file atomically using a temporary file in the same directory.
506pub fn write_atomically(path: impl AsRef<Path>, data: &[u8]) -> Result<(), ApplyError> {
507    let path = path.as_ref();
508    let directory = path.parent().unwrap_or_else(|| Path::new("."));
509    let temporary = directory.join(format!(
510        ".{}.{}.tmp",
511        path.file_name()
512            .and_then(|name| name.to_str())
513            .unwrap_or("oap"),
514        std::process::id()
515    ));
516    let result = (|| {
517        let mut file = OpenOptions::new()
518            .create_new(true)
519            .write(true)
520            .open(&temporary)?;
521        file.write_all(data)?;
522        file.sync_all()?;
523        drop(file);
524        fs::rename(&temporary, path)?;
525        if let Ok(directory) = fs::File::open(directory) {
526            directory.sync_all()?;
527        }
528        Ok::<(), std::io::Error>(())
529    })();
530    if result.is_err() {
531        let _ = fs::remove_file(&temporary);
532    }
533    result.map_err(|error| ApplyError::Message(error.to_string()))
534}