use std::collections::BTreeSet;
use super::super::normalize_tool_call_shape;
use super::syntax::{preview_str, unknown_tool_feedback};
pub(crate) struct NativeJsonParse {
pub calls: Vec<serde_json::Value>,
pub errors: Vec<String>,
pub spans: Vec<(usize, usize)>,
}
pub(crate) fn parse_native_json_tool_calls(
text: &str,
known_tools: &BTreeSet<String>,
) -> NativeJsonParse {
let mut results = Vec::new();
let mut errors = Vec::new();
let mut spans = Vec::new();
let mut items = Vec::new();
for payload in find_native_json_payloads(text) {
spans.push(payload.span);
items.extend(payload.items);
}
for item in items {
let Some(item_obj) = item.as_object() else {
continue;
};
let func = item
.get("function")
.and_then(|function| function.as_object())
.unwrap_or(item_obj);
let name = func
.get("name")
.or_else(|| func.get("tool"))
.and_then(|name| name.as_str())
.unwrap_or("");
if name.is_empty() {
continue;
}
let arguments = match func.get("arguments").or_else(|| func.get("parameters")) {
Some(serde_json::Value::String(raw)) => match serde_json::from_str(raw) {
Ok(value) => value,
Err(error) => {
errors.push(format!(
"Could not parse arguments for tool '{}': {}. Raw: {}",
name,
error,
preview_str(raw, 200)
));
continue;
}
},
Some(obj @ serde_json::Value::Object(_)) => obj.clone(),
_ => serde_json::Value::Object(Default::default()),
};
let (name, arguments) = normalize_tool_call_shape(name, arguments);
if !known_tools.contains(&name) {
errors.push(unknown_tool_feedback(&name, known_tools));
continue;
}
let call_id = item
.get("id")
.and_then(|id| id.as_str())
.unwrap_or("native_fallback");
results.push(serde_json::json!({
"id": call_id,
"name": name,
"arguments": arguments,
}));
}
NativeJsonParse {
calls: results,
errors,
spans,
}
}
struct NativeJsonPayload {
items: Vec<serde_json::Value>,
span: (usize, usize),
}
fn is_native_tool_call_item(item: &serde_json::Value) -> bool {
let slot_present = |key: &str| {
item.get(key)
.is_some_and(|slot| slot.is_object() || slot.is_string())
};
let name_present = item.get("name").is_some_and(serde_json::Value::is_string)
|| item.get("tool").is_some_and(serde_json::Value::is_string);
item.get("function")
.is_some_and(serde_json::Value::is_object)
|| (name_present && (slot_present("arguments") || slot_present("parameters")))
}
#[expect(
clippy::string_slice,
reason = "offset sits on an ASCII `[` or `{` byte and consumed lands on a JSON value's \
structural end, both char boundaries"
)]
fn find_native_json_payloads(text: &str) -> Vec<NativeJsonPayload> {
let bytes = text.as_bytes();
let mut payloads = Vec::new();
let mut offset = 0usize;
while offset < bytes.len() {
if bytes[offset] != b'[' && bytes[offset] != b'{' {
offset += 1;
continue;
}
let mut stream =
serde_json::Deserializer::from_str(&text[offset..]).into_iter::<serde_json::Value>();
let Some(Ok(value)) = stream.next() else {
offset += 1;
continue;
};
let consumed = stream.byte_offset();
let items = match value {
serde_json::Value::Array(items) => items,
other => vec![other],
};
if consumed > 0 && items.iter().any(is_native_tool_call_item) {
payloads.push(NativeJsonPayload {
items,
span: (offset, offset + consumed),
});
offset += consumed;
} else {
offset += 1;
}
}
payloads
}
#[cfg(test)]
#[expect(
clippy::string_slice,
reason = "these tests slice by the spans the scan returned, which are the byte ranges of \
whole JSON values and so are char boundaries by construction; a span that was \
not would fail the assertion rather than pass silently"
)]
mod tests {
use std::collections::BTreeSet;
use super::parse_native_json_tool_calls;
fn known() -> BTreeSet<String> {
["look", "run"]
.iter()
.map(|name| (*name).to_string())
.collect()
}
fn names(calls: &[serde_json::Value]) -> Vec<String> {
calls
.iter()
.map(|call| call["name"].as_str().unwrap_or_default().to_string())
.collect()
}
fn paths(calls: &[serde_json::Value]) -> Vec<String> {
calls
.iter()
.map(|call| {
call["arguments"]["path"]
.as_str()
.unwrap_or_default()
.to_string()
})
.collect()
}
#[test]
fn a_run_of_bare_call_objects_recovers_every_one() {
let text = "{\"name\": \"look\", \"arguments\": {\"path\": \"b\"}}\n\
{\"name\": \"look\", \"arguments\": {\"path\": \"z\"}}\n";
let parsed = parse_native_json_tool_calls(text, &known());
assert_eq!(paths(&parsed.calls), vec!["b", "z"], "{:?}", parsed.calls);
assert_eq!(parsed.spans.len(), 2);
}
#[test]
fn narration_between_two_payloads_survives_as_prose() {
let text = "First this.\n{\"name\": \"look\", \"arguments\": {\"path\": \"b\"}}\n\
Then this.\n{\"name\": \"run\", \"arguments\": {\"command\": \"ls\"}}\nDone.";
let parsed = parse_native_json_tool_calls(text, &known());
assert_eq!(names(&parsed.calls), vec!["look", "run"]);
for (start, end) in &parsed.spans {
assert!(text[*start..*end].starts_with('{'), "span is the payload");
}
let outside: String = {
let mut buf = String::new();
let mut cursor = 0usize;
for (start, end) in &parsed.spans {
buf.push_str(&text[cursor..*start]);
cursor = *end;
}
buf.push_str(&text[cursor..]);
buf
};
assert!(outside.contains("First this."), "{outside}");
assert!(outside.contains("Then this."), "{outside}");
assert!(outside.contains("Done."), "{outside}");
assert!(!outside.contains("\"arguments\""), "{outside}");
}
#[test]
fn a_later_unknown_name_is_diagnosed_rather_than_dropped() {
let text = "{\"name\": \"look\", \"arguments\": {\"path\": \"b\"}}\n\
{\"name\": \"nope\", \"arguments\": {}}\n";
let parsed = parse_native_json_tool_calls(text, &known());
assert_eq!(names(&parsed.calls), vec!["look"]);
assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors);
assert!(parsed.errors[0].contains("nope"), "{:?}", parsed.errors);
}
#[test]
fn incidental_prose_json_after_a_call_stays_prose() {
let text = "{\"name\": \"look\", \"arguments\": {\"path\": \"b\"}}\n\
Config: {\"name\": \"my-pkg\", \"version\": \"1.0.0\"}";
let parsed = parse_native_json_tool_calls(text, &known());
assert_eq!(names(&parsed.calls), vec!["look"]);
assert_eq!(
parsed.spans.len(),
1,
"the package.json object is not a payload"
);
}
#[test]
fn narration_alone_yields_nothing() {
let parsed = parse_native_json_tool_calls(
"I will read the file. Config: {\"name\": \"my-pkg\"}",
&known(),
);
assert!(parsed.calls.is_empty());
assert!(parsed.errors.is_empty());
assert!(parsed.spans.is_empty());
}
#[test]
fn a_multi_byte_tail_after_a_payload_does_not_panic() {
let text = "{\"name\": \"look\", \"arguments\": {\"path\": \"café.go\"}} done ✅ — 完了";
let parsed = parse_native_json_tool_calls(text, &known());
assert_eq!(paths(&parsed.calls), vec!["café.go"]);
let (_, end) = parsed.spans[0];
assert!(text[end..].contains('✅'));
}
}