use super::syntax::preview_str;
use super::TextToolParseResult;
const FENCE: &str = "```";
const OPEN_INFO: &str = "tool";
#[derive(Debug)]
enum BlockError {
Unterminated { open_line: usize },
ExpectedSingleObject,
MissingName,
ArgsNotObject,
InvalidJson { 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."
),
}
}
}
struct FenceBlock {
body: String,
open_line: usize,
drifted_info: 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, prose, mut violations, mut errors) = chunk_fence_blocks(src);
let mut calls: Vec<serde_json::Value> = Vec::new();
for block in blocks {
if let Some(info) = block.drifted_info {
violations.push(format!(
"protocol_violation: a tool call was emitted in a ```{info} 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()),
}
}
TextToolParseResult {
calls,
errors,
prose,
user_response: None,
violations,
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]) {
closed = true;
idx += 1;
break;
}
body_lines.push(lines[idx]);
idx += 1;
}
let _ = closed; let drifted_info = match open {
FenceOpen::Tool => None,
FenceOpen::DriftJson => Some("json".to_string()),
};
blocks.push(FenceBlock {
body: body_lines.join("\n"),
open_line,
drifted_info,
});
}
None => {
prose_lines.push(line);
idx += 1;
}
}
}
let _ = &mut violations;
let prose = collapse_prose(&prose_lines);
(blocks, prose, violations, errors)
}
enum FenceOpen {
Tool,
DriftJson,
}
fn fence_open_kind(line: &str) -> Option<FenceOpen> {
let trimmed = line.trim();
let info = trimmed.strip_prefix(FENCE)?;
let info = info.trim();
match info {
OPEN_INFO => Some(FenceOpen::Tool),
"json" => Some(FenceOpen::DriftJson),
_ => None,
}
}
fn is_bare_fence(line: &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),
};
let name = match obj.get("name") {
Some(serde_json::Value::String(name)) if !name.trim().is_empty() => name.trim().to_string(),
_ => return Err(BlockError::MissingName),
};
let arguments = match obj.get("args") {
Some(serde_json::Value::Object(_)) => obj.get("args").cloned().unwrap_or_else(empty_object),
Some(serde_json::Value::Null) | None => empty_object(),
Some(_) => return Err(BlockError::ArgsNotObject),
};
Ok((name, arguments))
}
fn empty_object() -> serde_json::Value {
serde_json::Value::Object(serde_json::Map::new())
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(text: &str) -> TextToolParseResult {
parse_fenced_json_tool_calls(text)
}
fn arg<'a>(call: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
call.get("arguments")?.get(key)
}
#[test]
fn parses_a_single_clean_call() {
let out = parse("```tool\n{\"name\": \"read_file\", \"args\": {\"path\": \"a.rs\"}}\n```");
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(out.calls.len(), 1);
assert_eq!(out.calls[0]["name"], "read_file");
assert_eq!(arg(&out.calls[0], "path").unwrap(), "a.rs");
}
#[test]
fn content_with_backticks_heredoc_brace_and_tag_survives() {
let content = "```\nx := `raw`\n<<EOF\n}\n</tool>\n```";
let json_content = serde_json::to_string(content).unwrap();
let src = format!(
"```tool\n{{\"name\": \"write_file\", \"args\": {{\"path\": \"f.go\", \"content\": {json_content}}}}}\n```"
);
let out = parse(&src);
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(out.calls.len(), 1);
assert_eq!(arg(&out.calls[0], "content").unwrap(), content);
}
#[test]
fn multiple_fences_yield_multiple_calls() {
let src = "```tool\n{\"name\": \"a\", \"args\": {}}\n```\nsome prose\n```tool\n{\"name\": \"b\", \"args\": {\"k\": 1}}\n```";
let out = parse(src);
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(out.calls.len(), 2);
assert_eq!(out.calls[0]["name"], "a");
assert_eq!(out.calls[1]["name"], "b");
assert!(out.prose.contains("some prose"));
}
#[test]
fn content_starting_with_heredoc_opener_is_just_a_string() {
let content = "<<EOF\npackage main\n";
let json_content = serde_json::to_string(content).unwrap();
let src = format!(
"```tool\n{{\"name\": \"write_file\", \"args\": {{\"content\": {json_content}}}}}\n```"
);
let out = parse(&src);
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(arg(&out.calls[0], "content").unwrap(), content);
}
#[test]
fn array_body_is_expected_single_object() {
let out = parse("```tool\n[{\"name\": \"a\", \"args\": {}}]\n```");
assert!(out.calls.is_empty());
assert_eq!(out.errors.len(), 1);
assert!(
out.errors[0].contains("exactly one JSON object"),
"got: {}",
out.errors[0]
);
}
#[test]
fn trailing_bytes_after_object_rejected() {
let out = parse("```tool\n{\"name\": \"a\", \"args\": {}} trailing\n```");
assert!(out.calls.is_empty());
assert_eq!(out.errors.len(), 1);
assert!(out.errors[0].contains("exactly one JSON object"));
}
#[test]
fn missing_name_rejected() {
let out = parse("```tool\n{\"args\": {\"path\": \"a\"}}\n```");
assert!(out.calls.is_empty());
assert_eq!(out.errors.len(), 1);
assert!(out.errors[0].contains("missing a non-empty string `name`"));
}
#[test]
fn empty_name_rejected() {
let out = parse("```tool\n{\"name\": \" \", \"args\": {}}\n```");
assert!(out.calls.is_empty());
assert!(out.errors[0].contains("`name`"));
}
#[test]
fn args_not_object_rejected() {
let out = parse("```tool\n{\"name\": \"a\", \"args\": \"oops\"}\n```");
assert!(out.calls.is_empty());
assert_eq!(out.errors.len(), 1);
assert!(out.errors[0].contains("must be a JSON object"));
}
#[test]
fn absent_args_is_empty_object() {
let out = parse("```tool\n{\"name\": \"list_dir\"}\n```");
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(out.calls.len(), 1);
assert!(out.calls[0]["arguments"].is_object());
assert_eq!(out.calls[0]["arguments"].as_object().unwrap().len(), 0);
}
#[test]
fn truncated_string_is_unterminated_not_half_applied() {
let out = parse("```tool\n{\"name\": \"write_file\", \"args\": {\"content\": \"half a str");
assert!(out.calls.is_empty(), "must not dispatch a truncated call");
assert_eq!(out.errors.len(), 1);
assert!(
out.errors[0].contains("Unterminated"),
"got: {}",
out.errors[0]
);
}
#[test]
fn complete_object_without_close_fence_is_accepted() {
let out = parse("```tool\n{\"name\": \"a\", \"args\": {\"k\": 1}}");
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(out.calls.len(), 1);
assert_eq!(out.calls[0]["name"], "a");
}
#[test]
fn json_fence_accepts_with_protocol_violation() {
let out = parse("```json\n{\"name\": \"a\", \"args\": {}}\n```");
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(out.calls.len(), 1);
assert_eq!(out.calls[0]["name"], "a");
assert!(
out.violations
.iter()
.any(|v| v.contains("protocol_violation")),
"violations: {:?}",
out.violations
);
}
#[test]
fn unrelated_fence_stays_in_prose() {
let out = parse("```python\nprint('hi')\n```");
assert!(out.calls.is_empty());
assert!(out.errors.is_empty());
assert!(out.prose.contains("print('hi')"));
}
#[test]
fn embedded_backtick_fence_does_not_close_early() {
let content = "before\n```\nafter";
let json_content = serde_json::to_string(content).unwrap();
let src = format!("```tool\n{{\"name\": \"w\", \"args\": {{\"c\": {json_content}}}}}\n```");
let out = parse(&src);
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(out.calls.len(), 1);
assert_eq!(arg(&out.calls[0], "c").unwrap(), content);
}
#[test]
fn content_with_close_tool_tag_survives() {
let content = "x </tool> y";
let json_content = serde_json::to_string(content).unwrap();
let src = format!("```tool\n{{\"name\": \"w\", \"args\": {{\"c\": {json_content}}}}}\n```");
let out = parse(&src);
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(arg(&out.calls[0], "c").unwrap(), content);
}
}