1use oxicode_ai::{ContentBlock, TextContent, ToolCall, ToolResultMessage};
3use std::sync::Arc;
4use std::sync::atomic::{AtomicBool, Ordering};
5
6pub fn extract_tool_calls(message: &oxicode_ai::AssistantMessage) -> Vec<ToolCall> {
8 let mut tool_calls = Vec::new();
9
10 for block in &message.content {
11 if let ContentBlock::ToolCall(tc) = block {
12 tool_calls.push(tc.clone());
13 }
14 }
15
16 tool_calls
17}
18
19pub fn create_tool_result_message(finalized: &FinalizedToolCall) -> ToolResultMessage {
21 let content_blocks = if let Some(ref blocks) = finalized.result.content_blocks {
22 blocks.clone()
23 } else {
24 vec![ContentBlock::Text(TextContent::new(
25 finalized.result.output.clone(),
26 ))]
27 };
28
29 ToolResultMessage::new(
30 finalized.tool_call.id.clone(),
31 &finalized.tool_call.name,
32 content_blocks,
33 )
34}
35
36pub fn should_terminate_batch(finalized_calls: &[FinalizedToolCall]) -> bool {
40 if finalized_calls.is_empty() {
41 return false;
42 }
43 finalized_calls.iter().all(|f| f.result.terminate)
44}
45
46pub fn should_stop_after_turn(external_stop: &Arc<AtomicBool>) -> bool {
51 external_stop.load(Ordering::SeqCst)
52}
53
54use crate::AgentToolResult;
55
56pub struct FinalizedToolCall {
58 pub tool_call: oxicode_ai::ToolCall,
60 pub result: AgentToolResult,
62 pub is_error: bool,
64}
65
66pub fn sanitize_orphaned_tool_results(messages: &mut Vec<oxicode_ai::Message>) -> usize {
82 use oxicode_ai::{ContentBlock, Message};
83 use std::collections::HashSet;
84
85 if messages.is_empty() {
86 return 0;
87 }
88
89 struct AssistantBatch {
97 msg_idx: usize,
99 issued: HashSet<String>,
101 matched: HashSet<String>,
103 }
104
105 let mut batches: Vec<AssistantBatch> = Vec::new();
106 let mut current: Option<AssistantBatch> = None;
107
108 let mut valid_result: Vec<bool> = vec![false; messages.len()];
110
111 for (i, msg) in messages.iter().enumerate() {
112 match msg {
113 Message::Assistant(a) => {
114 if let Some(b) = current.take() {
118 batches.push(b);
119 }
120 let issued: HashSet<String> = a
121 .content
122 .iter()
123 .filter_map(|b| match b {
124 ContentBlock::ToolCall(tc) => Some(tc.id.clone()),
125 _ => None,
126 })
127 .collect();
128 if !issued.is_empty() {
129 current = Some(AssistantBatch {
130 msg_idx: i,
131 issued,
132 matched: HashSet::new(),
133 });
134 }
135 }
136 Message::ToolResult(t) => {
137 if let Some(ref mut b) = current
138 && b.issued.contains(&t.tool_call_id)
139 {
140 b.matched.insert(t.tool_call_id.clone());
141 valid_result[i] = true;
142 }
143 }
146 Message::User(_) => {
147 if let Some(b) = current.take() {
148 batches.push(b);
149 }
150 }
151 }
152 }
153 if let Some(b) = current {
154 batches.push(b);
155 }
156
157 let mut removed = 0;
159 let mut kept: Vec<Message> = Vec::with_capacity(messages.len());
160
161 let mut strip_from_assistant: HashSet<usize> = HashSet::new();
164 for b in &batches {
165 if b.matched.len() < b.issued.len() {
166 strip_from_assistant.insert(b.msg_idx);
170 }
171 }
172
173 for (i, msg) in messages.drain(..).enumerate() {
174 match msg {
175 Message::ToolResult(_) => {
176 if valid_result[i] {
177 kept.push(msg);
178 } else {
179 removed += 1;
180 }
181 }
182 Message::Assistant(mut a) => {
183 if strip_from_assistant.contains(&i) {
184 let before = a.content.len();
185 a.content
186 .retain(|b| !matches!(b, ContentBlock::ToolCall(_)));
187 removed += before - a.content.len();
188 if a.content.is_empty() {
189 removed += 1;
192 } else {
193 kept.push(Message::Assistant(a));
194 }
195 } else {
196 kept.push(Message::Assistant(a));
197 }
198 }
199 other => kept.push(other),
200 }
201 }
202
203 *messages = kept;
204 removed
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn test_should_stop_returns_false_when_no_external_stop() {
213 let external_stop = Arc::new(AtomicBool::new(false));
214 assert!(!should_stop_after_turn(&external_stop));
215 }
216
217 #[test]
218 fn test_should_stop_returns_true_on_external_stop() {
219 let external_stop = Arc::new(AtomicBool::new(true));
220 assert!(should_stop_after_turn(&external_stop));
221 }
222
223 #[test]
224 fn test_sanitize_no_orphans() {
225 use oxicode_ai::{ContentBlock, Message, TextContent, ToolCall, ToolResultMessage};
226 let mut messages = vec![
227 Message::User(oxicode_ai::UserMessage::new("hello")),
228 Message::Assistant({
229 let mut m = oxicode_ai::AssistantMessage::new(
230 oxicode_ai::Api::OpenAiCompletions,
231 "agent",
232 "gpt-4",
233 );
234 m.content.push(ContentBlock::ToolCall(ToolCall::new(
235 "call_1",
236 "bash",
237 serde_json::json!({"cmd": "ls"}),
238 )));
239 m
240 }),
241 Message::ToolResult(ToolResultMessage::new(
242 "call_1",
243 "bash",
244 vec![ContentBlock::Text(TextContent::new("output"))],
245 )),
246 ];
247 let removed = sanitize_orphaned_tool_results(&mut messages);
248 assert_eq!(removed, 0);
249 assert_eq!(messages.len(), 3);
250 }
251
252 #[test]
253 fn test_sanitize_removes_orphans() {
254 use oxicode_ai::{ContentBlock, Message, TextContent, ToolResultMessage};
255 let mut messages = vec![
256 Message::User(oxicode_ai::UserMessage::new("hello")),
257 Message::ToolResult(ToolResultMessage::new(
259 "orphan_1",
260 "bash",
261 vec![ContentBlock::Text(TextContent::new("orphan output"))],
262 )),
263 ];
264 let removed = sanitize_orphaned_tool_results(&mut messages);
265 assert_eq!(removed, 1);
266 assert_eq!(messages.len(), 1);
267 assert!(matches!(messages[0], Message::User(_)));
268 }
269
270 #[test]
271 fn test_sanitize_tool_result_after_user_is_orphan() {
272 use oxicode_ai::{ContentBlock, Message, TextContent, ToolResultMessage};
273 let mut messages = vec![
275 Message::User(oxicode_ai::UserMessage::new("hello")),
276 Message::ToolResult(ToolResultMessage::new(
278 "call_x",
279 "bash",
280 vec![ContentBlock::Text(TextContent::new("result"))],
281 )),
282 ];
283 let removed = sanitize_orphaned_tool_results(&mut messages);
284 assert_eq!(removed, 1);
285 }
286
287 #[test]
288 fn test_sanitize_multiple_orphans_removes_only_orphans() {
289 use oxicode_ai::{ContentBlock, Message, TextContent, ToolCall, ToolResultMessage};
290 let mut messages = vec![
291 Message::ToolResult(ToolResultMessage::new(
293 "orphan_1",
294 "bash",
295 vec![ContentBlock::Text(TextContent::new("o1"))],
296 )),
297 Message::ToolResult(ToolResultMessage::new(
299 "orphan_2",
300 "bash",
301 vec![ContentBlock::Text(TextContent::new("o2"))],
302 )),
303 Message::Assistant({
305 let mut m = oxicode_ai::AssistantMessage::new(
306 oxicode_ai::Api::OpenAiCompletions,
307 "agent",
308 "gpt-4",
309 );
310 m.content.push(ContentBlock::ToolCall(ToolCall::new(
311 "call_1",
312 "read",
313 serde_json::json!({"path": "foo"}),
314 )));
315 m
316 }),
317 Message::ToolResult(ToolResultMessage::new(
318 "call_1",
319 "read",
320 vec![ContentBlock::Text(TextContent::new("valid"))],
321 )),
322 Message::ToolResult(ToolResultMessage::new(
324 "orphan_3",
325 "write",
326 vec![ContentBlock::Text(TextContent::new("o3"))],
327 )),
328 ];
329 let removed = sanitize_orphaned_tool_results(&mut messages);
330 assert_eq!(removed, 3);
332 assert_eq!(messages.len(), 2);
334 assert!(matches!(messages[0], Message::Assistant(_)));
335 assert!(matches!(messages[1], Message::ToolResult(_)));
336 }
337
338 #[test]
339 fn test_sanitize_multi_tool_call_assistant_preserves_all_results() {
340 use oxicode_ai::{ContentBlock, Message, TextContent, ToolCall, ToolResultMessage};
341 let mut messages = vec![
344 Message::User(oxicode_ai::UserMessage::new("do two things")),
345 Message::Assistant({
346 let mut m = oxicode_ai::AssistantMessage::new(
347 oxicode_ai::Api::OpenAiCompletions,
348 "agent",
349 "gpt-4",
350 );
351 m.content.push(ContentBlock::ToolCall(ToolCall::new(
352 "call_1",
353 "read",
354 serde_json::json!({"path": "a.txt"}),
355 )));
356 m.content.push(ContentBlock::ToolCall(ToolCall::new(
357 "call_2",
358 "read",
359 serde_json::json!({"path": "b.txt"}),
360 )));
361 m
362 }),
363 Message::ToolResult(ToolResultMessage::new(
364 "call_1",
365 "read",
366 vec![ContentBlock::Text(TextContent::new("aaa"))],
367 )),
368 Message::ToolResult(ToolResultMessage::new(
369 "call_2",
370 "read",
371 vec![ContentBlock::Text(TextContent::new("bbb"))],
372 )),
373 ];
374 let removed = sanitize_orphaned_tool_results(&mut messages);
375 assert_eq!(removed, 0, "no tool results should be orphaned");
376 assert_eq!(messages.len(), 4, "all 4 messages should be kept");
377 }
378
379 #[test]
380 fn test_sanitize_orphan_tool_call_stripped_from_assistant() {
381 use oxicode_ai::{ContentBlock, Message, TextContent, ToolCall, ToolResultMessage};
382 let mut messages = vec![
387 Message::Assistant({
388 let mut m = oxicode_ai::AssistantMessage::new(
389 oxicode_ai::Api::OpenAiCompletions,
390 "agent",
391 "gpt-4",
392 );
393 m.content.push(ContentBlock::ToolCall(ToolCall::new(
394 "call_1",
395 "read",
396 serde_json::json!({"path": "a.txt"}),
397 )));
398 m
399 }),
400 Message::Assistant({
403 let mut m = oxicode_ai::AssistantMessage::new(
404 oxicode_ai::Api::OpenAiCompletions,
405 "agent",
406 "gpt-4",
407 );
408 m.content.push(ContentBlock::ToolCall(ToolCall::new(
409 "call_2",
410 "bash",
411 serde_json::json!({"cmd": "ls"}),
412 )));
413 m
414 }),
415 Message::ToolResult(ToolResultMessage::new(
416 "call_2",
417 "bash",
418 vec![ContentBlock::Text(TextContent::new("ok"))],
419 )),
420 ];
421 let removed = sanitize_orphaned_tool_results(&mut messages);
422 assert_eq!(
426 removed, 2,
427 "1 tool_call block stripped + 1 empty assistant dropped"
428 );
429 assert_eq!(messages.len(), 2);
430 assert!(matches!(messages[0], Message::Assistant(_)));
432 assert!(matches!(messages[1], Message::ToolResult(_)));
433 }
434
435 #[test]
436 fn test_sanitize_assistant_with_text_and_orphan_tool_call_keeps_text() {
437 use oxicode_ai::{ContentBlock, Message, TextContent, ToolCall};
438 let mut messages = vec![
441 Message::Assistant({
442 let mut m = oxicode_ai::AssistantMessage::new(
443 oxicode_ai::Api::OpenAiCompletions,
444 "agent",
445 "gpt-4",
446 );
447 m.content
448 .push(ContentBlock::Text(TextContent::new("let me check")));
449 m.content.push(ContentBlock::ToolCall(ToolCall::new(
450 "call_1",
451 "read",
452 serde_json::json!({"path": "a.txt"}),
453 )));
454 m
455 }),
456 Message::User(oxicode_ai::UserMessage::new("hi")),
458 ];
459 let removed = sanitize_orphaned_tool_results(&mut messages);
460 assert_eq!(removed, 1);
463 assert_eq!(messages.len(), 2);
464 if let Message::Assistant(a) = &messages[0] {
465 assert_eq!(a.content.len(), 1, "only the text block should remain");
466 if let ContentBlock::Text(t) = &a.content[0] {
467 assert_eq!(t.text, "let me check");
468 } else {
469 panic!("expected text block");
470 }
471 } else {
472 panic!("expected assistant message");
473 }
474 }
475
476 #[test]
477 fn test_sanitize_orphan_tool_result_with_no_assistant_removed() {
478 use oxicode_ai::{ContentBlock, Message, TextContent, ToolResultMessage};
479 let mut messages = vec![
482 Message::User(oxicode_ai::UserMessage::new("hello")),
483 Message::ToolResult(ToolResultMessage::new(
484 "orphan_1",
485 "bash",
486 vec![ContentBlock::Text(TextContent::new("orphan output"))],
487 )),
488 ];
489 let removed = sanitize_orphaned_tool_results(&mut messages);
490 assert_eq!(removed, 1);
491 assert_eq!(messages.len(), 1);
492 }
493
494 #[test]
495 fn test_sanitize_wrong_tool_call_id_removed() {
496 use oxicode_ai::{ContentBlock, Message, TextContent, ToolCall, ToolResultMessage};
497 let mut messages = vec![
500 Message::Assistant({
501 let mut m = oxicode_ai::AssistantMessage::new(
502 oxicode_ai::Api::OpenAiCompletions,
503 "agent",
504 "gpt-4",
505 );
506 m.content.push(ContentBlock::ToolCall(ToolCall::new(
507 "call_1",
508 "bash",
509 serde_json::json!({"cmd": "ls"}),
510 )));
511 m
512 }),
513 Message::ToolResult(ToolResultMessage::new(
514 "wrong_id", "bash",
516 vec![ContentBlock::Text(TextContent::new("orphan"))],
517 )),
518 ];
519 let removed = sanitize_orphaned_tool_results(&mut messages);
520 assert_eq!(removed, 3);
525 assert_eq!(messages.len(), 0);
526 }
527}