Skip to main content

ferrin_core/generate_text/
parse_tool_call.rs

1//! Parsing, validation and repair of model tool calls.
2
3use std::collections::HashMap;
4use std::fmt;
5use std::sync::Arc;
6
7use ferrin_message::Message;
8use ferrin_spec::BoxFuture;
9use ferrin_spec::JsonValue;
10use ferrin_spec::ToolCall;
11use ferrin_spec::ToolChoice;
12use ferrin_spec::ToolName;
13use ferrin_tool::ToolSet;
14
15use super::ParsedToolCall;
16use crate::error::BoxError;
17use crate::error::Error;
18use crate::prompt::Instructions;
19
20/// Information handed to a [`ToolCallRepair`].
21#[derive(Debug)]
22pub struct RepairRequest<'a> {
23    /// The tool call as issued by the model.
24    pub tool_call: &'a ToolCall,
25    /// The tool set of the call.
26    pub tools: &'a ToolSet,
27    /// System instructions of the call.
28    pub system: Option<&'a Instructions>,
29    /// Messages sent to the model in this step.
30    pub messages: &'a [Message],
31    /// The error that triggered the repair ([`Error::NoSuchTool`] or
32    /// [`Error::InvalidToolInput`]).
33    pub error: &'a Error,
34}
35
36impl RepairRequest<'_> {
37    /// JSON schema of `tool_name`'s input, when the tool exists.
38    #[must_use]
39    pub fn input_schema(&self, tool_name: &str) -> Option<&JsonValue> {
40        self.tools
41            .get(tool_name)
42            .map(|tool| tool.input_schema().json_schema())
43    }
44}
45
46/// Repairs tool calls the model got wrong (unknown tool or invalid input).
47///
48/// Return `Ok(None)` to give up (the original error is kept), `Ok(Some(call))`
49/// to re-parse the repaired call, or `Err` to fail with
50/// [`Error::ToolCallRepair`].
51pub trait ToolCallRepair: Send + Sync {
52    /// Attempts a repair.
53    fn repair<'a>(
54        &'a self,
55        request: RepairRequest<'a>,
56    ) -> BoxFuture<'a, Result<Option<ToolCall>, BoxError>>;
57}
58
59/// Rewrites a validated tool input before execution.
60pub type RefineToolInputFn =
61    Arc<dyn Fn(JsonValue) -> BoxFuture<'static, Result<JsonValue, Error>> + Send + Sync>;
62
63/// Refinement functions by tool name.
64#[derive(Clone, Default)]
65pub struct RefineToolInputs(HashMap<ToolName, RefineToolInputFn>);
66
67impl RefineToolInputs {
68    /// Registers `refine` for `tool_name`.
69    pub fn insert(&mut self, tool_name: impl Into<ToolName>, refine: RefineToolInputFn) {
70        self.0.insert(tool_name.into(), refine);
71    }
72
73    /// Looks up the function for `tool_name`.
74    #[must_use]
75    pub fn get(&self, tool_name: &str) -> Option<&RefineToolInputFn> {
76        self.0.get(tool_name)
77    }
78
79    /// Returns `true` when no function is registered.
80    #[must_use]
81    pub fn is_empty(&self) -> bool {
82        self.0.is_empty()
83    }
84}
85
86impl fmt::Debug for RefineToolInputs {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        f.debug_set().entries(self.0.keys()).finish()
89    }
90}
91
92/// Inputs of [`parse_tool_call`].
93pub(crate) struct ParseContext<'a> {
94    pub(crate) tools: &'a ToolSet,
95    pub(crate) tool_choice: Option<&'a ToolChoice>,
96    pub(crate) repair: Option<&'a dyn ToolCallRepair>,
97    pub(crate) refine: &'a RefineToolInputs,
98    pub(crate) system: Option<&'a Instructions>,
99    pub(crate) messages: &'a [Message],
100}
101
102/// Parses a model tool call. Failures never propagate: the call is marked
103/// `invalid` (and `dynamic`) with the error message attached.
104pub(crate) async fn parse_tool_call(call: &ToolCall, ctx: &ParseContext<'_>) -> ParsedToolCall {
105    match try_parse(call, ctx).await {
106        Ok(parsed) => parsed,
107        Err(error) => invalid_call(call, &error, ctx.tools),
108    }
109}
110
111/// Parses a model tool call, returning the error on failure.
112pub(crate) async fn try_parse(
113    call: &ToolCall,
114    ctx: &ParseContext<'_>,
115) -> Result<ParsedToolCall, Error> {
116    let error = match do_parse(call, ctx).await {
117        Ok(parsed) => return Ok(parsed),
118        Err(error) => error,
119    };
120    let Some(repair) = ctx.repair else {
121        return Err(error);
122    };
123    if !matches!(error, Error::NoSuchTool { .. } | Error::InvalidToolInput(_)) {
124        return Err(error);
125    }
126    let repaired = repair
127        .repair(RepairRequest {
128            tool_call: call,
129            tools: ctx.tools,
130            system: ctx.system,
131            messages: ctx.messages,
132            error: &error,
133        })
134        .await;
135    match repaired {
136        Err(cause) => Err(Error::ToolCallRepair {
137            original: Box::new(error),
138            cause,
139        }),
140        Ok(None) => Err(error),
141        Ok(Some(repaired_call)) => do_parse(&repaired_call, ctx).await,
142    }
143}
144
145async fn do_parse(call: &ToolCall, ctx: &ParseContext<'_>) -> Result<ParsedToolCall, Error> {
146    let Some(tool) = ctx.tools.get(call.tool_name.as_str()) else {
147        if call.provider_executed && call.dynamic {
148            let mut input = parse_raw_input(call)?;
149            if let Some(refine) = ctx.refine.get(call.tool_name.as_str()) {
150                input = refine(input).await?;
151            }
152            return Ok(ParsedToolCall {
153                tool_call_id: call.tool_call_id.clone(),
154                tool_name: call.tool_name.clone(),
155                input,
156                provider_executed: true,
157                dynamic: true,
158                invalid: false,
159                error: None,
160                title: None,
161                tool_metadata: None,
162                provider_metadata: call.provider_metadata.clone(),
163            });
164        }
165        return Err(Error::no_such_tool(
166            call.tool_name.clone(),
167            ctx.tools.names().cloned().collect(),
168        ));
169    };
170    if let Some(ToolChoice::Tool { tool_name }) = ctx.tool_choice
171        && *tool_name != call.tool_name
172    {
173        return Err(Error::ToolChoiceViolation {
174            expected: tool_name.clone(),
175            actual: call.tool_name.clone(),
176        });
177    }
178    let raw = parse_raw_input(call)?;
179    let mut input = if call.provider_executed || tool.kind().is_provider_executed() {
180        raw
181    } else {
182        tool.validate_input(&call.tool_name, raw).map_err(|error| {
183            Error::invalid_tool_input(call.tool_name.clone(), call.input.clone(), Box::new(error))
184        })?
185    };
186    if let Some(refine) = ctx.refine.get(call.tool_name.as_str()) {
187        input = refine(input).await?;
188    }
189    Ok(ParsedToolCall {
190        tool_call_id: call.tool_call_id.clone(),
191        tool_name: call.tool_name.clone(),
192        input,
193        provider_executed: call.provider_executed,
194        dynamic: call.dynamic || tool.kind().is_dynamic(),
195        invalid: false,
196        error: None,
197        title: tool.title().map(str::to_owned),
198        tool_metadata: tool.metadata().cloned(),
199        provider_metadata: call.provider_metadata.clone(),
200    })
201}
202
203fn parse_raw_input(call: &ToolCall) -> Result<JsonValue, Error> {
204    if call.input.trim().is_empty() {
205        return Ok(JsonValue::Object(serde_json::Map::new()));
206    }
207    ferrin_schema::json::parse(&call.input).map_err(|error| {
208        Error::invalid_tool_input(call.tool_name.clone(), call.input.clone(), Box::new(error))
209    })
210}
211
212fn invalid_call(call: &ToolCall, error: &Error, tools: &ToolSet) -> ParsedToolCall {
213    let input = serde_json::from_str::<JsonValue>(&call.input)
214        .unwrap_or_else(|_| JsonValue::String(call.input.clone()));
215    ParsedToolCall {
216        tool_call_id: call.tool_call_id.clone(),
217        tool_name: call.tool_name.clone(),
218        input,
219        provider_executed: call.provider_executed,
220        dynamic: true,
221        invalid: true,
222        error: Some(error.to_string()),
223        title: None,
224        tool_metadata: tools
225            .get(call.tool_name.as_str())
226            .and_then(|tool| tool.metadata().cloned()),
227        provider_metadata: call.provider_metadata.clone(),
228    }
229}
230
231/// Checks the effective choice shared by both generation loops.
232pub(crate) fn check_tool_choice(
233    choice: Option<&ToolChoice>,
234    calls: &[ParsedToolCall],
235) -> Result<(), Error> {
236    match choice {
237        Some(ToolChoice::Required) if calls.is_empty() => {
238            Err(Error::ToolChoiceNotSatisfied { expected: None })
239        }
240        Some(ToolChoice::Tool { tool_name })
241            if !calls.iter().any(|call| call.tool_name == *tool_name) =>
242        {
243            Err(Error::ToolChoiceNotSatisfied {
244                expected: Some(tool_name.clone()),
245            })
246        }
247        _ => Ok(()),
248    }
249}