aivcs_core/diff/
tool_calls.rs1use oxidized_state::RunEvent;
2use serde_json::Value;
3use std::collections::HashMap;
4
5#[derive(Debug, Clone, PartialEq)]
7pub struct ToolCall {
8 pub seq: u64,
9 pub tool_name: String,
10 pub params: Value,
11}
12
13#[derive(Debug, Clone, PartialEq)]
18pub struct ParamDelta {
19 pub key: String,
20 pub before: Value,
21 pub after: Value,
22}
23
24#[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#[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
54fn 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
85fn 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
130pub 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 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 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 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 for call in list_a.iter().skip(paired) {
217 changes.push(ToolCallChange::Removed(call.clone()));
218 }
219 for call in list_b.iter().skip(paired) {
221 changes.push(ToolCallChange::Added(call.clone()));
222 }
223 }
224 }
225 }
226
227 ToolCallDiff { changes }
228}