use crate::tools::ToolCall;
use llm_json::repair_json;
use once_cell::sync::Lazy;
use regex::Regex;
static CODE_FENCE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?s)```(?:json)?\s*\n?(.*?)```").expect("CODE_FENCE regex"));
pub fn strip_markdown_code_fences(s: &str) -> String {
if let Some(caps) = CODE_FENCE.captures(s)
&& let Some(m) = caps.get(1)
{
return m.as_str().trim().to_string();
}
s.to_string()
}
pub fn looks_like_tool_json_attempt(s: &str) -> bool {
if !extract_tool_calls(s).is_empty() {
return false;
}
let trimmed = s.trim();
let has_name = trimmed.contains("\"name\"") || trimmed.contains("'name'");
if !has_name {
return false;
}
let has_args = ["\"parameters\"", "\"arguments\"", "'parameters'", "'arguments'"]
.iter()
.any(|k| trimmed.contains(k));
if has_args {
return true;
}
let body = strip_markdown_code_fences(trimmed);
let body = body.trim();
body.starts_with('{') && serde_json::from_str::<serde_json::Value>(body).is_err()
}
fn extract_tool_calls_inner(input: &str) -> Vec<ToolCall> {
let mut results = Vec::new();
let chars: Vec<char> = input.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i] == '{' {
let start = i;
let mut brace_count = 0;
let mut in_string = false;
let mut escaped = false;
let mut j = i;
while j < chars.len() {
let c = chars[j];
if escaped {
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == '"' {
in_string = !in_string;
} else if !in_string {
if c == '{' {
brace_count += 1;
} else if c == '}' {
brace_count -= 1;
if brace_count == 0 {
let raw_obj: String = chars[start..=j].iter().collect();
if let Ok(repaired) =
repair_json(&raw_obj, &llm_json::RepairOptions::default())
&& let Ok(tool_call) = serde_json::from_str::<ToolCall>(&repaired)
{
results.push(tool_call);
}
i = j; break;
}
}
}
j += 1;
}
if j == chars.len() && brace_count > 0 {
let raw_obj: String = chars[start..j].iter().collect();
if let Ok(repaired) = repair_json(&raw_obj, &llm_json::RepairOptions::default())
&& let Ok(tool_call) = serde_json::from_str::<ToolCall>(&repaired)
{
results.push(tool_call);
}
}
}
i += 1;
}
results
}
fn first_object_slice(input: &str) -> Option<&str> {
let bytes = input.as_bytes();
let start = input.find('{')?;
let mut depth = 0usize;
let mut in_string = false;
let mut escaped = false;
for (i, &b) in bytes.iter().enumerate().skip(start) {
if escaped {
escaped = false;
} else if b == b'\\' {
escaped = true;
} else if b == b'"' {
in_string = !in_string;
} else if !in_string {
if b == b'{' {
depth += 1;
} else if b == b'}' {
depth -= 1;
if depth == 0 {
return Some(&input[start..=i]);
}
}
}
}
Some(&input[start..])
}
pub fn extract_first_json_object(input: &str) -> Option<serde_json::Value> {
let stripped = strip_markdown_code_fences(input);
for candidate in [stripped.as_str(), input] {
if let Some(slice) = first_object_slice(candidate)
&& let Ok(repaired) = repair_json(slice, &llm_json::RepairOptions::default())
&& let Ok(value) = serde_json::from_str::<serde_json::Value>(&repaired)
&& value.is_object()
{
return Some(value);
}
}
None
}
pub fn extract_tool_calls(input: &str) -> Vec<ToolCall> {
let mut results = extract_tool_calls_inner(input);
if results.is_empty() {
let stripped = strip_markdown_code_fences(input);
if stripped != input {
results = extract_tool_calls_inner(&stripped);
}
}
results
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_tool_call() {
let input = "Here is a call: {\"name\": \"fs_tool\", \"parameters\": {\"task\": \"list_dir\", \"path\": \"/\"}}";
let calls = extract_tool_calls(input);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "fs_tool");
}
#[test]
fn test_extract_repaired_call() {
let input = "Broken: {\"name\": \"fs_tool\", \"parameters\": {\"task\": \"list_dir\"";
let calls = extract_tool_calls(input);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "fs_tool");
let input2 = "Messy: {name: \"fs_tool\", parameters: {task: \"list_dir\", path: \"/tmp\"}}";
let calls2 = extract_tool_calls(input2);
assert_eq!(calls2.len(), 1);
assert_eq!(calls2[0].name, "fs_tool");
}
#[test]
fn test_extract_from_markdown_fence() {
let input = r#"Thought: use tool
```json
{"name": "fs_tool", "parameters": {"task": "list_dir", "path": "/"}}
```"#;
let calls = extract_tool_calls(input);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "fs_tool");
}
#[test]
fn looks_like_attempt_when_fenced_but_unparseable() {
let s = r#"```json
{ "name": "oops"
```"#;
assert!(looks_like_tool_json_attempt(s));
}
#[test]
fn an_answer_with_a_code_block_is_not_a_tool_attempt() {
let s = "- A rookery is where penguins breed.\n\nTo list files:\n\n```bash\nls -la\n```";
assert!(!looks_like_tool_json_attempt(s));
let example = "A person record looks like this:\n\n```json\n{\"name\": \"Ada\", \"age\": 36}\n```";
assert!(!looks_like_tool_json_attempt(example), "a valid JSON example is an answer");
}
#[test]
fn looks_like_attempt_false_when_tool_call_parses() {
let s = r#"{"name": "fs_tool", "parameters": {}}"#;
assert!(!looks_like_tool_json_attempt(s));
}
#[test]
fn looks_like_attempt_true_when_tool_shape_but_invalid_json() {
let s = r#"{"name": 999, "parameters": {}}"#;
assert!(
extract_tool_calls(s).is_empty(),
"precondition: should not parse as ToolCall"
);
assert!(looks_like_tool_json_attempt(s));
}
#[test]
fn looks_like_attempt_false_on_plain_text() {
assert!(!looks_like_tool_json_attempt("Hello, no JSON here."));
}
#[test]
fn extract_first_json_object_table() {
let expected = serde_json::json!({"ops": [{"op": "remove_step", "step_id": "a"}]});
let cases: &[&str] = &[
r#"{"ops": [{"op": "remove_step", "step_id": "a"}]}"#,
"Here you go:\n```json\n{\"ops\": [{\"op\": \"remove_step\", \"step_id\": \"a\"}]}\n```",
"```\n{\"ops\": [{\"op\": \"remove_step\", \"step_id\": \"a\"}]}\n```",
"Sure! The batch is {\"ops\": [{\"op\": \"remove_step\", \"step_id\": \"a\"}]} — done.",
"Thinking...\n```json\n{\"ops\": [{\"op\": \"remove_step\", \"step_id\": \"a\"}]}\n```\nLet me know!",
];
for case in cases {
assert_eq!(
extract_first_json_object(case).as_ref(),
Some(&expected),
"case: {case}"
);
}
}
#[test]
fn extract_first_json_object_repairs_sloppy_json() {
let truncated = r#"{"ops": [{"op": "remove_step", "step_id": "a""#;
let v = extract_first_json_object(truncated).expect("repairable");
assert_eq!(v["ops"][0]["op"], "remove_step");
let unquoted = "{ops: [{op: \"remove_step\", step_id: \"a\"}]}";
let v = extract_first_json_object(unquoted).expect("repairable");
assert_eq!(v["ops"][0]["step_id"], "a");
}
#[test]
fn extract_first_json_object_none_on_plain_text() {
assert!(extract_first_json_object("No JSON to be found here.").is_none());
assert!(extract_first_json_object("").is_none());
assert!(extract_first_json_object("[1, 2, 3]").is_none());
}
}