Skip to main content

oxicode_agent/agent_loop/
helpers.rs

1/// Helper functions for agent loop
2use oxicode_ai::{ContentBlock, TextContent, ToolCall, ToolResultMessage};
3use std::sync::Arc;
4use std::sync::atomic::{AtomicBool, Ordering};
5
6/// Extract tool calls from an assistant message.
7pub 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
19/// Create a tool result message from a finalized tool call.
20pub 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
36/// Check if a batch of finalized tool calls should terminate the loop.
37/// pi-mono: ALL finalized results must have `terminate === true` for the
38/// batch to terminate. This is the unanimous consent pattern.
39pub 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
46/// Check if the loop should stop after a turn due to external cancellation.
47///
48/// The loop exits naturally when the LLM stops making tool calls (text-only
49/// response). This function only checks for out-of-band cancellation (Ctrl+C).
50pub fn should_stop_after_turn(external_stop: &Arc<AtomicBool>) -> bool {
51    external_stop.load(Ordering::SeqCst)
52}
53
54use crate::AgentToolResult;
55
56/// Finalized tool call with result.
57pub struct FinalizedToolCall {
58    /// pub.
59    pub tool_call: oxicode_ai::ToolCall,
60    /// pub.
61    pub result: AgentToolResult,
62    /// pub.
63    pub is_error: bool,
64}
65
66/// Remove orphaned `ToolResult` messages and orphaned `ToolCall` blocks
67/// from `Assistant` messages.
68///
69/// Some providers (e.g. OpenAI) reject messages where:
70/// 1. A `tool` role message doesn't follow an `assistant` message containing
71///    `tool_calls` (orphaned ToolResult).
72/// 2. An assistant message contains `tool_calls` that are not followed by
73///    the corresponding `ToolResult` messages before the next user or
74///    assistant turn (orphaned ToolCall).
75///
76/// Both cases can happen after compaction, state restoration, or partial
77/// tool execution failure. This function restores a valid
78/// tool_call/tool_result adjacency that the provider will accept.
79///
80/// Returns the number of orphaned items removed.
81pub 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    // ---- Pass 1: forward scan, collect metadata for assistants and results ----
90    // For each assistant message with tool_calls, record the set of
91    // tool_call_ids it issued. For each tool result, remember its id.
92    //
93    // We use a sliding window: a user message (or the start of a new
94    // assistant turn) closes the current tool-calling "batch" and starts
95    // a fresh one.
96    struct AssistantBatch {
97        /// Index into `messages` of the assistant message.
98        msg_idx: usize,
99        /// tool_call_ids issued by this assistant.
100        issued: HashSet<String>,
101        /// tool_call_ids that have been matched by a ToolResult below.
102        matched: HashSet<String>,
103    }
104
105    let mut batches: Vec<AssistantBatch> = Vec::new();
106    let mut current: Option<AssistantBatch> = None;
107
108    // Track which tool_results are valid (matched to some assistant's id).
109    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                // Close any prior batch — a new assistant turn starts a fresh
115                // tool-call window even if its tool_calls haven't completed
116                // (those become orphans to be stripped).
117                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                // Else: orphan result (no active batch, or batch doesn't have
144                // this id) — marked invalid, will be removed.
145            }
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    // ---- Pass 2: build the result vec, stripping orphans ----
158    let mut removed = 0;
159    let mut kept: Vec<Message> = Vec::with_capacity(messages.len());
160
161    // For each batch, compute the set of unmatched tool_call_ids to strip
162    // from the corresponding assistant message.
163    let mut strip_from_assistant: HashSet<usize> = HashSet::new();
164    for b in &batches {
165        if b.matched.len() < b.issued.len() {
166            // Some tool_calls were not answered. We strip the orphan
167            // ToolCall blocks; if that empties the assistant, the whole
168            // message is removed.
169            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                        // Drop the assistant entirely — it has no text and
190                        // no tool_calls left, so it would be a no-op.
191                        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            // This ToolResult has no preceding Assistant with tool_calls — orphaned.
258            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        // A user message resets the tool_calls context.
274        let mut messages = vec![
275            Message::User(oxicode_ai::UserMessage::new("hello")),
276            // No assistant with tool_calls before this — orphaned.
277            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            // Orphan 1
292            Message::ToolResult(ToolResultMessage::new(
293                "orphan_1",
294                "bash",
295                vec![ContentBlock::Text(TextContent::new("o1"))],
296            )),
297            // Orphan 2
298            Message::ToolResult(ToolResultMessage::new(
299                "orphan_2",
300                "bash",
301                vec![ContentBlock::Text(TextContent::new("o2"))],
302            )),
303            // Valid pair: assistant with tool_calls + tool result
304            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            // This one is orphaned — no preceding assistant with tool_calls
323            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        // Should remove 3 orphans (orphan_1, orphan_2, orphan_3)
331        assert_eq!(removed, 3);
332        // Only the valid assistant + valid tool result remain
333        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        // Regression test: an assistant with 2+ tool_calls must preserve ALL
342        // corresponding ToolResult messages, not just the first one.
343        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        // When an assistant's tool_call has no matching result before a
383        // new assistant turn, the orphan tool_call block must be stripped
384        // (or the whole assistant dropped) so the provider doesn't reject
385        // the request.
386        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            // No ToolResult for call_1 — it's an orphan tool_call.
401            // A new assistant starts a fresh batch:
402            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        // call_1's tool_call is orphan → stripped from the first assistant.
423        // The first assistant is now empty (no text, no tool_calls) → dropped.
424        // call_2's tool_call + result is valid → kept.
425        assert_eq!(
426            removed, 2,
427            "1 tool_call block stripped + 1 empty assistant dropped"
428        );
429        assert_eq!(messages.len(), 2);
430        // Only the second assistant and its result remain.
431        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        // An assistant that has BOTH text content AND a tool_call whose
439        // result is missing should keep its text but lose the tool_call.
440        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            // No ToolResult for call_1.
457            Message::User(oxicode_ai::UserMessage::new("hi")),
458        ];
459        let removed = sanitize_orphaned_tool_results(&mut messages);
460        // The orphan tool_call is stripped (1 item); the assistant's text
461        // and the user message are kept.
462        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        // A tool result with no preceding assistant that has tool_calls
480        // is an orphan and should be removed.
481        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        // A ToolResult whose tool_call_id doesn't match any active
498        // assistant's tool_call_id is an orphan.
499        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", // doesn't match call_1
515                "bash",
516                vec![ContentBlock::Text(TextContent::new("orphan"))],
517            )),
518        ];
519        let removed = sanitize_orphaned_tool_results(&mut messages);
520        // Breakdown:
521        //   - 1 wrong-id ToolResult removed
522        //   - 1 ToolCall block stripped from the assistant (call_1 had no match)
523        //   - 1 empty assistant dropped (only contained the orphan tool_call)
524        assert_eq!(removed, 3);
525        assert_eq!(messages.len(), 0);
526    }
527}