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, validate_supported_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 for translating between
16/// 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        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` and derives the SIM
99    /// symbol from the function name. Inbound request JSON cannot set trusted
100    /// SIM symbols or required capabilities; those come from the server-owned
101    /// runtime registry.
102    pub fn from_openai_descriptor(value: &Value) -> Result<Self> {
103        let object = value
104            .as_object()
105            .ok_or_else(|| Error::Eval("openai tool descriptor must be an object".to_owned()))?;
106        let kind = object
107            .get("type")
108            .and_then(Value::as_str)
109            .unwrap_or("function");
110        if kind != "function" {
111            return Err(Error::Eval(format!(
112                "unsupported OpenAI tool descriptor type {kind}"
113            )));
114        }
115        let function = object
116            .get("function")
117            .and_then(Value::as_object)
118            .ok_or_else(|| Error::Eval("openai tool missing function object".to_owned()))?;
119        let openai_name = string_member(function, "name")?.to_owned();
120        validate_openai_name(&openai_name)?;
121        let description = function
122            .get("description")
123            .and_then(Value::as_str)
124            .unwrap_or("")
125            .to_owned();
126        let parameters = function
127            .get("parameters")
128            .cloned()
129            .unwrap_or_else(default_parameters);
130        reject_untrusted_authority_fields(object)?;
131        reject_untrusted_authority_fields(function)?;
132        let symbol = openai_name_to_symbol(&openai_name);
133        Self::new(
134            openai_name,
135            symbol,
136            description,
137            parameters,
138            Vec::new(),
139            None,
140            None,
141        )
142    }
143
144    fn new(
145        openai_name: impl Into<String>,
146        symbol: Symbol,
147        description: impl Into<String>,
148        parameters: Value,
149        capabilities: Vec<CapabilityName>,
150        args_shape: Option<Expr>,
151        result_shape: Option<Expr>,
152    ) -> Result<Self> {
153        validate_supported_schema(&parameters, "function.parameters").map_err(Error::Eval)?;
154        let arg_order = argument_order(&parameters);
155        Ok(Self {
156            openai_name: openai_name.into(),
157            symbol,
158            description: description.into(),
159            parameters,
160            arg_order,
161            capabilities,
162            args_shape,
163            result_shape,
164        })
165    }
166
167    /// Returns the OpenAI-facing tool name.
168    pub fn openai_name(&self) -> &str {
169        &self.openai_name
170    }
171
172    /// Returns the SIM symbol this tool dispatches to.
173    pub fn symbol(&self) -> &Symbol {
174        &self.symbol
175    }
176
177    /// Returns the capabilities required to invoke this tool.
178    pub fn capabilities(&self) -> &[CapabilityName] {
179        &self.capabilities
180    }
181
182    /// Validates `arguments` against the tool's JSON Schema parameters.
183    pub fn validate_arguments(&self, arguments: &Value) -> std::result::Result<(), String> {
184        validate_schema(&self.parameters, arguments, "arguments")
185    }
186
187    /// Validates and converts JSON `arguments` into ordered SIM values for the call.
188    pub fn argument_values(
189        &self,
190        cx: &mut Cx,
191        arguments: &Value,
192    ) -> std::result::Result<Vec<sim_kernel::Value>, String> {
193        self.validate_arguments(arguments)?;
194        let Some(object) = arguments.as_object() else {
195            return Err("tool arguments must be an object".to_owned());
196        };
197        self.arg_order
198            .iter()
199            .filter_map(|name| object.get(name))
200            .map(|value| json_to_value(cx, value).map_err(|err| err.to_string()))
201            .collect()
202    }
203
204    /// Renders this tool as an OpenAI tool descriptor JSON object, including
205    /// the `x-sim-*` extension fields for symbol, shapes, and capabilities.
206    pub fn descriptor_json(&self) -> Value {
207        let mut function = Map::new();
208        function.insert("name".to_owned(), Value::String(self.openai_name.clone()));
209        function.insert(
210            "description".to_owned(),
211            Value::String(self.description.clone()),
212        );
213        function.insert("parameters".to_owned(), self.parameters.clone());
214        function.insert(
215            "x-sim-symbol".to_owned(),
216            Value::String(self.symbol.as_qualified_str()),
217        );
218        if let Some(shape) = &self.args_shape {
219            function.insert("x-sim-args-shape".to_owned(), expr_to_json_string(shape));
220        }
221        if let Some(shape) = &self.result_shape {
222            function.insert("x-sim-result-shape".to_owned(), expr_to_json_string(shape));
223        }
224        if !self.capabilities.is_empty() {
225            function.insert(
226                "x-sim-capabilities".to_owned(),
227                Value::Array(
228                    self.capabilities
229                        .iter()
230                        .map(|capability| Value::String(capability.as_str().to_owned()))
231                        .collect(),
232                ),
233            );
234        }
235        let mut descriptor = Map::new();
236        descriptor.insert("type".to_owned(), Value::String("function".to_owned()));
237        descriptor.insert("function".to_owned(), Value::Object(function));
238        Value::Object(descriptor)
239    }
240
241    /// Renders this tool as an OpenAI request descriptor without SIM authority
242    /// extension fields.
243    ///
244    /// Use this form when a descriptor may cross an untrusted request boundary:
245    /// the gateway resolves the SIM symbol and required capabilities from its
246    /// server-owned registry rather than from `x-sim-*` request fields.
247    pub fn request_descriptor_json(&self) -> Value {
248        let mut function = Map::new();
249        function.insert("name".to_owned(), Value::String(self.openai_name.clone()));
250        function.insert(
251            "description".to_owned(),
252            Value::String(self.description.clone()),
253        );
254        function.insert("parameters".to_owned(), self.parameters.clone());
255        let mut descriptor = Map::new();
256        descriptor.insert("type".to_owned(), Value::String("function".to_owned()));
257        descriptor.insert("function".to_owned(), Value::Object(function));
258        Value::Object(descriptor)
259    }
260
261    /// Renders this tool as a SIM expression map.
262    pub fn to_expr(&self) -> Expr {
263        Expr::Map(vec![
264            field("kind", Expr::Symbol(Symbol::new("openai-gateway/tool"))),
265            field("openai-name", Expr::String(self.openai_name.clone())),
266            field("symbol", Expr::Symbol(self.symbol.clone())),
267            field("description", Expr::String(self.description.clone())),
268            field("parameters", json_to_expr(&self.parameters)),
269            field(
270                "capabilities",
271                Expr::List(
272                    self.capabilities
273                        .iter()
274                        .map(|capability| Expr::String(capability.as_str().to_owned()))
275                        .collect(),
276                ),
277            ),
278            field("args-shape", self.args_shape.clone().unwrap_or(Expr::Nil)),
279            field(
280                "result-shape",
281                self.result_shape.clone().unwrap_or(Expr::Nil),
282            ),
283        ])
284    }
285}
286
287impl OpenAiToolRegistry {
288    /// Builds a registry from the `tools` array of an OpenAI request object;
289    /// a missing or null `tools` field yields an empty registry.
290    pub fn from_request(cx: &mut Cx, object: &Map<String, Value>) -> Result<Self> {
291        let Some(tools) = object.get("tools") else {
292            return Ok(Self::default());
293        };
294        if tools.is_null() {
295            return Ok(Self::default());
296        }
297        let tools = tools
298            .as_array()
299            .ok_or_else(|| Error::Eval("openai tools field must be an array".to_owned()))?;
300        let mut registry = Self::default();
301        for tool in tools {
302            let tool = OpenAiTool::from_openai_descriptor(tool)?;
303            ensure_server_registered_tool(cx, &tool)?;
304            registry.insert(tool)?;
305        }
306        Ok(registry)
307    }
308
309    /// Inserts a tool, erroring if its OpenAI name is already registered.
310    pub fn insert(&mut self, tool: OpenAiTool) -> Result<()> {
311        if self.tools.contains_key(tool.openai_name()) {
312            return Err(Error::Eval(format!(
313                "duplicate OpenAI tool name {}",
314                tool.openai_name()
315            )));
316        }
317        self.tools.insert(tool.openai_name.clone(), tool);
318        Ok(())
319    }
320
321    /// Returns `true` when no tools are registered.
322    pub fn is_empty(&self) -> bool {
323        self.tools.is_empty()
324    }
325
326    /// Looks up a tool by its OpenAI name.
327    pub fn get(&self, name: &str) -> Option<&OpenAiTool> {
328        self.tools.get(name)
329    }
330
331    /// Renders the registry as a JSON array of OpenAI tool descriptors.
332    pub fn descriptor_json(&self) -> Value {
333        Value::Array(
334            self.tools
335                .values()
336                .map(OpenAiTool::descriptor_json)
337                .collect(),
338        )
339    }
340
341    /// Renders the registry as a SIM list of tool expressions.
342    pub fn to_expr(&self) -> Expr {
343        Expr::List(self.tools.values().map(OpenAiTool::to_expr).collect())
344    }
345}
346
347impl OpenAiToolCall {
348    /// Extracts a tool call from a transcript content part, returning `None`
349    /// when the part is not a `tool-call` map.
350    pub fn from_content_part(part: &Expr) -> Result<Option<Self>> {
351        let Expr::Map(entries) = part else {
352            return Ok(None);
353        };
354        if symbol_field(entries, "type").as_deref() != Some("tool-call") {
355            return Ok(None);
356        }
357        let name = required_string_field(entries, "name")?;
358        let id = string_field(entries, "id").unwrap_or_else(|| format!("call_{name}"));
359        let arguments = entries
360            .iter()
361            .find_map(|(key, value)| match key {
362                Expr::Symbol(symbol)
363                    if symbol.namespace.is_none() && symbol.name.as_ref() == "arguments" =>
364                {
365                    Some(arguments_json(value))
366                }
367                _ => None,
368            })
369            .transpose()?
370            .unwrap_or_else(|| Value::Object(Map::new()));
371        Ok(Some(Self {
372            id,
373            name,
374            arguments,
375        }))
376    }
377
378    /// Renders this tool call as a SIM expression map.
379    pub fn to_expr(&self) -> Expr {
380        Expr::Map(vec![
381            field("id", Expr::String(self.id.clone())),
382            field("name", Expr::String(self.name.clone())),
383            field("arguments", json_to_expr(&self.arguments)),
384        ])
385    }
386
387    /// Returns a stable `name:arguments` fingerprint for deduplicating calls.
388    pub fn fingerprint(&self) -> String {
389        format!("{}:{}", self.name, canonical_json(&self.arguments))
390    }
391}
392
393impl OpenAiToolResult {
394    /// Builds a successful (`ok`) result carrying `output` for `call`.
395    pub fn success(call: &OpenAiToolCall, output: Expr) -> Self {
396        Self {
397            call_id: call.id.clone(),
398            name: call.name.clone(),
399            status: Symbol::new("ok"),
400            output,
401        }
402    }
403
404    /// Builds an `invalid-arguments` failure result for `call`.
405    pub fn invalid_arguments(call: &OpenAiToolCall, message: impl Into<String>) -> Self {
406        Self::failure(call, "invalid-arguments", message)
407    }
408
409    /// Builds a `capability-denied` failure result for `call`.
410    pub fn capability_denied(call: &OpenAiToolCall, message: impl Into<String>) -> Self {
411        Self::failure(call, "capability-denied", message)
412    }
413
414    /// Builds an `unknown-tool` failure result for `call`.
415    pub fn unknown_tool(call: &OpenAiToolCall) -> Self {
416        Self::failure(call, "unknown-tool", format!("unknown tool {}", call.name))
417    }
418
419    fn failure(call: &OpenAiToolCall, status: &'static str, message: impl Into<String>) -> Self {
420        Self {
421            call_id: call.id.clone(),
422            name: call.name.clone(),
423            status: Symbol::new(status),
424            output: Expr::String(message.into()),
425        }
426    }
427
428    /// Renders this tool result as a SIM expression map.
429    pub fn to_expr(&self) -> Expr {
430        Expr::Map(vec![
431            field("tool-call-id", Expr::String(self.call_id.clone())),
432            field("name", Expr::String(self.name.clone())),
433            field("status", Expr::Symbol(self.status.clone())),
434            field("output", self.output.clone()),
435        ])
436    }
437
438    /// Returns a human-readable one-line summary of this result.
439    pub fn message_text(&self) -> String {
440        format!(
441            "tool {} {}: {}",
442            self.name,
443            self.status.name,
444            expr_text(&self.output)
445        )
446    }
447}
448
449/// Converts an OpenAI tool name into a SIM [`Symbol`], splitting on the first
450/// `_` into a namespace and local name and replacing remaining `_` with `-`.
451pub fn openai_name_to_symbol(name: &str) -> Symbol {
452    if let Some((namespace, local)) = name.split_once('_') {
453        Symbol::qualified(namespace.replace('_', "-"), local.replace('_', "-"))
454    } else {
455        Symbol::new(name.replace('_', "-"))
456    }
457}
458
459/// Parses a SIM symbol from text, treating a `namespace/name` form as a
460/// qualified symbol; errors on empty or malformed input.
461pub fn symbol_from_text(text: &str) -> Result<Symbol> {
462    if text.trim().is_empty() {
463        return Err(Error::Eval("SIM tool symbol must not be empty".to_owned()));
464    }
465    Ok(if let Some((namespace, name)) = text.split_once('/') {
466        if namespace.is_empty() || name.is_empty() {
467            return Err(Error::Eval(format!("invalid SIM symbol {text}")));
468        }
469        Symbol::qualified(namespace.to_owned(), name.to_owned())
470    } else {
471        Symbol::new(text.to_owned())
472    })
473}
474
475fn validate_openai_name(name: &str) -> Result<()> {
476    if name.is_empty() {
477        return Err(Error::Eval("OpenAI tool name must not be empty".to_owned()));
478    }
479    if name
480        .chars()
481        .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
482    {
483        Ok(())
484    } else {
485        Err(Error::Eval(format!(
486            "OpenAI tool name {name} contains unsupported characters"
487        )))
488    }
489}
490
491fn string_member<'a>(object: &'a Map<String, Value>, name: &str) -> Result<&'a str> {
492    object
493        .get(name)
494        .and_then(Value::as_str)
495        .ok_or_else(|| Error::Eval(format!("openai tool missing string {name}")))
496}
497
498fn ensure_server_registered_tool(cx: &mut Cx, tool: &OpenAiTool) -> Result<()> {
499    let function = cx.resolve_function(tool.symbol())?;
500    if function.object().as_callable().is_some() {
501        Ok(())
502    } else {
503        Err(Error::TypeMismatch {
504            expected: "callable",
505            found: "non-callable",
506        })
507    }
508}
509
510fn reject_untrusted_authority_fields(object: &Map<String, Value>) -> Result<()> {
511    for field in ["x-sim-symbol", "x-sim-capabilities"] {
512        if object.contains_key(field) {
513            return Err(Error::Eval(format!(
514                "untrusted OpenAI tool descriptor cannot set {field}"
515            )));
516        }
517    }
518    Ok(())
519}
520
521fn required_string_field(entries: &[(Expr, Expr)], name: &str) -> Result<String> {
522    string_field(entries, name)
523        .ok_or_else(|| Error::Eval(format!("tool-call missing string {name}")))
524}
525
526fn string_field(entries: &[(Expr, Expr)], name: &str) -> Option<String> {
527    sim_value::access::entry_field(entries, name)
528        .and_then(sim_value::access::as_str)
529        .map(str::to_owned)
530}
531
532fn symbol_field(entries: &[(Expr, Expr)], name: &str) -> Option<String> {
533    match sim_value::access::entry_field(entries, name) {
534        Some(Expr::Symbol(value)) => Some(value.name.as_ref().to_owned()),
535        _ => None,
536    }
537}
538
539use sim_value::build::entry as field;