Skip to main content

recursive/tools/
audit.rs

1//! Tool audit types: metadata recorded for every tool invocation.
2//!
3//! These types are used by [`super::dispatch::ToolDispatch`] and
4//! [`super::registry::ToolRegistry`] to record side-effect
5//! classification, timing, and exit status for each tool call.
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::HashSet;
10
11/// Classification of a tool's observable side-effects on state outside
12/// the agent process. Used by orphan detection and safe-replay (g154) to
13/// decide how aggressively to retry or skip an unfinished tool call.
14///
15/// Distinct from `crate::kernel::SideEffect`, which tracks background-job
16/// scheduling; the two live in different modules and never collide.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum ToolSideEffect {
20    /// No mutation of any state outside the agent process. Safe to
21    /// replay at any time. Examples: `Read`, `Grep`,
22    /// `recall`, `checkpoint_list`.
23    ReadOnly,
24    /// Modifies local state (filesystem, scratchpad) in an idempotent-
25    /// friendly way. Examples: `Write`, `Edit`, `remember`.
26    Mutating,
27    /// Reaches out to the external world or triggers opaque side-effects.
28    /// Cannot determine safe re-execution from local state alone.
29    /// Examples: `Bash`, `Agent`, `schedule_wakeup`.
30    /// **Default** for any tool that does not override `side_effect_class`.
31    External,
32}
33
34/// Maximum length of the persisted error message in [`ExitStatus::Err`].
35/// Anything longer is UTF-8 char-boundary clipped and `truncated` is set.
36pub const AUDIT_ERR_MAX_BYTES: usize = 512;
37
38#[inline]
39fn is_false(b: &bool) -> bool {
40    !b
41}
42
43/// Outcome of a single tool invocation, as recorded in [`AuditMeta`].
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45#[serde(tag = "type", rename_all = "snake_case")]
46pub enum ExitStatus {
47    Ok,
48    Err {
49        /// Error message, truncated to [`AUDIT_ERR_MAX_BYTES`] bytes.
50        message: String,
51        /// `true` when the original message was longer and was clipped.
52        #[serde(default, skip_serializing_if = "is_false")]
53        truncated: bool,
54    },
55}
56
57/// Key type for [`AuditMeta`] maps, scoped by (turn, tool_call_id).
58///
59/// Each turn may reuse tool_call_ids (e.g. MockProvider recycles them),
60/// so the turn index disambiguates collisions across turns.
61pub type AuditKey = (u32 /* turn */, String /* tool_call_id */);
62
63/// Per-call audit record returned by [`super::registry::ToolRegistry::invoke_with_audit`]
64/// and stored in [`crate::session::TranscriptEntry::audit`].
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub struct AuditMeta {
67    /// UUIDv7 step identifier (time-ordered).
68    pub step_id: String,
69    /// Unix epoch millis at registry dispatch start.
70    pub started_at: i64,
71    /// Unix epoch millis when the tool returned.
72    pub finished_at: i64,
73    /// BLAKE3 of the canonical JSON of `arguments` (hex-encoded).
74    /// Detects argument drift across resumes.
75    pub args_hash: String,
76    /// Side-effect class as reported by the tool at call time.
77    pub side_effect: ToolSideEffect,
78    /// Whether the tool returned `Ok` or `Err`.
79    pub exit_status: ExitStatus,
80}
81
82impl AuditMeta {
83    /// Synthetic `AuditMeta` for an unknown-tool dispatch (tool not in
84    /// registry). Called when `invoke_with_audit` cannot find the tool.
85    pub fn synthetic_unknown_tool(name: &str) -> Self {
86        let now = unix_millis();
87        Self {
88            step_id: uuid::Uuid::now_v7().hyphenated().to_string(),
89            started_at: now,
90            finished_at: now,
91            args_hash: String::new(),
92            side_effect: ToolSideEffect::External,
93            exit_status: ExitStatus::Err {
94                message: format!("unknown tool: {name}"),
95                truncated: false,
96            },
97        }
98    }
99}
100
101/// Observer that records files touched by structured filesystem tools
102/// during a single agent turn. Owned by `AgentRuntime` and reset at
103/// every turn boundary; passed by `Arc<Mutex<...>>` to the
104/// `ToolRegistry` so tool dispatch can record `path` arguments.
105#[derive(Debug, Default, Clone)]
106pub struct TouchedFiles {
107    /// Workspace-relative file paths recorded from `Write`, `Edit`, etc.
108    pub paths: HashSet<String>,
109    /// True if the agent invoked `Bash` this turn — runtime will
110    /// use a pre/post snapshot diff to attribute file changes.
111    pub saw_shell: bool,
112}
113
114impl TouchedFiles {
115    pub fn new() -> Self {
116        Self::default()
117    }
118    pub fn is_empty(&self) -> bool {
119        self.paths.is_empty() && !self.saw_shell
120    }
121    pub fn paths_sorted(&self) -> Vec<String> {
122        let mut v: Vec<_> = self.paths.iter().cloned().collect();
123        v.sort();
124        v
125    }
126}
127
128// ── helpers ─────────────────────────────────────────────────────────────────
129
130pub(crate) fn unix_millis() -> i64 {
131    std::time::SystemTime::now()
132        .duration_since(std::time::UNIX_EPOCH)
133        .map(|d| d.as_millis() as i64)
134        .unwrap_or(0)
135}
136
137/// Clip `s` to at most `AUDIT_ERR_MAX_BYTES` bytes on a UTF-8 char boundary.
138/// Returns `(clipped, was_truncated)`.
139pub(crate) fn truncate_for_audit(s: &str) -> (String, bool) {
140    if s.len() <= AUDIT_ERR_MAX_BYTES {
141        return (s.to_string(), false);
142    }
143    let mut end = AUDIT_ERR_MAX_BYTES;
144    while !s.is_char_boundary(end) {
145        end -= 1;
146    }
147    (s[..end].to_string(), true)
148}
149
150/// BLAKE3 hash of the canonical JSON encoding of `v`.
151pub(crate) fn blake3_canonical_json(v: &Value) -> String {
152    let canonical = v.to_string();
153    let hash = blake3::hash(canonical.as_bytes());
154    hash.to_hex().to_string()
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    // --- is_false (serde skip helper) ---
162
163    #[test]
164    fn is_false_returns_true_for_false() {
165        assert!(is_false(&false), "is_false(&false) must be true");
166    }
167
168    #[test]
169    fn is_false_returns_false_for_true() {
170        assert!(!is_false(&true), "is_false(&true) must be false");
171    }
172
173    // --- AuditMeta::synthetic_unknown_tool ---
174
175    #[test]
176    fn synthetic_unknown_tool_has_reasonable_fields() {
177        let meta = AuditMeta::synthetic_unknown_tool("my_tool");
178        assert!(!meta.step_id.is_empty(), "step_id must be non-empty");
179        assert!(meta.started_at > 0, "started_at must be positive");
180        assert_eq!(meta.side_effect, ToolSideEffect::External);
181        match &meta.exit_status {
182            ExitStatus::Err { message, .. } => {
183                assert!(
184                    message.contains("my_tool"),
185                    "error message must contain tool name"
186                );
187            }
188            ExitStatus::Ok => panic!("unknown tool must produce ExitStatus::Err"),
189        }
190    }
191
192    // --- TouchedFiles::is_empty ---
193
194    #[test]
195    fn touched_files_empty_paths_no_shell_is_empty() {
196        let tf = TouchedFiles::new();
197        assert!(tf.is_empty(), "no paths + no shell must be empty");
198    }
199
200    #[test]
201    fn touched_files_with_path_is_not_empty() {
202        let mut tf = TouchedFiles::new();
203        tf.paths.insert("src/main.rs".into());
204        assert!(!tf.is_empty(), "non-empty paths must not be empty");
205    }
206
207    #[test]
208    fn touched_files_saw_shell_is_not_empty() {
209        let mut tf = TouchedFiles::new();
210        tf.saw_shell = true;
211        assert!(
212            !tf.is_empty(),
213            "saw_shell=true must not be empty (kills &&→|| mutant)"
214        );
215    }
216
217    // --- TouchedFiles::paths_sorted ---
218
219    #[test]
220    fn paths_sorted_returns_sorted_vec() {
221        let mut tf = TouchedFiles::new();
222        tf.paths.insert("z.rs".into());
223        tf.paths.insert("a.rs".into());
224        tf.paths.insert("m.rs".into());
225        let sorted = tf.paths_sorted();
226        assert_eq!(sorted, vec!["a.rs", "m.rs", "z.rs"]);
227    }
228
229    #[test]
230    fn paths_sorted_on_empty_is_empty_vec() {
231        let tf = TouchedFiles::new();
232        assert!(tf.paths_sorted().is_empty());
233    }
234
235    // --- unix_millis ---
236
237    #[test]
238    fn unix_millis_is_positive() {
239        let ms = unix_millis();
240        assert!(
241            ms > 0,
242            "unix_millis must return a positive timestamp; got {ms}"
243        );
244        // Sanity: must be after 2024-01-01 (1704067200000 ms)
245        assert!(
246            ms > 1_704_067_200_000,
247            "unix_millis must be after 2024-01-01"
248        );
249    }
250
251    // --- truncate_for_audit ---
252
253    #[test]
254    fn truncate_short_string_unchanged() {
255        let short = "hello";
256        let (out, truncated) = truncate_for_audit(short);
257        assert_eq!(out, short);
258        assert!(!truncated, "short string must not be truncated");
259    }
260
261    #[test]
262    fn truncate_long_string_is_clipped_and_flagged() {
263        let long = "x".repeat(AUDIT_ERR_MAX_BYTES + 10);
264        let (out, truncated) = truncate_for_audit(&long);
265        assert!(truncated, "long string must be flagged as truncated");
266        assert_eq!(out.len(), AUDIT_ERR_MAX_BYTES);
267    }
268
269    #[test]
270    fn truncate_exactly_max_bytes_not_truncated() {
271        let exact = "a".repeat(AUDIT_ERR_MAX_BYTES);
272        let (out, truncated) = truncate_for_audit(&exact);
273        assert!(!truncated, "exactly max bytes must NOT be truncated");
274        assert_eq!(out.len(), AUDIT_ERR_MAX_BYTES);
275    }
276
277    // --- blake3_canonical_json ---
278
279    #[test]
280    fn blake3_canonical_json_is_nonempty_and_not_placeholder() {
281        let hash = blake3_canonical_json(&serde_json::json!({"key": "value"}));
282        assert!(!hash.is_empty(), "blake3 hash must not be empty");
283        assert_ne!(hash, "xyzzy", "blake3 hash must not be placeholder");
284    }
285
286    #[test]
287    fn blake3_canonical_json_differs_for_different_inputs() {
288        let h1 = blake3_canonical_json(&serde_json::json!({"a": 1}));
289        let h2 = blake3_canonical_json(&serde_json::json!({"a": 2}));
290        assert_ne!(h1, h2, "different inputs must produce different hashes");
291    }
292}