use crate::policy::parser::tool_call::ToolSchema;
use crate::policy::parser::{ReasoningFormat, ReasoningParser, ToolCallFormat, ToolCallParser};
use crate::{ToolDef, ToolFunctionDef};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ParsedToolCall {
pub(crate) name: String,
pub(crate) arguments: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct ParsedOutput {
pub(crate) reasoning: Option<String>,
pub(crate) content: String,
pub(crate) calls: Vec<ParsedToolCall>,
}
pub(crate) fn tool_schemas(tools: &[ToolDef]) -> Vec<ToolSchema> {
tools
.iter()
.map(|tool| {
let ToolFunctionDef {
name, parameters, ..
} = &tool.function;
match parameters {
Some(schema) => ToolSchema::with_parameters(name.clone(), schema.clone()),
None => ToolSchema::new(name.clone()),
}
})
.collect()
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct OutputPosture {
reasoning: Option<ReasoningFormat>,
reasoning_open: bool,
tools: ToolCallFormat,
}
impl OutputPosture {
pub(crate) fn resolve(model_name: &str, prompt: &str) -> Self {
let reasoning = ReasoningFormat::infer(model_name);
OutputPosture {
reasoning,
reasoning_open: reasoning.is_some_and(|f| f.prompt_opens_reasoning(prompt)),
tools: ToolCallFormat::infer(model_name),
}
}
#[cfg(test)]
pub(crate) fn for_model(model_name: &str) -> Self {
Self::resolve(model_name, "")
}
pub(crate) fn reasoning_parser(&self) -> Option<ReasoningParser> {
self.reasoning
.map(|format| ReasoningParser::new(format, self.reasoning_open, true))
}
pub(crate) fn tool_call_parser(&self, tools: &[ToolDef]) -> ToolCallParser {
ToolCallParser::new(self.tools, tool_schemas(tools))
}
}
pub(crate) fn parse_output(text: &str, tools: &[ToolDef], posture: OutputPosture) -> ParsedOutput {
let (reasoning, remainder) = split_reasoning(text, posture);
let (content, calls) = extract_tool_calls(&remainder, tools, posture);
ParsedOutput {
reasoning,
content,
calls,
}
}
fn split_reasoning(text: &str, posture: OutputPosture) -> (Option<String>, String) {
let Some(parser) = posture.reasoning_parser() else {
return (None, text.to_string());
};
let split = parser.parse_complete(text);
if split.reasoning.is_empty() {
return (None, split.content);
}
(Some(split.reasoning), split.content)
}
fn extract_tool_calls(
text: &str,
tools: &[ToolDef],
posture: OutputPosture,
) -> (String, Vec<ParsedToolCall>) {
if tools.is_empty() || !ToolCallParser::text_may_contain_call(text) {
return (text.to_string(), Vec::new());
}
let schemas = tool_schemas(tools);
let native = posture.tools;
let mut formats = vec![native];
if native != ToolCallFormat::Qwen25 {
formats.push(ToolCallFormat::Qwen25);
}
for format in formats {
let parser = ToolCallParser::new(format, schemas.clone());
let (content, calls) = parser.parse_complete(text);
if !calls.is_empty() {
return (
content.trim().to_string(),
calls
.into_iter()
.map(|call| ParsedToolCall {
name: call.name,
arguments: call.arguments,
})
.collect(),
);
}
}
(text.to_string(), Vec::new())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn tools() -> Vec<ToolDef> {
vec![ToolDef {
kind: "function".to_string(),
function: ToolFunctionDef {
name: "get_weather".to_string(),
description: None,
parameters: Some(json!({
"type": "object",
"properties": {"city": {"type": "string"}}
})),
},
}]
}
#[test]
fn the_prompt_engineered_marker_is_still_understood() {
let parsed = parse_output(
"<tool_call>{\"name\": \"get_weather\", \"arguments\": {\"city\": \"Rome\"}}</tool_call>",
&tools(),
OutputPosture::for_model("some-random-7b"),
);
assert_eq!(parsed.calls.len(), 1);
assert_eq!(parsed.calls[0].name, "get_weather");
assert_eq!(parsed.calls[0].arguments, r#"{"city":"Rome"}"#);
}
#[test]
fn a_models_own_format_is_understood_too() {
let parsed = parse_output(
"<tool_call><function=get_weather><parameter=city>\nRome\n</parameter>\
</function></tool_call>",
&tools(),
OutputPosture::for_model("Qwen3-Coder-30B"),
);
assert_eq!(parsed.calls.len(), 1);
assert_eq!(parsed.calls[0].name, "get_weather");
assert_eq!(parsed.calls[0].arguments, r#"{"city":"Rome"}"#);
}
#[test]
fn a_native_family_that_followed_the_preamble_still_works() {
let parsed = parse_output(
"<tool_call>{\"name\": \"get_weather\", \"arguments\": {\"city\": \"Oslo\"}}</tool_call>",
&tools(),
OutputPosture::for_model("Qwen3-Coder-30B"),
);
assert_eq!(parsed.calls.len(), 1);
assert_eq!(parsed.calls[0].arguments, r#"{"city":"Oslo"}"#);
}
#[test]
fn a_family_that_always_thinks_first_still_has_its_call_found() {
let parsed = parse_output(
"I need the weather.</think>\
<minimax:tool_call><invoke name=\"get_weather\">\
<parameter name=\"city\">Rome</parameter></invoke></minimax:tool_call>",
&tools(),
OutputPosture::for_model("MiniMax-M2"),
);
assert_eq!(parsed.reasoning.as_deref(), Some("I need the weather."));
assert_eq!(parsed.calls.len(), 1);
assert_eq!(parsed.calls[0].arguments, r#"{"city":"Rome"}"#);
}
#[test]
fn every_call_is_returned_not_just_the_first() {
let parsed = parse_output(
"<tool_call>{\"name\": \"get_weather\", \"arguments\": {\"city\": \"Rome\"}}</tool_call>\n\
<tool_call>{\"name\": \"get_weather\", \"arguments\": {\"city\": \"Oslo\"}}</tool_call>",
&tools(),
OutputPosture::for_model("qwen2.5-7b"),
);
assert_eq!(parsed.calls.len(), 2);
assert_eq!(parsed.calls[1].arguments, r#"{"city":"Oslo"}"#);
}
#[test]
fn a_plain_answer_with_tools_offered_stays_a_plain_answer() {
let parsed = parse_output(
"The weather is fine.",
&tools(),
OutputPosture::for_model("qwen2.5-7b"),
);
assert!(parsed.calls.is_empty());
assert_eq!(parsed.content, "The weather is fine.");
assert_eq!(parsed.reasoning, None);
}
#[test]
fn a_reasoning_block_leaves_the_answer() {
let parsed = parse_output(
"<think>The user wants weather. I should just say it.</think>It is sunny.",
&[],
OutputPosture::for_model("Qwen3-8B"),
);
assert_eq!(
parsed.reasoning.as_deref(),
Some("The user wants weather. I should just say it.")
);
assert_eq!(parsed.content, "It is sunny.");
}
#[test]
fn a_non_reasoning_model_keeps_its_markers_as_text() {
let parsed = parse_output(
"Use the tag <think> like this.",
&[],
OutputPosture::for_model("llama-3.1-8b-instruct"),
);
assert_eq!(parsed.reasoning, None);
assert_eq!(parsed.content, "Use the tag <think> like this.");
}
#[test]
fn reasoning_and_a_call_are_separated_from_each_other() {
let parsed = parse_output(
"<think>I need the weather.</think>\
<tool_call>{\"name\": \"get_weather\", \"arguments\": {\"city\": \"Rome\"}}</tool_call>",
&tools(),
OutputPosture::for_model("Qwen3-8B"),
);
assert_eq!(parsed.reasoning.as_deref(), Some("I need the weather."));
assert_eq!(parsed.calls.len(), 1);
assert!(parsed.content.is_empty(), "{:?}", parsed.content);
}
#[test]
fn a_tool_the_request_never_offered_is_not_returned() {
let parsed = parse_output(
"<tool_call>{\"name\": \"rm_rf\", \"arguments\": {}}</tool_call>",
&tools(),
OutputPosture::for_model("qwen2.5-7b"),
);
assert!(parsed.calls.is_empty());
}
#[test]
fn schemas_carry_the_declared_parameter_types() {
let schemas = tool_schemas(&tools());
assert_eq!(schemas.len(), 1);
assert_eq!(schemas[0].name, "get_weather");
assert!(schemas[0].parameters.is_some());
}
}