use super::syntax::preview_str;
use super::TextToolParseResult;
const BACKTICK_FENCE: &str = "```";
const TILDE_FENCE: &str = "~~~";
const OPEN_INFO: &str = "tool";
#[derive(Debug)]
enum BlockError {
Unterminated { open_line: usize },
ExpectedSingleObject,
MissingName,
ArgsNotObject,
InvalidJson { detail: String },
ChatTemplateEnvelope { detail: String },
}
impl BlockError {
fn into_message(self) -> String {
match self {
BlockError::Unterminated { open_line } => format!(
"Unterminated ```tool fence opened on line {} (1-based): the block reached \
end-of-output with an incomplete JSON object. Re-emit the whole call inside a \
```tool ... ``` block and close it with a line that is exactly ```.",
open_line + 1
),
BlockError::ExpectedSingleObject => "A ```tool block must contain exactly one JSON \
object `{ \"name\": ..., \"args\": { ... } }`. Arrays, scalars, and trailing \
bytes are rejected; emit one ```tool block per tool call."
.to_string(),
BlockError::MissingName => "The ```tool JSON object is missing a non-empty string \
`name`. Shape: `{ \"name\": \"edit\", \"args\": { ... } }`."
.to_string(),
BlockError::ArgsNotObject => "The `args` field of a ```tool object must be a JSON \
object (`{ ... }`), or omitted when the tool takes no arguments."
.to_string(),
BlockError::InvalidJson { detail } => format!(
"The ```tool block is not valid JSON: {detail}. Pass multi-line or code-bearing \
fields as ordinary JSON string values (escape newlines as \\n, quotes as \\\", \
backslashes as \\\\); backticks need no escaping."
),
BlockError::ChatTemplateEnvelope { detail } => format!(
"The `<tool_calls>` chat-template envelope is malformed: {detail}. Re-emit \
complete calls as canonical ```tool JSON blocks; incomplete entries were not \
executed."
),
}
}
}
struct FenceBlock {
body: String,
open_line: usize,
drifted_opener: Option<String>,
}
pub(crate) fn parse_fenced_json_tool_calls(text: &str) -> TextToolParseResult {
let cleaned = super::syntax::strip_thinking_tags(text);
let src = cleaned.as_ref();
let (blocks, mut prose, mut violations, mut errors) = chunk_fence_blocks(src);
let mut calls: Vec<serde_json::Value> = Vec::new();
let saw_fenced_blocks = !blocks.is_empty();
for block in blocks {
if let Some(opener) = block.drifted_opener {
violations.push(format!(
"protocol_violation: a tool call was emitted in a {opener} fence; the contract \
requires a bare ```tool fence. Accepted this turn, but switch to ```tool."
));
}
match parse_block_body(&block.body, block.open_line) {
Ok((name, arguments)) => {
calls.push(serde_json::json!({
"id": format!("tc_{}", calls.len()),
"name": name,
"arguments": arguments,
}));
}
Err(err) => errors.push(err.into_message()),
}
}
if !saw_fenced_blocks {
let trimmed = src.trim();
if let Some(located) = locate_and_parse_tool_envelope(src) {
match located.result {
Ok(template_calls) => {
violations.push(
"protocol_violation: a tool call was emitted in a chat-template tool \
envelope while `tool_format` is `json`; accepted this turn, but emit \
canonical ```tool JSON blocks next turn."
.to_string(),
);
for (name, arguments) in template_calls {
calls.push(serde_json::json!({
"id": format!("tc_{}", calls.len()),
"name": name,
"arguments": arguments,
}));
}
prose = located.prose;
}
Err(error) => errors.push(error.into_message()),
}
} else if let Ok((name, arguments)) = parse_bare_json_tool_call(trimmed) {
violations.push(
"protocol_violation: a tool call was emitted as a bare JSON object; the contract \
requires wrapping each `{ \"name\": ..., \"args\": { ... } }` object in a \
```tool fence. Accepted this turn, but switch to ```tool."
.to_string(),
);
calls.push(serde_json::json!({
"id": "tc_0",
"name": name,
"arguments": arguments,
}));
prose.clear();
} else if contains_legacy_tool_call_markup(src) {
violations.push(
"protocol_violation: `<tool_call>...</tool_call>` markup was emitted while \
`tool_format` is `json`; wrap each call as a strict JSON object inside a \
```tool fence instead."
.to_string(),
);
}
}
TextToolParseResult {
calls,
errors,
prose,
user_response: None,
violations,
recovered_from_stray_count: 0,
done_marker: None,
canonical: src.to_string(),
}
}
fn chunk_fence_blocks(src: &str) -> (Vec<FenceBlock>, String, Vec<String>, Vec<String>) {
let mut blocks: Vec<FenceBlock> = Vec::new();
let mut prose_lines: Vec<&str> = Vec::new();
let mut violations: Vec<String> = Vec::new();
let errors: Vec<String> = Vec::new();
let lines: Vec<&str> = src.lines().collect();
let mut idx = 0usize;
while idx < lines.len() {
let line = lines[idx];
match fence_open_kind(line) {
Some(open) => {
let open_line = idx;
let mut body_lines: Vec<&str> = Vec::new();
let mut closed = false;
idx += 1;
while idx < lines.len() {
if is_bare_fence(lines[idx], open.close_fence) {
closed = true;
idx += 1;
break;
}
body_lines.push(lines[idx]);
idx += 1;
}
let _ = closed; blocks.push(FenceBlock {
body: body_lines.join("\n"),
open_line,
drifted_opener: open.drifted_opener,
});
}
None => {
prose_lines.push(line);
idx += 1;
}
}
}
let _ = &mut violations;
let prose = collapse_prose(&prose_lines);
(blocks, prose, violations, errors)
}
struct FenceOpen {
close_fence: &'static str,
drifted_opener: Option<String>,
}
fn fence_open_kind(line: &str) -> Option<FenceOpen> {
let trimmed = line.trim();
let (fence, info) = if let Some(info) = trimmed.strip_prefix(BACKTICK_FENCE) {
(BACKTICK_FENCE, info)
} else if let Some(info) = trimmed.strip_prefix(TILDE_FENCE) {
(TILDE_FENCE, info)
} else {
return None;
};
let info = info.trim();
if fence == BACKTICK_FENCE && info == OPEN_INFO {
return Some(FenceOpen {
close_fence: BACKTICK_FENCE,
drifted_opener: None,
});
}
if is_recoverable_tool_fence_drift(fence, info) {
return Some(FenceOpen {
close_fence: fence,
drifted_opener: Some(format!("{fence}{info}")),
});
}
None
}
fn is_recoverable_tool_fence_drift(fence: &str, info: &str) -> bool {
if info == "json" || info == "tool_code" || info == "tool_call" || info == "function_call" {
return true;
}
if fence == TILDE_FENCE && info == OPEN_INFO {
return true;
}
info.strip_prefix(OPEN_INFO)
.and_then(|rest| rest.chars().next())
.is_some_and(|ch| ch.is_ascii_whitespace() || ch == '_' || ch == '-')
}
fn is_bare_fence(line: &str, fence: &str) -> bool {
line.trim() == fence
}
fn collapse_prose(lines: &[&str]) -> String {
lines.join("\n").trim().to_string()
}
fn parse_block_body(
body: &str,
open_line: usize,
) -> Result<(String, serde_json::Value), BlockError> {
let trimmed = body.trim();
if trimmed.is_empty() {
return Err(BlockError::Unterminated { open_line });
}
let mut stream = serde_json::Deserializer::from_str(trimmed).into_iter::<serde_json::Value>();
let first = match stream.next() {
Some(Ok(value)) => value,
Some(Err(err)) => {
if err.is_eof() {
return Err(BlockError::Unterminated { open_line });
}
return Err(BlockError::InvalidJson {
detail: format!("{} (near `{}`)", err, preview_str(trimmed, 80)),
});
}
None => return Err(BlockError::Unterminated { open_line }),
};
let consumed = stream.byte_offset();
if !trimmed[consumed..].trim().is_empty() {
return Err(BlockError::ExpectedSingleObject);
}
let obj = match first {
serde_json::Value::Object(map) => map,
_ => return Err(BlockError::ExpectedSingleObject),
};
normalize_json_tool_call_object(obj, false)
}
fn normalize_json_tool_call_object(
obj: serde_json::Map<String, serde_json::Value>,
allow_inline_arguments: bool,
) -> Result<(String, serde_json::Value), BlockError> {
let name = match obj.get("name").or_else(|| obj.get("tool")) {
Some(serde_json::Value::String(name)) if !name.trim().is_empty() => name.trim().to_string(),
_ => return Err(BlockError::MissingName),
};
let args_value = obj.get("args").or_else(|| obj.get("arguments"));
let arguments = match args_value {
Some(value @ serde_json::Value::Object(_)) => value.clone(),
Some(serde_json::Value::Null) => empty_object(),
None if allow_inline_arguments => serde_json::Value::Object(
obj.into_iter()
.filter(|(key, _)| key != "name" && key != "tool")
.collect(),
),
None => empty_object(),
Some(_) => return Err(BlockError::ArgsNotObject),
};
Ok(super::super::normalize_tool_call_shape(&name, arguments))
}
fn empty_object() -> serde_json::Value {
serde_json::Value::Object(serde_json::Map::new())
}
fn parse_bare_json_tool_call(body: &str) -> Result<(String, serde_json::Value), BlockError> {
if body.is_empty()
|| !body.starts_with('{')
|| !(body.contains("\"name\"") || body.contains("\"tool\""))
|| !(body.contains("\"args\"") || body.contains("\"arguments\""))
{
return Err(BlockError::ExpectedSingleObject);
}
parse_block_body(body, 0)
}
#[allow(clippy::enum_variant_names)]
#[derive(Clone, Copy)]
enum EnvelopeKind {
ToolCalls,
ToolCode,
ToolCall,
}
impl EnvelopeKind {
fn opener(self) -> &'static str {
match self {
EnvelopeKind::ToolCalls => "<tool_calls>",
EnvelopeKind::ToolCode => "<tool_code>",
EnvelopeKind::ToolCall => "<tool_call>",
}
}
fn close(self) -> &'static str {
match self {
EnvelopeKind::ToolCalls => "</tool_calls>",
EnvelopeKind::ToolCode => "</tool_code>",
EnvelopeKind::ToolCall => "</tool_call>",
}
}
}
struct LocatedEnvelope {
prose: String,
result: Result<Vec<(String, serde_json::Value)>, BlockError>,
}
type EnvelopeBody<'a> = Result<(Vec<(String, serde_json::Value)>, &'a str), BlockError>;
fn env_err(detail: String) -> BlockError {
BlockError::ChatTemplateEnvelope { detail }
}
fn locate_and_parse_tool_envelope(src: &str) -> Option<LocatedEnvelope> {
let (opener_start, kind, content_start) = find_top_level_opener(src)?;
let body = &src[content_start..];
if matches!(kind, EnvelopeKind::ToolCall) && !body.trim_start().starts_with('{') {
return None;
}
let close = kind.close();
let parsed = match kind {
EnvelopeKind::ToolCalls => parse_tool_calls_body(body, close),
EnvelopeKind::ToolCode | EnvelopeKind::ToolCall => parse_json_object_list(body, close),
};
let before = &src[..opener_start];
Some(match parsed {
Ok((calls, after)) => LocatedEnvelope {
prose: collapse_prose_around(before, after),
result: Ok(calls),
},
Err(error) => LocatedEnvelope {
prose: collapse_prose_around(before, ""),
result: Err(error),
},
})
}
fn find_top_level_opener(src: &str) -> Option<(usize, EnvelopeKind, usize)> {
let mut offset = 0usize;
let mut in_fence = false;
let mut fence_marker = "";
for line in src.split_inclusive('\n') {
let trimmed = line.trim();
if in_fence {
if trimmed == fence_marker {
in_fence = false;
}
offset += line.len();
continue;
}
if let Some(marker) = markdown_fence_marker(trimmed) {
in_fence = true;
fence_marker = marker;
offset += line.len();
continue;
}
if let Some((rel, kind)) = first_opener_in(line) {
let opener_start = offset + rel;
return Some((opener_start, kind, opener_start + kind.opener().len()));
}
offset += line.len();
}
None
}
fn markdown_fence_marker(trimmed: &str) -> Option<&'static str> {
if trimmed.starts_with(BACKTICK_FENCE) {
Some(BACKTICK_FENCE)
} else if trimmed.starts_with(TILDE_FENCE) {
Some(TILDE_FENCE)
} else {
None
}
}
fn first_opener_in(line: &str) -> Option<(usize, EnvelopeKind)> {
[
(line.find("<tool_calls>"), EnvelopeKind::ToolCalls),
(line.find("<tool_code>"), EnvelopeKind::ToolCode),
(line.find("<tool_call>"), EnvelopeKind::ToolCall),
]
.into_iter()
.filter_map(|(pos, kind)| pos.map(|p| (p, kind)))
.min_by_key(|(p, _)| *p)
}
fn collapse_prose_around(before: &str, after: &str) -> String {
let mut parts: Vec<&str> = Vec::new();
let before = before.trim();
if !before.is_empty() {
parts.push(before);
}
let after = after.trim();
if !after.is_empty() {
parts.push(after);
}
parts.join("\n")
}
fn parse_tool_calls_body<'a>(body: &'a str, close: &str) -> EnvelopeBody<'a> {
let trimmed = body.trim_start();
if trimmed.starts_with('{') {
return parse_tool_marker_json_body(body, close);
}
match first_tag_name(trimmed) {
Some(name) if is_known_marker(&name) => parse_tool_marker_json_body(body, close),
Some(_) => parse_xml_tool_calls(body, close),
None => parse_tool_marker_json_body(body, close),
}
}
fn first_tag_name(s: &str) -> Option<String> {
let lt = s.find('<')?;
let after = &s[lt + 1..];
let after = after.strip_prefix('/').unwrap_or(after);
let name: String = after
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
.collect();
if name.is_empty() {
None
} else {
Some(name.to_ascii_lowercase())
}
}
fn is_known_marker(name: &str) -> bool {
matches!(name, "tool" | "tool_call" | "tool_calls" | "tool_code")
}
fn parse_tool_marker_json_body<'a>(body: &'a str, close: &str) -> EnvelopeBody<'a> {
const TOOL_OPEN: &str = "<tool>";
const TOOL_CLOSE: &str = "</tool>";
let mut body = body.trim_start();
let mut saw_tool_marker = false;
let mut tool_marker_active = false;
let mut pending_tool_object = false;
let mut calls = Vec::new();
let after = loop {
if let Some(after) = body.strip_prefix(close) {
break after;
}
if body.is_empty() {
break "";
}
if let Some(rest) = body.strip_prefix(TOOL_OPEN) {
if pending_tool_object {
return Err(env_err(
"a `<tool>` marker must be followed by a JSON object before another `<tool>` marker"
.to_string(),
));
}
saw_tool_marker = true;
tool_marker_active = true;
pending_tool_object = true;
body = rest.trim_start();
continue;
}
if let Some(rest) = body.strip_prefix(TOOL_CLOSE) {
if !tool_marker_active {
return Err(env_err(
"found an unmatched `</tool>` close without a preceding `<tool>` marker"
.to_string(),
));
}
if pending_tool_object {
return Err(env_err(
"a `<tool>` marker must be followed by a JSON object before `</tool>`"
.to_string(),
));
}
tool_marker_active = false;
body = rest.trim_start();
continue;
}
if !tool_marker_active {
let detail = if saw_tool_marker {
"expected a `<tool>` marker before this JSON object"
} else {
"the envelope contained no `<tool>` marker"
};
return Err(env_err(detail.to_string()));
}
if !body.starts_with('{') {
return Err(env_err(format!(
"expected a `{TOOL_OPEN}` marker or a JSON object, found `{}`",
preview_str(body, 80)
)));
}
let mut values = serde_json::Deserializer::from_str(body).into_iter::<serde_json::Value>();
let value = match values.next() {
Some(Ok(value)) => value,
Some(Err(error)) if error.is_eof() => {
return Err(env_err(
"a JSON tool object ended before its closing `}`".to_string(),
));
}
Some(Err(error)) => {
return Err(env_err(format!("invalid JSON tool object: {error}")));
}
None => {
return Err(env_err(
"expected a JSON tool object after `<tool>`".to_string(),
));
}
};
let consumed = values.byte_offset();
let obj = match value {
serde_json::Value::Object(obj) => obj,
_ => {
return Err(env_err(
"each chat-template tool entry must be a JSON object".to_string(),
));
}
};
match normalize_json_tool_call_object(obj, true) {
Ok(call) => calls.push(call),
Err(error) => return Err(env_err(error.into_message())),
}
pending_tool_object = false;
body = body[consumed..].trim_start();
};
if pending_tool_object {
return Err(env_err(
"a `<tool>` marker ended without a complete JSON object".to_string(),
));
}
if !saw_tool_marker {
return Err(env_err(
"the envelope contained no `<tool>` marker".to_string(),
));
}
if calls.is_empty() {
return Err(env_err(
"the envelope contained no JSON tool object".to_string(),
));
}
Ok((calls, after))
}
fn parse_json_object_list<'a>(body: &'a str, close: &str) -> EnvelopeBody<'a> {
let mut body = body.trim_start();
let mut calls = Vec::new();
let after = loop {
if let Some(after) = body.strip_prefix(close) {
break after;
}
if body.is_empty() {
break "";
}
if !body.starts_with('{') {
return Err(env_err(format!(
"expected a JSON tool object, found `{}`",
preview_str(body, 80)
)));
}
let mut values = serde_json::Deserializer::from_str(body).into_iter::<serde_json::Value>();
let value = match values.next() {
Some(Ok(value)) => value,
Some(Err(error)) if error.is_eof() => {
return Err(env_err(
"a JSON tool object ended before its closing `}`".to_string(),
));
}
Some(Err(error)) => {
return Err(env_err(format!("invalid JSON tool object: {error}")));
}
None => break "",
};
let consumed = values.byte_offset();
let obj = match value {
serde_json::Value::Object(obj) => obj,
_ => {
return Err(env_err(
"each chat-template tool entry must be a JSON object".to_string(),
));
}
};
match normalize_json_tool_call_object(obj, true) {
Ok(call) => calls.push(call),
Err(error) => return Err(env_err(error.into_message())),
}
body = body[consumed..].trim_start();
};
if calls.is_empty() {
return Err(env_err(
"the envelope contained no JSON tool object".to_string(),
));
}
Ok((calls, after))
}
fn parse_xml_tool_calls<'a>(body: &'a str, close: &str) -> EnvelopeBody<'a> {
let mut rest = body.trim_start();
let mut calls = Vec::new();
let after = loop {
if let Some(after) = rest.strip_prefix(close) {
break after;
}
if rest.is_empty() {
break "";
}
if !rest.starts_with('<') {
return Err(env_err(format!(
"expected a tool-call tag inside `<tool_calls>`, found `{}`",
preview_str(rest, 60)
)));
}
let (tag_name, after_open) = parse_open_tag(rest)?;
if is_known_marker(&tag_name.to_ascii_lowercase()) {
return Err(env_err(format!(
"`<{tag_name}>` is a structural marker, not a tool-call tag"
)));
}
let call_close = format!("</{tag_name}>");
let (args, after_close) = parse_xml_call_args(after_open, &call_close, &tag_name)?;
calls.push(super::super::normalize_tool_call_shape(
&tag_name,
serde_json::Value::Object(args),
));
rest = after_close.trim_start();
};
if calls.is_empty() {
return Err(env_err(
"the `<tool_calls>` envelope contained no tool-call tags".to_string(),
));
}
Ok((calls, after))
}
fn parse_open_tag(s: &str) -> Result<(String, &str), BlockError> {
let after_lt = &s[1..];
if after_lt.starts_with('/') {
return Err(env_err(format!(
"unexpected close tag `{}`",
preview_str(s, 40)
)));
}
let gt = after_lt.find('>').ok_or_else(|| {
env_err("an XML tool tag was not closed with `>` before end of output".to_string())
})?;
let name: String = after_lt[..gt]
.trim()
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
.collect();
if name.is_empty() {
return Err(env_err("an XML tool tag had an empty name".to_string()));
}
Ok((name, &after_lt[gt + 1..]))
}
fn parse_xml_call_args<'a>(
mut s: &'a str,
close: &str,
tag_name: &str,
) -> Result<(serde_json::Map<String, serde_json::Value>, &'a str), BlockError> {
let mut args = serde_json::Map::new();
loop {
let t = s.trim_start();
if let Some(after) = t.strip_prefix(close) {
return Ok((args, after));
}
if t.is_empty() {
return Err(env_err(format!(
"the `<{tag_name}>` call tag was not closed with `{close}` before end of output"
)));
}
if !t.starts_with('<') {
return Err(env_err(format!(
"expected an argument tag or `{close}` inside `<{tag_name}>`, found `{}`",
preview_str(t, 60)
)));
}
let (arg_name, after_open) = parse_open_tag(t)?;
if args.contains_key(&arg_name) {
return Err(env_err(format!(
"the `<{tag_name}>` call repeated the `<{arg_name}>` argument; a call with an ambiguous duplicate argument is not executed"
)));
}
let arg_close = format!("</{arg_name}>");
let end = after_open.find(&arg_close).ok_or_else(|| {
env_err(format!(
"the `<{arg_name}>` argument tag was not closed with `{arg_close}` before end of output"
))
})?;
let value = after_open[..end].trim().to_string();
args.insert(arg_name, serde_json::Value::String(value));
s = &after_open[end + arg_close.len()..];
}
}
fn contains_legacy_tool_call_markup(src: &str) -> bool {
src.contains("<tool_call>")
|| src.contains("<tool_call ")
|| src.contains("<toolcall>")
|| src.contains("<toolcall ")
}
#[cfg(test)]
mod tests;