mod bare;
mod fenced_json;
mod native_json;
mod streaming;
mod syntax;
mod tagged;
#[cfg(test)]
pub(crate) use bare::parse_bare_calls_in_body;
pub(crate) use fenced_json::parse_fenced_json_tool_calls;
#[cfg(test)]
pub(crate) use native_json::parse_native_json_tool_calls;
pub(crate) use streaming::StreamingToolCallDetector;
pub(crate) use syntax::ident_length;
pub(crate) use syntax::unescape_heredoc_body;
pub(crate) use syntax::unwrap_fully_wrapping_heredoc;
pub(crate) use syntax::{scan_heredoc, HeredocError};
pub(crate) use tagged::parse_text_tool_calls_with_tools;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TextToolFormat {
Tagged,
FencedJson,
}
impl TextToolFormat {
pub(crate) fn from_option(tool_format: &str) -> Self {
match tool_format {
"json" => TextToolFormat::FencedJson,
_ => TextToolFormat::Tagged,
}
}
}
pub(crate) fn parse_text_tool_calls_in_format(
text: &str,
tools_val: Option<&crate::value::VmValue>,
format: TextToolFormat,
) -> TextToolParseResult {
match format {
TextToolFormat::Tagged => parse_text_tool_calls_with_tools(text, tools_val),
TextToolFormat::FencedJson => parse_fenced_json_tool_calls(text),
}
}
pub(crate) fn parse_text_tool_argument_payload(
text: &str,
name: &str,
) -> Result<serde_json::Value, String> {
let trimmed = text.trim();
if trimmed.is_empty() {
return Ok(serde_json::json!({}));
}
match syntax::parse_object_literal_from(trimmed, name) {
Ok((arguments, consumed)) if trimmed[consumed..].trim().is_empty() => Ok(arguments),
Ok((_arguments, consumed)) => Err(format!(
"trailing bytes after object literal argument at byte {consumed}"
)),
Err(object_error) => {
if let Some(name_len) = syntax::ident_length(trimmed.as_bytes()) {
if trimmed.as_bytes().get(name_len) == Some(&b'(') {
let call_name = trimmed[..name_len].to_string();
match syntax::parse_ts_call_from(trimmed, call_name) {
Ok((arguments, consumed)) if trimmed[consumed..].trim().is_empty() => {
return Ok(arguments);
}
Ok((_arguments, consumed)) => {
return Err(format!(
"trailing bytes after tool-call expression at byte {consumed}"
));
}
Err(call_error) => {
return Err(format!("{object_error}; {call_error}"));
}
}
}
}
Err(object_error)
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum NativeToolNameTextCall {
NotCall,
Parsed {
name: String,
arguments: serde_json::Value,
},
Malformed {
name: String,
error: String,
},
}
pub(crate) fn parse_text_tool_call_from_native_arguments(text: &str) -> NativeToolNameTextCall {
let unwrapped = syntax::strip_tool_call_wrappers(text);
parse_text_tool_call_from_native_name(unwrapped.as_ref())
}
pub(crate) fn parse_text_tool_call_from_native_name(text: &str) -> NativeToolNameTextCall {
let trimmed = strip_native_name_provider_suffixes(text);
let Some(name_len) = syntax::ident_length(trimmed.as_bytes()) else {
return NativeToolNameTextCall::NotCall;
};
if trimmed.as_bytes().get(name_len) != Some(&b'(') {
return NativeToolNameTextCall::NotCall;
}
let name = trimmed[..name_len].to_string();
match syntax::parse_ts_call_from(trimmed, name.clone()) {
Ok((arguments, consumed)) => {
let trailing = trimmed[consumed..].trim();
if trailing.is_empty() {
NativeToolNameTextCall::Parsed { name, arguments }
} else {
NativeToolNameTextCall::Malformed {
name,
error: format!("trailing bytes after tool-call expression at byte {consumed}"),
}
}
}
Err(error) => NativeToolNameTextCall::Malformed { name, error },
}
}
fn strip_native_name_provider_suffixes(mut text: &str) -> &str {
loop {
let trimmed = text.trim();
let Some(next) = trimmed
.strip_suffix("</arg_value>")
.or_else(|| trimmed.strip_suffix("</tool_call>"))
.or_else(|| trimmed.strip_suffix("</toolcall>"))
else {
return trimmed;
};
text = next;
}
}
pub(crate) struct TextToolParseResult {
pub calls: Vec<serde_json::Value>,
pub errors: Vec<String>,
pub prose: String,
pub user_response: Option<String>,
pub violations: Vec<String>,
pub done_marker: Option<String>,
pub canonical: String,
}
#[cfg(test)]
mod tests {
use super::{
parse_text_tool_argument_payload, parse_text_tool_call_from_native_name,
NativeToolNameTextCall,
};
#[test]
fn text_tool_argument_payload_parses_object_literal_heredoc() {
let parsed = parse_text_tool_argument_payload(
r#"{ action: "create", path: "src/main.rs", content: <<EOF
fn main() {
println!("hello");
}
EOF
}"#,
"edit",
)
.expect("object literal payload parses");
assert_eq!(parsed["action"], serde_json::json!("create"));
assert_eq!(parsed["path"], serde_json::json!("src/main.rs"));
assert!(
parsed["content"]
.as_str()
.is_some_and(|content| content.contains("println!(\"hello\")")),
"content should come from the heredoc body: {parsed:?}"
);
}
#[test]
fn text_tool_argument_payload_parses_wrapped_call() {
let parsed = parse_text_tool_argument_payload(
r#"edit({ action: "replace_range", path: "src/lib.rs", range_start: 1, range_end: 2 })"#,
"edit",
)
.expect("wrapped call payload parses");
assert_eq!(parsed["action"], serde_json::json!("replace_range"));
assert_eq!(parsed["path"], serde_json::json!("src/lib.rs"));
assert_eq!(parsed["range_start"], serde_json::json!(1));
}
#[test]
fn text_tool_argument_payload_rejects_trailing_bytes() {
let error = parse_text_tool_argument_payload(
r#"{ action: "create", path: "src/main.rs" } trailing"#,
"edit",
)
.expect_err("trailing bytes should fail");
assert!(
error.contains("trailing bytes"),
"unexpected error: {error}"
);
}
#[test]
fn native_name_text_call_recovers_complete_call_with_provider_suffix() {
let parsed = parse_text_tool_call_from_native_name(
r#"look({ file: "include/kvdb/status.h", intent: "read" })</arg_value>"#,
);
match parsed {
NativeToolNameTextCall::Parsed { name, arguments } => {
assert_eq!(name, "look");
assert_eq!(
arguments["file"],
serde_json::json!("include/kvdb/status.h")
);
assert_eq!(arguments["intent"], serde_json::json!("read"));
}
other => panic!("expected recovered text tool call, got {other:?}"),
}
}
#[test]
fn native_name_text_call_rejects_partial_heredoc_call() {
let parsed = parse_text_tool_call_from_native_name(
r#"edit({ action: "create", path: "tests/page_cache.cpp", content: <<EOF"#,
);
match parsed {
NativeToolNameTextCall::Malformed { name, error } => {
assert_eq!(name, "edit");
assert!(
error.contains("unexpected end of input")
|| error.contains("expected newline after heredoc tag")
|| error.contains("missing closing `)`"),
"unexpected error: {error}"
);
}
other => panic!("expected malformed text tool call, got {other:?}"),
}
}
#[test]
fn native_name_text_call_ignores_plain_native_name() {
assert_eq!(
parse_text_tool_call_from_native_name("look"),
NativeToolNameTextCall::NotCall
);
}
}