use serde_json::Value;
use crate::Error;
use crate::client::ToolCall;
use super::codec::parse_tool_code_call;
pub(crate) enum ContentParse {
NotProtocol,
Calls(Vec<ToolCall>),
Malformed(Error),
}
enum Peel<'a> {
Calls(Vec<ToolCall>, &'a str),
Malformed(Error),
NotAFence,
}
pub(crate) fn parse_content_tool_dialect(content: &str) -> ContentParse {
let mut rest = content.trim();
let mut calls = Vec::new();
let mut next_id = 0usize;
let mut saw_fence = false;
loop {
rest = rest.trim_start();
if rest.is_empty() {
break;
}
match peel_tool_code_fence(rest, &mut next_id) {
Peel::Calls(parsed, remain) => {
saw_fence = true;
calls.extend(parsed);
rest = remain;
continue;
}
Peel::Malformed(error) => return ContentParse::Malformed(error),
Peel::NotAFence => {}
}
match peel_json_tool_calls_fence(rest) {
Peel::Calls(parsed, remain) => {
saw_fence = true;
calls.extend(parsed);
rest = remain;
continue;
}
Peel::Malformed(error) => return ContentParse::Malformed(error),
Peel::NotAFence => {}
}
if saw_fence {
return ContentParse::Malformed(Error::MalformedResponse(
"trailing content after tool_code fence".into(),
));
}
return ContentParse::NotProtocol;
}
if calls.is_empty() {
ContentParse::NotProtocol
} else {
ContentParse::Calls(calls)
}
}
fn peel_tool_code_fence<'a>(input: &'a str, next_id: &mut usize) -> Peel<'a> {
let Some(rest) = strip_fence_open(input, "tool_code") else {
return Peel::NotAFence;
};
let Some((body, after)) = split_fence_close_standalone(rest) else {
return Peel::Malformed(Error::MalformedResponse(
"unterminated tool_code fence".into(),
));
};
let mut calls = Vec::new();
for line in body.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Some(call) = parse_tool_code_call(line, *next_id) else {
return Peel::Malformed(Error::MalformedResponse(
"malformed tool_code call line".into(),
));
};
*next_id += 1;
calls.push(call);
}
if calls.is_empty() {
return Peel::Malformed(Error::MalformedResponse(
"tool_code fence contained no calls".into(),
));
}
Peel::Calls(calls, after)
}
fn peel_json_tool_calls_fence(input: &str) -> Peel<'_> {
let Some(rest) = strip_fence_open(input, "json").or_else(|| strip_fence_open(input, "")) else {
return Peel::NotAFence;
};
let Some((body, after)) = split_fence_close_standalone(rest) else {
return Peel::NotAFence;
};
let Ok(value) = serde_json::from_str::<Value>(body.trim()) else {
return Peel::NotAFence;
};
let Some(raw_calls) = value.get("tool_calls").and_then(Value::as_array) else {
return Peel::NotAFence;
};
if raw_calls.is_empty() {
return Peel::NotAFence;
}
match crate::normalize::parse_openai_tool_calls(raw_calls) {
Ok(calls) => Peel::Calls(calls, after),
Err(error) => Peel::Malformed(error),
}
}
fn strip_fence_open<'a>(input: &'a str, language: &str) -> Option<&'a str> {
let trimmed = input.trim_start();
let prefix = if language.is_empty() {
"```".to_string()
} else {
format!("```{language}")
};
let rest = trimmed.strip_prefix(&prefix)?;
let rest = rest.strip_prefix('\r').unwrap_or(rest);
let rest = rest.strip_prefix('\n')?;
Some(rest)
}
fn split_fence_close_standalone(input: &str) -> Option<(&str, &str)> {
let mut offset = 0usize;
let mut in_quote: Option<char> = None;
for line in input.split_inclusive('\n') {
let line_start = offset;
offset += line.len();
let content = line.strip_suffix('\n').unwrap_or(line);
let content = content.strip_suffix('\r').unwrap_or(content);
if in_quote.is_none() && content.trim() == "```" {
return Some((&input[..line_start], &input[offset..]));
}
let mut escaped = false;
for ch in content.chars() {
if let Some(q) = in_quote {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == q {
in_quote = None;
}
} else if ch == '"' || ch == '\'' {
in_quote = Some(ch);
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn standalone_close_ignores_backticks_inside_quotes() {
let input = "echo(value=\"a ``` b\")\n```\ntrailing";
let (body, after) = split_fence_close_standalone(input).expect("closes at standalone line");
assert_eq!(body, "echo(value=\"a ``` b\")\n");
assert_eq!(after, "trailing");
}
#[test]
fn unterminated_fence_has_no_standalone_close() {
assert!(split_fence_close_standalone("search(query=\"a\")").is_none());
}
}