Skip to main content

agentic_core/tool/
registry.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7use super::codex::NamespaceMap;
8use super::{CodexNamespaceHandler, GatewayExecutor, ToolError, ToolOutput};
9use crate::types::io::OutputItem;
10use crate::types::io::output::FunctionToolCall;
11use crate::types::tools::{CodexNamespaceMember, ResponsesTool};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum ToolType {
16    Function,
17    CodexNamespace,
18    Mcp,
19    /// Internal routing discriminant. Serializes as `"web_search"`.
20    /// Note: the corresponding `ResponsesTool` wire tag is `"web_search_preview"`.
21    /// `ToolType` is not used in wire-facing types so the names differ intentionally.
22    WebSearch,
23    FileSearch,
24    CodeInterpreter,
25}
26
27impl ToolType {
28    #[must_use]
29    pub const fn is_gateway_owned(self) -> bool {
30        !matches!(self, Self::Function | Self::CodexNamespace)
31    }
32}
33
34/// Per-request routing entry keyed by the tool name the model will call.
35#[derive(Clone)]
36pub struct ToolEntry {
37    pub tool_type: ToolType,
38    /// Full serialised tool param for the executor (used during dispatch).
39    pub config: Value,
40    /// For MCP tools: which server this tool belongs to.
41    pub server_label: Option<String>,
42    pub handler: Option<Arc<dyn GatewayExecutor>>,
43}
44
45impl std::fmt::Debug for ToolEntry {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        f.debug_struct("ToolEntry")
48            .field("tool_type", &self.tool_type)
49            .field("config", &self.config)
50            .field("server_label", &self.server_label)
51            .field("handler", &self.handler.is_some())
52            .finish()
53    }
54}
55
56pub struct GatewayDispatchResult {
57    pub tool_type: ToolType,
58    pub output: Result<ToolOutput, ToolError>,
59}
60
61/// Request-scoped registry built from `RequestPayload.tools`.
62/// Maps the name the LLM sees → routing metadata.
63#[derive(Debug, Default)]
64pub struct ToolRegistry {
65    entries: HashMap<String, ToolEntry>,
66    /// Built once from the declared tools, so `restore_final_payload_output`
67    /// and `restore_stream_event_value` — the latter called once per SSE line
68    /// during streaming — don't rebuild it on every call.
69    namespace_map: Option<NamespaceMap>,
70}
71
72impl ToolRegistry {
73    /// Build a registry from the declared tools.
74    ///
75    /// Duplicate tool names result in last-write-wins, logged at `warn` level.
76    ///
77    /// # Errors
78    ///
79    /// Returns [`ToolError::Config`] when Codex namespace member flattening
80    /// would collide with another declared tool name.
81    ///
82    /// # Panics
83    ///
84    /// Panics if serialization of a tool param struct fails, which cannot happen
85    /// for the types defined in this module (`#[derive(Serialize)]` on plain structs).
86    pub fn build(tools: &[ResponsesTool]) -> Result<Self, ToolError> {
87        Self::build_with_handlers(tools, |_| None)
88    }
89
90    /// Build a registry from declared tools and attach gateway handlers for dispatchable tool types.
91    ///
92    /// # Errors
93    ///
94    /// Returns [`ToolError::Config`] when Codex namespace member flattening
95    /// would collide with another declared tool name.
96    ///
97    /// # Panics
98    ///
99    /// Panics if serialization of a tool param struct fails, which cannot happen
100    /// for the types defined in this module (`#[derive(Serialize)]` on plain structs).
101    pub fn build_with_handlers(
102        tools: &[ResponsesTool],
103        mut handler_for: impl FnMut(ToolType) -> Option<Arc<dyn GatewayExecutor>>,
104    ) -> Result<Self, ToolError> {
105        let mut entries = HashMap::with_capacity(tools.len());
106        // Namespace members must be keyed by the same flat, model-visible name
107        // the model will call, so resolve them first — the same pure pass used
108        // to build the upstream request.
109        let resolved_tools = CodexNamespaceHandler.resolve_namespace_members(tools)?;
110
111        for tool in &resolved_tools {
112            match tool {
113                ResponsesTool::Function(p) => {
114                    // p.name is NonEmptyToolName — empty names are impossible here
115                    // (serde rejects them at deserialization time).
116                    if entries
117                        .insert(
118                            p.name.as_str().to_owned(),
119                            ToolEntry {
120                                tool_type: ToolType::Function,
121                                config: serde_json::to_value(p).expect("serialization of known struct is infallible"),
122                                server_label: None,
123                                handler: None,
124                            },
125                        )
126                        .is_some()
127                    {
128                        tracing::warn!(name = %p.name, "duplicate tool name — previous definition overwritten");
129                    }
130                }
131                ResponsesTool::Mcp(p) => {
132                    // MCP tool names are discovered at request-time via `tools/list`.
133                    // Without discovery, we cannot know which tool names to register —
134                    // keying by server_label would cause all MCP calls to miss on lookup
135                    // since gateway_owned/client_owned look up by tool name, not server.
136                    // MCP entries will be populated in PR C once HttpMcpHandler
137                    // implements discover() and the executor calls it before build().
138                    tracing::debug!(
139                        server_label = %p.server_label,
140                        "MCP server declared but skipped in registry — tool names unknown until discovery (PR C)"
141                    );
142                }
143                ResponsesTool::WebSearch(p) => {
144                    entries.insert(
145                        "web_search".to_owned(),
146                        ToolEntry {
147                            tool_type: ToolType::WebSearch,
148                            config: serde_json::to_value(p).expect("serialization of known struct is infallible"),
149                            server_label: None,
150                            handler: handler_for(ToolType::WebSearch),
151                        },
152                    );
153                }
154                ResponsesTool::FileSearch(p) => {
155                    entries.insert(
156                        "file_search".to_owned(),
157                        ToolEntry {
158                            tool_type: ToolType::FileSearch,
159                            config: serde_json::to_value(p).expect("serialization of known struct is infallible"),
160                            server_label: None,
161                            handler: handler_for(ToolType::FileSearch),
162                        },
163                    );
164                }
165                ResponsesTool::CodeInterpreter(p) => {
166                    entries.insert(
167                        "code_interpreter".to_owned(),
168                        ToolEntry {
169                            tool_type: ToolType::CodeInterpreter,
170                            config: serde_json::to_value(p).expect("serialization of known struct is infallible"),
171                            server_label: None,
172                            handler: handler_for(ToolType::CodeInterpreter),
173                        },
174                    );
175                }
176                ResponsesTool::Namespace(p) => {
177                    // p's members already carry their flat, model-visible names
178                    // (see the `resolve_namespace_members` call above).
179                    let config = serde_json::to_value(p).expect("serialization of known struct is infallible");
180                    for member in &p.tools {
181                        let CodexNamespaceMember::Function(function) = member else {
182                            continue;
183                        };
184                        let name = function.name.as_str().to_owned();
185                        if entries
186                            .insert(
187                                name.clone(),
188                                ToolEntry {
189                                    tool_type: ToolType::CodexNamespace,
190                                    config: config.clone(),
191                                    server_label: Some(p.name.clone()),
192                                    handler: None,
193                                },
194                            )
195                            .is_some()
196                        {
197                            tracing::warn!(name = %name, namespace = %p.name, "duplicate tool name - previous definition overwritten");
198                        }
199                    }
200                }
201                ResponsesTool::Unknown => {
202                    tracing::debug!("unknown tool declared but skipped in registry");
203                }
204            }
205        }
206
207        let namespace_map = CodexNamespaceHandler.build_namespace_map((!tools.is_empty()).then_some(tools))?;
208
209        Ok(Self { entries, namespace_map })
210    }
211
212    #[must_use]
213    pub fn lookup(&self, tool_name: &str) -> Option<&ToolEntry> {
214        self.entries.get(tool_name)
215    }
216
217    #[must_use]
218    pub fn is_empty(&self) -> bool {
219        self.entries.is_empty()
220    }
221
222    #[must_use]
223    pub fn len(&self) -> usize {
224        self.entries.len()
225    }
226
227    pub fn restore_final_payload_output(&self, output: &mut [OutputItem]) {
228        CodexNamespaceHandler.restore_output_items(output, self.namespace_map.as_ref());
229    }
230
231    pub fn restore_stream_event_value(&self, value: &mut Value) -> bool {
232        CodexNamespaceHandler.restore_response_value(value, self.namespace_map.as_ref())
233    }
234
235    /// Returns the subset of `calls` whose names map to gateway-owned tools.
236    #[must_use]
237    pub fn gateway_owned<'a>(&self, calls: &'a [FunctionToolCall]) -> Vec<&'a FunctionToolCall> {
238        calls
239            .iter()
240            .filter(|c| {
241                self.entries
242                    .get(&c.name)
243                    .is_some_and(|e| e.tool_type.is_gateway_owned())
244            })
245            .collect()
246    }
247
248    #[must_use]
249    pub fn is_gateway_owned_name(&self, name: &str) -> bool {
250        self.entries
251            .get(name)
252            .is_some_and(|entry| entry.tool_type.is_gateway_owned())
253    }
254
255    /// Returns the subset of `calls` whose names map to client-owned tools
256    /// (`Function`, Codex namespace members, or unknown names).
257    #[must_use]
258    pub fn client_owned<'a>(&self, calls: &'a [FunctionToolCall]) -> Vec<&'a FunctionToolCall> {
259        calls
260            .iter()
261            .filter(|c| {
262                self.entries
263                    .get(&c.name)
264                    .is_none_or(|e| !e.tool_type.is_gateway_owned())
265            })
266            .collect()
267    }
268
269    pub async fn dispatch(&self, call: &FunctionToolCall) -> Option<GatewayDispatchResult> {
270        let entry = self.entries.get(&call.name)?;
271        let handler = entry.handler.clone()?;
272        let tool_type = entry.tool_type;
273        let config = entry.config.clone();
274        Some(GatewayDispatchResult {
275            tool_type,
276            output: handler
277                .execute(&call.call_id, &call.name, &call.arguments, &config)
278                .await,
279        })
280    }
281}