Skip to main content

agent_decision_log/
lib.rs

1//! # agent-decision-log
2//!
3//! WHY-layer decision log for AI agents. Records the reasoning behind each
4//! branch in an agent run: what options were considered, which one was
5//! chosen, why, and what happened afterward.
6//!
7//! Sibling of [`agentsnap`] (CALLS) and [`agenttrace`] (COST + LATENCY).
8//! Together they cover the three audit dimensions of an agent run.
9//!
10//! [`agentsnap`]: https://crates.io/crates/agentsnap
11//! [`agenttrace`]: https://crates.io/crates/agenttrace
12//!
13//! ## Quick example
14//!
15//! ```
16//! use agent_decision_log::DecisionLog;
17//! use serde_json::json;
18//!
19//! let mut log = DecisionLog::new();
20//! let id = log.add(
21//!     vec!["search_web", "ask_user"],
22//!     "search_web",
23//!     "Query is specific enough to search without clarification.",
24//!     json!({"turn": 3}),
25//! );
26//! log.set_outcome(&id, "Found 5 relevant docs.");
27//!
28//! let d = log.find_by_id(&id).unwrap();
29//! assert_eq!(d.chosen, "search_web");
30//! assert_eq!(d.outcome.as_deref(), Some("Found 5 relevant docs."));
31//! ```
32//!
33//! ## Round-trip to JSONL
34//!
35//! ```
36//! # fn main() -> std::io::Result<()> {
37//! use agent_decision_log::DecisionLog;
38//! use serde_json::json;
39//!
40//! let dir = tempfile::tempdir()?;
41//! let path = dir.path().join("decisions.jsonl");
42//!
43//! let mut log = DecisionLog::new();
44//! log.add(
45//!     vec!["a", "b"],
46//!     "a",
47//!     "a is cheaper",
48//!     json!({}),
49//! );
50//! log.to_jsonl(&path)?;
51//!
52//! let loaded = DecisionLog::from_jsonl(&path)?;
53//! assert_eq!(loaded.len(), 1);
54//! # Ok(())
55//! # }
56//! ```
57
58#![deny(missing_docs)]
59
60use std::fs::File;
61use std::io::{BufRead, BufReader, BufWriter, Write};
62use std::path::Path;
63use std::sync::atomic::{AtomicU64, Ordering};
64use std::time::{SystemTime, UNIX_EPOCH};
65
66use serde::{Deserialize, Serialize};
67
68/// A single decision point in an agent run.
69///
70/// The shape is intentionally wide so different agent frameworks can map
71/// onto the same record without inventing new schemas.
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73pub struct Decision {
74    /// Stable unique id, used by [`DecisionLog::set_outcome`] to find the record.
75    pub id: String,
76
77    /// When the decision was recorded.
78    #[serde(with = "system_time_serde")]
79    pub timestamp: SystemTime,
80
81    /// Candidate options the model considered at this branch.
82    pub options: Vec<String>,
83
84    /// The option the model actually picked. May or may not be in `options`.
85    pub chosen: String,
86
87    /// Free or structured text explaining the pick.
88    pub rationale: String,
89
90    /// Post-hoc observation of what happened. `None` until
91    /// [`DecisionLog::set_outcome`] is called.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub outcome: Option<String>,
94
95    /// Free-form metadata. Stored verbatim.
96    #[serde(default)]
97    pub meta: serde_json::Value,
98}
99
100impl Decision {
101    /// Whether the chosen value was actually one of the candidates.
102    ///
103    /// `false` here usually means the model hallucinated a tool name.
104    pub fn chose_listed_option(&self) -> bool {
105        self.options.iter().any(|o| o == &self.chosen)
106    }
107}
108
109/// Append-only log of agent decisions.
110///
111/// Holds an in-memory `Vec<Decision>`. Persist with [`DecisionLog::to_jsonl`]
112/// and round-trip with [`DecisionLog::from_jsonl`].
113#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
114pub struct DecisionLog {
115    /// The recorded decisions, in insertion order.
116    pub decisions: Vec<Decision>,
117}
118
119impl DecisionLog {
120    /// Build an empty log.
121    pub fn new() -> Self {
122        Self::default()
123    }
124
125    /// Record a new decision and return its id.
126    ///
127    /// The id is generated from a high-resolution timestamp plus a process
128    /// local atomic counter, so successive calls always produce distinct
129    /// ids even when issued in the same nanosecond.
130    pub fn add<O, C, R>(
131        &mut self,
132        options: Vec<O>,
133        chosen: C,
134        rationale: R,
135        meta: serde_json::Value,
136    ) -> String
137    where
138        O: Into<String>,
139        C: Into<String>,
140        R: Into<String>,
141    {
142        let id = new_id();
143        let decision = Decision {
144            id: id.clone(),
145            timestamp: SystemTime::now(),
146            options: options.into_iter().map(Into::into).collect(),
147            chosen: chosen.into(),
148            rationale: rationale.into(),
149            outcome: None,
150            meta,
151        };
152        self.decisions.push(decision);
153        id
154    }
155
156    /// Attach an outcome to an existing decision.
157    ///
158    /// Returns `true` if a decision with that id was found, `false`
159    /// otherwise. Existing outcomes are overwritten.
160    pub fn set_outcome<S: Into<String>>(&mut self, id: &str, outcome: S) -> bool {
161        if let Some(d) = self.decisions.iter_mut().find(|d| d.id == id) {
162            d.outcome = Some(outcome.into());
163            true
164        } else {
165            false
166        }
167    }
168
169    /// Find a decision by its id.
170    pub fn find_by_id(&self, id: &str) -> Option<&Decision> {
171        self.decisions.iter().find(|d| d.id == id)
172    }
173
174    /// The most recently added decision, if any.
175    pub fn last(&self) -> Option<&Decision> {
176        self.decisions.last()
177    }
178
179    /// Number of recorded decisions.
180    pub fn len(&self) -> usize {
181        self.decisions.len()
182    }
183
184    /// Whether the log has no decisions.
185    pub fn is_empty(&self) -> bool {
186        self.decisions.is_empty()
187    }
188
189    /// Write each decision as one JSON line at `path`.
190    ///
191    /// Overwrites any existing file. The output is UTF-8 JSON with a
192    /// trailing newline after each record, suitable for streaming ingest
193    /// tools like `jq -c` or DuckDB's `read_json_auto`.
194    pub fn to_jsonl<P: AsRef<Path>>(&self, path: P) -> std::io::Result<()> {
195        let file = File::create(path)?;
196        let mut w = BufWriter::new(file);
197        for d in &self.decisions {
198            let line = serde_json::to_string(d).map_err(io_invalid_data)?;
199            w.write_all(line.as_bytes())?;
200            w.write_all(b"\n")?;
201        }
202        w.flush()
203    }
204
205    /// Read a JSONL file produced by [`DecisionLog::to_jsonl`].
206    ///
207    /// Blank lines are skipped. Any malformed line returns an `InvalidData`
208    /// error and stops the load; partial state is not returned.
209    pub fn from_jsonl<P: AsRef<Path>>(path: P) -> std::io::Result<Self> {
210        let file = File::open(path)?;
211        let reader = BufReader::new(file);
212        let mut decisions = Vec::new();
213        for line in reader.lines() {
214            let line = line?;
215            let trimmed = line.trim();
216            if trimmed.is_empty() {
217                continue;
218            }
219            let d: Decision = serde_json::from_str(trimmed).map_err(io_invalid_data)?;
220            decisions.push(d);
221        }
222        Ok(Self { decisions })
223    }
224}
225
226fn io_invalid_data(e: serde_json::Error) -> std::io::Error {
227    std::io::Error::new(std::io::ErrorKind::InvalidData, e)
228}
229
230// SystemTime nanos since epoch + process atomic counter. The counter is
231// what guarantees uniqueness inside the same nanosecond; the timestamp is
232// what keeps ids roughly sortable.
233fn new_id() -> String {
234    static COUNTER: AtomicU64 = AtomicU64::new(0);
235    let nanos = SystemTime::now()
236        .duration_since(UNIX_EPOCH)
237        .map(|d| d.as_nanos() as u64)
238        .unwrap_or(0);
239    let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
240    format!("dec_{:016x}{:08x}", nanos, seq)
241}
242
243mod system_time_serde {
244    use std::time::{Duration, SystemTime, UNIX_EPOCH};
245
246    use serde::{Deserialize, Deserializer, Serialize, Serializer};
247
248    pub fn serialize<S: Serializer>(t: &SystemTime, s: S) -> Result<S::Ok, S::Error> {
249        let nanos = t
250            .duration_since(UNIX_EPOCH)
251            .map(|d| d.as_nanos())
252            .unwrap_or(0);
253        nanos.to_string().serialize(s)
254    }
255
256    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<SystemTime, D::Error> {
257        let raw = String::deserialize(d)?;
258        let nanos: u128 = raw.parse().map_err(serde::de::Error::custom)?;
259        let secs = (nanos / 1_000_000_000) as u64;
260        let sub = (nanos % 1_000_000_000) as u32;
261        Ok(UNIX_EPOCH + Duration::new(secs, sub))
262    }
263}