Skip to main content

aivcs_core/diff/
tool_calls.rs

1use oxidized_state::RunEvent;
2use serde_json::Value;
3use std::collections::HashSet;
4
5/// A single tool call extracted from a `RunEvent` stream.
6#[derive(Debug, Clone, PartialEq)]
7pub struct ToolCall {
8    pub seq: u64,
9    pub tool_name: String,
10    pub params: Value,
11}
12
13/// A single parameter-level delta between two tool calls.
14///
15/// The `key` uses dot-separated JSON paths (e.g. `"config.retries"`) for
16/// nested object fields. Root-level non-object changes use `"."`.
17#[derive(Debug, Clone, PartialEq)]
18pub struct ParamDelta {
19    pub key: String,
20    pub before: Value,
21    pub after: Value,
22}
23
24/// A change detected between two tool-call sequences.
25#[derive(Debug, Clone, PartialEq)]
26pub enum ToolCallChange {
27    Added(ToolCall),
28    Removed(ToolCall),
29    Reordered {
30        call: ToolCall,
31        from_index: usize,
32        to_index: usize,
33    },
34    ParamChanged {
35        tool_name: String,
36        seq_a: u64,
37        seq_b: u64,
38        deltas: Vec<ParamDelta>,
39    },
40}
41
42/// The result of diffing two tool-call sequences.
43#[derive(Debug, Clone, PartialEq)]
44pub struct ToolCallDiff {
45    pub changes: Vec<ToolCallChange>,
46}
47
48impl ToolCallDiff {
49    pub fn is_empty(&self) -> bool {
50        self.changes.is_empty()
51    }
52}
53
54// ---------------------------------------------------------------------------
55// Extraction
56// ---------------------------------------------------------------------------
57
58fn extract_tool_calls(events: &[RunEvent]) -> Vec<ToolCall> {
59    events
60        .iter()
61        .filter(|e| e.kind == "tool_called")
62        .filter_map(|e| {
63            let tool_name = e
64                .payload
65                .get("tool_name")
66                .and_then(|v| v.as_str())
67                .map(|s| s.to_string())?;
68            Some(ToolCall {
69                seq: e.seq,
70                tool_name,
71                params: e.payload.clone(),
72            })
73        })
74        .collect()
75}
76
77// ---------------------------------------------------------------------------
78// Alignment (LCS)
79// ---------------------------------------------------------------------------
80
81fn lcs_alignment(calls_a: &[ToolCall], calls_b: &[ToolCall]) -> Vec<(usize, usize)> {
82    let m = calls_a.len();
83    let n = calls_b.len();
84
85    if m == 0 || n == 0 {
86        return Vec::new();
87    }
88
89    let mut dp = vec![vec![0usize; n + 1]; m + 1];
90
91    for i in 1..=m {
92        for j in 1..=n {
93            if calls_a[i - 1].tool_name == calls_b[j - 1].tool_name {
94                dp[i][j] = dp[i - 1][j - 1] + 1;
95            } else {
96                dp[i][j] = dp[i][j - 1].max(dp[i - 1][j]);
97            }
98        }
99    }
100
101    let mut alignment = Vec::new();
102    let mut i = m;
103    let mut j = n;
104
105    while i > 0 && j > 0 {
106        if calls_a[i - 1].tool_name == calls_b[j - 1].tool_name {
107            alignment.push((i - 1, j - 1));
108            i -= 1;
109            j -= 1;
110        } else if dp[i][j - 1] > dp[i - 1][j] {
111            j -= 1;
112        } else {
113            i -= 1;
114        }
115    }
116
117    alignment.reverse();
118    alignment
119}
120
121// ---------------------------------------------------------------------------
122// Param diffing (recursive)
123// ---------------------------------------------------------------------------
124
125fn param_delta_recursive(prefix: &str, a: &Value, b: &Value, out: &mut Vec<ParamDelta>) {
126    if a == b {
127        return;
128    }
129    match (a.as_object(), b.as_object()) {
130        (Some(obj_a), Some(obj_b)) => {
131            let mut all_keys: Vec<&String> = obj_a.keys().chain(obj_b.keys()).collect();
132            all_keys.sort();
133            all_keys.dedup();
134            for key in all_keys {
135                let child_path = if prefix.is_empty() {
136                    key.clone()
137                } else {
138                    format!("{prefix}.{key}")
139                };
140                let val_a = obj_a.get(key).unwrap_or(&Value::Null);
141                let val_b = obj_b.get(key).unwrap_or(&Value::Null);
142                param_delta_recursive(&child_path, val_a, val_b, out);
143            }
144        }
145        _ => {
146            let key = if prefix.is_empty() {
147                ".".to_string()
148            } else {
149                prefix.to_string()
150            };
151            out.push(ParamDelta {
152                key,
153                before: a.clone(),
154                after: b.clone(),
155            });
156        }
157    }
158}
159
160fn param_delta(a: &Value, b: &Value) -> Vec<ParamDelta> {
161    let mut deltas = Vec::new();
162    param_delta_recursive("", a, b, &mut deltas);
163    deltas
164}
165
166// ---------------------------------------------------------------------------
167// Public API
168// ---------------------------------------------------------------------------
169
170/// Diff two ordered `RunEvent` sequences, producing a `ToolCallDiff`.
171///
172/// Uses Longest Common Subsequence (LCS) alignment on tool names to detect
173/// added, removed, and reordered tool calls accurately.
174pub fn diff_tool_calls(a: &[RunEvent], b: &[RunEvent]) -> ToolCallDiff {
175    let calls_a = extract_tool_calls(a);
176    let calls_b = extract_tool_calls(b);
177
178    let alignment = lcs_alignment(&calls_a, &calls_b);
179
180    let mut aligned_a = HashSet::new();
181    let mut aligned_b = HashSet::new();
182    for (i_a, i_b) in &alignment {
183        aligned_a.insert(*i_a);
184        aligned_b.insert(*i_b);
185    }
186
187    let mut changes = Vec::new();
188
189    // 1. Removed calls (in A but not aligned in B)
190    for (i, call) in calls_a.iter().enumerate() {
191        if !aligned_a.contains(&i) {
192            changes.push(ToolCallChange::Removed(call.clone()));
193        }
194    }
195
196    // 2. Aligned calls: check for param changes and reordering
197    for (i_a, i_b) in alignment {
198        let ca = &calls_a[i_a];
199        let cb = &calls_b[i_b];
200
201        // Check param changes
202        let deltas = param_delta(&ca.params, &cb.params);
203        if !deltas.is_empty() {
204            changes.push(ToolCallChange::ParamChanged {
205                tool_name: ca.tool_name.clone(),
206                seq_a: ca.seq,
207                seq_b: cb.seq,
208                deltas,
209            });
210        }
211
212        // Check reorder: if relative positions in the aligned sequences differ
213        if i_a != i_b {
214            changes.push(ToolCallChange::Reordered {
215                call: cb.clone(),
216                from_index: i_a,
217                to_index: i_b,
218            });
219        }
220    }
221
222    // 3. Added calls (in B but not aligned in A)
223    for (i, call) in calls_b.iter().enumerate() {
224        if !aligned_b.contains(&i) {
225            changes.push(ToolCallChange::Added(call.clone()));
226        }
227    }
228
229    ToolCallDiff { changes }
230}