Skip to main content

sim_lib_openai_server/translate/
tools.rs

1use std::collections::BTreeMap;
2
3use serde_json::{Map, Value};
4use sim_kernel::{CapabilityName, Cx, Error, Expr, Result, Symbol};
5
6use super::tool_schema::{
7    argument_order, arguments_json, canonical_json, default_parameters, expr_text,
8    expr_to_json_string, json_to_expr, json_to_value, validate_schema,
9};
10
11/// A single OpenAI function tool bound to a SIM callable.
12///
13/// Holds the OpenAI-facing function name and JSON Schema `parameters`
14/// alongside the resolved SIM `symbol`, argument order, required
15/// capabilities, and optional argument/result shapes used to translate
16/// between OpenAI tool descriptors and SIM calls.
17#[derive(Clone, Debug, PartialEq)]
18pub struct OpenAiTool {
19    openai_name: String,
20    symbol: Symbol,
21    description: String,
22    parameters: Value,
23    arg_order: Vec<String>,
24    capabilities: Vec<CapabilityName>,
25    args_shape: Option<Expr>,
26    result_shape: Option<Expr>,
27}
28
29/// Collection of [`OpenAiTool`] entries keyed by OpenAI tool name.
30#[derive(Clone, Debug, Default, PartialEq)]
31pub struct OpenAiToolRegistry {
32    tools: BTreeMap<String, OpenAiTool>,
33}
34
35/// A tool invocation requested by the model: an id, tool name, and JSON arguments.
36#[derive(Clone, Debug, PartialEq)]
37pub struct OpenAiToolCall {
38    /// Provider-assigned call id correlating the call with its result.
39    pub id: String,
40    /// OpenAI tool name being invoked.
41    pub name: String,
42    /// Arguments supplied for the call as a JSON value.
43    pub arguments: Value,
44}
45
46/// The outcome of an [`OpenAiToolCall`]: a status symbol and result payload.
47#[derive(Clone, Debug, PartialEq)]
48pub struct OpenAiToolResult {
49    /// Id of the originating [`OpenAiToolCall`].
50    pub call_id: String,
51    /// OpenAI tool name that produced this result.
52    pub name: String,
53    /// Outcome status, e.g. `ok`, `invalid-arguments`, or `unknown-tool`.
54    pub status: Symbol,
55    /// Result payload expression (the call output or an error message).
56    pub output: Expr,
57}
58
59impl OpenAiTool {
60    /// Builds a tool from a resolved SIM callable, capturing its argument and
61    /// result shapes from the callable's browse metadata.
62    pub fn from_callable(
63        cx: &mut Cx,
64        openai_name: impl Into<String>,
65        symbol: Symbol,
66        description: impl Into<String>,
67        parameters: Value,
68        capabilities: Vec<CapabilityName>,
69    ) -> Result<Self> {
70        let function = cx.resolve_function(&symbol)?;
71        let Some(callable) = function.object().as_callable() else {
72            return Err(Error::TypeMismatch {
73                expected: "callable",
74                found: "non-callable",
75            });
76        };
77        let args_shape = callable
78            .browse_args_shape(cx)?
79            .map(|shape| shape.object().as_expr(cx))
80            .transpose()?;
81        let result_shape = callable
82            .browse_result_shape(cx)?
83            .map(|shape| shape.object().as_expr(cx))
84            .transpose()?;
85        Ok(Self::new(
86            openai_name,
87            symbol,
88            description,
89            parameters,
90            capabilities,
91            args_shape,
92            result_shape,
93        ))
94    }
95
96    /// Parses an OpenAI tool descriptor JSON object into an [`OpenAiTool`].
97    ///
98    /// Requires the descriptor `type` to be `function`, derives the SIM
99    /// symbol from an `x-sim-symbol` hint or from the function name, and reads
100    /// optional `x-sim-capabilities`.
101    pub fn from_openai_descriptor(value: &Value) -> Result<Self> {
102        let object = value
103            .as_object()
104            .ok_or_else(|| Error::Eval("openai tool descriptor must be an object".to_owned()))?;
105        let kind = object
106            .get("type")
107            .and_then(Value::as_str)
108            .unwrap_or("function");
109        if kind != "function" {
110            return Err(Error::Eval(format!(
111                "unsupported OpenAI tool descriptor type {kind}"
112            )));
113        }
114        let function = object
115            .get("function")
116            .and_then(Value::as_object)
117            .ok_or_else(|| Error::Eval("openai tool missing function object".to_owned()))?;
118        let openai_name = string_member(function, "name")?.to_owned();
119        validate_openai_name(&openai_name)?;
120        let description = function
121            .get("description")
122            .and_then(Value::as_str)
123            .unwrap_or("")
124            .to_owned();
125        let parameters = function
126            .get("parameters")
127            .cloned()
128            .unwrap_or_else(default_parameters);
129        let symbol = function
130            .get("x-sim-symbol")
131            .or_else(|| object.get("x-sim-symbol"))
132            .and_then(Value::as_str)
133            .map(symbol_from_text)
134            .transpose()?
135            .unwrap_or_else(|| openai_name_to_symbol(&openai_name));
136        let capabilities = capabilities_from(function)
137            .or_else(|| capabilities_from(object))
138            .unwrap_or_default();
139        Ok(Self::new(
140            openai_name,
141            symbol,
142            description,
143            parameters,
144            capabilities,
145            None,
146            None,
147        ))
148    }
149
150    fn new(
151        openai_name: impl Into<String>,
152        symbol: Symbol,
153        description: impl Into<String>,
154        parameters: Value,
155        capabilities: Vec<CapabilityName>,
156        args_shape: Option<Expr>,
157        result_shape: Option<Expr>,
158    ) -> Self {
159        let arg_order = argument_order(&parameters);
160        Self {
161            openai_name: openai_name.into(),
162            symbol,
163            description: description.into(),
164            parameters,
165            arg_order,
166            capabilities,
167            args_shape,
168            result_shape,
169        }
170    }
171
172    /// Returns the OpenAI-facing tool name.
173    pub fn openai_name(&self) -> &str {
174        &self.openai_name
175    }
176
177    /// Returns the SIM symbol this tool dispatches to.
178    pub fn symbol(&self) -> &Symbol {
179        &self.symbol
180    }
181
182    /// Returns the capabilities required to invoke this tool.
183    pub fn capabilities(&self) -> &[CapabilityName] {
184        &self.capabilities
185    }
186
187    /// Validates `arguments` against the tool's JSON Schema parameters.
188    pub fn validate_arguments(&self, arguments: &Value) -> std::result::Result<(), String> {
189        validate_schema(&self.parameters, arguments, "arguments")
190    }
191
192    /// Validates and converts JSON `arguments` into ordered SIM values for the call.
193    pub fn argument_values(
194        &self,
195        cx: &mut Cx,
196        arguments: &Value,
197    ) -> std::result::Result<Vec<sim_kernel::Value>, String> {
198        self.validate_arguments(arguments)?;
199        let Some(object) = arguments.as_object() else {
200            return Err("tool arguments must be an object".to_owned());
201        };
202        self.arg_order
203            .iter()
204            .filter_map(|name| object.get(name))
205            .map(|value| json_to_value(cx, value).map_err(|err| err.to_string()))
206            .collect()
207    }
208
209    /// Renders this tool as an OpenAI tool descriptor JSON object, including
210    /// the `x-sim-*` extension fields for symbol, shapes, and capabilities.
211    pub fn descriptor_json(&self) -> Value {
212        let mut function = Map::new();
213        function.insert("name".to_owned(), Value::String(self.openai_name.clone()));
214        function.insert(
215            "description".to_owned(),
216            Value::String(self.description.clone()),
217        );
218        function.insert("parameters".to_owned(), self.parameters.clone());
219        function.insert(
220            "x-sim-symbol".to_owned(),
221            Value::String(self.symbol.as_qualified_str()),
222        );
223        if let Some(shape) = &self.args_shape {
224            function.insert("x-sim-args-shape".to_owned(), expr_to_json_string(shape));
225        }
226        if let Some(shape) = &self.result_shape {
227            function.insert("x-sim-result-shape".to_owned(), expr_to_json_string(shape));
228        }
229        if !self.capabilities.is_empty() {
230            function.insert(
231                "x-sim-capabilities".to_owned(),
232                Value::Array(
233                    self.capabilities
234                        .iter()
235                        .map(|capability| Value::String(capability.as_str().to_owned()))
236                        .collect(),
237                ),
238            );
239        }
240        let mut descriptor = Map::new();
241        descriptor.insert("type".to_owned(), Value::String("function".to_owned()));
242        descriptor.insert("function".to_owned(), Value::Object(function));
243        Value::Object(descriptor)
244    }
245
246    /// Renders this tool as a SIM expression map.
247    pub fn to_expr(&self) -> Expr {
248        Expr::Map(vec![
249            field("kind", Expr::Symbol(Symbol::new("openai-gateway/tool"))),
250            field("openai-name", Expr::String(self.openai_name.clone())),
251            field("symbol", Expr::Symbol(self.symbol.clone())),
252            field("description", Expr::String(self.description.clone())),
253            field("parameters", json_to_expr(&self.parameters)),
254            field(
255                "capabilities",
256                Expr::List(
257                    self.capabilities
258                        .iter()
259                        .map(|capability| Expr::String(capability.as_str().to_owned()))
260                        .collect(),
261                ),
262            ),
263            field("args-shape", self.args_shape.clone().unwrap_or(Expr::Nil)),
264            field(
265                "result-shape",
266                self.result_shape.clone().unwrap_or(Expr::Nil),
267            ),
268        ])
269    }
270}
271
272impl OpenAiToolRegistry {
273    /// Builds a registry from the `tools` array of an OpenAI request object;
274    /// a missing or null `tools` field yields an empty registry.
275    pub fn from_request(object: &Map<String, Value>) -> Result<Self> {
276        let Some(tools) = object.get("tools") else {
277            return Ok(Self::default());
278        };
279        if tools.is_null() {
280            return Ok(Self::default());
281        }
282        let tools = tools
283            .as_array()
284            .ok_or_else(|| Error::Eval("openai tools field must be an array".to_owned()))?;
285        let mut registry = Self::default();
286        for tool in tools {
287            registry.insert(OpenAiTool::from_openai_descriptor(tool)?)?;
288        }
289        Ok(registry)
290    }
291
292    /// Inserts a tool, erroring if its OpenAI name is already registered.
293    pub fn insert(&mut self, tool: OpenAiTool) -> Result<()> {
294        if self.tools.contains_key(tool.openai_name()) {
295            return Err(Error::Eval(format!(
296                "duplicate OpenAI tool name {}",
297                tool.openai_name()
298            )));
299        }
300        self.tools.insert(tool.openai_name.clone(), tool);
301        Ok(())
302    }
303
304    /// Returns `true` when no tools are registered.
305    pub fn is_empty(&self) -> bool {
306        self.tools.is_empty()
307    }
308
309    /// Looks up a tool by its OpenAI name.
310    pub fn get(&self, name: &str) -> Option<&OpenAiTool> {
311        self.tools.get(name)
312    }
313
314    /// Renders the registry as a JSON array of OpenAI tool descriptors.
315    pub fn descriptor_json(&self) -> Value {
316        Value::Array(
317            self.tools
318                .values()
319                .map(OpenAiTool::descriptor_json)
320                .collect(),
321        )
322    }
323
324    /// Renders the registry as a SIM list of tool expressions.
325    pub fn to_expr(&self) -> Expr {
326        Expr::List(self.tools.values().map(OpenAiTool::to_expr).collect())
327    }
328}
329
330impl OpenAiToolCall {
331    /// Extracts a tool call from a transcript content part, returning `None`
332    /// when the part is not a `tool-call` map.
333    pub fn from_content_part(part: &Expr) -> Result<Option<Self>> {
334        let Expr::Map(entries) = part else {
335            return Ok(None);
336        };
337        if symbol_field(entries, "type").as_deref() != Some("tool-call") {
338            return Ok(None);
339        }
340        let name = required_string_field(entries, "name")?;
341        let id = string_field(entries, "id").unwrap_or_else(|| format!("call_{name}"));
342        let arguments = entries
343            .iter()
344            .find_map(|(key, value)| match key {
345                Expr::Symbol(symbol)
346                    if symbol.namespace.is_none() && symbol.name.as_ref() == "arguments" =>
347                {
348                    Some(arguments_json(value))
349                }
350                _ => None,
351            })
352            .transpose()?
353            .unwrap_or_else(|| Value::Object(Map::new()));
354        Ok(Some(Self {
355            id,
356            name,
357            arguments,
358        }))
359    }
360
361    /// Renders this tool call as a SIM expression map.
362    pub fn to_expr(&self) -> Expr {
363        Expr::Map(vec![
364            field("id", Expr::String(self.id.clone())),
365            field("name", Expr::String(self.name.clone())),
366            field("arguments", json_to_expr(&self.arguments)),
367        ])
368    }
369
370    /// Returns a stable `name:arguments` fingerprint for deduplicating calls.
371    pub fn fingerprint(&self) -> String {
372        format!("{}:{}", self.name, canonical_json(&self.arguments))
373    }
374}
375
376impl OpenAiToolResult {
377    /// Builds a successful (`ok`) result carrying `output` for `call`.
378    pub fn success(call: &OpenAiToolCall, output: Expr) -> Self {
379        Self {
380            call_id: call.id.clone(),
381            name: call.name.clone(),
382            status: Symbol::new("ok"),
383            output,
384        }
385    }
386
387    /// Builds an `invalid-arguments` failure result for `call`.
388    pub fn invalid_arguments(call: &OpenAiToolCall, message: impl Into<String>) -> Self {
389        Self::failure(call, "invalid-arguments", message)
390    }
391
392    /// Builds a `capability-denied` failure result for `call`.
393    pub fn capability_denied(call: &OpenAiToolCall, message: impl Into<String>) -> Self {
394        Self::failure(call, "capability-denied", message)
395    }
396
397    /// Builds an `unknown-tool` failure result for `call`.
398    pub fn unknown_tool(call: &OpenAiToolCall) -> Self {
399        Self::failure(call, "unknown-tool", format!("unknown tool {}", call.name))
400    }
401
402    fn failure(call: &OpenAiToolCall, status: &'static str, message: impl Into<String>) -> Self {
403        Self {
404            call_id: call.id.clone(),
405            name: call.name.clone(),
406            status: Symbol::new(status),
407            output: Expr::String(message.into()),
408        }
409    }
410
411    /// Renders this tool result as a SIM expression map.
412    pub fn to_expr(&self) -> Expr {
413        Expr::Map(vec![
414            field("tool-call-id", Expr::String(self.call_id.clone())),
415            field("name", Expr::String(self.name.clone())),
416            field("status", Expr::Symbol(self.status.clone())),
417            field("output", self.output.clone()),
418        ])
419    }
420
421    /// Returns a human-readable one-line summary of this result.
422    pub fn message_text(&self) -> String {
423        format!(
424            "tool {} {}: {}",
425            self.name,
426            self.status.name,
427            expr_text(&self.output)
428        )
429    }
430}
431
432/// Converts an OpenAI tool name into a SIM [`Symbol`], splitting on the first
433/// `_` into a namespace and local name and replacing remaining `_` with `-`.
434pub fn openai_name_to_symbol(name: &str) -> Symbol {
435    if let Some((namespace, local)) = name.split_once('_') {
436        Symbol::qualified(namespace.replace('_', "-"), local.replace('_', "-"))
437    } else {
438        Symbol::new(name.replace('_', "-"))
439    }
440}
441
442/// Parses a SIM symbol from text, treating a `namespace/name` form as a
443/// qualified symbol; errors on empty or malformed input.
444pub fn symbol_from_text(text: &str) -> Result<Symbol> {
445    if text.trim().is_empty() {
446        return Err(Error::Eval("SIM tool symbol must not be empty".to_owned()));
447    }
448    Ok(if let Some((namespace, name)) = text.split_once('/') {
449        if namespace.is_empty() || name.is_empty() {
450            return Err(Error::Eval(format!("invalid SIM symbol {text}")));
451        }
452        Symbol::qualified(namespace.to_owned(), name.to_owned())
453    } else {
454        Symbol::new(text.to_owned())
455    })
456}
457
458fn validate_openai_name(name: &str) -> Result<()> {
459    if name.is_empty() {
460        return Err(Error::Eval("OpenAI tool name must not be empty".to_owned()));
461    }
462    if name
463        .chars()
464        .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
465    {
466        Ok(())
467    } else {
468        Err(Error::Eval(format!(
469            "OpenAI tool name {name} contains unsupported characters"
470        )))
471    }
472}
473
474fn string_member<'a>(object: &'a Map<String, Value>, name: &str) -> Result<&'a str> {
475    object
476        .get(name)
477        .and_then(Value::as_str)
478        .ok_or_else(|| Error::Eval(format!("openai tool missing string {name}")))
479}
480
481fn capabilities_from(object: &Map<String, Value>) -> Option<Vec<CapabilityName>> {
482    let values = object.get("x-sim-capabilities")?.as_array()?;
483    Some(
484        values
485            .iter()
486            .filter_map(Value::as_str)
487            .map(|value| CapabilityName::new(value.to_owned()))
488            .collect(),
489    )
490}
491
492fn required_string_field(entries: &[(Expr, Expr)], name: &str) -> Result<String> {
493    string_field(entries, name)
494        .ok_or_else(|| Error::Eval(format!("tool-call missing string {name}")))
495}
496
497fn string_field(entries: &[(Expr, Expr)], name: &str) -> Option<String> {
498    sim_value::access::entry_field(entries, name)
499        .and_then(sim_value::access::as_str)
500        .map(str::to_owned)
501}
502
503fn symbol_field(entries: &[(Expr, Expr)], name: &str) -> Option<String> {
504    match sim_value::access::entry_field(entries, name) {
505        Some(Expr::Symbol(value)) => Some(value.name.as_ref().to_owned()),
506        _ => None,
507    }
508}
509
510use sim_value::build::entry as field;