pub(crate) mod agents; pub(crate) mod jobs; pub(crate) mod memory; pub(crate) mod rag_query; pub(crate) mod skill; pub(crate) mod todo; pub(crate) mod user_interaction; use crate::{ client::ThinkingBlock, config::{ Agent, RequestContext, flatten_prompt_messages, resolve_prompt_args, sanitize_display_text, }, graph, utils::*, }; use crate::config::ensure_parent_exists; use crate::config::paths; use crate::mcp::{ MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_INVOKE_META_FUNCTION_NAME_PREFIX, MCP_META_FUNCTION_PREFIXES, MCP_PROMPT_META_FUNCTION_NAME_PREFIX, MCP_READ_META_FUNCTION_NAME_PREFIX, MCP_SEARCH_META_FUNCTION_NAME_PREFIX, McpServerFeatures, McpServersConfig, is_mcp_meta_function, render, }; use crate::parsers::{bash, python, typescript}; use agents::AGENT_FUNCTION_PREFIX; use anyhow::{Context, Result, anyhow, bail}; use futures_util::future; use indexmap::IndexMap; use indoc::formatdoc; use jobs::JOB_FUNCTION_PREFIX; use memory::MEMORY_FUNCTION_PREFIX; use rag_query::RAG_FUNCTION_PREFIX; use rust_embed::Embed; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use skill::SKILL_FUNCTION_PREFIX; use std::ffi::OsStr; use std::fs::File; use std::io::{Read, Write}; use std::sync::atomic::{AtomicU64, Ordering}; use std::{collections::VecDeque, thread}; use std::{ collections::{HashMap, HashSet}, env, fs, io, path::{Path, PathBuf}, process::{Command, Stdio}, time::{Duration, Instant}, }; use strum_macros::AsRefStr; use todo::TODO_FUNCTION_PREFIX; use user_interaction::USER_FUNCTION_PREFIX; #[derive(Embed)] #[folder = "assets/functions/"] struct FunctionAssets; #[cfg(windows)] const PATH_SEP: &str = ";"; #[cfg(not(windows))] const PATH_SEP: &str = ":"; #[derive(AsRefStr)] enum BinaryType<'a> { Tool(Option<&'a str>), Agent, } #[derive(Debug, Clone, Copy, PartialEq, Eq, AsRefStr)] pub enum Language { Bash, Python, TypeScript, Unsupported, } impl From<&String> for Language { fn from(s: &String) -> Self { Language::from_extension(s) } } impl Language { pub fn from_extension(ext: &str) -> Self { match ext.to_lowercase().as_str() { "sh" => Language::Bash, "py" => Language::Python, "ts" => Language::TypeScript, _ => Language::Unsupported, } } } #[cfg_attr(not(windows), expect(dead_code))] impl Language { fn to_cmd(self) -> &'static str { match self { Language::Bash => "bash", Language::Python => "python", Language::TypeScript => "npx tsx", Language::Unsupported => "sh", } } fn to_extension(self) -> &'static str { match self { Language::Bash => "sh", Language::Python => "py", Language::TypeScript => "ts", _ => "sh", } } } impl Language { pub fn direct_invoker(self) -> Option<(&'static str, &'static [&'static str])> { match self { Language::Bash => Some(("bash", &[])), Language::Python => Some(("python3", &[])), Language::TypeScript => Some(("npx", &["tsx"])), Language::Unsupported => None, } } } fn extract_shebang_runtime(path: &Path) -> Option { let file = File::open(path).ok()?; let reader = io::BufReader::new(file); let first_line = io::BufRead::lines(reader).next()?.ok()?; let shebang = first_line.strip_prefix("#!")?; let cmd = shebang.trim(); if cmd.is_empty() { return None; } if let Some(after_env) = cmd.strip_prefix("/usr/bin/env ") { let runtime = after_env.trim(); if runtime.is_empty() { return None; } Some(runtime.to_string()) } else { Some(cmd.to_string()) } } pub(crate) fn write_file_atomic( path: &Path, content: &str, #[cfg_attr(not(unix), expect(unused))] mode: Option, ) -> Result<()> { #[cfg(unix)] use std::os::unix::fs::PermissionsExt; if fs::read_to_string(path).is_ok_and(|existing| existing == content) { #[cfg(unix)] if let Some(mode) = mode { fs::set_permissions(path, fs::Permissions::from_mode(mode))?; } return Ok(()); } let file_name = path .file_name() .and_then(OsStr::to_str) .ok_or_else(|| anyhow!("Unable to extract file name from path: {}", path.display()))?; static TMP_COUNTER: AtomicU64 = AtomicU64::new(0); let tmp = path.with_file_name(format!( ".{file_name}.tmp.{}.{}", std::process::id(), TMP_COUNTER.fetch_add(1, Ordering::Relaxed) )); let write_synced = || -> io::Result<()> { use std::io::Write; let mut file = File::create(&tmp)?; file.write_all(content.as_bytes())?; file.sync_all() }; if let Err(err) = write_synced() { let _ = fs::remove_file(&tmp); return Err(err.into()); } #[cfg(unix)] if let Some(mode) = mode { fs::set_permissions(&tmp, fs::Permissions::from_mode(mode))?; } if let Err(err) = fs::rename(&tmp, path) { let _ = fs::remove_file(&tmp); return Err(err.into()); } Ok(()) } fn tool_source_stems() -> Result> { let mut stems = HashSet::new(); let tools_dir = paths::global_tools_dir(); if !tools_dir.exists() { return Ok(stems); } for entry in fs::read_dir(&tools_dir)? { let path = entry?.path(); if path.is_file() && let Some(stem) = path.file_stem().and_then(OsStr::to_str) { stems.insert(stem.to_string()); } } Ok(stems) } fn bin_entry_stem(file_name: &str) -> &str { let name = file_name.strip_prefix("run-").unwrap_or(file_name); Path::new(name) .file_stem() .and_then(OsStr::to_str) .unwrap_or(name) } fn prune_stale_bin_entries( bin_dir: &Path, valid_stems: &HashSet, extra_valid_stem: Option<&str>, ) -> Result<()> { if !bin_dir.exists() { fs::create_dir_all(bin_dir)?; return Ok(()); } for entry in fs::read_dir(bin_dir)? { let entry = entry?; let path = entry.path(); if path.is_dir() { debug!( "Removing unexpected directory in bin dir: {}", path.display() ); fs::remove_dir_all(&path)?; continue; } let file_name = entry.file_name(); let Some(file_name) = file_name.to_str() else { continue; }; let stem = bin_entry_stem(file_name); if valid_stems.contains(stem) || extra_valid_stem == Some(stem) { continue; } debug!("Removing stale bin entry: {}", path.display()); fs::remove_file(&path)?; } Ok(()) } pub async fn eval_tool_calls( ctx: &mut RequestContext, mut calls: Vec, ) -> Result> { let mut output = vec![]; if calls.is_empty() { return Ok(output); } calls = ToolCall::dedup(calls); if calls.is_empty() { bail!("The request was aborted because an infinite loop of function calls was detected.") } let mut to_execute: Vec<(usize, ToolCall)> = Vec::with_capacity(calls.len()); let mut indexed_results: Vec<(usize, ToolResult)> = vec![]; for (idx, call) in calls.into_iter().enumerate() { if let Some(msg) = ctx.tool_scope.tool_tracker.check_loop(&call.clone()) { let dup_msg = format!("{{\"tool_call_loop_alert\":{}}}", msg.trim()); println!( "{}", muted_warning_text( format!("{}: ⚠️ Tool-call loop detected! ⚠️", call.name).as_str() ) ); indexed_results.push((idx, ToolResult::new(call, json!(dup_msg)))); } else { to_execute.push((idx, call)); } } let (mcp_calls, sequential_calls): (Vec<_>, Vec<_>) = to_execute .into_iter() .partition(|(_, call)| is_mcp_meta_function(&call.name)); if !mcp_calls.is_empty() { let ctx_ref: &RequestContext = ctx; let futs: Vec<_> = mcp_calls .into_iter() .map(|(idx, call)| async move { let result = call.eval_mcp(ctx_ref).await; (idx, call, result) }) .collect(); for (idx, call, result) in future::join_all(futs).await { let value = match result { Ok(v) => normalize_tool_result(v), Err(e) => json!({"tool_call_error": format!("{e}")}), }; indexed_results.push((idx, ToolResult::new(call, value))); } } for (idx, call) in sequential_calls { let value = match call.eval(ctx).await { Ok(v) => normalize_tool_result(v), Err(e) => json!({ "tool_call_error": format!( "{e}. This tool is not available or the call failed; use only tools listed in your catalog." ) }), }; indexed_results.push((idx, ToolResult::new(call, value))); } indexed_results.sort_unstable_by_key(|(idx, _)| *idx); output = indexed_results.into_iter().map(|(_, r)| r).collect(); { let max_chars = ctx .agent .as_ref() .and_then(|a| a.max_tool_result_chars()) .or_else(|| ctx.app.config.max_tool_result_chars); if let Some(max_chars) = max_chars.filter(|&n| n > 0) { output = output .into_iter() .map(|r| r.truncate_if_needed(max_chars)) .collect(); } } if let Some(last) = output.last_mut() { let escalations = if ctx.current_depth == 0 { ctx.root_escalation_queue() .filter(|queue| queue.has_pending()) .map(|queue| queue.pending_summary()) .unwrap_or_default() } else { vec![] }; let notifications = drain_live_notifications(ctx); merge_system_channel(last, escalations, notifications); } Ok(output) } /// Tools that succeed silently (e.g. `mkdir -p` via execute_command) evaluate to /// `Null`. Substitute a concrete `"DONE"` marker so every call produces a /// `ToolResult`: agentic loops (graph llm nodes, spawned agents, the REPL) treat /// an empty `tool_results` as "the LLM concluded", so dropping silent results /// would prematurely terminate a turn that called only silent tools. fn normalize_tool_result(result: Value) -> Value { if result.is_null() { json!("DONE") } else { result } } /// Drains this context's own notification queue and drops events whose /// handle is no longer registered with the supervisor (already collected or /// cancelled), so the model is never pointed at a dead id. fn drain_live_notifications(ctx: &RequestContext) -> Vec { let events = ctx.notification_queue.drain(); if events.is_empty() { return vec![]; } let Some(supervisor) = ctx.supervisor.as_ref() else { return vec![]; }; let sup = supervisor.read(); events .into_iter() .filter(|event| sup.has_job(&event.id) || sup.has_agent(&event.id)) .map(|event| event.to_value()) .collect() } /// Single-pass merge of both system channels onto the last tool result of a /// batch: pending escalations (children are blocked; listed first) and /// background-task completion notifications. A single pass is mandatory — /// two independent mergers would each apply the non-object wrap and nest the /// output twice. With both channels empty this is a no-op, and with only /// escalations it produces exactly the pre-notification output shape. fn merge_system_channel(last: &mut ToolResult, escalations: Vec, notifications: Vec) { if escalations.is_empty() && notifications.is_empty() { return; } let escalation_instruction = "Child agents are BLOCKED waiting for your reply. \ Call agent__reply_escalation for each pending escalation to unblock them."; let notification_instruction = "Background tasks have finished; collect each result with its next_action command."; let map = match &mut last.output { Value::Object(map) => map, other => { let mut map = serde_json::Map::new(); map.insert("output".into(), other.take()); *other = Value::Object(map); match other { Value::Object(map) => map, _ => unreachable!(), } } }; if !escalations.is_empty() { map.insert("pending_escalations".into(), json!(escalations)); map.insert( "escalation_instruction".into(), json!(escalation_instruction), ); } if !notifications.is_empty() { map.insert("system_notifications".into(), json!(notifications)); map.insert( "notification_instruction".into(), json!(notification_instruction), ); } } /// Escalation-only entry point retained so the characterization tests that /// pinned the pre-merger output shape keep proving, unmodified, that /// `merge_system_channel` with no notifications is byte-identical to the /// injection behavior they were written against. #[cfg(test)] fn inject_escalation_notification(last: &mut ToolResult, summary: Vec) { merge_system_channel(last, summary, vec![]); } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ToolResult { pub call: ToolCall, pub output: Value, #[serde(default, skip_serializing_if = "Option::is_none")] pub text: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub thinking: Vec, } impl ToolResult { pub fn new(call: ToolCall, output: Value) -> Self { Self { call, output, text: None, thinking: vec![], } } pub fn truncate_if_needed(mut self, max_chars: usize) -> Self { let s = self.output.to_string(); if s.len() > max_chars { let mut cut = max_chars; while !s.is_char_boundary(cut) { cut -= 1; } let prefix = &s[..cut]; self.output = json!(format!( "[truncated: tool output exceeded {max_chars} chars]\n{prefix}" )); } self } } fn gated_meta_function_prefixes(features: &McpServerFeatures) -> Vec<&'static str> { MCP_META_FUNCTION_PREFIXES .into_iter() .filter(|&prefix| match prefix { MCP_INVOKE_META_FUNCTION_NAME_PREFIX => features.tools, MCP_READ_META_FUNCTION_NAME_PREFIX => features.resources, MCP_PROMPT_META_FUNCTION_NAME_PREFIX => features.prompts, _ => true, }) .collect() } #[derive(Debug, Clone, Default)] pub struct Functions { declarations: Vec, } impl Functions { pub fn install_builtin_global_tools(force: bool) -> Result<()> { info!( "Installing global built-in functions in {}", paths::functions_dir().display() ); for file in FunctionAssets::iter() { debug!("Processing function file: {}", file.as_ref()); if file.as_ref().starts_with("scripts/") { debug!("Skipping script file: {}", file.as_ref()); continue; } let embedded_file = FunctionAssets::get(&file).ok_or_else(|| { anyhow!("Failed to load embedded function file: {}", file.as_ref()) })?; let content = unsafe { std::str::from_utf8_unchecked(&embedded_file.data) }; let file_path = if file.as_ref() == "mcp.json" { paths::mcp_config_file() } else { paths::functions_dir().join(file.as_ref()) }; let is_script = file_path .extension() .and_then(OsStr::to_str) .is_some_and(|ext| Language::from_extension(ext) != Language::Unsupported); let force_this = force && file.as_ref() != "mcp.json"; if file_path.exists() && !force_this { debug!( "Function file already exists, skipping: {}", file_path.display() ); continue; } ensure_parent_exists(&file_path)?; info!("Creating function file: {}", file_path.display()); write_file_atomic(&file_path, content, is_script.then_some(0o755))?; } Ok(()) } pub fn install_mcp_config() -> Result<()> { let file_path = paths::mcp_config_file(); let embedded = FunctionAssets::get("mcp.json") .ok_or_else(|| anyhow!("Failed to load embedded mcp.json"))?; let bundled_content = unsafe { std::str::from_utf8_unchecked(&embedded.data) }; let bundled: McpServersConfig = serde_json::from_str(bundled_content).context("failed to parse embedded mcp.json")?; ensure_parent_exists(&file_path)?; let mut merged = if file_path.exists() { let existing = fs::read_to_string(&file_path).context("failed to read existing mcp.json")?; serde_json::from_str::(&existing) .context("failed to parse existing mcp.json")? } else { McpServersConfig { mcp_servers: IndexMap::new(), } }; let mut added = Vec::new(); for (name, server) in bundled.mcp_servers { if !merged.mcp_servers.contains_key(&name) { merged.mcp_servers.insert(name.clone(), server); added.push(name); } } info!("Merging bundled MCP config into: {}", file_path.display()); let serialized = serde_json::to_string_pretty(&merged).context("failed to serialize merged mcp.json")?; write_file_atomic(&file_path, &serialized, None) .context("failed to write merged mcp.json")?; if !added.is_empty() { println!(" + new MCP servers: {}", added.join(", ")); } Ok(()) } pub fn init(visible_tools: &[String]) -> Result { Self::remove_stale_global_function_binaries()?; let declarations = Self { declarations: Self::build_global_tool_declarations(visible_tools)?, }; info!( "Building global function binaries in {}", paths::functions_bin_dir().display() ); Self::build_global_function_binaries(visible_tools, None)?; Ok(declarations) } pub fn init_agent(name: &str, global_tools: &[String]) -> Result { Self::remove_stale_agent_bin_entries(name)?; let global_tools_declarations = if !global_tools.is_empty() { info!("Loading global tools for agent: {name}: {global_tools:?}"); let tools_declarations = Self::build_global_tool_declarations(global_tools)?; info!( "Building global function binaries required by agent: {name} in {}", paths::functions_bin_dir().display() ); Self::build_global_function_binaries(global_tools, Some(name))?; tools_declarations } else { debug!("No global tools found for agent: {}", name); Vec::new() }; let agent_script_declarations = match paths::agent_functions_file(name) { Ok(path) if path.exists() => { info!( "Loading functions script for agent: {name} from {}", path.display() ); let script_declarations = Self::generate_declarations(&path)?; debug!("agent_declarations: {:#?}", script_declarations); info!( "Building function binary for agent: {name} in {}", paths::agent_bin_dir(name).display() ); Self::build_agent_tool_binaries(name)?; script_declarations } _ => { debug!("No functions script found for agent: {}", name); Vec::new() } }; let declarations = [global_tools_declarations, agent_script_declarations].concat(); Ok(Self { declarations }) } pub fn find(&self, name: &str) -> Option<&FunctionDeclaration> { self.declarations.iter().find(|v| v.name == name) } pub fn contains(&self, name: &str) -> bool { self.declarations.iter().any(|v| v.name == name) } pub fn declarations(&self) -> &[FunctionDeclaration] { &self.declarations } pub fn is_empty(&self) -> bool { self.declarations.is_empty() } pub fn append_todo_functions(&mut self) { self.declarations.extend(todo::todo_function_declarations()); } pub fn remove_todo_functions(&mut self) { self.declarations .retain(|f| !f.name.starts_with(TODO_FUNCTION_PREFIX)); } pub fn append_memory_functions(&mut self) { self.declarations .extend(memory::memory_function_declarations()); } pub fn remove_memory_functions(&mut self) { self.declarations .retain(|f| !f.name.starts_with(MEMORY_FUNCTION_PREFIX)); } pub fn append_skill_functions(&mut self) { self.declarations .extend(skill::skill_function_declarations()); } pub fn append_supervisor_functions(&mut self) { self.declarations .extend(agents::agent_function_declarations()); self.declarations .extend(agents::escalation_function_declarations()); } pub fn append_job_functions(&mut self) { self.declarations.extend(jobs::job_function_declarations()); } #[cfg(test)] pub fn append_declaration(&mut self, declaration: FunctionDeclaration) { self.declarations.push(declaration); } pub fn append_teammate_functions(&mut self) { self.declarations .extend(agents::teammate_function_declarations()); } pub fn append_user_interaction_functions(&mut self) { self.declarations .extend(user_interaction::user_interaction_function_declarations()); } pub fn append_rag_query_functions(&mut self) { self.declarations .extend(rag_query::rag_query_function_declarations()); } pub fn remove_rag_query_functions(&mut self) { self.declarations .retain(|f| !f.name.starts_with(RAG_FUNCTION_PREFIX)); } pub fn append_mcp_meta_functions(&mut self, mcp_servers: Vec) { let mut invoke_function_properties = IndexMap::new(); invoke_function_properties.insert( "tool".to_string(), JsonSchema { type_value: Some("string".to_string()), ..Default::default() }, ); invoke_function_properties.insert( "arguments".to_string(), JsonSchema { type_value: Some("object".to_string()), ..Default::default() }, ); let mut search_function_properties = IndexMap::new(); search_function_properties.insert( "query".to_string(), JsonSchema { type_value: Some("string".to_string()), description: Some("Generalized explanation of what you want to do".into()), ..Default::default() }, ); search_function_properties.insert( "top_k".to_string(), JsonSchema { type_value: Some("integer".to_string()), description: Some("How many results to return, between 1 and 20".into()), default: Some(Value::from(8usize)), ..Default::default() }, ); let mut describe_function_properties = IndexMap::new(); describe_function_properties.insert( "tool".to_string(), JsonSchema { type_value: Some("string".to_string()), description: Some("The name of the tool; e.g., search_issues".into()), ..Default::default() }, ); describe_function_properties.insert( "kind".to_string(), JsonSchema { type_value: Some("string".to_string()), description: Some( "Catalog item kind: tool (default), resource, resource_template, or prompt" .into(), ), default: Some(Value::from("tool")), ..Default::default() }, ); let mut read_function_properties = IndexMap::new(); read_function_properties.insert( "uri".to_string(), JsonSchema { type_value: Some("string".to_string()), description: Some( "Resource URI, or a resource template with {var} placeholders".into(), ), ..Default::default() }, ); read_function_properties.insert( "arguments".to_string(), JsonSchema { type_value: Some("object".to_string()), description: Some("Template variable values (RFC 6570 Level 1 only)".into()), ..Default::default() }, ); read_function_properties.insert( "pattern".to_string(), JsonSchema { type_value: Some("string".to_string()), description: Some( "Optional regex; returns only matching lines (with context) from text content" .into(), ), ..Default::default() }, ); read_function_properties.insert( "offset".to_string(), JsonSchema { type_value: Some("integer".to_string()), description: Some( "Byte offset for paging text. When pattern is set, offsets (and \ next_offset/total_bytes in the result) refer to the filtered stream, not \ the raw resource" .into(), ), default: Some(Value::from(0usize)), ..Default::default() }, ); read_function_properties.insert( "max_bytes".to_string(), JsonSchema { type_value: Some("integer".to_string()), description: Some(format!( "Max text bytes to return (clamped to {})", render::TEXT_MAX_BYTES_CLAMP )), default: Some(Value::from(render::DEFAULT_TEXT_MAX_BYTES)), ..Default::default() }, ); let mut prompt_function_properties = IndexMap::new(); prompt_function_properties.insert( "prompt".to_string(), JsonSchema { type_value: Some("string".to_string()), ..Default::default() }, ); prompt_function_properties.insert( "arguments".to_string(), JsonSchema { type_value: Some("object".to_string()), description: Some("String values only; prompt arguments have no schemas".into()), ..Default::default() }, ); for features in mcp_servers { let server = &features.name; let search_function_name = format!("{}_{server}", MCP_SEARCH_META_FUNCTION_NAME_PREFIX); let describe_function_name = format!("{}_{server}", MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX); let invoke_function_name = format!("{}_{server}", MCP_INVOKE_META_FUNCTION_NAME_PREFIX); let read_function_name = format!("{}_{server}", MCP_READ_META_FUNCTION_NAME_PREFIX); let prompt_function_name = format!("{}_{server}", MCP_PROMPT_META_FUNCTION_NAME_PREFIX); for prefix in gated_meta_function_prefixes(&features) { match prefix { MCP_INVOKE_META_FUNCTION_NAME_PREFIX => { self.declarations.push(FunctionDeclaration { name: invoke_function_name.clone(), description: formatdoc!( r#" Invoke the specified tool on the {server} MCP server. Always call {describe_function_name} first to find the correct invocation schema for the given tool. "# ), parameters: JsonSchema { type_value: Some("object".to_string()), properties: Some(invoke_function_properties.clone()), required: Some(vec!["tool".to_string()]), ..Default::default() }, agent: false, }); } MCP_SEARCH_META_FUNCTION_NAME_PREFIX => { self.declarations.push(FunctionDeclaration { name: search_function_name.clone(), description: formatdoc!( r#" Find candidate tools by keywords for the {server} MCP server. Returns small suggestions; fetch schemas with {describe_function_name}. "# ), parameters: JsonSchema { type_value: Some("object".to_string()), properties: Some(search_function_properties.clone()), required: Some(vec!["query".to_string()]), ..Default::default() }, agent: false, }); } MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX => { self.declarations.push(FunctionDeclaration { name: describe_function_name.clone(), description: "Get the full schema or metadata for exactly one MCP \ catalog item: a tool, resource, resource template, or \ prompt." .to_string(), parameters: JsonSchema { type_value: Some("object".to_string()), properties: Some(describe_function_properties.clone()), required: Some(vec!["tool".to_string()]), ..Default::default() }, agent: false, }); } MCP_READ_META_FUNCTION_NAME_PREFIX => { self.declarations.push(FunctionDeclaration { name: read_function_name.clone(), description: formatdoc!( r#" Read a resource, or expand a resource template, from the {server} MCP server. Call {describe_function_name} with kind "resource" or "resource_template" to find URIs and template variables. Text content is paged via offset/max_bytes and can be filtered with pattern; binary content is spilled to disk and its metadata returned. "# ), parameters: JsonSchema { type_value: Some("object".to_string()), properties: Some(read_function_properties.clone()), required: Some(vec!["uri".to_string()]), ..Default::default() }, agent: false, }); } MCP_PROMPT_META_FUNCTION_NAME_PREFIX => { self.declarations.push(FunctionDeclaration { name: prompt_function_name.clone(), description: formatdoc!( r#" Fetch a prompt from the {server} MCP server, rendered with the given arguments. Call {describe_function_name} with kind "prompt" to discover prompt names and their arguments. The result is the prompt text, labeled per message; fold it into your reasoning. "# ), parameters: JsonSchema { type_value: Some("object".to_string()), properties: Some(prompt_function_properties.clone()), required: Some(vec!["prompt".to_string()]), ..Default::default() }, agent: false, }); } _ => debug_assert!(false, "unhandled MCP meta-function prefix: {prefix}"), } } } } fn build_global_tool_declarations( enabled_tools: &[String], ) -> Result> { let global_tools_directory = paths::global_tools_dir(); let mut function_declarations = Vec::new(); for tool in enabled_tools { let declaration = Self::generate_declarations(&global_tools_directory.join(tool))?; function_declarations.extend(declaration); } Ok(function_declarations) } fn generate_declarations(tools_file_path: &Path) -> Result> { info!( "Loading tool definitions from {}", tools_file_path.display() ); let file_name = tools_file_path .file_stem() .and_then(|s| s.to_str()) .ok_or_else(|| { anyhow::format_err!("Unable to extract file name from path: {tools_file_path:?}") })?; match File::open(tools_file_path) { Ok(tool_file) => { let language = Language::from( &tools_file_path .extension() .and_then(OsStr::to_str) .map(|s| s.to_lowercase()) .ok_or_else(|| { anyhow!("Unable to extract language from tool file: {file_name}") })?, ); match language { Language::Bash => { bash::generate_bash_declarations(tool_file, tools_file_path, file_name) } Language::Python => python::generate_python_declarations( tool_file, file_name, tools_file_path.parent(), ), Language::TypeScript => typescript::generate_typescript_declarations( tool_file, file_name, tools_file_path.parent(), ), Language::Unsupported => { bail!("Unsupported tool file extension: {}", language.as_ref()) } } } Err(err) if err.kind() == io::ErrorKind::NotFound => { bail!( "Tool definition file not found: {}", tools_file_path.display() ); } Err(err) => bail!("Unable to open tool definition file. {}", err), } } fn build_global_function_binaries( enabled_tools: &[String], agent_name: Option<&str>, ) -> Result<()> { for tool in enabled_tools { let language = Language::from( &Path::new(&tool) .extension() .and_then(OsStr::to_str) .map(|s| s.to_lowercase()) .ok_or_else(|| { anyhow::format_err!("Unable to extract file extension from path: {tool:?}") })?, ); let binary_name = Path::new(&tool) .file_stem() .and_then(OsStr::to_str) .ok_or_else(|| { anyhow::format_err!("Unable to extract file name from path: {tool:?}") })?; if language == Language::Unsupported { bail!("Unsupported tool file extension: {}", language.as_ref()); } let tool_path = paths::global_tools_dir().join(tool); let custom_runtime = extract_shebang_runtime(&tool_path); Self::build_binaries( binary_name, language, BinaryType::Tool(agent_name), custom_runtime.as_deref(), )?; } Ok(()) } fn remove_stale_agent_bin_entries(name: &str) -> Result<()> { let agent_bin_directory = paths::agent_bin_dir(name); debug!( "Pruning stale entries in agent bin directory: {}", agent_bin_directory.display() ); prune_stale_bin_entries(&agent_bin_directory, &tool_source_stems()?, Some(name)) } fn remove_stale_global_function_binaries() -> Result<()> { let bin_dir = paths::functions_bin_dir(); info!("Pruning stale function binaries in {}", bin_dir.display()); prune_stale_bin_entries(&bin_dir, &tool_source_stems()?, None) } fn build_agent_tool_binaries(name: &str) -> Result<()> { let tools_file = paths::agent_functions_file(name)?; let language = Language::from( &tools_file .extension() .and_then(OsStr::to_str) .map(|s| s.to_lowercase()) .ok_or_else(|| { anyhow::format_err!("Unable to extract file extension from path: {name:?}") })?, ); if language == Language::Unsupported { bail!("Unsupported tool file extension: {}", language.as_ref()); } let custom_runtime = extract_shebang_runtime(&tools_file); Self::build_binaries(name, language, BinaryType::Agent, custom_runtime.as_deref()) } #[cfg(windows)] fn build_binaries( binary_name: &str, language: Language, binary_type: BinaryType, custom_runtime: Option<&str>, ) -> Result<()> { use native::runtime; let (binary_file, binary_script_file) = match binary_type { BinaryType::Tool(None) => ( paths::functions_bin_dir().join(format!("{binary_name}.cmd")), paths::functions_bin_dir() .join(format!("run-{binary_name}.{}", language.to_extension())), ), BinaryType::Tool(Some(agent_name)) => ( paths::agent_bin_dir(agent_name).join(format!("{binary_name}.cmd")), paths::agent_bin_dir(agent_name) .join(format!("run-{binary_name}.{}", language.to_extension())), ), BinaryType::Agent => ( paths::agent_bin_dir(binary_name).join(format!("{binary_name}.cmd")), paths::agent_bin_dir(binary_name) .join(format!("run-{binary_name}.{}", language.to_extension())), ), }; info!( "Building binary runner for function: {} ({})", binary_name, binary_script_file.display(), ); let embedded_file = FunctionAssets::get(&format!( "scripts/run-{}.{}", binary_type.as_ref().to_lowercase(), language.to_extension() )) .ok_or_else(|| { anyhow!( "Failed to load embedded script for run-{}.{}", binary_type.as_ref().to_lowercase(), language.to_extension() ) })?; let content_template = unsafe { std::str::from_utf8_unchecked(&embedded_file.data) }; let to_script_path = |p: &str| -> String { p.replace('\\', "/") }; let content = match binary_type { BinaryType::Tool(None) => { let root_dir = paths::functions_dir(); let tool_path = format!( "{}/{binary_name}", paths::global_tools_dir().to_string_lossy() ); content_template .replace("{function_name}", binary_name) .replace("{root_dir}", &to_script_path(&root_dir.to_string_lossy())) .replace("{tool_path}", &to_script_path(&tool_path)) } BinaryType::Tool(Some(agent_name)) => { let root_dir = paths::agent_data_dir(agent_name); let tool_path = format!( "{}/{binary_name}", paths::global_tools_dir().to_string_lossy() ); content_template .replace("{function_name}", binary_name) .replace("{root_dir}", &to_script_path(&root_dir.to_string_lossy())) .replace("{tool_path}", &to_script_path(&tool_path)) } BinaryType::Agent => content_template .replace("{agent_name}", binary_name) .replace( "{config_dir}", &to_script_path(&paths::config_dir().to_string_lossy()), ), } .replace( "{prompt_utils_file}", &to_script_path(&paths::bash_prompt_utils_file().to_string_lossy()), ); write_file_atomic(&binary_script_file, &content, None)?; info!( "Building binary for function: {} ({})", binary_name, binary_file.display() ); let run = if let Some(rt) = custom_runtime { rt.to_string() } else { match language { Language::Bash => { let shell = runtime::bash_path().ok_or_else(|| anyhow!("Shell not found"))?; format!("{shell} --noprofile --norc") } Language::Python if Path::new(".venv").exists() => { let executable_path = env::current_dir()? .join(".venv") .join("Scripts") .join("activate.bat"); let canonicalized_path = dunce::canonicalize(&executable_path)?; format!( "call \"{}\" && {}", canonicalized_path.to_string_lossy(), language.to_cmd() ) } Language::Python => { let executable_path = which::which("python") .or_else(|_| which::which("python3")) .map_err(|_| anyhow!("Python executable not found in PATH"))?; let canonicalized_path = dunce::canonicalize(&executable_path)?; canonicalized_path.to_string_lossy().into_owned() } Language::TypeScript => { let npx_path = which::which("npx").map_err(|_| { anyhow!("npx executable not found in PATH (required for TypeScript tools)") })?; let canonicalized_path = dunce::canonicalize(&npx_path)?; format!("{} tsx", canonicalized_path.to_string_lossy()) } _ => bail!("Unsupported language: {}", language.as_ref()), } }; let bin_dir = binary_file .parent() .expect("Failed to get parent directory of binary file"); let canonical_bin_dir = dunce::canonicalize(bin_dir)?.to_string_lossy().into_owned(); let wrapper_binary = dunce::canonicalize(&binary_script_file)? .to_string_lossy() .into_owned(); let content = formatdoc!( r#" @echo off setlocal set "bin_dir={canonical_bin_dir}" {run} "{wrapper_binary}" %*"#, ); write_file_atomic(&binary_file, &content, None)?; Ok(()) } #[cfg(not(windows))] fn build_binaries( binary_name: &str, language: Language, binary_type: BinaryType, custom_runtime: Option<&str>, ) -> Result<()> { let binary_file = match binary_type { BinaryType::Tool(None) => paths::functions_bin_dir().join(binary_name), BinaryType::Tool(Some(agent_name)) => { paths::agent_bin_dir(agent_name).join(binary_name) } BinaryType::Agent => paths::agent_bin_dir(binary_name).join(binary_name), }; info!( "Building binary for function: {} ({})", binary_name, binary_file.display() ); let embedded_file = FunctionAssets::get(&format!( "scripts/run-{}.{}", binary_type.as_ref().to_lowercase(), language.to_extension() )) .ok_or_else(|| { anyhow!( "Failed to load embedded script for run-{}.{}", binary_type.as_ref().to_lowercase(), language.to_extension() ) })?; let content_template = unsafe { std::str::from_utf8_unchecked(&embedded_file.data) }; let mut content = match binary_type { BinaryType::Tool(None) => { let root_dir = paths::functions_dir(); let tool_path = format!( "{}/{binary_name}", paths::global_tools_dir().to_string_lossy() ); content_template .replace("{function_name}", binary_name) .replace("{root_dir}", &root_dir.to_string_lossy()) .replace("{tool_path}", &tool_path) } BinaryType::Tool(Some(agent_name)) => { let root_dir = paths::agent_data_dir(agent_name); let tool_path = format!( "{}/{binary_name}", paths::global_tools_dir().to_string_lossy() ); content_template .replace("{function_name}", binary_name) .replace("{root_dir}", &root_dir.to_string_lossy()) .replace("{tool_path}", &tool_path) } BinaryType::Agent => content_template .replace("{agent_name}", binary_name) .replace("{config_dir}", &paths::config_dir().to_string_lossy()), } .replace( "{prompt_utils_file}", &paths::bash_prompt_utils_file().to_string_lossy(), ); if let Some(rt) = custom_runtime && let Some(newline_pos) = content.find('\n') { content = format!("#!/usr/bin/env {rt}{}", &content[newline_pos..]); } if language == Language::TypeScript { let bin_dir = binary_file .parent() .expect("Failed to get parent directory of binary file"); let script_file = bin_dir.join(format!("run-{binary_name}.ts")); write_file_atomic(&script_file, &content, Some(0o755))?; let ts_runtime = custom_runtime.unwrap_or("tsx"); let wrapper = format!( "#!/bin/sh\nexec {ts_runtime} \"{}\" \"$@\"\n", script_file.display() ); write_file_atomic(&binary_file, &wrapper, Some(0o755))?; } else { write_file_atomic(&binary_file, &content, Some(0o755))?; } Ok(()) } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FunctionDeclaration { pub name: String, pub description: String, pub parameters: JsonSchema, #[serde(skip_serializing, default)] pub agent: bool, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct JsonSchema { #[serde(rename = "type", skip_serializing_if = "Option::is_none")] pub type_value: Option, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, #[serde(skip_serializing_if = "Option::is_none")] pub properties: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub items: Option>, #[serde(rename = "anyOf", skip_serializing_if = "Option::is_none")] pub any_of: Option>, #[serde(rename = "enum", skip_serializing_if = "Option::is_none")] pub enum_value: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub default: Option, #[serde(skip_serializing_if = "Option::is_none")] pub required: Option>, } impl JsonSchema { pub fn is_empty_properties(&self) -> bool { match &self.properties { Some(v) => v.is_empty(), None => true, } } } #[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct ToolCall { pub name: String, pub arguments: Value, pub id: Option, /// Gemini 3's thought signature for stateful reasoning in function calling. /// Must be preserved and sent back when submitting function responses. #[serde(skip_serializing_if = "Option::is_none")] pub thought_signature: Option, } type CallConfig = (String, String, Vec, HashMap); impl ToolCall { pub fn dedup(calls: Vec) -> Vec { let mut new_calls = vec![]; let mut seen_ids = HashSet::new(); for call in calls.into_iter().rev() { if let Some(id) = &call.id { if !seen_ids.contains(id) { seen_ids.insert(id.clone()); new_calls.push(call); } } else { new_calls.push(call); } } new_calls.reverse(); new_calls } pub fn new(name: String, arguments: Value, id: Option) -> Self { Self { name, arguments, id, thought_signature: None, } } pub fn with_thought_signature(mut self, thought_signature: Option) -> Self { self.thought_signature = thought_signature; self } fn parse_arguments(&self) -> Result { if self.arguments.is_object() { Ok(self.arguments.clone()) } else if let Some(arguments) = self.arguments.as_str() { serde_json::from_str(arguments).map_err(|_| { anyhow!( "The call '{}' has invalid arguments: {arguments}", self.name ) }) } else { bail!( "The call '{}' has invalid arguments: {}", self.name, self.arguments ) } } async fn eval_mcp(&self, ctx: &RequestContext) -> Result { let json_data = self.parse_arguments()?; let cmd_name = self.name.as_str(); if *IS_STDOUT_TERMINAL && ctx.current_depth == 0 && !HEADLESS.load(Ordering::SeqCst) { println!( "{}", format_call_log(cmd_name, &[json_data.to_string()], &json_data) ); } let result = if cmd_name.starts_with(MCP_SEARCH_META_FUNCTION_NAME_PREFIX) { Self::search_mcp_tools(ctx, cmd_name, &json_data) .await .unwrap_or_else(|e| { let error_msg = format!("MCP search failed: {e}"); eprintln!("{}", muted_warning_text(&mcp_error_display(&error_msg))); json!({"tool_call_error": error_msg}) }) } else if cmd_name.starts_with(MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX) { Self::describe_mcp_tool(ctx, cmd_name, json_data.clone()) .await .unwrap_or_else(|e| { let error_msg = format!("MCP describe failed: {e}"); eprintln!("{}", muted_warning_text(&mcp_error_display(&error_msg))); json!({"tool_call_error": error_msg}) }) } else if cmd_name.starts_with(MCP_READ_META_FUNCTION_NAME_PREFIX) { Self::read_mcp_resource(ctx, cmd_name, &json_data) .await .unwrap_or_else(|e| { let error_msg = format!("MCP read failed: {e}"); eprintln!("{}", muted_warning_text(&mcp_error_display(&error_msg))); json!({"tool_call_error": error_msg}) }) } else if cmd_name.starts_with(MCP_PROMPT_META_FUNCTION_NAME_PREFIX) { Self::get_mcp_prompt(ctx, cmd_name, &json_data) .await .unwrap_or_else(|e| { let error_msg = format!("MCP prompt failed: {e}"); eprintln!("{}", muted_warning_text(&mcp_error_display(&error_msg))); json!({"tool_call_error": error_msg}) }) } else { Self::invoke_mcp_tool(ctx, cmd_name, &json_data) .await .unwrap_or_else(|e| { let error_msg = format!("MCP tool invocation failed: {e}"); eprintln!("{}", muted_warning_text(&mcp_error_display(&error_msg))); json!({"tool_call_error": error_msg}) }) }; Ok(result) } pub async fn eval(&self, ctx: &mut RequestContext) -> Result { let agent = ctx.agent.clone(); let functions = ctx.tool_scope.functions.clone(); let current_depth = ctx.current_depth; let agent_name = agent.as_ref().map(|agent| agent.name().to_owned()); let (call_name, cmd_name, mut cmd_args, envs) = match agent.as_ref() { Some(agent) => self.extract_call_config_from_agent(&functions, agent)?, None => self.extract_call_config_from_ctx(&functions)?, }; let json_data = if self.arguments.is_object() { self.arguments.clone() } else if let Some(arguments) = self.arguments.as_str() { let arguments: Value = serde_json::from_str(arguments).map_err(|_| { anyhow!("The call '{call_name}' has invalid arguments: {arguments}") })?; arguments } else { bail!( "The call '{call_name}' has invalid arguments: {}", self.arguments ); }; cmd_args.push(json_data.to_string()); if *IS_STDOUT_TERMINAL && current_depth == 0 && !HEADLESS.load(Ordering::SeqCst) { println!("{}", format_call_log(&cmd_name, &cmd_args, &json_data)); } let output = match cmd_name.as_str() { _ if cmd_name.starts_with(MCP_SEARCH_META_FUNCTION_NAME_PREFIX) => { Self::search_mcp_tools(ctx, &cmd_name, &json_data) .await .unwrap_or_else(|e| { let error_msg = format!("MCP search failed: {e}"); eprintln!("{}", muted_warning_text(&mcp_error_display(&error_msg))); json!({"tool_call_error": error_msg}) }) } _ if cmd_name.starts_with(MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX) => { Self::describe_mcp_tool(ctx, &cmd_name, json_data) .await .unwrap_or_else(|e| { let error_msg = format!("MCP describe failed: {e}"); eprintln!("{}", muted_warning_text(&mcp_error_display(&error_msg))); json!({"tool_call_error": error_msg}) }) } _ if cmd_name.starts_with(MCP_READ_META_FUNCTION_NAME_PREFIX) => { Self::read_mcp_resource(ctx, &cmd_name, &json_data) .await .unwrap_or_else(|e| { let error_msg = format!("MCP read failed: {e}"); eprintln!("{}", muted_warning_text(&mcp_error_display(&error_msg))); json!({"tool_call_error": error_msg}) }) } _ if cmd_name.starts_with(MCP_PROMPT_META_FUNCTION_NAME_PREFIX) => { Self::get_mcp_prompt(ctx, &cmd_name, &json_data) .await .unwrap_or_else(|e| { let error_msg = format!("MCP prompt failed: {e}"); eprintln!("{}", muted_warning_text(&mcp_error_display(&error_msg))); json!({"tool_call_error": error_msg}) }) } _ if cmd_name.starts_with(MCP_INVOKE_META_FUNCTION_NAME_PREFIX) => { Self::invoke_mcp_tool(ctx, &cmd_name, &json_data) .await .unwrap_or_else(|e| { let error_msg = format!("MCP tool invocation failed: {e}"); eprintln!("{}", muted_warning_text(&mcp_error_display(&error_msg))); json!({"tool_call_error": error_msg}) }) } _ if cmd_name.starts_with(TODO_FUNCTION_PREFIX) => { todo::handle_todo_tool(ctx, &cmd_name, &json_data).unwrap_or_else(|e| { let error_msg = format!("Todo tool failed: {e}"); eprintln!("{}", muted_warning_text(&format!("⚠️ {error_msg} ⚠️"))); json!({"tool_call_error": error_msg}) }) } _ if cmd_name.starts_with(MEMORY_FUNCTION_PREFIX) => { memory::handle_memory_tool(ctx, &cmd_name, &json_data).unwrap_or_else(|e| { let error_msg = format!("Memory tool failed: {e}"); eprintln!("{}", muted_warning_text(&format!("⚠️ {error_msg} ⚠️"))); json!({"tool_call_error": error_msg}) }) } _ if cmd_name.starts_with(SKILL_FUNCTION_PREFIX) => { skill::handle_skill_tool(ctx, &cmd_name, &json_data) .await .unwrap_or_else(|e| { let error_msg = format!("Skill tool failed: {e}"); eprintln!("{}", muted_warning_text(&format!("⚠️ {error_msg} ⚠️"))); json!({"tool_call_error": error_msg}) }) } _ if cmd_name.starts_with(AGENT_FUNCTION_PREFIX) => { agents::handle_agent_tool(ctx, &cmd_name, &json_data) .await .unwrap_or_else(|e| { let error_msg = format!("Agent tool failed: {e}"); eprintln!("{}", muted_warning_text(&format!("⚠️ {error_msg} ⚠️"))); json!({"tool_call_error": error_msg}) }) } _ if cmd_name.starts_with(USER_FUNCTION_PREFIX) => { user_interaction::handle_user_tool(ctx, &cmd_name, &json_data) .await .unwrap_or_else(|e| { let error_msg = format!("User interaction failed: {e}"); eprintln!("{}", muted_warning_text(&format!("⚠️ {error_msg} ⚠️"))); json!({"tool_call_error": error_msg}) }) } _ if cmd_name.starts_with(RAG_FUNCTION_PREFIX) => { rag_query::handle_rag_tool(ctx, &cmd_name, &json_data) .await .unwrap_or_else(|e| { let error_msg = format!("RAG query failed: {e}"); eprintln!("{}", muted_warning_text(&format!("⚠️ {error_msg} ⚠️"))); json!({"tool_call_error": error_msg}) }) } _ if cmd_name.starts_with(JOB_FUNCTION_PREFIX) => { jobs::handle_job_tool(ctx, &cmd_name, &json_data) .await .unwrap_or_else(|e| { let error_msg = format!("Job tool failed: {e}"); eprintln!("{}", muted_warning_text(&format!("⚠️ {error_msg} ⚠️"))); json!({"tool_call_error": error_msg}) }) } _ => match run_llm_function(cmd_name, cmd_args, envs, agent_name) { Ok(Some(contents)) => serde_json::from_str(&contents) .ok() .unwrap_or_else(|| json!({"output": contents})), Ok(None) => Value::Null, Err(e) => serde_json::from_str(&e.to_string()) .ok() .unwrap_or_else(|| json!({"output": e.to_string()})), }, }; Ok(output) } async fn describe_mcp_tool( ctx: &RequestContext, cmd_name: &str, json_data: Value, ) -> Result { let server_id = cmd_name.replace(&format!("{MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX}_"), ""); let tool = json_data .get("tool") .ok_or_else(|| anyhow!("Missing 'tool' in arguments"))? .as_str() .ok_or_else(|| anyhow!("Invalid 'tool' in arguments"))?; let kind = match json_data.get("kind") { Some(value) => value .as_str() .ok_or_else(|| anyhow!("Invalid 'kind' in arguments"))?, None => "tool", }; let result = ctx .tool_scope .mcp_runtime .describe(&server_id, kind, tool) .await?; Ok(serde_json::to_value(result)?) } async fn search_mcp_tools( ctx: &RequestContext, cmd_name: &str, json_data: &Value, ) -> Result { let server = cmd_name.replace(&format!("{MCP_SEARCH_META_FUNCTION_NAME_PREFIX}_"), ""); let query = json_data .get("query") .ok_or_else(|| anyhow!("Missing 'query' in arguments"))? .as_str() .ok_or_else(|| anyhow!("Invalid 'query' in arguments"))?; let top_k = json_data .get("top_k") .cloned() .unwrap_or_else(|| Value::from(8u64)) .as_u64() .ok_or_else(|| anyhow!("Invalid 'top_k' in arguments"))? as usize; let catalog_items = ctx .tool_scope .mcp_runtime .search(&server, query, top_k) .await? .into_iter() .map(|it| serde_json::to_value(&it).unwrap_or_default()) .collect(); Ok(Value::Array(catalog_items)) } async fn invoke_mcp_tool( ctx: &RequestContext, cmd_name: &str, json_data: &Value, ) -> Result { let server = cmd_name.replace(&format!("{MCP_INVOKE_META_FUNCTION_NAME_PREFIX}_"), ""); let tool = json_data .get("tool") .ok_or_else(|| anyhow!("Missing 'tool' in arguments"))? .as_str() .ok_or_else(|| anyhow!("Invalid 'tool' in arguments"))?; let arguments = json_data .get("arguments") .cloned() .unwrap_or_else(|| json!({})); let result = ctx .tool_scope .mcp_runtime .invoke(&server, tool, arguments) .await?; render_tool_result(serde_json::to_value(result)?, &server) } async fn read_mcp_resource( ctx: &RequestContext, cmd_name: &str, json_data: &Value, ) -> Result { let server = cmd_name .strip_prefix(&format!("{MCP_READ_META_FUNCTION_NAME_PREFIX}_")) .ok_or_else(|| anyhow!("Malformed MCP read function name: {cmd_name}"))?; let uri = json_data .get("uri") .ok_or_else(|| anyhow!("Missing 'uri' in arguments"))? .as_str() .ok_or_else(|| anyhow!("Invalid 'uri' in arguments"))?; let pattern = match json_data.get("pattern") { Some(value) => Some( value .as_str() .ok_or_else(|| anyhow!("Invalid 'pattern' in arguments"))?, ), None => None, }; let offset = match json_data.get("offset") { Some(value) => value .as_u64() .ok_or_else(|| anyhow!("Invalid 'offset' in arguments"))? as usize, None => 0, }; let max_bytes = match json_data.get("max_bytes") { Some(value) => Some( value .as_u64() .ok_or_else(|| anyhow!("Invalid 'max_bytes' in arguments"))? as usize, ), None => None, }; let uri = match json_data.get("arguments").and_then(Value::as_object) { Some(args) if !args.is_empty() => expand_uri_template(uri, args)?, _ => uri.to_string(), }; let result = ctx.tool_scope.mcp_runtime.read(server, &uri).await?; let audience = ctx .tool_scope .mcp_runtime .resource_audience(server, &uri) .await; let items: Vec = result .contents .iter() .map(serde_json::to_value) .collect::>()?; let mut rendered_items = Vec::with_capacity(items.len()); let mut total_size = 0usize; for (index, item) in items.iter().enumerate() { let mut rendered = render_resource_content(item, pattern, offset, max_bytes, server)?; if let (Some(audience), Some(map)) = (&audience, rendered.as_object_mut()) { map.insert("audience".to_string(), json!(audience)); } let size = rendered.to_string().len(); // Bound the overall response; the first item is always included. if index > 0 && total_size + size > render::TEXT_MAX_BYTES_CLAMP { let omitted = items.len() - index; rendered_items.push(json!({ "truncated": true, "omitted_items": omitted, "note": format!( "{omitted} content item(s) omitted: the combined response would exceed \ {} bytes", render::TEXT_MAX_BYTES_CLAMP ), })); break; } total_size += size; rendered_items.push(rendered); } if rendered_items.len() == 1 { Ok(rendered_items.remove(0)) } else { Ok(Value::Array(rendered_items)) } } async fn get_mcp_prompt( ctx: &RequestContext, cmd_name: &str, json_data: &Value, ) -> Result { let server = cmd_name .strip_prefix(&format!("{MCP_PROMPT_META_FUNCTION_NAME_PREFIX}_")) .ok_or_else(|| anyhow!("Malformed MCP prompt function name: {cmd_name}"))?; let prompt = json_data .get("prompt") .ok_or_else(|| anyhow!("Missing 'prompt' in arguments"))? .as_str() .ok_or_else(|| anyhow!("Invalid 'prompt' in arguments"))?; let mut provided = HashMap::new(); if let Some(value) = json_data.get("arguments") { let entries = value .as_object() .ok_or_else(|| anyhow!("Invalid 'arguments' in arguments"))?; for (key, value) in entries { let value = value.as_str().ok_or_else(|| { anyhow!( "Invalid value for prompt argument '{key}': prompt arguments are strings" ) })?; provided.insert(key.clone(), value.to_string()); } } let declared = ctx .tool_scope .mcp_runtime .list_prompts(server) .await? .into_iter() .find(|candidate| candidate.name == prompt) .ok_or_else(|| { anyhow!( "Prompt '{prompt}' not found on MCP server '{server}'; call the describe \ meta-tool with kind \"prompt\" to list available prompts" ) })? .arguments .unwrap_or_default(); let (arguments, missing) = resolve_prompt_args(&declared, provided); if !missing.is_empty() { bail!( "Missing required prompt argument(s): {}. Provide them as string values in \ 'arguments'.", missing.join(", ") ); } let result = ctx .tool_scope .mcp_runtime .prompt(server, prompt, arguments) .await?; Ok(Value::String(flatten_prompt_messages(&result.messages))) } fn extract_call_config_from_agent( &self, functions: &Functions, agent: &Agent, ) -> Result { let function_name = self.name.clone(); match agent.functions().find(&function_name) { Some(function) => { let agent_name = agent.name().to_string(); if function.agent { Ok(( format!("{agent_name}-{function_name}"), agent_name, vec![function_name], agent.variable_envs(), )) } else { Ok(( function_name.clone(), function_name, vec![], agent.variable_envs(), )) } } None => self.extract_call_config_from_ctx(functions), } } fn extract_call_config_from_ctx(&self, functions: &Functions) -> Result { let function_name = self.name.clone(); match functions.contains(&function_name) { true => Ok(( function_name.clone(), function_name, vec![], Default::default(), )), false => bail!("Unexpected call: {function_name} {}", self.arguments), } } } fn expand_uri_template(template: &str, args: &serde_json::Map) -> Result { let mut expanded = String::with_capacity(template.len()); let mut rest = template; while let Some(start) = rest.find('{') { expanded.push_str(&rest[..start]); let after = &rest[start + 1..]; let Some(len) = after.find('}') else { bail!("Unclosed '{{' in URI template: {template}"); }; expanded.push_str(&expand_uri_template_variable(&after[..len], args)?); rest = &after[len + 1..]; } expanded.push_str(rest); Ok(expanded) } fn expand_uri_template_variable( expr: &str, args: &serde_json::Map, ) -> Result { const LEVEL_1_ONLY: &str = "only RFC 6570 Level 1 simple substitution {var} is supported"; if let Some(operator) = expr.chars().next().filter(|c| "+#./;?&".contains(*c)) { let name = match operator { '+' => "reserved-expansion", '#' => "fragment-expansion", '.' => "label-expansion", '/' => "path-segment-expansion", ';' => "path-style-parameter-expansion", '?' => "form-style-query-expansion", _ => "form-style-query-continuation", }; bail!( "The '{operator}' {name} operator in '{{{expr}}}' requires RFC 6570 Level 2 or \ higher; {LEVEL_1_ONLY}" ); } if expr.contains(',') { bail!( "The ',' multi-variable expression '{{{expr}}}' requires RFC 6570 Level 3; {LEVEL_1_ONLY}" ); } if expr.contains(':') { bail!("The ':' prefix modifier in '{{{expr}}}' requires RFC 6570 Level 4; {LEVEL_1_ONLY}"); } if expr.ends_with('*') { bail!("The '*' explode modifier in '{{{expr}}}' requires RFC 6570 Level 4; {LEVEL_1_ONLY}"); } if expr.is_empty() || !expr .chars() .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.') { bail!("Invalid variable name '{{{expr}}}' in URI template; expected [A-Za-z0-9_.]+"); } let value = args .get(expr) .ok_or_else(|| anyhow!("URI template variable '{expr}' is missing from 'arguments'"))?; let text = match value { Value::String(text) => text.clone(), Value::Number(number) => number.to_string(), Value::Bool(boolean) => boolean.to_string(), other => bail!( "URI template variable '{expr}' must be a string, number, or boolean; got {other}" ), }; Ok(urlencoding::encode(&text).into_owned()) } #[derive(Debug)] enum ResourceContentBody { Text(String), Blob(String), } // rmcp's untagged ResourceContents enum cannot represent malformed items // (both or neither of text/blob), so classification happens on the raw Value. fn parse_resource_content(item: &Value) -> Result { let text = item.get("text"); let blob = item.get("blob"); match (text, blob) { (Some(text), None) => Ok(ResourceContentBody::Text( text.as_str() .ok_or_else(|| anyhow!("Resource content 'text' is not a string"))? .to_string(), )), (None, Some(blob)) => Ok(ResourceContentBody::Blob( blob.as_str() .ok_or_else(|| anyhow!("Resource content 'blob' is not a string"))? .to_string(), )), (Some(_), Some(_)) => { bail!("Resource content item has both 'text' and 'blob'; expected exactly one") } (None, None) => { bail!("Resource content item has neither 'text' nor 'blob'; expected exactly one") } } } fn render_resource_content( item: &Value, pattern: Option<&str>, offset: usize, max_bytes: Option, server: &str, ) -> Result { let uri = item .get("uri") .and_then(Value::as_str) .map(render::clamp_metadata); let mime_type = item .get("mimeType") .and_then(Value::as_str) .map(render::clamp_metadata); let text = match parse_resource_content(item)? { ResourceContentBody::Text(text) => text, ResourceContentBody::Blob(blob) => { match render::render_blob(&blob, mime_type.as_deref(), server)? { render::RenderedBlob::Text(text) => text, render::RenderedBlob::Spilled(meta) => { let mut value = serde_json::to_value(meta)?; if let Some(map) = value.as_object_mut() { map.insert("uri".to_string(), json!(uri)); } return Ok(value); } } } }; let rendered = render::render_text(&text, pattern, offset, max_bytes)?; let mut value = json!({ "uri": uri, "mime_type": mime_type, "text": rendered.text, "truncated": rendered.truncated, "total_bytes": rendered.total_bytes, "next_offset": rendered.next_offset, }); if let Some(next_offset) = rendered.next_offset { value["note"] = json!(format!( "Content truncated; re-call with offset={next_offset} to continue (max_bytes is \ clamped to {})", render::TEXT_MAX_BYTES_CLAMP )); } Ok(value) } // Terminal-only rendering of an MCP dispatch error: escape sequences are // stripped so a hostile server cannot drive the terminal, while the JSON // payload keeps the raw message. fn mcp_error_display(error_msg: &str) -> String { sanitize_display_text(&format!("⚠️ {error_msg} ⚠️")) } /// Bounds a raw `CallToolResult` JSON value: oversized text is sliced, /// base64 blob content is routed through the blob renderer instead of /// reaching model context, and oversized structured content is replaced with /// a truncation marker. In-bounds results pass through unchanged. fn render_tool_result(mut result: Value, server: &str) -> Result { let Some(map) = result.as_object_mut() else { return Ok(result); }; if let Some(items) = map.get_mut("content").and_then(Value::as_array_mut) { for item in items { render_tool_content_item(item, server)?; } } let oversized_structured = map .get("structuredContent") .is_some_and(|structured| structured.to_string().len() > render::TEXT_MAX_BYTES_CLAMP); if oversized_structured { map.insert( "structuredContent".to_string(), json!({ "truncated": true, "note": format!( "structuredContent omitted: its serialized form exceeds \ TEXT_MAX_BYTES_CLAMP ({} bytes); re-call the tool with narrower \ arguments", render::TEXT_MAX_BYTES_CLAMP ), }), ); } Ok(result) } fn render_tool_content_item(item: &mut Value, server: &str) -> Result<()> { match item.get("type").and_then(Value::as_str) { Some("text") => clamp_tool_text(item), Some("image") | Some("audio") => { let mime_type = item .get("mimeType") .and_then(Value::as_str) .map(render::clamp_metadata); if let Some(data) = item.get("data").and_then(Value::as_str) { let replacement = render_tool_blob(data, mime_type, None, server)?; *item = replacement; } } Some("resource") => { let Some(resource) = item.get("resource") else { return Ok(()); }; let uri = resource .get("uri") .and_then(Value::as_str) .map(render::clamp_metadata); let mime_type = resource .get("mimeType") .and_then(Value::as_str) .map(render::clamp_metadata); if let Some(blob) = resource.get("blob").and_then(Value::as_str) { let replacement = render_tool_blob(blob, mime_type, uri, server)?; *item = replacement; } else if let Some(resource) = item.get_mut("resource") { clamp_tool_text(resource); clamp_metadata_field(resource, "uri"); clamp_metadata_field(resource, "mimeType"); } } _ => {} } Ok(()) } fn render_tool_blob( b64: &str, mime_type: Option, uri: Option, server: &str, ) -> Result { let mut value = match render::render_blob(b64, mime_type.as_deref(), server) { Ok(render::RenderedBlob::Text(text)) => { let mut item = json!({ "type": "text", "text": text }); clamp_tool_text(&mut item); item } Ok(render::RenderedBlob::Spilled(meta)) => serde_json::to_value(meta)?, // One undecodable item must not sink the rest of the result. Err(error) => json!({ "error": format!("Failed to render blob content: {error}") }), }; if let Some(map) = value.as_object_mut() { if let Some(mime_type) = mime_type && !map.contains_key("mime_type") { map.insert("mime_type".to_string(), json!(mime_type)); } if let Some(uri) = uri { map.insert("uri".to_string(), json!(uri)); } } Ok(value) } fn clamp_tool_text(container: &mut Value) { let Some(text) = container.get("text").and_then(Value::as_str) else { return; }; if text.len() <= render::TEXT_MAX_BYTES_CLAMP { return; } let total_bytes = text.len(); let clamped = render::truncate_utf8(text, render::TEXT_MAX_BYTES_CLAMP).to_string(); let Some(map) = container.as_object_mut() else { return; }; map.insert("text".to_string(), json!(clamped)); map.insert("truncated".to_string(), json!(true)); map.insert("total_bytes".to_string(), json!(total_bytes)); map.insert( "note".to_string(), json!(format!( "Text truncated; re-call the tool with narrower arguments (text is clamped to \ TEXT_MAX_BYTES_CLAMP = {} bytes)", render::TEXT_MAX_BYTES_CLAMP )), ); } fn clamp_metadata_field(object: &mut Value, key: &str) { let Some(text) = object.get(key).and_then(Value::as_str) else { return; }; if text.len() <= render::METADATA_MAX_BYTES { return; } let clamped = render::clamp_metadata(text); object[key] = json!(clamped); } pub fn run_llm_function( cmd_name: String, cmd_args: Vec, mut envs: HashMap, agent_name: Option, ) -> Result> { let mut bin_dirs: Vec = vec![]; let mut command_name = cmd_name.clone(); if let Some(agent_name) = agent_name { command_name = cmd_args[0].clone(); let dir = paths::agent_bin_dir(&agent_name); if dir.exists() { bin_dirs.push(dir); } if graph::agent_has_graph(&agent_name) { envs.insert("AUTO_CONFIRM".into(), "true".into()); } } else { bin_dirs.push(paths::functions_bin_dir()); } let current_path = env::var("PATH").context("No PATH environment variable")?; let prepend_path = bin_dirs .iter() .map(|v| format!("{}{PATH_SEP}", v.display())) .collect::>() .join(""); envs.insert("PATH".into(), format!("{prepend_path}{current_path}")); let tmp_file = temp_file("-eval-", ""); envs.insert("LLM_OUTPUT".into(), tmp_file.display().to_string()); #[cfg(windows)] let cmd_name = polyfill_cmd_name(&cmd_name, &bin_dirs); #[cfg(windows)] let cmd_args = { let mut args = cmd_args; if let Some(json_data) = args.pop() { let tool_data_file = temp_file("-tool-data-", ".json"); fs::write(&tool_data_file, &json_data)?; envs.insert( "LLM_TOOL_DATA_FILE".into(), tool_data_file.display().to_string(), ); } args }; envs.insert("CLICOLOR_FORCE".into(), "1".into()); envs.insert("FORCE_COLOR".into(), "1".into()); let mut child = Command::new(&cmd_name) .args(&cmd_args) .envs(envs) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|err| anyhow!("Unable to run {command_name}, {err}"))?; let stdout = child.stdout.take().expect("Failed to capture stdout"); let stderr = child.stderr.take().expect("Failed to capture stderr"); let stdout_thread = thread::spawn(move || { let mut buffer = [0; 1024]; let mut reader = stdout; let mut out = io::stdout(); let mut buf = Vec::new(); while let Ok(n) = reader.read(&mut buffer) { if n == 0 { break; } let chunk = &buffer[0..n]; buf.extend_from_slice(chunk); let mut last_pos = 0; for (i, &byte) in chunk.iter().enumerate() { if byte == b'\n' { let _ = out.write_all(&chunk[last_pos..i]); let _ = out.write_all(b"\r\n"); last_pos = i + 1; } } if last_pos < n { let _ = out.write_all(&chunk[last_pos..n]); } let _ = out.flush(); } buf }); let stderr_thread = thread::spawn(move || { let mut buffer = [0; 1024]; let mut reader = stderr; let mut err = io::stderr(); let mut buf = Vec::new(); while let Ok(n) = reader.read(&mut buffer) { if n == 0 { break; } let chunk = &buffer[0..n]; buf.extend_from_slice(chunk); let mut last_pos = 0; for (i, &byte) in chunk.iter().enumerate() { if byte == b'\n' { let _ = err.write_all(&chunk[last_pos..i]); let _ = err.write_all(b"\r\n"); last_pos = i + 1; } } if last_pos < n { let _ = err.write_all(&chunk[last_pos..n]); } let _ = err.flush(); } buf }); let timeout_secs = env::var("COYOTE_TOOL_TIMEOUT") .ok() .and_then(|v| v.parse::().ok()) .unwrap_or(1800); let deadline = (timeout_secs > 0).then(|| Instant::now() + Duration::from_secs(timeout_secs)); let status = loop { match child.try_wait() { Ok(Some(status)) => break status, Ok(None) => {} Err(err) => bail!("Unable to run {command_name}, {err}"), } if let Some(deadline) = deadline && Instant::now() >= deadline { let _ = child.kill(); let _ = child.wait(); drop(stdout_thread); drop(stderr_thread); let tool_error_message = format!( "Tool call '{command_name}' timed out after {timeout_secs}s and was killed (set COYOTE_TOOL_TIMEOUT to adjust; 0 = unlimited)" ); eprintln!( "{}", muted_warning_text(&format!("⚠️ {tool_error_message} ⚠️")) ); let error_json = json!({"tool_call_error": tool_error_message}); debug!("Tool call error: {error_json:?}"); return Ok(Some(error_json.to_string())); } thread::sleep(Duration::from_millis(100)); }; let stdout_bytes = stdout_thread.join().unwrap_or_default(); let stderr_bytes = stderr_thread.join().unwrap_or_default(); let exit_code = status.code().unwrap_or_default(); if exit_code != 0 { let stderr = String::from_utf8_lossy(&stderr_bytes).trim().to_string(); let stdout = String::from_utf8_lossy(&stdout_bytes).trim().to_string(); let tool_error_message = format!("Tool call '{command_name}' exited with code {exit_code}"); eprintln!( "{}", muted_warning_text(&format!("⚠️ {tool_error_message} ⚠️")) ); let mut error_json = json!({"tool_call_error": tool_error_message}); if !stderr.is_empty() { error_json["stderr"] = json!(stderr); } if !stdout.is_empty() { error_json["stdout"] = json!(stdout); } if let Ok(contents) = fs::read_to_string(&tmp_file) && !contents.trim().is_empty() { error_json["output"] = json!(contents); } debug!("Tool call error: {error_json:?}"); return Ok(Some(error_json.to_string())); } let mut output = None; if tmp_file.exists() { let contents = fs::read_to_string(tmp_file).context("Failed to retrieve tool call output")?; if !contents.is_empty() { debug!("Tool {command_name} output: {}", contents); output = Some(contents); } }; Ok(output) } #[cfg(windows)] fn polyfill_cmd_name>(cmd_name: &str, bin_dir: &[T]) -> String { let cmd_name = cmd_name.to_string(); if let Ok(exts) = env::var("PATHEXT") { for name in exts.split(';').map(|ext| format!("{cmd_name}{ext}")) { for dir in bin_dir { let path = dir.as_ref().join(&name); if path.exists() { return name.to_string(); } } } } cmd_name } // Polling tools are expected to repeat; recording them would also let them // break up detection of a real loop in the calls they interleave with. const LOOP_TRACKER_EXEMPT_TOOLS: [&str; 4] = [ "job__check", "job__list", "agent__check", "agent__list_running", ]; fn is_loop_tracker_exempt(name: &str) -> bool { LOOP_TRACKER_EXEMPT_TOOLS.contains(&name) } #[derive(Debug, Clone)] pub struct ToolCallTracker { last_calls: VecDeque, max_repeats: usize, chain_len: usize, } impl ToolCallTracker { pub fn new(max_repeats: usize, chain_len: usize) -> Self { Self { last_calls: VecDeque::new(), max_repeats, chain_len, } } pub fn default() -> Self { Self::new(2, 3) } pub fn check_loop(&self, new_call: &ToolCall) -> Option { if is_loop_tracker_exempt(&new_call.name) { return None; } if self.last_calls.len() < self.max_repeats { return None; } if let Some(last) = self.last_calls.back() && self.calls_match(last, new_call) { let mut repeat_count = 1; for i in (1..self.last_calls.len()).rev() { if self.calls_match(&self.last_calls[i - 1], &self.last_calls[i]) { repeat_count += 1; if repeat_count >= self.max_repeats { return Some(self.create_loop_message()); } } else { break; } } } let start = self.last_calls.len().saturating_sub(self.chain_len); let chain: Vec<_> = self.last_calls.iter().skip(start).collect(); if chain.len() == self.chain_len { let mut is_repeating = true; for i in 0..chain.len() - 1 { if !self.calls_match(chain[i], chain[i + 1]) { is_repeating = false; break; } } if is_repeating && self.calls_match(chain[chain.len() - 1], new_call) { return Some(self.create_loop_message()); } } None } fn calls_match(&self, a: &ToolCall, b: &ToolCall) -> bool { a.name == b.name && a.arguments == b.arguments } fn create_loop_message(&self) -> String { let message = r#"{"error":{"message":"⚠️ Tool-call loop detected! ⚠️","code":400,"param":"Use the output of the last call to this function and parameter-set then move on to the next step of workflow, change tools/parameters called, or request assistance in the conversation sream"}}"#; if self.last_calls.len() >= self.chain_len { let start = self.last_calls.len().saturating_sub(self.chain_len); let chain: Vec<_> = self.last_calls.iter().skip(start).collect(); let mut loopset = "[".to_string(); for c in chain { loopset += format!("{{\"name\":{},\"parameters\":{}}},", c.name, c.arguments).as_str(); } let _ = loopset.pop(); loopset.push(']'); format!( "{},\"call_history\":{}}}}}", &message[..(&message.len() - 2)], loopset ) } else { message.to_string() } } pub fn record_call(&mut self, call: ToolCall) { if is_loop_tracker_exempt(&call.name) { return; } if self.last_calls.len() >= self.chain_len * self.max_repeats { self.last_calls.pop_front(); } self.last_calls.push_back(call); } } fn format_call_log(cmd_name: &str, cmd_args: &[String], json_data: &serde_json::Value) -> String { if *NO_COLOR { return format!("Call {cmd_name} {}", cmd_args.join(" ")); } let prefix_args = &cmd_args[..cmd_args.len().saturating_sub(1)]; let prefix = if prefix_args.is_empty() { String::new() } else { format!("{} ", dimmed_text(&prefix_args.join(" "))) }; format!( "{}{} {}{}", dimmed_text("Call "), cyan_bold_text(cmd_name), prefix, format_json_colored_keys(json_data), ) } fn format_json_colored_keys(value: &serde_json::Value) -> String { let serde_json::Value::Object(map) = value else { return dimmed_text(&value.to_string()); }; if map.is_empty() { return dimmed_text("{}"); } let pairs: Vec = map .iter() .map(|(k, v)| { let key = magenta_text(&format!("\"{k}\"")); format!("{}{}", key, dimmed_text(&format!(": {v}"))) }) .collect(); format!( "{}{}{}", dimmed_text("{"), pairs.join(&dimmed_text(", ")), dimmed_text("}") ) } #[cfg(test)] mod tests { use super::*; use crate::config::test_fixtures::{ FIXTURE_ANNOTATED_TEXT, FIXTURE_ANNOTATED_URI, FIXTURE_BLOB_BYTES, FIXTURE_BLOB_URI, FIXTURE_LOG_TEXT, FIXTURE_LOG_URI, FixtureServer, fixture_runtime, }; use crate::config::{Agent, AgentConfig, AppConfig, AppState, WorkingMode}; use crate::supervisor::escalation::{EscalationQueue, EscalationRequest}; use crate::supervisor::mailbox::Inbox; use crate::supervisor::notification::{agent_notification, job_notification}; use crate::supervisor::{ AgentExitStatus, AgentHandle, AgentResult, JobHandle, JobResult, JobState, JobStatus, Supervisor, }; use base64::Engine; use base64::engine::general_purpose::STANDARD; use jobs::RingBuf; use rmcp::model::{CallToolResult, ContentBlock}; use serde_json::json; use serial_test::serial; use std::sync::Arc; use std::{mem, process}; fn call(name: &str, id: Option<&str>) -> ToolCall { ToolCall::new(name.to_string(), json!({}), id.map(|s| s.to_string())) } fn call_with_args(name: &str, args: Value) -> ToolCall { ToolCall::new(name.to_string(), args, Some("id1".to_string())) } fn mcp_features(name: &str, tools: bool, resources: bool, prompts: bool) -> McpServerFeatures { McpServerFeatures { name: name.to_string(), tools, resources, prompts, } } fn tools_only(name: &str) -> McpServerFeatures { mcp_features(name, true, false, false) } fn run_async(f: F) -> F::Output { tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap() .block_on(f) } fn submit_escalation(queue: &EscalationQueue, id: &str) { let (tx, _rx) = tokio::sync::oneshot::channel(); queue.submit(EscalationRequest { id: id.to_string(), from_agent_id: "a1".into(), from_agent_name: "explore".into(), question: "What do?".into(), options: None, reply_tx: tx, }); } #[test] fn normalize_tool_result_substitutes_done_for_null() { assert_eq!(normalize_tool_result(Value::Null), json!("DONE")); } #[test] fn inject_escalation_notification_extends_object_output() { let mut result = ToolResult::new(call("t", Some("id-1")), json!({"status": "ok"})); inject_escalation_notification(&mut result, vec![json!({"escalation_id": "esc_1"})]); assert_eq!(result.output["status"], "ok"); assert_eq!( result.output["pending_escalations"], json!([{"escalation_id": "esc_1"}]) ); assert!( result.output["escalation_instruction"] .as_str() .unwrap() .contains("agent__reply_escalation") ); assert!(result.text.is_none()); } #[test] fn inject_escalation_notification_wraps_non_object_output() { let mut result = ToolResult::new(call("t", Some("id-1")), json!("DONE")); inject_escalation_notification(&mut result, vec![json!({"escalation_id": "esc_2"})]); assert_eq!(result.output["output"], json!("DONE")); assert_eq!( result.output["pending_escalations"], json!([{"escalation_id": "esc_2"}]) ); assert!(result.output["escalation_instruction"].is_string()); } fn ctx_with_registered_job(id: &str) -> RequestContext { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); let join_handle = rt.spawn(async { Ok(JobResult { output: Value::Null, exit_code: Some(0), output_bytes_captured: 0, }) }); mem::forget(rt); let handle = JobHandle { id: id.to_string(), tool: "execute_command".to_string(), started_at: Instant::now(), join_handle, abort_signal: create_abort_signal(), state: Arc::new(parking_lot::Mutex::new(JobState { status: JobStatus::Completed, pgid: None, })), output_buf: Arc::new(parking_lot::Mutex::new(RingBuf::default())), no_change_checks: 0, last_check_state: None, }; let mut sup = Supervisor::new(0, 3).with_max_concurrent_jobs(4); sup.register(handle).unwrap(); let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd); ctx.supervisor = Some(Arc::new(parking_lot::RwLock::new(sup))); ctx } #[test] fn merge_system_channel_noop_when_both_channels_empty() { let mut object_result = ToolResult::new(call("t", Some("id-1")), json!({"status": "ok"})); merge_system_channel(&mut object_result, vec![], vec![]); assert_eq!(object_result.output, json!({"status": "ok"})); let mut plain_result = ToolResult::new(call("t", Some("id-1")), json!("DONE")); merge_system_channel(&mut plain_result, vec![], vec![]); assert_eq!(plain_result.output, json!("DONE")); } #[test] fn merge_system_channel_escalations_only_matches_legacy_wrap_bytes() { let summary = vec![json!({"escalation_id": "esc_1"})]; let expected = json!({ "output": "DONE", "pending_escalations": summary, "escalation_instruction": "Child agents are BLOCKED waiting for your reply. \ Call agent__reply_escalation for each pending escalation to unblock them.", }); let mut result = ToolResult::new(call("t", Some("id-1")), json!("DONE")); merge_system_channel(&mut result, summary, vec![]); assert_eq!( serde_json::to_string(&result.output).unwrap(), serde_json::to_string(&expected).unwrap() ); } #[test] fn merge_system_channel_adds_notifications_without_escalation_keys() { let mut result = ToolResult::new(call("t", Some("id-1")), json!({"status": "ok"})); merge_system_channel(&mut result, vec![], vec![json!({"id": "job_1"})]); assert_eq!(result.output["status"], "ok"); assert_eq!( result.output["system_notifications"], json!([{"id": "job_1"}]) ); assert!( result.output["notification_instruction"] .as_str() .unwrap() .contains("next_action") ); assert!(result.output.get("pending_escalations").is_none()); assert!(result.output.get("escalation_instruction").is_none()); } #[test] fn merge_system_channel_wraps_non_object_once_with_both_channels() { let mut result = ToolResult::new(call("t", Some("id-1")), json!("DONE")); merge_system_channel( &mut result, vec![json!({"escalation_id": "esc_1"})], vec![json!({"id": "job_1"})], ); assert_eq!(result.output["output"], json!("DONE")); assert_eq!( result.output["pending_escalations"][0]["escalation_id"], "esc_1" ); assert_eq!(result.output["system_notifications"][0]["id"], "job_1"); let keys: Vec<&str> = result .output .as_object() .unwrap() .keys() .map(|k| k.as_str()) .collect(); assert_eq!( keys, vec![ "output", "pending_escalations", "escalation_instruction", "system_notifications", "notification_instruction" ] ); } #[test] fn drain_live_notifications_drops_unregistered_ids() { let ctx = ctx_with_registered_job("job_live"); ctx.notification_queue .push(job_notification("job_live", "execute_command", true)); ctx.notification_queue .push(job_notification("job_gone", "execute_command", true)); let live = drain_live_notifications(&ctx); assert_eq!(live.len(), 1); assert_eq!(live[0]["id"], "job_live"); assert!( ctx.notification_queue.drain().is_empty(), "drain must consume the queue" ); } #[test] fn drain_live_notifications_without_supervisor_drops_everything() { let ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd); ctx.notification_queue .push(job_notification("job_x", "execute_command", true)); assert!(drain_live_notifications(&ctx).is_empty()); } fn ctx_with_registered_agent(id: &str) -> RequestContext { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); let agent_id = id.to_string(); let join_handle = rt.spawn(async move { Ok(AgentResult { id: agent_id, agent_name: "explore".into(), output: String::new(), exit_status: AgentExitStatus::Completed, }) }); mem::forget(rt); let handle = AgentHandle { id: id.to_string(), agent_name: "explore".to_string(), depth: 1, inbox: Arc::new(Inbox::new()), abort_signal: create_abort_signal(), join_handle, child_supervisor: None, }; let mut sup = Supervisor::new(4, 3); sup.register(handle).unwrap(); let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd); ctx.supervisor = Some(Arc::new(parking_lot::RwLock::new(sup))); ctx } #[test] fn drain_live_notifications_keeps_registered_agent_events() { let ctx = ctx_with_registered_agent("agent_explore_1"); ctx.notification_queue .push(agent_notification("agent_explore_1", "explore", true)); let live = drain_live_notifications(&ctx); assert_eq!(live.len(), 1); assert_eq!(live[0]["event"], "agent_completed"); assert_eq!( live[0]["next_action"], "agent__collect --id agent_explore_1 for output" ); } #[test] fn drain_live_notifications_drops_collected_agent_events() { let ctx = ctx_with_registered_agent("agent_explore_1"); ctx.supervisor .as_ref() .unwrap() .write() .take("agent_explore_1") .unwrap(); ctx.notification_queue .push(agent_notification("agent_explore_1", "explore", true)); assert!(drain_live_notifications(&ctx).is_empty()); } #[test] fn eval_tool_calls_merges_notifications_at_depth_without_escalations() { let mut ctx = ctx_with_registered_job("job_n1"); ctx.current_depth = 1; let queue = ctx.ensure_root_escalation_queue(); submit_escalation(&queue, "esc_1"); ctx.notification_queue .push(job_notification("job_n1", "execute_command", true)); let calls = vec![call("unknown_tool", Some("id-1"))]; let results = run_async(eval_tool_calls(&mut ctx, calls)).unwrap(); let out = &results[0].output; assert_eq!(out["system_notifications"][0]["id"], "job_n1"); assert_eq!(out["system_notifications"][0]["event"], "job_completed"); assert!(out["notification_instruction"].is_string()); assert!( out.get("pending_escalations").is_none(), "escalations are root-only" ); } #[test] fn eval_tool_calls_merges_both_channels_onto_last_result() { let mut ctx = ctx_with_registered_job("job_n1"); let queue = ctx.ensure_root_escalation_queue(); submit_escalation(&queue, "esc_1"); ctx.notification_queue .push(job_notification("job_n1", "execute_command", false)); let calls = vec![call("unknown_tool", Some("id-1"))]; let results = run_async(eval_tool_calls(&mut ctx, calls)).unwrap(); let out = &results[0].output; assert_eq!(out["pending_escalations"][0]["escalation_id"], "esc_1"); assert_eq!(out["system_notifications"][0]["event"], "job_failed"); assert!( out.get("output").is_none(), "object outputs are extended in place, never wrapped" ); } #[test] fn eval_tool_calls_soft_fails_unknown_tool() { let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd); let calls = vec![call("__escalation_notification", Some("id-1"))]; let results = run_async(eval_tool_calls(&mut ctx, calls)).unwrap(); assert_eq!(results.len(), 1); let err = results[0].output["tool_call_error"].as_str().unwrap(); assert!(err.contains("Unexpected call")); assert!(err.contains("use only tools listed in your catalog")); } #[test] fn eval_tool_calls_injects_escalations_into_last_result() { let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd); let queue = ctx.ensure_root_escalation_queue(); submit_escalation(&queue, "esc_1"); let calls = vec![call("unknown_tool", Some("id-1"))]; let results = run_async(eval_tool_calls(&mut ctx, calls)).unwrap(); assert_eq!(results.len(), 1); assert!( results .iter() .all(|r| r.call.name != "__escalation_notification") ); let out = &results[0].output; assert!(out["tool_call_error"].is_string()); assert_eq!(out["pending_escalations"][0]["escalation_id"], "esc_1"); assert!( out["escalation_instruction"] .as_str() .unwrap() .contains("agent__reply_escalation") ); } #[cfg(unix)] #[test] fn job_finished_earlier_drains_notification_on_later_batch() { run_async(async { let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd); ctx.declared_function_names.insert("echo".into()); let started = jobs::handle_job_tool( &mut ctx, "job__start", &json!({"tool": "echo", "arguments": {}}), ) .await .unwrap(); assert_eq!(started["status"], "ok"); let job_id = started["job_id"].as_str().unwrap().to_string(); let supervisor = ctx.supervisor.clone().unwrap(); let deadline = tokio::time::Instant::now() + Duration::from_secs(5); while tokio::time::Instant::now() < deadline { let finished = supervisor .read() .job(&job_id) .is_none_or(|job| job.join_handle.is_finished()); if finished { break; } tokio::time::sleep(Duration::from_millis(10)).await; } let calls = vec![call("unknown_tool", Some("id-2"))]; let results = eval_tool_calls(&mut ctx, calls).await.unwrap(); let out = &results.last().unwrap().output; assert_eq!(out["system_notifications"][0]["id"], job_id); assert_eq!(out["system_notifications"][0]["event"], "job_completed"); assert!( out["notification_instruction"] .as_str() .unwrap() .contains("next_action") ); }); } #[test] fn normalize_tool_result_preserves_non_null_values() { assert_eq!( normalize_tool_result(json!({"output": "hi"})), json!({"output": "hi"}) ); assert_eq!(normalize_tool_result(json!("")), json!("")); assert_eq!(normalize_tool_result(json!(false)), json!(false)); } #[test] fn toolcall_new_sets_fields() { let tc = ToolCall::new("my_tool".into(), json!({"x": 1}), Some("call-1".into())); assert_eq!(tc.name, "my_tool"); assert_eq!(tc.arguments, json!({"x": 1})); assert_eq!(tc.id, Some("call-1".to_string())); assert!(tc.thought_signature.is_none()); } #[test] fn toolcall_default_has_empty_fields() { let tc = ToolCall::default(); assert_eq!(tc.name, ""); assert_eq!(tc.arguments, Value::Null); assert!(tc.id.is_none()); assert!(tc.thought_signature.is_none()); } #[test] fn direct_invoker_maps_each_language() { assert_eq!( Language::Bash.direct_invoker(), Some(("bash", &[] as &[&str])) ); assert_eq!( Language::Python.direct_invoker(), Some(("python3", &[] as &[&str])) ); assert_eq!( Language::TypeScript.direct_invoker(), Some(("npx", &["tsx"] as &[&str])) ); assert_eq!(Language::Unsupported.direct_invoker(), None); } #[test] fn toolcall_with_thought_signature() { let tc = ToolCall::new("t".into(), json!({}), None) .with_thought_signature(Some("sig123".into())); assert_eq!(tc.thought_signature, Some("sig123".to_string())); } #[test] fn toolcall_with_thought_signature_none() { let tc = ToolCall::new("t".into(), json!({}), None).with_thought_signature(None); assert!(tc.thought_signature.is_none()); } #[test] fn dedup_keeps_unique_ids() { let calls = vec![call("tool_a", Some("id-1")), call("tool_b", Some("id-2"))]; let result = ToolCall::dedup(calls); assert_eq!(result.len(), 2); } #[test] fn dedup_keeps_calls_without_ids() { let calls = vec![call("tool_a", None), call("tool_b", None)]; let result = ToolCall::dedup(calls); assert_eq!(result.len(), 2); } #[test] fn dedup_removes_duplicate_ids_keeps_last() { let calls = vec![call("tool_a", Some("id-1")), call("tool_b", Some("id-1"))]; let result = ToolCall::dedup(calls); assert_eq!(result.len(), 1); assert_eq!(result[0].name, "tool_b"); } #[test] fn dedup_empty_input_returns_empty() { let result = ToolCall::dedup(vec![]); assert!(result.is_empty()); } #[test] fn dedup_mixed_with_and_without_ids() { let calls = vec![ call("a", Some("id-1")), call("b", None), call("c", Some("id-1")), call("d", None), ]; let result = ToolCall::dedup(calls); assert_eq!(result.len(), 3); assert_eq!(result[0].name, "b"); assert_eq!(result[1].name, "c"); assert_eq!(result[2].name, "d"); } #[test] fn tracker_default_values() { let tracker = ToolCallTracker::default(); assert_eq!(tracker.max_repeats, 2); assert_eq!(tracker.chain_len, 3); assert!(tracker.last_calls.is_empty()); } #[test] fn tracker_no_loop_on_fresh_tracker() { let tracker = ToolCallTracker::default(); assert!(tracker.check_loop(&call("tool", None)).is_none()); } #[test] fn tracker_no_loop_below_threshold() { let mut tracker = ToolCallTracker::new(3, 5); let c = call_with_args("tool", json!({"a": 1})); tracker.record_call(c.clone()); tracker.record_call(c.clone()); assert!(tracker.check_loop(&c).is_none()); } #[test] fn tracker_detects_loop_at_max_repeats() { let mut tracker = ToolCallTracker::new(2, 3); let c = call_with_args("tool", json!({"a": 1})); tracker.record_call(c.clone()); tracker.record_call(c.clone()); let result = tracker.check_loop(&c); assert!(result.is_some()); assert!(result.unwrap().contains("loop")); } #[test] fn tracker_different_args_no_loop() { let mut tracker = ToolCallTracker::new(2, 3); tracker.record_call(call_with_args("tool", json!({"a": 1}))); tracker.record_call(call_with_args("tool", json!({"a": 2}))); let new_call = call_with_args("tool", json!({"a": 3})); assert!(tracker.check_loop(&new_call).is_none()); } #[test] fn tracker_different_names_no_loop() { let mut tracker = ToolCallTracker::new(2, 3); tracker.record_call(call_with_args("tool_a", json!({}))); tracker.record_call(call_with_args("tool_b", json!({}))); let new_call = call_with_args("tool_a", json!({})); assert!(tracker.check_loop(&new_call).is_none()); } #[test] fn tracker_chain_detection() { let mut tracker = ToolCallTracker::new(2, 3); let c = call_with_args("tool", json!({"x": "same"})); tracker.record_call(c.clone()); tracker.record_call(c.clone()); tracker.record_call(c.clone()); let result = tracker.check_loop(&c); assert!(result.is_some()); } #[test] fn tracker_record_call_respects_capacity() { let mut tracker = ToolCallTracker::new(2, 2); for i in 0..10 { tracker.record_call(call_with_args(&format!("tool_{i}"), json!({}))); } assert!(tracker.last_calls.len() <= 2 * 2); } #[test] fn tracker_loop_message_contains_call_history() { let mut tracker = ToolCallTracker::new(2, 3); let c = call_with_args("repeat_tool", json!({"k": "v"})); tracker.record_call(c.clone()); tracker.record_call(c.clone()); tracker.record_call(c.clone()); let msg = tracker.check_loop(&c).unwrap(); assert!(msg.contains("call_history")); assert!(msg.contains("repeat_tool")); } #[test] fn loop_tracker_exempt_list_is_exactly_the_polling_tools() { let actual: HashSet<&str> = LOOP_TRACKER_EXEMPT_TOOLS.iter().copied().collect(); let expected: HashSet<&str> = [ "job__check", "job__list", "agent__check", "agent__list_running", ] .into_iter() .collect(); assert_eq!(LOOP_TRACKER_EXEMPT_TOOLS.len(), 4); assert_eq!(actual, expected); } #[test] fn tracker_exempt_tools_never_trip() { for name in LOOP_TRACKER_EXEMPT_TOOLS { let mut tracker = ToolCallTracker::default(); let exempt = call_with_args(name, json!({"id": "j1"})); tracker.record_call(exempt.clone()); tracker.record_call(exempt.clone()); assert!(tracker.check_loop(&exempt).is_none()); let other = call_with_args("execute_command", json!({"command": "ls"})); tracker.record_call(other.clone()); assert!( tracker.check_loop(&other).is_none(), "exempt calls must not count toward the repeat threshold" ); tracker.record_call(other.clone()); assert!(tracker.check_loop(&other).is_some()); } } #[test] fn tracker_exempt_interleave_does_not_mask_real_loop() { let mut tracker = ToolCallTracker::default(); let x = call_with_args("execute_command", json!({"command": "ls"})); tracker.record_call(call_with_args("job__check", json!({"id": "j1"}))); tracker.record_call(x.clone()); tracker.record_call(call_with_args("job__check", json!({"id": "j1"}))); tracker.record_call(x.clone()); assert!(tracker.check_loop(&x).is_some()); } #[test] fn tracker_non_exempt_behavior_unchanged() { let mut tracker = ToolCallTracker::default(); let c = call_with_args("fs_cat", json!({"path": "a.txt"})); tracker.record_call(c.clone()); tracker.record_call(c.clone()); assert!(tracker.check_loop(&c).is_some()); } #[test] fn prefix_constants_are_correct() { assert_eq!(TODO_FUNCTION_PREFIX, "todo__"); assert_eq!(AGENT_FUNCTION_PREFIX, "agent__"); assert_eq!(USER_FUNCTION_PREFIX, "user__"); assert_eq!(MCP_INVOKE_META_FUNCTION_NAME_PREFIX, "mcp_invoke"); assert_eq!(MCP_SEARCH_META_FUNCTION_NAME_PREFIX, "mcp_search"); assert_eq!(MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, "mcp_describe"); assert_eq!(MCP_READ_META_FUNCTION_NAME_PREFIX, "mcp_read"); assert_eq!(MCP_PROMPT_META_FUNCTION_NAME_PREFIX, "mcp_prompt"); } #[test] fn functions_default_is_empty() { let f = Functions::default(); assert!(f.is_empty()); assert!(f.declarations().is_empty()); } #[test] fn bundled_bash_tools_generate_declarations() { let tools_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/functions/tools"); let mut checked = Vec::new(); for entry in std::fs::read_dir(&tools_dir).unwrap() { let path = entry.unwrap().path(); if path.extension().and_then(OsStr::to_str) != Some("sh") { continue; } let name = path.file_stem().unwrap().to_string_lossy().to_string(); let declarations = Functions::generate_declarations(&path) .unwrap_or_else(|e| panic!("bundled tool '{name}' failed to parse: {e}")); assert!( !declarations.is_empty(), "bundled tool '{name}' produced no function declaration" ); checked.push(name); } for expected in ["fs_grep", "ast_grep", "execute_command"] { assert!( checked.iter().any(|n| n == expected), "expected bundled tool '{expected}' to be checked; found {checked:?}" ); } } #[test] fn functions_append_todo_adds_declarations() { let mut f = Functions::default(); f.append_todo_functions(); assert!(!f.is_empty()); assert!(f.contains("todo__init")); assert!(f.contains("todo__add")); assert!(f.contains("todo__done")); assert!(f.contains("todo__list")); assert!(f.contains("todo__clear")); } #[test] fn functions_append_supervisor_adds_declarations() { let mut f = Functions::default(); f.append_supervisor_functions(); assert!(f.contains("agent__spawn")); assert!(f.contains("agent__check")); assert!(f.contains("agent__collect")); assert!(f.contains("agent__list_running")); assert!(f.contains("agent__list_available")); assert!(f.contains("agent__cancel")); assert!(f.contains("agent__reply_escalation")); } #[test] fn functions_append_job_adds_declarations() { let mut f = Functions::default(); f.append_job_functions(); assert!(f.contains("job__start")); assert!(f.contains("job__check")); assert!(f.contains("job__collect")); assert!(f.contains("job__cancel")); assert!(f.contains("job__list")); } #[test] fn eval_routes_declared_job_calls_to_job_handlers() { let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd); ctx.tool_scope.functions.append_job_functions(); let calls = vec![call("job__list", Some("id-1"))]; let results = run_async(eval_tool_calls(&mut ctx, calls)).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].output["active_jobs"], 0); assert_eq!(results[0].output["jobs"], json!([])); } #[test] fn eval_soft_fails_job_calls_when_jobs_not_declared() { let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd); let calls = vec![call("job__start", Some("id-1"))]; let results = run_async(eval_tool_calls(&mut ctx, calls)).unwrap(); let err = results[0].output["tool_call_error"].as_str().unwrap(); assert!(err.contains("Unexpected call")); } #[test] fn functions_append_teammate_adds_declarations() { let mut f = Functions::default(); f.append_teammate_functions(); assert!(f.contains("agent__send_message")); assert!(f.contains("agent__check_inbox")); } #[test] fn functions_append_user_interaction_adds_declarations() { let mut f = Functions::default(); f.append_user_interaction_functions(); assert!(f.contains("user__select")); assert!(f.contains("user__confirm")); assert!(f.contains("user__input")); assert!(f.contains("user__checkbox")); } #[test] fn functions_append_mcp_meta_creates_three_per_server() { let mut f = Functions::default(); f.append_mcp_meta_functions(vec![tools_only("github")]); assert_eq!(f.declarations().len(), 3); assert!(f.contains("mcp_invoke_github")); assert!(f.contains("mcp_search_github")); assert!(f.contains("mcp_describe_github")); } #[test] fn functions_append_mcp_meta_multiple_servers() { let mut f = Functions::default(); f.append_mcp_meta_functions(vec![tools_only("github"), tools_only("slack")]); assert_eq!(f.declarations().len(), 6); assert!(f.contains("mcp_invoke_github")); assert!(f.contains("mcp_invoke_slack")); } #[test] fn functions_append_mcp_meta_empty_servers() { let mut f = Functions::default(); f.append_mcp_meta_functions(vec![]); assert!(f.is_empty()); } #[test] fn functions_append_mcp_meta_resources_only_omits_invoke() { let mut f = Functions::default(); f.append_mcp_meta_functions(vec![mcp_features("res", false, true, false)]); assert_eq!(f.declarations().len(), 3); assert!(!f.contains("mcp_invoke_res")); assert!(!f.contains("mcp_prompt_res")); assert!(f.contains("mcp_search_res")); assert!(f.contains("mcp_describe_res")); assert!(f.contains("mcp_read_res")); } #[test] fn functions_append_mcp_meta_all_capabilities_emits_five() { let mut f = Functions::default(); f.append_mcp_meta_functions(vec![mcp_features("srv", true, true, true)]); assert_eq!(f.declarations().len(), 5); assert!(f.contains("mcp_invoke_srv")); assert!(f.contains("mcp_search_srv")); assert!(f.contains("mcp_describe_srv")); assert!(f.contains("mcp_read_srv")); assert!(f.contains("mcp_prompt_srv")); } #[test] fn features_from_missing_capabilities_fail_open_for_tools() { let features = McpServerFeatures::from_capabilities("srv", None); assert!(features.tools); assert!(!features.resources); assert!(!features.prompts); let mut f = Functions::default(); f.append_mcp_meta_functions(vec![features]); assert!(f.contains("mcp_invoke_srv")); } #[test] fn gated_prefixes_tools_only() { assert_eq!( gated_meta_function_prefixes(&mcp_features("srv", true, false, false)), vec![ MCP_INVOKE_META_FUNCTION_NAME_PREFIX, MCP_SEARCH_META_FUNCTION_NAME_PREFIX, MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, ] ); } #[test] fn gated_prefixes_tools_and_resources_include_read() { assert_eq!( gated_meta_function_prefixes(&mcp_features("srv", true, true, false)), vec![ MCP_INVOKE_META_FUNCTION_NAME_PREFIX, MCP_SEARCH_META_FUNCTION_NAME_PREFIX, MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_READ_META_FUNCTION_NAME_PREFIX, ] ); } #[test] fn gated_prefixes_tools_and_prompts_include_prompt() { assert_eq!( gated_meta_function_prefixes(&mcp_features("srv", true, false, true)), vec![ MCP_INVOKE_META_FUNCTION_NAME_PREFIX, MCP_SEARCH_META_FUNCTION_NAME_PREFIX, MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_PROMPT_META_FUNCTION_NAME_PREFIX, ] ); } #[test] fn gated_prefixes_all_capabilities_include_all() { assert_eq!( gated_meta_function_prefixes(&mcp_features("srv", true, true, true)), MCP_META_FUNCTION_PREFIXES.to_vec() ); } #[test] fn gated_prefixes_resources_only_omit_invoke() { assert_eq!( gated_meta_function_prefixes(&mcp_features("srv", false, true, false)), vec![ MCP_SEARCH_META_FUNCTION_NAME_PREFIX, MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_READ_META_FUNCTION_NAME_PREFIX, ] ); } #[test] fn gated_prefixes_prompts_only_omit_invoke_and_read() { assert_eq!( gated_meta_function_prefixes(&mcp_features("srv", false, false, true)), vec![ MCP_SEARCH_META_FUNCTION_NAME_PREFIX, MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_PROMPT_META_FUNCTION_NAME_PREFIX, ] ); } #[test] fn gated_prefixes_resources_and_prompts_omit_invoke() { assert_eq!( gated_meta_function_prefixes(&mcp_features("srv", false, true, true)), vec![ MCP_SEARCH_META_FUNCTION_NAME_PREFIX, MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_READ_META_FUNCTION_NAME_PREFIX, MCP_PROMPT_META_FUNCTION_NAME_PREFIX, ] ); } #[test] fn gated_prefixes_no_capabilities_keep_search_and_describe() { assert_eq!( gated_meta_function_prefixes(&mcp_features("srv", false, false, false)), vec![ MCP_SEARCH_META_FUNCTION_NAME_PREFIX, MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, ] ); } #[test] fn functions_find_returns_declaration() { let mut f = Functions::default(); f.append_todo_functions(); let decl = f.find("todo__init"); assert!(decl.is_some()); assert_eq!(decl.unwrap().name, "todo__init"); } #[test] fn functions_find_returns_none_for_missing() { let f = Functions::default(); assert!(f.find("nonexistent").is_none()); } #[test] fn functions_contains_true_for_existing() { let mut f = Functions::default(); f.append_todo_functions(); assert!(f.contains("todo__init")); } #[test] fn functions_contains_false_for_missing() { let f = Functions::default(); assert!(!f.contains("todo__init")); } #[test] fn functions_mcp_invoke_declaration_has_tool_and_arguments_params() { let mut f = Functions::default(); f.append_mcp_meta_functions(vec![tools_only("srv")]); let decl = f.find("mcp_invoke_srv").unwrap(); let props = decl.parameters.properties.as_ref().unwrap(); assert!(props.contains_key("tool")); assert!(props.contains_key("arguments")); let required = decl.parameters.required.as_ref().unwrap(); assert!(required.contains(&"tool".to_string())); } #[test] fn functions_mcp_search_declaration_has_query_and_top_k_params() { let mut f = Functions::default(); f.append_mcp_meta_functions(vec![tools_only("srv")]); let decl = f.find("mcp_search_srv").unwrap(); let props = decl.parameters.properties.as_ref().unwrap(); assert!(props.contains_key("query")); assert!(props.contains_key("top_k")); } #[test] fn functions_mcp_describe_declaration_has_tool_param() { let mut f = Functions::default(); f.append_mcp_meta_functions(vec![tools_only("srv")]); let decl = f.find("mcp_describe_srv").unwrap(); let props = decl.parameters.properties.as_ref().unwrap(); assert!(props.contains_key("tool")); } #[test] fn functions_mcp_describe_declaration_has_optional_kind_param() { let mut f = Functions::default(); f.append_mcp_meta_functions(vec![tools_only("srv")]); let decl = f.find("mcp_describe_srv").unwrap(); let props = decl.parameters.properties.as_ref().unwrap(); let kind = props.get("kind").unwrap(); assert_eq!(kind.default, Some(Value::from("tool"))); let required = decl.parameters.required.as_ref().unwrap(); assert_eq!(required, &vec!["tool".to_string()]); } #[test] fn eval_mcp_describe_without_kind_defaults_to_tool() { let output = run_async(async { let (runtime, _server) = fixture_runtime(FixtureServer::default()).await; let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd); ctx.tool_scope.mcp_runtime = runtime; let call = call_with_args("mcp_describe_fixture", json!({"tool": "dup"})); call.eval_mcp(&ctx).await }) .unwrap(); assert_eq!( output, json!({ "type": "object", "properties": { "tool": { "type": "string" }, "arguments": { "type": "object", "properties": { "q": { "type": "string" } } } } }) ); } fn template_args(pairs: &[(&str, Value)]) -> serde_json::Map { pairs .iter() .map(|(key, value)| (key.to_string(), value.clone())) .collect() } fn resources_fixture() -> FixtureServer { FixtureServer { resources_capability: true, ..Default::default() } } async fn eval_mcp_read(args: Value) -> Result { let (runtime, _server) = fixture_runtime(resources_fixture()).await; let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd); ctx.tool_scope.mcp_runtime = runtime; call_with_args("mcp_read_fixture", args) .eval_mcp(&ctx) .await } fn prompts_fixture() -> FixtureServer { FixtureServer { prompts_capability: true, ..Default::default() } } async fn eval_mcp_prompt(args: Value) -> Result { let (runtime, _server) = fixture_runtime(prompts_fixture()).await; let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd); ctx.tool_scope.mcp_runtime = runtime; call_with_args("mcp_prompt_fixture", args) .eval_mcp(&ctx) .await } const FLATTENED_SUMMARIZE_PROMPT: &str = "[user]\nSummarize notes.txt\n\n[assistant]\nIn which style?\n\n[user]\nConcise."; #[test] fn expand_uri_template_substitutes_simple_vars() { let args = template_args(&[("path", json!("docs")), ("name", json!("readme"))]); assert_eq!( expand_uri_template("file:///{path}/{name}", &args).unwrap(), "file:///docs/readme" ); } #[test] fn expand_uri_template_stringifies_numbers_and_bools() { let args = template_args(&[("id", json!(42)), ("flag", json!(true))]); assert_eq!( expand_uri_template("item://{id}/{flag}", &args).unwrap(), "item://42/true" ); } #[test] fn expand_uri_template_percent_encodes_values() { let args = template_args(&[("q", json!("a b/c✓"))]); assert_eq!( expand_uri_template("search://{q}", &args).unwrap(), "search://a%20b%2Fc%E2%9C%93" ); } #[test] fn expand_uri_template_without_placeholders_is_noop() { let args = template_args(&[("unused", json!("x"))]); assert_eq!( expand_uri_template("file:///static", &args).unwrap(), "file:///static" ); } #[test] fn expand_uri_template_missing_variable_names_it() { let err = expand_uri_template("file:///{path}", &template_args(&[])) .unwrap_err() .to_string(); assert!(err.contains("'path'"), "{err}"); assert!(err.contains("missing"), "{err}"); } #[test] fn expand_uri_template_rejects_higher_level_operators() { let args = template_args(&[("var", json!("v"))]); for operator in ["+", "#", ".", "/", ";", "?", "&"] { let err = expand_uri_template(&format!("x://{{{operator}var}}"), &args) .unwrap_err() .to_string(); assert!(err.contains(&format!("'{operator}'")), "{err}"); assert!(err.contains("Level 1 simple substitution"), "{err}"); } } #[test] fn expand_uri_template_rejects_modifiers_and_multi_vars() { let args = template_args(&[("a", json!("v")), ("b", json!("w")), ("var", json!("v"))]); for (template, construct) in [ ("x://{var*}", "'*' explode modifier"), ("x://{var:3}", "':' prefix modifier"), ("x://{a,b}", "',' multi-variable"), ] { let err = expand_uri_template(template, &args) .unwrap_err() .to_string(); assert!(err.contains(construct), "{err}"); assert!(err.contains("Level 1 simple substitution"), "{err}"); } } #[test] fn expand_uri_template_unclosed_brace_errors() { let err = expand_uri_template("file:///{path", &template_args(&[])) .unwrap_err() .to_string(); assert!(err.contains("Unclosed"), "{err}"); } #[test] fn expand_uri_template_rejects_invalid_variable_names() { let err = expand_uri_template("x://{va r}", &template_args(&[])) .unwrap_err() .to_string(); assert!(err.contains("Invalid variable name"), "{err}"); } #[test] fn expand_uri_template_rejects_non_scalar_values() { for value in [json!(null), json!(["a"]), json!({"k": "v"})] { let args = template_args(&[("v", value)]); let err = expand_uri_template("x://{v}", &args) .unwrap_err() .to_string(); assert!(err.contains("string, number, or boolean"), "{err}"); } } #[test] fn parse_resource_content_classifies_by_field_presence() { assert!(matches!( parse_resource_content(&json!({"uri": "u", "text": "hi"})).unwrap(), ResourceContentBody::Text(text) if text == "hi" )); assert!(matches!( parse_resource_content(&json!({"uri": "u", "blob": "aGk="})).unwrap(), ResourceContentBody::Blob(blob) if blob == "aGk=" )); } #[test] fn parse_resource_content_rejects_both_and_neither() { let err = parse_resource_content(&json!({"text": "t", "blob": "b"})) .unwrap_err() .to_string(); assert!(err.contains("both"), "{err}"); let err = parse_resource_content(&json!({"uri": "u"})) .unwrap_err() .to_string(); assert!(err.contains("neither"), "{err}"); } #[test] fn functions_mcp_read_declaration_has_paging_params() { let mut f = Functions::default(); f.append_mcp_meta_functions(vec![mcp_features("srv", false, true, false)]); let decl = f.find("mcp_read_srv").unwrap(); let props = decl.parameters.properties.as_ref().unwrap(); for param in ["uri", "arguments", "pattern", "offset", "max_bytes"] { assert!(props.contains_key(param), "missing {param}"); } assert_eq!(props.len(), 5); assert_eq!(props["offset"].default, Some(Value::from(0usize))); assert_eq!( props["max_bytes"].default, Some(Value::from(render::DEFAULT_TEXT_MAX_BYTES)) ); assert_eq!(decl.parameters.required, Some(vec!["uri".to_string()])); } #[test] fn mcp_read_routes_through_the_concurrent_mcp_path() { assert!(is_mcp_meta_function("mcp_read_x")); } #[test] fn eval_mcp_read_returns_rendered_text() { let output = run_async(eval_mcp_read(json!({"uri": FIXTURE_LOG_URI}))).unwrap(); assert_eq!(output["uri"], FIXTURE_LOG_URI); assert_eq!(output["mime_type"], "text/plain"); assert_eq!(output["text"], FIXTURE_LOG_TEXT); assert_eq!(output["truncated"], false); assert_eq!(output["next_offset"], Value::Null); } #[test] fn eval_routes_mcp_read_to_resource_handler() { let output = run_async(async { let (runtime, _server) = fixture_runtime(resources_fixture()).await; let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd); ctx.tool_scope .functions .append_mcp_meta_functions(vec![mcp_features("fixture", true, true, false)]); ctx.tool_scope.mcp_runtime = runtime; let call = call_with_args("mcp_read_fixture", json!({"uri": FIXTURE_LOG_URI})); call.eval(&mut ctx).await }) .unwrap(); assert_eq!(output["text"], FIXTURE_LOG_TEXT); } #[test] fn functions_mcp_prompt_declaration_has_prompt_and_arguments_params() { let mut f = Functions::default(); f.append_mcp_meta_functions(vec![mcp_features("srv", false, false, true)]); let decl = f.find("mcp_prompt_srv").unwrap(); let props = decl.parameters.properties.as_ref().unwrap(); assert!(props.contains_key("prompt")); assert!(props.contains_key("arguments")); assert_eq!(props.len(), 2); assert_eq!(decl.parameters.required, Some(vec!["prompt".to_string()])); } #[test] fn eval_mcp_routes_mcp_prompt_to_prompt_handler() { let fixture = prompts_fixture(); let get_prompt_calls = Arc::clone(&fixture.get_prompt_calls); let call_tool_calls = Arc::clone(&fixture.call_tool_calls); let output = run_async(async { let (runtime, _server) = fixture_runtime(fixture).await; let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd); ctx.tool_scope.mcp_runtime = runtime; let call = call_with_args( "mcp_prompt_fixture", json!({"prompt": "summarize", "arguments": {"path": "notes.txt"}}), ); call.eval_mcp(&ctx).await }) .unwrap(); assert_eq!(output, Value::String(FLATTENED_SUMMARIZE_PROMPT.into())); assert_eq!(get_prompt_calls.load(Ordering::SeqCst), 1); assert_eq!(call_tool_calls.load(Ordering::SeqCst), 0); } #[test] fn eval_routes_mcp_prompt_to_prompt_handler() { let fixture = prompts_fixture(); let get_prompt_calls = Arc::clone(&fixture.get_prompt_calls); let call_tool_calls = Arc::clone(&fixture.call_tool_calls); let output = run_async(async { let (runtime, _server) = fixture_runtime(fixture).await; let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd); ctx.tool_scope .functions .append_mcp_meta_functions(vec![mcp_features("fixture", true, false, true)]); ctx.tool_scope.mcp_runtime = runtime; let call = call_with_args( "mcp_prompt_fixture", json!({"prompt": "summarize", "arguments": {"path": "notes.txt"}}), ); call.eval(&mut ctx).await }) .unwrap(); assert_eq!(output, Value::String(FLATTENED_SUMMARIZE_PROMPT.into())); assert_eq!(get_prompt_calls.load(Ordering::SeqCst), 1); assert_eq!(call_tool_calls.load(Ordering::SeqCst), 0); } #[test] fn eval_mcp_prompt_missing_required_arg_returns_teaching_error() { let output = run_async(eval_mcp_prompt(json!({"prompt": "summarize"}))).unwrap(); let err = output["tool_call_error"].as_str().unwrap(); assert!( err.contains("Missing required prompt argument(s): path"), "{err}" ); } #[test] fn eval_mcp_prompt_unknown_prompt_returns_teaching_error() { let output = run_async(eval_mcp_prompt(json!({"prompt": "ghost"}))).unwrap(); let err = output["tool_call_error"].as_str().unwrap(); assert!( err.contains("Prompt 'ghost' not found on MCP server 'fixture'"), "{err}" ); assert!(err.contains("kind \"prompt\""), "{err}"); } #[test] fn eval_mcp_prompt_rejects_non_string_argument_values() { let output = run_async(eval_mcp_prompt( json!({"prompt": "summarize", "arguments": {"path": 5}}), )) .unwrap(); let err = output["tool_call_error"].as_str().unwrap(); assert!(err.contains("prompt arguments are strings"), "{err}"); } #[test] fn eval_mcp_read_pages_text_with_offset() { let (page1, page2) = run_async(async { let (runtime, _server) = fixture_runtime(resources_fixture()).await; let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd); ctx.tool_scope.mcp_runtime = runtime; let call = call_with_args( "mcp_read_fixture", json!({"uri": FIXTURE_LOG_URI, "max_bytes": 20}), ); let page1 = call.eval_mcp(&ctx).await.unwrap(); let call = call_with_args( "mcp_read_fixture", json!({"uri": FIXTURE_LOG_URI, "offset": page1["next_offset"], "max_bytes": 20}), ); let page2 = call.eval_mcp(&ctx).await.unwrap(); (page1, page2) }); assert_eq!(page1["truncated"], true); assert!(page1["note"].as_str().unwrap().contains("204800")); let text1 = page1["text"].as_str().unwrap(); let text2 = page2["text"].as_str().unwrap(); assert!(!text2.is_empty()); assert!(FIXTURE_LOG_TEXT.starts_with(&format!("{text1}{text2}"))); } #[test] fn eval_mcp_read_pattern_filters_lines_with_context() { let output = run_async(eval_mcp_read( json!({"uri": FIXTURE_LOG_URI, "pattern": "café"}), )) .unwrap(); let text = output["text"].as_str().unwrap(); assert!(text.contains("6:ERROR: café overheated"), "{text}"); assert!(text.contains("4-fourth line"), "{text}"); assert!(!text.contains("disk full"), "{text}"); assert_eq!(output["total_bytes"], text.len()); } #[test] fn eval_mcp_read_invalid_pattern_returns_teaching_error() { let output = run_async(eval_mcp_read( json!({"uri": FIXTURE_LOG_URI, "pattern": "("}), )) .unwrap(); let err = output["tool_call_error"].as_str().unwrap(); assert!(err.contains("Invalid filter pattern"), "{err}"); } #[test] #[serial] fn eval_mcp_read_blob_spills_with_metadata() { static COUNTER: AtomicU64 = AtomicU64::new(0); let cache_dir = env::temp_dir().join(format!( "coyote-read-blob-{}-{}", process::id(), COUNTER.fetch_add(1, Ordering::Relaxed) )); fs::create_dir_all(&cache_dir).unwrap(); let env_name = get_env_name("cache_dir"); let previous = env::var_os(&env_name); unsafe { env::set_var(&env_name, &cache_dir) }; let output = run_async(eval_mcp_read(json!({"uri": FIXTURE_BLOB_URI}))).unwrap(); unsafe { match previous { Some(value) => env::set_var(&env_name, value), None => env::remove_var(&env_name), } } assert_eq!(output["spilled"], true); assert_eq!(output["uri"], FIXTURE_BLOB_URI); assert_eq!(output["mime_type"], "application/pdf"); assert_eq!(output["sha256"].as_str().unwrap().len(), 64); let path = PathBuf::from(output["path"].as_str().unwrap()); assert!(path.starts_with(&cache_dir)); assert_eq!(fs::read(&path).unwrap(), FIXTURE_BLOB_BYTES); fs::remove_dir_all(&cache_dir).unwrap(); } #[test] fn eval_mcp_read_multi_content_returns_array() { let output = run_async(eval_mcp_read(json!({"uri": "file:///multi"}))).unwrap(); let items = output.as_array().unwrap(); assert_eq!(items.len(), 3); assert_eq!(items[0]["text"], "first"); assert_eq!(items[1]["text"], "second"); assert_eq!(items[2]["text"], "third"); assert_eq!(items[1]["uri"], "file:///multi/1"); } #[test] fn eval_mcp_read_multi_content_enforces_overall_ceiling() { let output = run_async(eval_mcp_read( json!({"uri": "file:///huge", "max_bytes": render::TEXT_MAX_BYTES_CLAMP}), )) .unwrap(); let items = output.as_array().unwrap(); assert_eq!(items.len(), 2); assert_eq!(items[0]["text"].as_str().unwrap().len(), 150 * 1024); let marker = &items[1]; assert_eq!(marker["truncated"], true); assert_eq!(marker["omitted_items"], 2); let note = marker["note"].as_str().unwrap(); assert!(note.contains("2 content item(s) omitted"), "{note}"); assert!(note.contains("204800"), "{note}"); } #[test] fn eval_mcp_read_expands_template_end_to_end() { let output = run_async(eval_mcp_read(json!({ "uri": "file:///{path}/{name}", "arguments": {"path": "docs", "name": "readme"}, }))) .unwrap(); assert_eq!(output["uri"], "file:///docs/readme"); assert_eq!(output["text"], "readme body"); } #[test] fn eval_mcp_read_attaches_catalog_audience() { let output = run_async(eval_mcp_read(json!({"uri": FIXTURE_ANNOTATED_URI}))).unwrap(); assert_eq!(output["audience"], json!(["user"])); assert_eq!(output["text"], FIXTURE_ANNOTATED_TEXT); } #[test] fn eval_mcp_read_omits_audience_for_unannotated_uri() { let output = run_async(eval_mcp_read(json!({"uri": FIXTURE_LOG_URI}))).unwrap(); assert!(output.get("audience").is_none()); } #[test] fn mcp_error_display_strips_terminal_escapes() { let hostile = "fail\u{1b}[31mred\u{1b}]0;pwn\u{7}end"; let display = mcp_error_display(hostile); assert!(!display.contains('\u{1b}')); assert_eq!(display, "⚠️ failredend ⚠️"); // The payload keeps the raw message; only the terminal string differs. assert_ne!(display, format!("⚠️ {hostile} ⚠️")); } #[test] fn render_tool_result_passes_in_bounds_result_through_unchanged() { let mut result = CallToolResult::success(vec![ContentBlock::text("small text")]); result.structured_content = Some(json!({"rows": [1, 2, 3]})); let bounded = render_tool_result(serde_json::to_value(&result).unwrap(), "srv").unwrap(); assert_eq!(bounded, serde_json::to_value(&result).unwrap()); assert_eq!(bounded["isError"], false); } #[test] fn render_tool_result_clamps_oversized_text() { let result = CallToolResult::success(vec![ContentBlock::text( "x".repeat(render::TEXT_MAX_BYTES_CLAMP + 10), )]); let bounded = render_tool_result(serde_json::to_value(&result).unwrap(), "srv").unwrap(); let item = &bounded["content"][0]; assert_eq!( item["text"].as_str().unwrap().len(), render::TEXT_MAX_BYTES_CLAMP ); assert_eq!(item["truncated"], true); assert_eq!(item["total_bytes"], render::TEXT_MAX_BYTES_CLAMP + 10); assert!( item["note"] .as_str() .unwrap() .contains("TEXT_MAX_BYTES_CLAMP") ); assert_eq!(bounded["isError"], false); } #[test] #[serial] fn render_tool_result_spills_blob_content_without_base64() { static COUNTER: AtomicU64 = AtomicU64::new(0); let cache_dir = env::temp_dir().join(format!( "coyote-tool-blob-{}-{}", process::id(), COUNTER.fetch_add(1, Ordering::Relaxed) )); fs::create_dir_all(&cache_dir).unwrap(); let env_name = get_env_name("cache_dir"); let previous = env::var_os(&env_name); unsafe { env::set_var(&env_name, &cache_dir) }; let b64 = STANDARD.encode(FIXTURE_BLOB_BYTES); let result = CallToolResult::success(vec![ContentBlock::image(b64.clone(), "image/png")]); let bounded = render_tool_result(serde_json::to_value(&result).unwrap(), "srv"); unsafe { match previous { Some(value) => env::set_var(&env_name, value), None => env::remove_var(&env_name), } } let bounded = bounded.unwrap(); let item = &bounded["content"][0]; assert_eq!(item["spilled"], true); assert_eq!(item["mime_type"], "image/png"); assert_eq!(item["sha256"].as_str().unwrap().len(), 64); assert!( !bounded.to_string().contains(&b64), "base64 payload must not reach model context" ); fs::remove_dir_all(&cache_dir).unwrap(); } #[test] fn render_tool_result_inlines_utf8_blob_as_text() { let b64 = STANDARD.encode("hello ✓ world"); let result = CallToolResult::success(vec![ContentBlock::image(b64.clone(), "image/png")]); let bounded = render_tool_result(serde_json::to_value(&result).unwrap(), "srv").unwrap(); let item = &bounded["content"][0]; assert_eq!(item["type"], "text"); assert_eq!(item["text"], "hello ✓ world"); assert!(item.get("spilled").is_none()); assert!(!bounded.to_string().contains(&b64)); } #[test] fn render_tool_result_degrades_undecodable_blob_item() { let value = json!({ "content": [ {"type": "image", "data": "!!!not base64!!!", "mimeType": "image/png"}, {"type": "text", "text": "still here"}, ], }); let bounded = render_tool_result(value, "srv").unwrap(); let error = bounded["content"][0]["error"].as_str().unwrap(); assert!(error.contains("base64"), "{error}"); assert_eq!(bounded["content"][0]["mime_type"], "image/png"); assert_eq!(bounded["content"][1]["text"], "still here"); } #[test] fn render_tool_result_replaces_oversized_structured_content() { let mut result = CallToolResult::success(vec![]); result.structured_content = Some(json!({"blob": "x".repeat(render::TEXT_MAX_BYTES_CLAMP + 1)})); let bounded = render_tool_result(serde_json::to_value(&result).unwrap(), "srv").unwrap(); let structured = &bounded["structuredContent"]; assert_eq!(structured["truncated"], true); assert!( structured["note"] .as_str() .unwrap() .contains("TEXT_MAX_BYTES_CLAMP") ); assert!(bounded.to_string().len() < render::TEXT_MAX_BYTES_CLAMP); } #[test] fn render_tool_result_clamps_embedded_resource_metadata() { let uri = "u".repeat(render::METADATA_MAX_BYTES + 1); let value = json!({ "content": [{"type": "resource", "resource": {"uri": uri, "text": "hi"}}], }); let bounded = render_tool_result(value, "srv").unwrap(); let resource = &bounded["content"][0]["resource"]; assert!( resource["uri"] .as_str() .unwrap() .contains("METADATA_MAX_BYTES") ); assert_eq!(resource["text"], "hi"); } #[test] fn render_resource_content_clamps_metadata_strings() { let uri = format!("file:///{}", "u".repeat(render::METADATA_MAX_BYTES)); let mime = format!("text/{}", "m".repeat(render::METADATA_MAX_BYTES)); let item = json!({"uri": uri, "mimeType": mime, "text": "hi"}); let value = render_resource_content(&item, None, 0, None, "srv").unwrap(); assert!( value["uri"] .as_str() .unwrap() .contains("METADATA_MAX_BYTES") ); assert!( value["mime_type"] .as_str() .unwrap() .contains("METADATA_MAX_BYTES") ); assert_eq!(value["text"], "hi"); } #[test] fn eval_mcp_invoke_bounds_tool_results_end_to_end() { let mut result = CallToolResult::success(vec![ContentBlock::text("hi")]); result.structured_content = Some(json!({"ok": true})); let fixture = FixtureServer { tool_result: Some(result), ..Default::default() }; let call_tool_calls = Arc::clone(&fixture.call_tool_calls); let output = run_async(async { let (runtime, _server) = fixture_runtime(fixture).await; let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd); ctx.tool_scope.mcp_runtime = runtime; call_with_args("mcp_invoke_fixture", json!({"tool": "dup"})) .eval_mcp(&ctx) .await }) .unwrap(); assert_eq!(output["content"][0]["text"], "hi"); assert_eq!(output["structuredContent"], json!({"ok": true})); assert_eq!(output["isError"], false); assert_eq!(call_tool_calls.load(Ordering::SeqCst), 1); } #[test] fn functions_supervisor_includes_task_queue_tools() { let mut f = Functions::default(); f.append_supervisor_functions(); assert!(f.contains("agent__task_create")); assert!(f.contains("agent__task_list")); assert!(f.contains("agent__task_complete")); assert!(f.contains("agent__task_fail")); } #[test] fn tool_result_stores_call_and_output() { let tc = call("my_tool", Some("id-1")); let result = ToolResult::new(tc.clone(), json!({"result": "ok"})); assert_eq!(result.call.name, "my_tool"); assert_eq!(result.output, json!({"result": "ok"})); } #[test] fn thinking_block_matches_anthropic_wire_format() { let block = ThinkingBlock::Thinking { thinking: "chain of thought".to_string(), signature: "sig123".to_string(), }; assert_eq!( serde_json::to_value(&block).unwrap(), json!({"type": "thinking", "thinking": "chain of thought", "signature": "sig123"}) ); let redacted = ThinkingBlock::RedactedThinking { data: "opaque".to_string(), }; assert_eq!( serde_json::to_value(&redacted).unwrap(), json!({"type": "redacted_thinking", "data": "opaque"}) ); } #[test] fn tool_result_deserializes_without_text_and_thinking() { let yaml = "call:\n name: my_tool\n arguments: {}\noutput: ok\n"; let result: ToolResult = serde_yaml::from_str(yaml).unwrap(); assert_eq!(result.call.name, "my_tool"); assert!(result.text.is_none()); assert!(result.thinking.is_empty()); } #[test] fn parse_arguments_passes_through_object() { let tc = call_with_args("t", json!({"x": 1, "y": "hello"})); assert_eq!(tc.parse_arguments().unwrap(), json!({"x": 1, "y": "hello"})); } #[test] fn parse_arguments_deserializes_json_string() { let tc = call_with_args("t", json!(r#"{"a": true}"#)); assert_eq!(tc.parse_arguments().unwrap(), json!({"a": true})); } #[test] fn parse_arguments_returns_err_for_invalid_json_string() { let tc = call_with_args("t", json!("not json {")); assert!(tc.parse_arguments().is_err()); } #[test] fn parse_arguments_returns_err_for_non_object_non_string() { let tc = call_with_args("t", json!(42)); assert!(tc.parse_arguments().is_err()); } #[test] fn write_file_atomic_writes_and_skips_unchanged() { let dir = temp_file("-atomic-", ""); fs::create_dir_all(&dir).unwrap(); let path = dir.join("shim"); write_file_atomic(&path, "one", Some(0o755)).unwrap(); assert_eq!(fs::read_to_string(&path).unwrap(), "one"); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; assert_eq!( fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o755 ); } #[cfg(unix)] { use std::os::unix::fs::MetadataExt; let ino = fs::metadata(&path).unwrap().ino(); write_file_atomic(&path, "one", Some(0o755)).unwrap(); assert_eq!( fs::metadata(&path).unwrap().ino(), ino, "unchanged content must not be rewritten" ); } write_file_atomic(&path, "two", None).unwrap(); assert_eq!(fs::read_to_string(&path).unwrap(), "two"); assert_eq!( fs::read_dir(&dir).unwrap().count(), 1, "no tmp files left behind" ); fs::remove_dir_all(&dir).unwrap(); } #[test] fn write_file_atomic_concurrent_writers_to_same_target() { let dir = temp_file("-atomic-concurrent-", ""); fs::create_dir_all(&dir).unwrap(); let path = dir.join("shim"); let contents: Vec = (0..8) .map(|i| format!("#!/bin/sh\necho writer-{i}\n")) .collect(); thread::scope(|scope| { for content in &contents { scope.spawn(|| { for _ in 0..50 { write_file_atomic(&path, content, Some(0o755)).unwrap(); } }); } }); let final_content = fs::read_to_string(&path).unwrap(); assert!( contents.contains(&final_content), "final content must be one writer's complete content, got: {final_content:?}" ); assert_eq!( fs::read_dir(&dir).unwrap().count(), 1, "no tmp files left behind" ); fs::remove_dir_all(&dir).unwrap(); } #[cfg(unix)] #[test] fn run_llm_function_includes_llm_output_on_nonzero_exit() { let result = run_llm_function( "bash".into(), vec![ "-c".into(), "echo partial-output >> \"$LLM_OUTPUT\"; echo err-text >&2; exit 3".into(), ], HashMap::new(), None, ) .unwrap() .expect("nonzero exit must return an error payload"); let json: serde_json::Value = serde_json::from_str(&result).unwrap(); assert!( json["tool_call_error"] .as_str() .unwrap() .contains("exited with code 3") ); assert_eq!(json["stderr"], "err-text"); assert_eq!(json["output"], "partial-output\n"); } #[test] fn bin_entry_stem_strips_run_prefix_and_extension() { assert_eq!(bin_entry_stem("fs_grep"), "fs_grep"); assert_eq!(bin_entry_stem("fs_grep.cmd"), "fs_grep"); assert_eq!(bin_entry_stem("run-web_search.ts"), "web_search"); assert_eq!(bin_entry_stem("run-fs_grep.sh"), "fs_grep"); } #[test] fn prune_stale_bin_entries_removes_only_stale_files() { let dir = temp_file("-prune-", ""); fs::create_dir_all(dir.join("nested")).unwrap(); for name in [ "fs_grep", "run-web_search.ts", "old_tool", "run-old_tool.ts", "myagent", ] { fs::write(dir.join(name), "x").unwrap(); } let valid_stems: HashSet = ["fs_grep", "web_search"] .iter() .map(|s| s.to_string()) .collect(); prune_stale_bin_entries(&dir, &valid_stems, Some("myagent")).unwrap(); assert!(dir.join("fs_grep").exists()); assert!(dir.join("run-web_search.ts").exists()); assert!(dir.join("myagent").exists(), "agent binary must survive"); assert!(!dir.join("old_tool").exists()); assert!(!dir.join("run-old_tool.ts").exists()); assert!(!dir.join("nested").exists(), "directories must be removed"); fs::remove_dir_all(&dir).unwrap(); } #[test] fn prune_stale_bin_entries_creates_missing_dir() { let dir = temp_file("-prune-missing-", ""); prune_stale_bin_entries(&dir, &HashSet::new(), None).unwrap(); assert!(dir.is_dir()); fs::remove_dir_all(&dir).unwrap(); } #[test] fn eval_tool_calls_partitions_mcp_and_sequential_then_resorts() { let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd); let calls = vec![ call("unknown_first", Some("id-1")), ToolCall::new( "mcp_search_foo".into(), json!({"query": "q"}), Some("id-2".into()), ), call("unknown_last", Some("id-3")), ]; let results = run_async(eval_tool_calls(&mut ctx, calls)).unwrap(); assert_eq!(results.len(), 3); assert_eq!(results[0].call.name, "unknown_first"); assert_eq!(results[1].call.name, "mcp_search_foo"); assert_eq!(results[2].call.name, "unknown_last"); for sequential in [&results[0], &results[2]] { let err = sequential.output["tool_call_error"].as_str().unwrap(); assert!( err.contains("use only tools listed in your catalog"), "{err}" ); } let mcp_err = results[1].output["tool_call_error"].as_str().unwrap(); assert!(mcp_err.starts_with("MCP search failed"), "{mcp_err}"); assert!(!mcp_err.contains("use only tools listed in your catalog")); } #[test] fn eval_tool_calls_isolates_failures_within_a_batch() { let app = AppState { config: Arc::new(AppConfig { auto_continue: true, ..Default::default() }), ..AppState::test_default() }; let mut ctx = RequestContext::new(Arc::new(app), WorkingMode::Cmd); ctx.tool_scope.functions.append_todo_functions(); let calls = vec![ ToolCall::new( "todo__init".into(), json!({"goal": "ship it"}), Some("id-1".into()), ), call("unknown_tool", Some("id-2")), ]; let results = run_async(eval_tool_calls(&mut ctx, calls)).unwrap(); assert_eq!(results.len(), 2); assert_eq!(results[0].call.name, "todo__init"); assert_eq!(results[0].output["status"], "ok"); assert!(results[0].output.get("tool_call_error").is_none()); let err = results[1].output["tool_call_error"].as_str().unwrap(); assert!(err.contains("Unexpected call"), "{err}"); } #[test] fn eval_tool_calls_reports_loop_alert_without_executing() { let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd); let looped = call_with_args("looped_tool", json!({"a": 1})); ctx.tool_scope.tool_tracker.record_call(looped.clone()); ctx.tool_scope.tool_tracker.record_call(looped.clone()); let calls = vec![ looped, ToolCall::new("other_tool".into(), json!({}), Some("id-2".into())), ]; let results = run_async(eval_tool_calls(&mut ctx, calls)).unwrap(); assert_eq!(results.len(), 2); let alert = results[0].output.as_str().unwrap(); assert!(alert.starts_with("{\"tool_call_loop_alert\":"), "{alert}"); let err = results[1].output["tool_call_error"].as_str().unwrap(); assert!(err.contains("Unexpected call"), "{err}"); } #[test] fn eval_tool_calls_truncates_with_global_max_chars() { let app = AppState { config: Arc::new(AppConfig { max_tool_result_chars: Some(50), ..Default::default() }), ..AppState::test_default() }; let mut ctx = RequestContext::new(Arc::new(app), WorkingMode::Cmd); let calls = vec![ToolCall::new( "unknown_tool".into(), json!({"padding": "x".repeat(200)}), Some("id-1".into()), )]; let results = run_async(eval_tool_calls(&mut ctx, calls)).unwrap(); let out = results[0].output.as_str().unwrap(); assert!( out.starts_with("[truncated: tool output exceeded 50 chars]\n"), "{out}" ); } #[test] fn eval_tool_calls_agent_max_chars_overrides_global() { let app = AppState { config: Arc::new(AppConfig { max_tool_result_chars: Some(5000), ..Default::default() }), ..AppState::test_default() }; let mut ctx = RequestContext::new(Arc::new(app), WorkingMode::Cmd); ctx.agent = Some(Agent::test_new(AgentConfig { max_tool_result_chars: Some(30), ..Default::default() })); let calls = vec![ToolCall::new( "unknown_tool".into(), json!({"padding": "x".repeat(200)}), Some("id-1".into()), )]; let results = run_async(eval_tool_calls(&mut ctx, calls)).unwrap(); let out = results[0].output.as_str().unwrap(); assert!( out.starts_with("[truncated: tool output exceeded 30 chars]\n"), "{out}" ); } #[test] fn eval_tool_calls_zero_max_chars_disables_truncation() { let app = AppState { config: Arc::new(AppConfig { max_tool_result_chars: Some(0), ..Default::default() }), ..AppState::test_default() }; let mut ctx = RequestContext::new(Arc::new(app), WorkingMode::Cmd); let calls = vec![ToolCall::new( "unknown_tool".into(), json!({"padding": "x".repeat(200)}), Some("id-1".into()), )]; let results = run_async(eval_tool_calls(&mut ctx, calls)).unwrap(); assert!(results[0].output["tool_call_error"].is_string()); assert!(!results[0].output.to_string().contains("[truncated")); } #[test] fn eval_tool_calls_no_max_chars_configured_never_truncates() { let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd); let calls = vec![ToolCall::new( "unknown_tool".into(), json!({"padding": "x".repeat(200)}), Some("id-1".into()), )]; let results = run_async(eval_tool_calls(&mut ctx, calls)).unwrap(); assert!(results[0].output["tool_call_error"].is_string()); assert!(!results[0].output.to_string().contains("[truncated")); } /// When the char cap lands inside a multi-byte UTF-8 character of the /// serialized output, the cut is floored to the previous char boundary /// so the output actually shrinks. #[test] fn truncate_if_needed_floors_cut_to_char_boundary() { let serialized = json!("aé").to_string(); let result = ToolResult::new(call("t", Some("id-1")), json!("aé")); let truncated = result.truncate_if_needed(3); let out = truncated.output.as_str().unwrap(); assert_eq!(out, "[truncated: tool output exceeded 3 chars]\n\"a"); assert!(out.len() < "[truncated: tool output exceeded 3 chars]\n".len() + serialized.len()); } #[test] fn truncate_if_needed_cap_on_char_boundary_truncates_normally() { let result = ToolResult::new(call("t", Some("id-1")), json!("aé")); let truncated = result.truncate_if_needed(2); assert_eq!( truncated.output.as_str().unwrap(), "[truncated: tool output exceeded 2 chars]\n\"a" ); } #[test] fn truncate_if_needed_cap_zero_yields_marker_and_empty_prefix() { let result = ToolResult::new(call("t", Some("id-1")), json!("aé")); let truncated = result.truncate_if_needed(0); assert_eq!( truncated.output.as_str().unwrap(), "[truncated: tool output exceeded 0 chars]\n" ); } #[test] fn truncate_if_needed_cap_at_or_above_length_leaves_output_unchanged() { for cap in [5, 100] { let result = ToolResult::new(call("t", Some("id-1")), json!("aé")); let truncated = result.truncate_if_needed(cap); assert_eq!(truncated.output, json!("aé")); } } #[test] fn eval_routes_agent_prefix_to_supervisor_handler() { let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd); ctx.tool_scope.functions.append_supervisor_functions(); let out = run_async(call_with_args("agent__check", json!({"id": "x"})).eval(&mut ctx)).unwrap(); let err = out["tool_call_error"].as_str().unwrap(); assert!(err.starts_with("Agent tool failed"), "{err}"); assert!(err.contains("No supervisor active"), "{err}"); } #[test] fn eval_routes_todo_prefix_to_todo_handler() { let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd); ctx.tool_scope.functions.append_todo_functions(); let out = run_async(call_with_args("todo__list", json!({})).eval(&mut ctx)).unwrap(); let err = out["tool_call_error"].as_str().unwrap(); assert!(err.starts_with("Todo tool failed"), "{err}"); assert!(err.contains("Auto-continue is not enabled"), "{err}"); } #[test] fn eval_routes_memory_prefix_to_memory_handler() { let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd); ctx.tool_scope.functions.append_memory_functions(); let out = run_async(call_with_args("memory__read", json!({})).eval(&mut ctx)).unwrap(); let err = out["tool_call_error"].as_str().unwrap(); assert!(err.starts_with("Memory tool failed"), "{err}"); assert!( err.contains("name is required") || err.contains("Memory tools are disabled"), "expected a memory-handler-owned error regardless of host memory files: {err}" ); } #[test] fn eval_routes_skill_prefix_to_skill_handler() { let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd); ctx.tool_scope.functions.append_skill_functions(); let out = run_async(call_with_args("skill__load", json!({})).eval(&mut ctx)).unwrap(); assert_eq!(out["error"], "name is required"); } #[test] fn eval_routes_user_prefix_to_user_handler() { let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd); ctx.tool_scope.functions.append_user_interaction_functions(); let out = run_async(call_with_args("user__confirm", json!({})).eval(&mut ctx)).unwrap(); let err = out["tool_call_error"].as_str().unwrap(); assert!(err.starts_with("User interaction failed"), "{err}"); assert!(err.contains("'question' is required"), "{err}"); } #[test] fn eval_routes_rag_prefix_to_rag_handler() { let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd); ctx.tool_scope.functions.append_rag_query_functions(); let out = run_async(call_with_args("rag__query", json!({"query": "x"})).eval(&mut ctx)).unwrap(); let err = out["tool_call_error"].as_str().unwrap(); assert!(err.starts_with("RAG query failed"), "{err}"); assert!(err.contains("No RAG is attached"), "{err}"); } #[test] fn eval_unknown_name_errors_with_unexpected_call() { let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd); let err = run_async(call_with_args("nope", json!({})).eval(&mut ctx)).unwrap_err(); assert!(err.to_string().contains("Unexpected call"), "{err}"); } #[test] fn eval_mcp_empty_runtime_returns_distinct_error_per_prefix() { let ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd); let cases = [ ( "mcp_invoke_ghost", json!({"tool": "t"}), "MCP tool invocation failed", ), ( "mcp_search_ghost", json!({"query": "q"}), "MCP search failed", ), ( "mcp_describe_ghost", json!({"tool": "t"}), "MCP describe failed", ), ( "mcp_read_ghost", json!({"uri": "file:///x"}), "MCP read failed", ), ( "mcp_prompt_ghost", json!({"prompt": "p"}), "MCP prompt failed", ), ]; for (name, args, expected) in cases { let out = run_async(call_with_args(name, args).eval_mcp(&ctx)).unwrap(); let err = out["tool_call_error"].as_str().unwrap(); assert!(err.starts_with(expected), "{name}: {err}"); } } }