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}