Skip to main content

whycodes_session/
pairing.rs

1//! Tool call / result pairing for outgoing requests.
2//!
3//! Strict APIs reject a transcript where an assistant tool call has no
4//! result (OpenAI: "tool_calls must be followed by tool messages"; Anthropic:
5//! every `tool_use` needs a `tool_result`) or where a tool result answers no
6//! call. The agent now answers interrupted calls itself, but sessions saved
7//! before that, imported transcripts and trimmed histories can still carry
8//! either shape. This repairs the request copy only; the stored transcript
9//! is left as the user saw it.
10
11use whycodes_core::types::{ContentBlock, Message, MessageContent, Role};
12
13/// Body of the result supplied for a call the transcript never answered.
14pub const MISSING_RESULT: &str = "Tool result missing: this call was interrupted and never ran.";
15
16/// The transcript with every tool call answered and every result owned by a
17/// call. `None` when it already pairs up, so the common case copies nothing.
18pub fn paired_messages(messages: &[Message]) -> Option<Vec<Message>> {
19    let mut out: Vec<Message> = Vec::with_capacity(messages.len());
20    let mut open: Vec<String> = Vec::new();
21    // A call with an empty id cannot be matched by id; keep whatever results
22    // follow that assistant turn rather than dropping real output.
23    let mut lenient = false;
24    let mut changed = false;
25    for msg in messages {
26        match msg.role {
27            Role::Tool => {
28                let id = msg.tool_call_id.as_deref().unwrap_or("");
29                let owned = take_open(&mut open, id) || lenient;
30                changed |= !owned;
31                push_if(&mut out, msg, owned);
32            }
33            Role::Assistant => {
34                changed |= close_open(&mut out, &mut open);
35                lenient = record_calls(msg, &mut open);
36                out.push(msg.clone());
37            }
38            Role::User | Role::System => {
39                changed |= close_open(&mut out, &mut open);
40                lenient = false;
41                out.push(msg.clone());
42            }
43        }
44    }
45    changed |= close_open(&mut out, &mut open);
46    changed.then_some(out)
47}
48
49/// Remove `id` from the open calls; true when it was one of them.
50fn take_open(open: &mut Vec<String>, id: &str) -> bool {
51    let before = open.len();
52    open.retain(|o| o != id);
53    open.len() != before
54}
55
56fn push_if(out: &mut Vec<Message>, msg: &Message, keep: bool) {
57    if keep {
58        out.push(msg.clone());
59    }
60}
61
62/// Note the assistant's tool call ids; true when one had no id.
63fn record_calls(msg: &Message, open: &mut Vec<String>) -> bool {
64    let MessageContent::Blocks(blocks) = &msg.content else {
65        return false;
66    };
67    let mut unnamed = false;
68    for block in blocks {
69        if let ContentBlock::ToolUse { id, .. } = block {
70            unnamed |= id.is_empty();
71            open.push(id.clone());
72        }
73    }
74    open.retain(|id| !id.is_empty());
75    unnamed
76}
77
78/// Answer every still-open call; true when any needed answering.
79fn close_open(out: &mut Vec<Message>, open: &mut Vec<String>) -> bool {
80    let any = !open.is_empty();
81    for id in open.drain(..) {
82        out.push(Message {
83            role: Role::Tool,
84            content: MessageContent::Text(MISSING_RESULT.to_string()),
85            tool_call_id: Some(id),
86            name: None,
87            created_at: None,
88        });
89    }
90    any
91}
92
93#[cfg(test)]
94#[path = "pairing_tests.rs"]
95mod tests;