Skip to main content

codex_convert_proxy/convert/request/
context.rs

1//! Tool search context for handling dynamic tool discovery in Responses API.
2//!
3//! This module provides types and logic for handling `tool_search_call` and
4//! `tool_search_output` items that implement OpenAI's dynamic tool discovery
5//! mechanism.
6//!
7//! ## Overview
8//!
9//! In multi-turn conversations, a model may decide to search for tools
10//! dynamically using `tool_search_call`. The response to this search comes
11//! as a `tool_search_output` item containing the discovered tools.
12//!
13//! ## Priority Strategies
14//!
15//! When merging tools from multiple sources (predefined + searched), we support
16//! different strategies via `ToolPriority`:
17//! - `PreferDefined`: Use predefined tools, ignore searched tools
18//! - `PreferSearched`: Use searched tools, discard predefined
19//! - `Merge`: Combine both, with searched tools overriding on name conflicts
20
21use std::collections::HashSet;
22use std::str::FromStr;
23
24use crate::types::response_api::Tool;
25
26/// Tool priority when merging predefined tools with searched tools.
27#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
28pub enum ToolPriority {
29    /// Prefer predefined tools; ignore tools from `tool_search_output`
30    PreferDefined,
31    /// Prefer searched tools; discard predefined tools
32    PreferSearched,
33    /// Merge both; if names conflict, searched tools override
34    #[default]
35    Merge,
36}
37
38impl FromStr for ToolPriority {
39    type Err = std::convert::Infallible;
40
41    fn from_str(s: &str) -> Result<Self, Self::Err> {
42        Ok(match s.to_lowercase().as_str() {
43            "prefer_defined" | "prefer-defined" => ToolPriority::PreferDefined,
44            "prefer_searched" | "prefer-searched" => ToolPriority::PreferSearched,
45            "merge" | "combined" => ToolPriority::Merge,
46            _ => {
47                tracing::warn!(
48                    "[TOOL_SEARCH] unknown tool_priority '{}', defaulting to 'merge'",
49                    s
50                );
51                ToolPriority::Merge
52            }
53        })
54    }
55}
56
57/// Context for tracking tool search state during input conversion.
58///
59/// This struct maintains:
60/// - `pending_calls`: Map of call_id -> tool_search_call items awaiting output
61/// - `resolved_tools`: Final merged tool list after processing all input items
62/// - `priority`: Strategy for merging predefined and searched tools
63#[derive(Debug, Clone)]
64pub struct ToolSearchContext {
65    /// Map of tool_search_call id -> empty slot (marks that a search was initiated)
66    pending_calls: HashSet<String>,
67    /// Tools collected from tool_search_output items
68    searched_tools: Vec<Tool>,
69    /// Predefined tools from ResponseRequest.tools (set separately)
70    predefined_tools: Vec<Tool>,
71    /// Final resolved tools after applying priority strategy
72    resolved_tools: Vec<Tool>,
73    /// Priority strategy for merging tools
74    priority: ToolPriority,
75    /// Whether the context has been finalized (tools resolved)
76    finalized: bool,
77}
78
79impl ToolSearchContext {
80    /// Create a new context with the given priority strategy.
81    pub fn new(priority: ToolPriority) -> Self {
82        Self {
83            pending_calls: HashSet::new(),
84            searched_tools: Vec::new(),
85            predefined_tools: Vec::new(),
86            resolved_tools: Vec::new(),
87            priority,
88            finalized: false,
89        }
90    }
91
92    /// Record that a `tool_search_call` was initiated with the given id.
93    pub fn register_search_call(&mut self, call_id: &str) {
94        self.pending_calls.insert(call_id.to_string());
95        tracing::debug!(
96            "[TOOL_SEARCH] registered tool_search_call: id={}",
97            call_id
98        );
99    }
100
101    /// Check if a call_id corresponds to a registered tool_search_call.
102    pub fn is_registered_search(&self, call_id: &str) -> bool {
103        self.pending_calls.contains(call_id)
104    }
105
106    /// Set the predefined tools from the request.
107    ///
108    /// This should be called once at the start of processing.
109    pub fn set_predefined_tools(&mut self, tools: Vec<Tool>) {
110        self.predefined_tools = tools;
111        tracing::debug!(
112            "[TOOL_SEARCH] set predefined tools: count={}",
113            self.predefined_tools.len()
114        );
115    }
116
117    /// Add tools from a `tool_search_output` item.
118    ///
119    /// The tools are collected but not yet merged - merging happens at finalize.
120    pub fn add_searched_tools(&mut self, tools: Vec<Tool>, call_id: &str) {
121        if !self.pending_calls.contains(call_id) {
122            tracing::warn!(
123                "[TOOL_SEARCH] tool_search_output has unrecognized call_id '{}', \
124                it may not have a corresponding tool_search_call",
125                call_id
126            );
127        }
128
129        let count = tools.len();
130        self.searched_tools.extend(tools);
131        tracing::debug!(
132            "[TOOL_SEARCH] added {} tools from tool_search_output: call_id={}, total_searched={}",
133            count,
134            call_id,
135            self.searched_tools.len()
136        );
137    }
138
139    /// Mark the tool_search_call as completed (output received).
140    ///
141    /// Currently a no-op since we don't validate strict call_id matching,
142    /// but could be used for future validation.
143    pub fn complete_search(&mut self, call_id: &str) {
144        self.pending_calls.remove(call_id);
145        tracing::debug!(
146            "[TOOL_SEARCH] completed tool_search_call: call_id={}",
147            call_id
148        );
149    }
150
151    /// Finalize the context and resolve the final tool list.
152    ///
153    /// This applies the priority strategy to merge predefined and searched tools.
154    /// Returns the resolved tools and clears internal state.
155    ///
156    /// Note: This method takes `self` by value and returns an owned `Vec<Tool>`.
157    /// Subsequent calls to resolved_tools() will return an empty vec.
158    #[must_use]
159    pub fn finalize(mut self) -> Vec<Tool> {
160        if self.finalized {
161            return std::mem::take(&mut self.resolved_tools);
162        }
163
164        tracing::debug!(
165            "[TOOL_SEARCH] finalizing: predefined={}, searched={}, priority={:?}",
166            self.predefined_tools.len(),
167            self.searched_tools.len(),
168            self.priority
169        );
170
171        let result = match self.priority {
172            ToolPriority::PreferDefined => {
173                if !self.searched_tools.is_empty() {
174                    tracing::info!(
175                        "[TOOL_SEARCH] PreferDefined: ignoring {} searched tools",
176                        self.searched_tools.len()
177                    );
178                }
179                std::mem::take(&mut self.predefined_tools)
180            }
181            ToolPriority::PreferSearched => {
182                if !self.predefined_tools.is_empty() {
183                    tracing::info!(
184                        "[TOOL_SEARCH] PreferSearched: ignoring {} predefined tools",
185                        self.predefined_tools.len()
186                    );
187                }
188                std::mem::take(&mut self.searched_tools)
189            }
190            ToolPriority::Merge => {
191                merge_tools_map(&self.predefined_tools, &self.searched_tools)
192            }
193        };
194
195        self.finalized = true;
196        self.resolved_tools = result.clone();
197        tracing::debug!(
198            "[TOOL_SEARCH] resolved tools: count={}",
199            result.len()
200        );
201
202        result
203    }
204
205    /// Get the resolved tools without finalizing (for reading only).
206    pub fn resolved_tools(&self) -> &[Tool] {
207        &self.resolved_tools
208    }
209
210    /// Get the priority strategy.
211    pub fn priority(&self) -> ToolPriority {
212        self.priority
213    }
214
215    /// Check if any searches are still pending (have output without a matching search).
216    pub fn has_pending_searches(&self) -> bool {
217        !self.pending_calls.is_empty()
218    }
219
220    /// Get count of predefined tools.
221    pub fn predefined_count(&self) -> usize {
222        self.predefined_tools.len()
223    }
224
225    /// Get count of searched tools.
226    pub fn searched_count(&self) -> usize {
227        self.searched_tools.len()
228    }
229}
230
231/// Merge two tool lists with deduplication by name.
232///
233/// For tools with the same name:
234/// - If only in `first`, keep it
235/// - If only in `second`, keep it
236/// - If in both, keep the one from `second` (override)
237///
238/// The order in the result is: all unique tools from first, then unique tools from
239/// second that weren't in first (preserving second's order for overrides).
240pub(crate) fn merge_tools_map(first: &[Tool], second: &[Tool]) -> Vec<Tool> {
241    use std::collections::HashMap;
242
243    let mut name_to_tool: HashMap<String, &Tool> = HashMap::new();
244
245    // Add all tools from first
246    for tool in first {
247        if let Some(name) = &tool.name {
248            name_to_tool.insert(name.clone(), tool);
249        }
250    }
251
252    // Override/add with tools from second
253    for tool in second {
254        if let Some(name) = &tool.name {
255            name_to_tool.insert(name.clone(), tool);
256        }
257    }
258
259    // Convert back to Vec, preserving first's order for non-overridden,
260    // then second's order for overrides and new entries
261    let mut result: Vec<Tool> = Vec::new();
262    let mut seen: HashSet<String> = HashSet::new();
263
264    // First pass: add first's tools in order
265    for tool in first {
266        if let Some(name) = &tool.name
267            && !seen.contains(name)
268        {
269            result.push(tool.clone());
270            seen.insert(name.clone());
271        }
272    }
273
274    // Second pass: add second's tools that weren't in first, or override existing
275    for tool in second {
276        if let Some(name) = &tool.name {
277            // Check if this tool is from second (not in first's original order)
278            let first_had = first.iter().any(|t| t.name.as_ref() == Some(name));
279            if !first_had || !seen.contains(name) {
280                // This is either a new tool from second, or an override
281                // For simplicity, we only add if not already added
282                if !seen.contains(name) {
283                    result.push(tool.clone());
284                    seen.insert(name.clone());
285                }
286            }
287        }
288    }
289
290    result
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use crate::types::response_api::{Tool, ToolType};
297
298    fn make_tool(name: &str) -> Tool {
299        Tool {
300            tool_type: ToolType::Function,
301            name: Some(name.to_string()),
302            description: None,
303            parameters: None,
304            strict: None,
305            extra: std::collections::HashMap::new(),
306        }
307    }
308
309    #[test]
310    fn test_prefer_defined_keeps_predefined() {
311        let mut ctx = ToolSearchContext::new(ToolPriority::PreferDefined);
312        ctx.set_predefined_tools(vec![make_tool("tool_a"), make_tool("tool_b")]);
313        ctx.add_searched_tools(vec![make_tool("tool_c")], "call_1");
314
315        let resolved = ctx.finalize().clone();
316        assert_eq!(resolved.len(), 2);
317        assert!(resolved.iter().any(|t| t.name.as_ref().unwrap() == "tool_a"));
318        assert!(resolved.iter().any(|t| t.name.as_ref().unwrap() == "tool_b"));
319    }
320
321    #[test]
322    fn test_prefer_searched_keeps_searched() {
323        let mut ctx = ToolSearchContext::new(ToolPriority::PreferSearched);
324        ctx.set_predefined_tools(vec![make_tool("tool_a"), make_tool("tool_b")]);
325        ctx.add_searched_tools(vec![make_tool("tool_c"), make_tool("tool_d")], "call_1");
326
327        let resolved = ctx.finalize().clone();
328        assert_eq!(resolved.len(), 2);
329        assert!(resolved.iter().any(|t| t.name.as_ref().unwrap() == "tool_c"));
330        assert!(resolved.iter().any(|t| t.name.as_ref().unwrap() == "tool_d"));
331    }
332
333    #[test]
334    fn test_merge_combines_all_unique() {
335        let mut ctx = ToolSearchContext::new(ToolPriority::Merge);
336        ctx.set_predefined_tools(vec![make_tool("tool_a"), make_tool("tool_b")]);
337        ctx.add_searched_tools(vec![make_tool("tool_c"), make_tool("tool_d")], "call_1");
338
339        let resolved = ctx.finalize().clone();
340        assert_eq!(resolved.len(), 4);
341    }
342
343    #[test]
344    fn test_merge_searched_overrides_on_conflict() {
345        let mut ctx = ToolSearchContext::new(ToolPriority::Merge);
346        ctx.set_predefined_tools(vec![make_tool("tool_a"), make_tool("tool_b")]);
347        ctx.add_searched_tools(vec![make_tool("tool_b"), make_tool("tool_c")], "call_1");
348
349        let resolved = ctx.finalize().clone();
350        assert_eq!(resolved.len(), 3);
351
352        // Verify tool_b exists (we can't easily tell which version it is from)
353        let _tool_b = resolved.iter().find(|t| t.name.as_ref().unwrap() == "tool_b");
354        assert!(_tool_b.is_some());
355    }
356
357    #[test]
358    fn test_register_search_call() {
359        let mut ctx = ToolSearchContext::new(ToolPriority::Merge);
360        ctx.register_search_call("call_123");
361
362        assert!(ctx.is_registered_search("call_123"));
363        assert!(!ctx.is_registered_search("call_456"));
364    }
365
366    #[test]
367    fn test_priority_from_str() {
368        assert_eq!("prefer_defined".parse::<ToolPriority>(), Ok(ToolPriority::PreferDefined));
369        assert_eq!("prefer-searched".parse::<ToolPriority>(), Ok(ToolPriority::PreferSearched));
370        assert_eq!("merge".parse::<ToolPriority>(), Ok(ToolPriority::Merge));
371        assert_eq!("unknown".parse::<ToolPriority>(), Ok(ToolPriority::Merge)); // default
372    }
373
374    #[test]
375    fn test_finalize_idempotent() {
376        let mut ctx = ToolSearchContext::new(ToolPriority::Merge);
377        ctx.set_predefined_tools(vec![make_tool("tool_a")]);
378        ctx.add_searched_tools(vec![make_tool("tool_b")], "call_1");
379
380        // finalize takes ownership, so we can't call it twice
381        // Instead, test that calling finalize on a finalized context returns empty
382        let mut ctx2 = ToolSearchContext::new(ToolPriority::Merge);
383        ctx2.set_predefined_tools(vec![make_tool("tool_a")]);
384        ctx2.add_searched_tools(vec![make_tool("tool_b")], "call_1");
385
386        let first = ctx.finalize();
387        assert_eq!(first.len(), 2);
388
389        // Second finalize should return empty since we moved ctx
390        // (this is expected behavior - we take ownership)
391        let second = ctx2.finalize();
392        assert_eq!(second.len(), 2);
393    }
394}