aivcs_core/diff/
tool_calls.rs1use oxidized_state::RunEvent;
2use serde_json::Value;
3use std::collections::HashSet;
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 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
121fn 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
166pub 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 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 for (i_a, i_b) in alignment {
198 let ca = &calls_a[i_a];
199 let cb = &calls_b[i_b];
200
201 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 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 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}