Skip to main content

aivcs_core/diff/
tool_calls.rs

1use oxidized_state::RunEvent;
2use serde_json::Value;
3use std::collections::HashMap;
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
77fn group_by_name(calls: Vec<ToolCall>) -> HashMap<String, Vec<ToolCall>> {
78    let mut map: HashMap<String, Vec<ToolCall>> = HashMap::new();
79    for call in calls {
80        map.entry(call.tool_name.clone()).or_default().push(call);
81    }
82    map
83}
84
85// ---------------------------------------------------------------------------
86// Param diffing (recursive)
87// ---------------------------------------------------------------------------
88
89fn param_delta_recursive(prefix: &str, a: &Value, b: &Value, out: &mut Vec<ParamDelta>) {
90    if a == b {
91        return;
92    }
93    match (a.as_object(), b.as_object()) {
94        (Some(obj_a), Some(obj_b)) => {
95            let mut all_keys: Vec<&String> = obj_a.keys().chain(obj_b.keys()).collect();
96            all_keys.sort();
97            all_keys.dedup();
98            for key in all_keys {
99                let child_path = if prefix.is_empty() {
100                    key.clone()
101                } else {
102                    format!("{prefix}.{key}")
103                };
104                let val_a = obj_a.get(key).unwrap_or(&Value::Null);
105                let val_b = obj_b.get(key).unwrap_or(&Value::Null);
106                param_delta_recursive(&child_path, val_a, val_b, out);
107            }
108        }
109        _ => {
110            let key = if prefix.is_empty() {
111                ".".to_string()
112            } else {
113                prefix.to_string()
114            };
115            out.push(ParamDelta {
116                key,
117                before: a.clone(),
118                after: b.clone(),
119            });
120        }
121    }
122}
123
124fn param_delta(a: &Value, b: &Value) -> Vec<ParamDelta> {
125    let mut deltas = Vec::new();
126    param_delta_recursive("", a, b, &mut deltas);
127    deltas
128}
129
130// ---------------------------------------------------------------------------
131// Public API
132// ---------------------------------------------------------------------------
133
134/// Diff two ordered `RunEvent` sequences, producing a `ToolCallDiff` that
135/// captures added, removed, reordered, and param-changed tool calls.
136///
137/// Events with `kind == "tool_called"` that lack a valid `payload.tool_name`
138/// string are silently skipped. Reorder detection uses the relative position
139/// of each tool call within the extracted tool-call stream (not the global
140/// run-level `seq`), so inserting or removing non-tool events between runs
141/// does not cause false positives.
142pub fn diff_tool_calls(a: &[RunEvent], b: &[RunEvent]) -> ToolCallDiff {
143    let calls_a = extract_tool_calls(a);
144    let calls_b = extract_tool_calls(b);
145
146    // Build a map from tool_name to relative position within the full
147    // tool-call stream (across all names). This is the basis for reorder
148    // detection — it is independent of the global run-event seq.
149    let position_a: HashMap<u64, usize> = calls_a
150        .iter()
151        .enumerate()
152        .map(|(i, c)| (c.seq, i))
153        .collect();
154    let position_b: HashMap<u64, usize> = calls_b
155        .iter()
156        .enumerate()
157        .map(|(i, c)| (c.seq, i))
158        .collect();
159
160    let group_a = group_by_name(calls_a);
161    let group_b = group_by_name(calls_b);
162
163    let mut changes = Vec::new();
164
165    let mut all_names: Vec<&String> = group_a.keys().chain(group_b.keys()).collect();
166    all_names.sort();
167    all_names.dedup();
168
169    for name in all_names {
170        let empty = Vec::new();
171        let list_a = group_a.get(name).unwrap_or(&empty);
172        let list_b = group_b.get(name).unwrap_or(&empty);
173
174        match (list_a.is_empty(), list_b.is_empty()) {
175            (true, false) => {
176                for call in list_b {
177                    changes.push(ToolCallChange::Added(call.clone()));
178                }
179            }
180            (false, true) => {
181                for call in list_a {
182                    changes.push(ToolCallChange::Removed(call.clone()));
183                }
184            }
185            _ => {
186                let paired = list_a.len().min(list_b.len());
187
188                for i in 0..paired {
189                    let ca = &list_a[i];
190                    let cb = &list_b[i];
191
192                    // Check param changes
193                    let deltas = param_delta(&ca.params, &cb.params);
194                    if !deltas.is_empty() {
195                        changes.push(ToolCallChange::ParamChanged {
196                            tool_name: name.clone(),
197                            seq_a: ca.seq,
198                            seq_b: cb.seq,
199                            deltas,
200                        });
201                    }
202
203                    // Check reorder by relative position in the tool-call stream
204                    let idx_a = position_a.get(&ca.seq).copied().unwrap_or(0);
205                    let idx_b = position_b.get(&cb.seq).copied().unwrap_or(0);
206                    if idx_a != idx_b {
207                        changes.push(ToolCallChange::Reordered {
208                            call: cb.clone(),
209                            from_index: idx_a,
210                            to_index: idx_b,
211                        });
212                    }
213                }
214
215                // Extra in A → Removed
216                for call in list_a.iter().skip(paired) {
217                    changes.push(ToolCallChange::Removed(call.clone()));
218                }
219                // Extra in B → Added
220                for call in list_b.iter().skip(paired) {
221                    changes.push(ToolCallChange::Added(call.clone()));
222                }
223            }
224        }
225    }
226
227    ToolCallDiff { changes }
228}