use serde_json::Value;
use super::client::McpToolSchema;
pub const MAX_TOOL_ITERATIONS: usize = 6;
const MARKER: &str = "TOOL_CALL:";
#[derive(Debug, Clone, PartialEq)]
pub struct ParsedToolCall {
pub tool: String,
pub args: Value,
}
fn signature_of(schema: &serde_json::Value) -> String {
let Some(props) = schema.get("properties").and_then(|p| p.as_object()) else {
return String::new();
};
if props.is_empty() {
return "()".to_string();
}
let required: Vec<String> = schema
.get("required")
.and_then(|r| r.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str())
.map(str::to_string)
.collect()
})
.unwrap_or_default();
let req_names: Vec<&str> = props
.keys()
.map(|s| s.as_str())
.filter(|k| required.iter().any(|r| r == k))
.collect();
let opt_names: Vec<&str> = props
.keys()
.map(|s| s.as_str())
.filter(|k| !required.iter().any(|r| r == k))
.collect();
let mut out = String::from("(");
if !req_names.is_empty() {
out.push_str("required: ");
out.push_str(&req_names.join(", "));
}
if !opt_names.is_empty() {
if !out.is_empty() && out != "(" {
out.push_str("; ");
}
out.push_str("optional: ");
out.push_str(&opt_names.join(", "));
}
out.push(')');
out
}
fn first_sentence(text: &str) -> String {
let trimmed = text.trim();
match trimmed.find(['.', '!', '?']) {
Some(i) if i > 0 => trimmed[..=i].trim().to_string(),
_ => trimmed.to_string(),
}
}
pub fn tool_schema_detail(tools: &[McpToolSchema], name: &str) -> Option<String> {
tools.iter().find(|t| t.name == name).map(|t| {
format!(
"{} — {}\ninput schema: {}",
t.name, t.description, t.input_schema
)
})
}
pub fn build_tool_instructions(tools: &[McpToolSchema]) -> String {
if tools.is_empty() {
return String::new();
}
let mut block = String::from(
"You have access to tools. To call one, reply with ONLY a single line of the \
EXACT form below (not a function-call-looking expression, not any other syntax):\n\
TOOL_CALL: {\"tool\": \"<tool_name>\", \"args\": { ... }}\n\
Nothing else on that line — no other text before or after it.\n\n\
Example:\n\
User: What is 9 times 6?\n\
Assistant: TOOL_CALL: {\"tool\": \"calculate\", \"args\": {\"expression\": \"9 * 6\"}}\n\
(the tool result is then given to you as \"Observation: ...\")\n\
Assistant: 9 times 6 is 54.\n\n\
Rules:\n\
- As soon as you receive an Observation, write your final answer in plain text \
using that result. Do not call the same tool again for the same question.\n\
- If no tool is needed, just answer normally in plain text.\n\n\
Available tools:\n",
);
for tool in tools {
let description = if tool.description.is_empty() {
"(no description)"
} else {
tool.description.as_str()
};
let signature = signature_of(&tool.input_schema);
if signature.is_empty() {
block.push_str(
&tool_schema_detail(std::slice::from_ref(tool), &tool.name)
.unwrap_or_else(|| format!("- {} — {}", tool.name, description)),
);
} else {
block.push_str(&format!(
"- {}{} — {}\n",
tool.name,
signature,
first_sentence(description),
));
}
}
block
}
pub fn extract_tool_call(text: &str) -> Option<ParsedToolCall> {
let marker_pos = text.find(MARKER)?;
let after_marker = &text[marker_pos + MARKER.len()..];
let json_start_rel = after_marker.find('{')?;
let json_region = &after_marker[json_start_rel..];
let mut depth = 0i32;
let mut end = None;
for (i, ch) in json_region.char_indices() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = Some(i + ch.len_utf8());
break;
}
}
_ => {}
}
}
let end = end?;
let json_str = &json_region[..end];
let parsed: Value = serde_json::from_str(json_str).ok()?;
let tool = parsed.get("tool")?.as_str()?.to_string();
let args = parsed.get("args").cloned().unwrap_or(Value::Null);
Some(ParsedToolCall { tool, args })
}
const MAX_OBSERVATION_CHARS: usize = 4000;
fn truncate_observation(text: &str) -> std::borrow::Cow<'_, str> {
let total_chars = text.chars().count();
if total_chars <= MAX_OBSERVATION_CHARS {
return std::borrow::Cow::Borrowed(text);
}
let kept: String = text.chars().take(MAX_OBSERVATION_CHARS).collect();
let omitted = total_chars - MAX_OBSERVATION_CHARS;
std::borrow::Cow::Owned(format!(
"{kept}... [truncated, {omitted} more characters omitted]"
))
}
pub fn format_observation(tool: &str, result_json: &Value) -> String {
let rendered = result_json.to_string();
let observation = truncate_observation(&rendered);
format!("\nObservation ({tool}): {observation}\n")
}
pub fn format_denial(tool: &str) -> String {
format!("\nObservation ({tool}): the user denied permission to run this tool.\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_marker_returns_none() {
assert_eq!(extract_tool_call("just a normal answer"), None);
}
#[test]
fn parses_simple_call() {
let text = r#"TOOL_CALL: {"tool": "calculate", "args": {"expression": "2+2"}}"#;
let call = extract_tool_call(text).unwrap();
assert_eq!(call.tool, "calculate");
assert_eq!(call.args, serde_json::json!({"expression": "2+2"}));
}
#[test]
fn tolerates_leading_and_trailing_commentary() {
let text = "Sure, let me check that.\nTOOL_CALL: {\"tool\": \"read_text_file\", \"args\": {\"path\": \"a.txt\"}}\nI'll wait for the result.";
let call = extract_tool_call(text).unwrap();
assert_eq!(call.tool, "read_text_file");
assert_eq!(call.args, serde_json::json!({"path": "a.txt"}));
}
#[test]
fn handles_nested_braces_in_args() {
let text = r#"TOOL_CALL: {"tool": "api_call", "args": {"body": {"nested": {"a": 1}}}}"#;
let call = extract_tool_call(text).unwrap();
assert_eq!(call.tool, "api_call");
assert_eq!(call.args, serde_json::json!({"body": {"nested": {"a": 1}}}));
}
#[test]
fn missing_tool_field_returns_none() {
let text = r#"TOOL_CALL: {"args": {}}"#;
assert_eq!(extract_tool_call(text), None);
}
#[test]
fn no_args_defaults_to_null() {
let text = r#"TOOL_CALL: {"tool": "list_allowed_directories"}"#;
let call = extract_tool_call(text).unwrap();
assert_eq!(call.tool, "list_allowed_directories");
assert_eq!(call.args, Value::Null);
}
#[test]
fn instructions_block_is_empty_for_no_tools() {
assert_eq!(build_tool_instructions(&[]), "");
}
#[test]
fn format_observation_passes_through_small_results_untouched() {
let result = serde_json::json!({"content": "hello world"});
let observation = format_observation("fetch", &result);
assert_eq!(
observation,
"\nObservation (fetch): {\"content\":\"hello world\"}\n"
);
}
#[test]
fn format_observation_truncates_oversized_results() {
let huge_text = "x".repeat(10_000);
let result = serde_json::json!({"content": huge_text});
let observation = format_observation("fetch", &result);
assert!(
observation.len() < 5_000,
"expected truncation, got {} chars",
observation.len()
);
assert!(observation.contains("truncated"));
assert!(observation.contains("more characters omitted"));
}
#[test]
fn format_observation_truncation_is_utf8_safe() {
let huge_text = "é".repeat(5_000);
let result = serde_json::json!({"content": huge_text});
let observation = format_observation("fetch", &result);
assert!(observation.contains("truncated"));
}
#[test]
fn instructions_block_lists_each_tool() {
let tools = vec![McpToolSchema {
server: "calculator".to_string(),
name: "calculate".to_string(),
description: "Evaluate a math expression".to_string(),
input_schema: serde_json::json!({"type": "object"}),
}];
let block = build_tool_instructions(&tools);
assert!(block.contains("TOOL_CALL:"));
assert!(block.contains("calculate"));
assert!(block.contains("Evaluate a math expression"));
}
#[test]
fn a_signature_names_required_and_optional_arguments() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path to the file"},
"content": {"type": "string", "description": "Content to write"},
"encoding": {"type": "string", "description": "Text encoding"}
},
"required": ["path"]
});
assert_eq!(
signature_of(&schema),
"(required: path; optional: content, encoding)"
);
}
#[test]
fn a_signature_with_no_properties_is_empty_parens() {
let schema = serde_json::json!({"type": "object", "properties": {}});
assert_eq!(signature_of(&schema), "()");
}
#[test]
fn an_unrecognised_schema_yields_no_signature_rather_than_a_wrong_one() {
assert_eq!(signature_of(&serde_json::json!({"type": "string"})), "");
assert_eq!(signature_of(&serde_json::json!({})), "");
}
#[test]
fn a_signature_is_ordered_and_deterministic() {
let schema = serde_json::json!({
"properties": {"a": {}, "b": {}, "c": {}},
"required": ["b"]
});
let first = signature_of(&schema);
assert_eq!(first, signature_of(&schema));
assert_eq!(first, "(required: b; optional: a, c)");
}
#[test]
fn the_signature_is_far_smaller_than_the_schema_it_replaces() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": "The absolute path to the file or directory to operate on. Must be inside the workspace root."},
"content": {"type": "string", "description": "The content to write. Interpreted as UTF-8 unless an encoding is given."},
"encoding": {"type": "string", "enum": ["utf8", "base64"], "description": "The text encoding to use when reading or writing."}
},
"required": ["path"],
"additionalProperties": false
});
let sig = signature_of(&schema);
let full = schema.to_string();
assert!(
sig.len() * 5 < full.len(),
"signature ({}) should be far smaller than the schema ({})",
sig.len(),
full.len()
);
}
#[test]
fn first_sentence_keeps_only_the_sentence() {
assert_eq!(
first_sentence("Read a file. Use this when you need contents."),
"Read a file."
);
assert_eq!(first_sentence(" Read a file. "), "Read a file.");
assert_eq!(first_sentence("No trailing period"), "No trailing period");
assert_eq!(
first_sentence("Ends with a question? Yes."),
"Ends with a question?"
);
assert_eq!(first_sentence(""), "");
}
#[test]
fn the_tool_block_omits_the_full_schema() {
let tools = vec![McpToolSchema {
server: "filesystem".to_string(),
name: "read_file".to_string(),
description: "Read a file from disk. Returns its contents as text.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": "Absolute path, must be inside the workspace root."},
"encoding": {"type": "string", "description": "Text encoding."}
},
"required": ["path"]
}),
}];
let block = build_tool_instructions(&tools);
assert!(block.contains("read_file"), "tool missing: {block}");
assert!(
block.contains("required: path"),
"signature missing: {block}"
);
assert!(
!block.contains("input schema:"),
"a tool with a usable signature must not carry its full schema: {block}"
);
assert!(
!block.contains("workspace root"),
"the per-property description leaked in: {block}"
);
}
#[test]
fn the_full_schema_is_still_reachable_on_demand() {
let tools = vec![McpToolSchema {
server: "filesystem".to_string(),
name: "read_file".to_string(),
description: "Read a file.".to_string(),
input_schema: serde_json::json!({"properties": {"path": {"description": "inside workspace"}}}),
}];
let detail = tool_schema_detail(&tools, "read_file").expect("tool exists");
assert!(
detail.contains("workspace"),
"detail lost the schema: {detail}"
);
assert!(tool_schema_detail(&tools, "nope").is_none());
}
#[test]
fn an_unrecognised_schema_falls_back_to_the_full_schema_for_that_one_tool() {
let tools = vec![McpToolSchema {
server: "x".to_string(),
name: "odd_tool".to_string(),
description: "Does something unusual.".to_string(),
input_schema: serde_json::json!({"oneOf": [{"a": 1}, {"b": 2}]}),
}];
let block = build_tool_instructions(&tools);
assert!(block.contains("input schema:"), "detail missing: {block}");
assert!(block.contains("oneOf"), "schema not inlined: {block}");
}
}