use crate::approval::{ApprovalManager, ApprovalRequest, ApprovalResponse}; use crate::config::Config; use crate::memory::{self, Memory, MemoryCategory}; use crate::multimodal; use crate::observability::{self, runtime_trace, Observer, ObserverEvent}; use crate::providers::{ self, ChatMessage, ChatRequest, Provider, ProviderCapabilityError, ToolCall, }; use crate::runtime; use crate::security::SecurityPolicy; use crate::tools::{self, Tool}; use crate::util::truncate_with_ellipsis; use anyhow::Result; use regex::{Regex, RegexSet}; use std::collections::HashSet; use std::fmt::Write; use std::io::Write as _; use std::sync::{Arc, LazyLock}; use std::time::{Duration, Instant}; use tokio_util::sync::CancellationToken; use uuid::Uuid; /// Minimum characters per chunk when relaying LLM text to a streaming draft. const STREAM_CHUNK_MIN_CHARS: usize = 80; /// Default maximum agentic tool-use iterations per user message to prevent runaway loops. /// Used as a safe fallback when `max_tool_iterations` is unset or configured as zero. const DEFAULT_MAX_TOOL_ITERATIONS: usize = 10; /// Minimum user-message length (in chars) for auto-save to memory. /// Matches the channel-side constant in `channels/mod.rs`. const AUTOSAVE_MIN_MESSAGE_CHARS: usize = 20; static SENSITIVE_KEY_PATTERNS: LazyLock = LazyLock::new(|| { RegexSet::new([ r"(?i)token", r"(?i)api[_-]?key", r"(?i)password", r"(?i)secret", r"(?i)user[_-]?key", r"(?i)bearer", r"(?i)credential", ]) .unwrap() }); static SENSITIVE_KV_REGEX: LazyLock = LazyLock::new(|| { Regex::new(r#"(?i)(token|api[_-]?key|password|secret|user[_-]?key|bearer|credential)["']?\s*[:=]\s*(?:"([^"]{8,})"|'([^']{8,})'|([a-zA-Z0-9_\-\.]{8,}))"#).unwrap() }); /// Scrub credentials from tool output to prevent accidental exfiltration. /// Replaces known credential patterns with a redacted placeholder while preserving /// a small prefix for context. pub(crate) fn scrub_credentials(input: &str) -> String { SENSITIVE_KV_REGEX .replace_all(input, |caps: ®ex::Captures| { let full_match = &caps[0]; let key = &caps[1]; let val = caps .get(2) .or(caps.get(3)) .or(caps.get(4)) .map(|m| m.as_str()) .unwrap_or(""); // Preserve first 4 chars for context, then redact let prefix = if val.len() > 4 { &val[..4] } else { "" }; if full_match.contains(':') { if full_match.contains('"') { format!("\"{}\": \"{}*[REDACTED]\"", key, prefix) } else { format!("{}: {}*[REDACTED]", key, prefix) } } else if full_match.contains('=') { if full_match.contains('"') { format!("{}=\"{}*[REDACTED]\"", key, prefix) } else { format!("{}={}*[REDACTED]", key, prefix) } } else { format!("{}: {}*[REDACTED]", key, prefix) } }) .to_string() } /// Default trigger for auto-compaction when non-system message count exceeds this threshold. /// Prefer passing the config-driven value via `run_tool_call_loop`; this constant is only /// used when callers omit the parameter. const DEFAULT_MAX_HISTORY_MESSAGES: usize = 50; /// Keep this many most-recent non-system messages after compaction. const COMPACTION_KEEP_RECENT_MESSAGES: usize = 20; /// Safety cap for compaction source transcript passed to the summarizer. const COMPACTION_MAX_SOURCE_CHARS: usize = 12_000; /// Max characters retained in stored compaction summary. const COMPACTION_MAX_SUMMARY_CHARS: usize = 2_000; /// Minimum interval between progress sends to avoid flooding the draft channel. pub(crate) const PROGRESS_MIN_INTERVAL_MS: u64 = 500; /// Sentinel value sent through on_delta to signal the draft updater to clear accumulated text. /// Used before streaming the final answer so progress lines are replaced by the clean response. pub(crate) const DRAFT_CLEAR_SENTINEL: &str = "\x00CLEAR\x00"; /// Extract a short hint from tool call arguments for progress display. fn truncate_tool_args_for_progress(name: &str, args: &serde_json::Value, max_len: usize) -> String { let hint = match name { "shell" => args.get("command").and_then(|v| v.as_str()), "file_read" | "file_write" => args.get("path").and_then(|v| v.as_str()), _ => args .get("action") .and_then(|v| v.as_str()) .or_else(|| args.get("query").and_then(|v| v.as_str())), }; match hint { Some(s) => truncate_with_ellipsis(s, max_len), None => String::new(), } } /// Convert a tool registry to OpenAI function-calling format for native tool support. fn tools_to_openai_format(tools_registry: &[Box]) -> Vec { tools_registry .iter() .map(|tool| { serde_json::json!({ "type": "function", "function": { "name": tool.name(), "description": tool.description(), "parameters": tool.parameters_schema() } }) }) .collect() } fn autosave_memory_key(prefix: &str) -> String { format!("{prefix}_{}", Uuid::new_v4()) } /// Trim conversation history to prevent unbounded growth. /// Preserves the system prompt (first message if role=system) and the most recent messages. fn trim_history(history: &mut Vec, max_history: usize) { // Nothing to trim if within limit let has_system = history.first().map_or(false, |m| m.role == "system"); let non_system_count = if has_system { history.len() - 1 } else { history.len() }; if non_system_count <= max_history { return; } let start = if has_system { 1 } else { 0 }; let to_remove = non_system_count - max_history; history.drain(start..start + to_remove); } fn build_compaction_transcript(messages: &[ChatMessage]) -> String { let mut transcript = String::new(); for msg in messages { let role = msg.role.to_uppercase(); let _ = writeln!(transcript, "{role}: {}", msg.content.trim()); } if transcript.chars().count() > COMPACTION_MAX_SOURCE_CHARS { truncate_with_ellipsis(&transcript, COMPACTION_MAX_SOURCE_CHARS) } else { transcript } } fn apply_compaction_summary( history: &mut Vec, start: usize, compact_end: usize, summary: &str, ) { let summary_msg = ChatMessage::assistant(format!("[Compaction summary]\n{}", summary.trim())); history.splice(start..compact_end, std::iter::once(summary_msg)); } async fn auto_compact_history( history: &mut Vec, provider: &dyn Provider, model: &str, max_history: usize, ) -> Result { let has_system = history.first().map_or(false, |m| m.role == "system"); let non_system_count = if has_system { history.len().saturating_sub(1) } else { history.len() }; if non_system_count <= max_history { return Ok(false); } let start = if has_system { 1 } else { 0 }; let keep_recent = COMPACTION_KEEP_RECENT_MESSAGES.min(non_system_count); let compact_count = non_system_count.saturating_sub(keep_recent); if compact_count == 0 { return Ok(false); } let compact_end = start + compact_count; let to_compact: Vec = history[start..compact_end].to_vec(); let transcript = build_compaction_transcript(&to_compact); let summarizer_system = "You are a conversation compaction engine. Summarize older chat history into concise context for future turns. Preserve: user preferences, commitments, decisions, unresolved tasks, key facts. Omit: filler, repeated chit-chat, verbose tool logs. Output plain text bullet points only."; let summarizer_user = format!( "Summarize the following conversation history for context preservation. Keep it short (max 12 bullet points).\n\n{}", transcript ); let summary_raw = provider .chat_with_system(Some(summarizer_system), &summarizer_user, model, 0.2) .await .unwrap_or_else(|_| { // Fallback to deterministic local truncation when summarization fails. truncate_with_ellipsis(&transcript, COMPACTION_MAX_SUMMARY_CHARS) }); let summary = truncate_with_ellipsis(&summary_raw, COMPACTION_MAX_SUMMARY_CHARS); apply_compaction_summary(history, start, compact_end, &summary); Ok(true) } /// Build context preamble by searching memory for relevant entries. /// Entries with a hybrid score below `min_relevance_score` are dropped to /// prevent unrelated memories from bleeding into the conversation. async fn build_context(mem: &dyn Memory, user_msg: &str, min_relevance_score: f64) -> String { let mut context = String::new(); // Pull relevant memories for this message if let Ok(entries) = mem.recall(user_msg, 5, None).await { let relevant: Vec<_> = entries .iter() .filter(|e| match e.score { Some(score) => score >= min_relevance_score, None => true, }) .collect(); if !relevant.is_empty() { context.push_str("[Memory context]\n"); for entry in &relevant { if memory::is_assistant_autosave_key(&entry.key) { continue; } let _ = writeln!(context, "- {}: {}", entry.key, entry.content); } if context == "[Memory context]\n" { context.clear(); } else { context.push('\n'); } } } context } /// Build hardware datasheet context from RAG when peripherals are enabled. /// Includes pin-alias lookup (e.g. "red_led" → 13) when query matches, plus retrieved chunks. fn build_hardware_context( rag: &crate::rag::HardwareRag, user_msg: &str, boards: &[String], chunk_limit: usize, ) -> String { if rag.is_empty() || boards.is_empty() { return String::new(); } let mut context = String::new(); // Pin aliases: when user says "red led", inject "red_led: 13" for matching boards let pin_ctx = rag.pin_alias_context(user_msg, boards); if !pin_ctx.is_empty() { context.push_str(&pin_ctx); } let chunks = rag.retrieve(user_msg, boards, chunk_limit); if chunks.is_empty() && pin_ctx.is_empty() { return String::new(); } if !chunks.is_empty() { context.push_str("[Hardware documentation]\n"); } for chunk in chunks { let board_tag = chunk.board.as_deref().unwrap_or("generic"); let _ = writeln!( context, "--- {} ({}) ---\n{}\n", chunk.source, board_tag, chunk.content ); } context.push('\n'); context } /// Find a tool by name in the registry. fn find_tool<'a>(tools: &'a [Box], name: &str) -> Option<&'a dyn Tool> { tools.iter().find(|t| t.name() == name).map(|t| t.as_ref()) } fn parse_arguments_value(raw: Option<&serde_json::Value>) -> serde_json::Value { match raw { Some(serde_json::Value::String(s)) => serde_json::from_str::(s) .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new())), Some(value) => value.clone(), None => serde_json::Value::Object(serde_json::Map::new()), } } fn parse_tool_call_id( root: &serde_json::Value, function: Option<&serde_json::Value>, ) -> Option { function .and_then(|func| func.get("id")) .or_else(|| root.get("id")) .or_else(|| root.get("tool_call_id")) .or_else(|| root.get("call_id")) .and_then(serde_json::Value::as_str) .map(str::trim) .filter(|id| !id.is_empty()) .map(ToString::to_string) } fn canonicalize_json_for_tool_signature(value: &serde_json::Value) -> serde_json::Value { match value { serde_json::Value::Object(map) => { let mut keys: Vec = map.keys().cloned().collect(); keys.sort_unstable(); let mut ordered = serde_json::Map::new(); for key in keys { if let Some(child) = map.get(&key) { ordered.insert(key, canonicalize_json_for_tool_signature(child)); } } serde_json::Value::Object(ordered) } serde_json::Value::Array(items) => serde_json::Value::Array( items .iter() .map(canonicalize_json_for_tool_signature) .collect(), ), _ => value.clone(), } } fn tool_call_signature(name: &str, arguments: &serde_json::Value) -> (String, String) { let canonical_args = canonicalize_json_for_tool_signature(arguments); let args_json = serde_json::to_string(&canonical_args).unwrap_or_else(|_| "{}".to_string()); (name.trim().to_ascii_lowercase(), args_json) } fn parse_tool_call_value(value: &serde_json::Value) -> Option { if let Some(function) = value.get("function") { let tool_call_id = parse_tool_call_id(value, Some(function)); let name = function .get("name") .and_then(|v| v.as_str()) .unwrap_or("") .trim() .to_string(); if !name.is_empty() { let arguments = parse_arguments_value( function .get("arguments") .or_else(|| function.get("parameters")), ); return Some(ParsedToolCall { name, arguments, tool_call_id: tool_call_id, }); } } let tool_call_id = parse_tool_call_id(value, None); let name = value .get("name") .and_then(|v| v.as_str()) .unwrap_or("") .trim() .to_string(); if name.is_empty() { return None; } let arguments = parse_arguments_value(value.get("arguments").or_else(|| value.get("parameters"))); Some(ParsedToolCall { name, arguments, tool_call_id: tool_call_id, }) } fn parse_tool_calls_from_json_value(value: &serde_json::Value) -> Vec { let mut calls = Vec::new(); if let Some(tool_calls) = value.get("tool_calls").and_then(|v| v.as_array()) { for call in tool_calls { if let Some(parsed) = parse_tool_call_value(call) { calls.push(parsed); } } if !calls.is_empty() { return calls; } } if let Some(array) = value.as_array() { for item in array { if let Some(parsed) = parse_tool_call_value(item) { calls.push(parsed); } } return calls; } if let Some(parsed) = parse_tool_call_value(value) { calls.push(parsed); } calls } fn is_xml_meta_tag(tag: &str) -> bool { let normalized = tag.to_ascii_lowercase(); matches!( normalized.as_str(), "tool_call" | "toolcall" | "tool-call" | "invoke" | "thinking" | "thought" | "analysis" | "reasoning" | "reflection" ) } /// Match opening XML tags: ``. Does NOT use backreferences. static XML_OPEN_TAG_RE: LazyLock = LazyLock::new(|| Regex::new(r"<([a-zA-Z_][a-zA-Z0-9_-]*)>").unwrap()); /// MiniMax XML invoke format: /// `pwd` static MINIMAX_INVOKE_RE: LazyLock = LazyLock::new(|| { Regex::new(r#"(?is)]*\bname\s*=\s*(?:"([^"]+)"|'([^']+)')[^>]*>(.*?)"#) .unwrap() }); static MINIMAX_PARAMETER_RE: LazyLock = LazyLock::new(|| { Regex::new( r#"(?is)]*\bname\s*=\s*(?:"([^"]+)"|'([^']+)')[^>]*>(.*?)"#, ) .unwrap() }); /// Extracts all `` pairs from `input`, returning `(tag_name, inner_content)`. /// Handles matching closing tags without regex backreferences. fn extract_xml_pairs(input: &str) -> Vec<(&str, &str)> { let mut results = Vec::new(); let mut search_start = 0; while let Some(open_cap) = XML_OPEN_TAG_RE.captures(&input[search_start..]) { let full_open = open_cap.get(0).unwrap(); let tag_name = open_cap.get(1).unwrap().as_str(); let open_end = search_start + full_open.end(); let closing_tag = format!(""); if let Some(close_pos) = input[open_end..].find(&closing_tag) { let inner = &input[open_end..open_end + close_pos]; results.push((tag_name, inner.trim())); search_start = open_end + close_pos + closing_tag.len(); } else { search_start = open_end; } } results } /// Parse XML-style tool calls in `` bodies. /// Supports both nested argument tags and JSON argument payloads: /// - `...` /// - `{"command":"pwd"}` fn parse_xml_tool_calls(xml_content: &str) -> Option> { let mut calls = Vec::new(); let trimmed = xml_content.trim(); if !trimmed.starts_with('<') || !trimmed.contains('>') { return None; } for (tool_name_str, inner_content) in extract_xml_pairs(trimmed) { let tool_name = tool_name_str.to_string(); if is_xml_meta_tag(&tool_name) { continue; } if inner_content.is_empty() { continue; } let mut args = serde_json::Map::new(); if let Some(first_json) = extract_json_values(inner_content).into_iter().next() { match first_json { serde_json::Value::Object(object_args) => { args = object_args; } other => { args.insert("value".to_string(), other); } } } else { for (key_str, value) in extract_xml_pairs(inner_content) { let key = key_str.to_string(); if is_xml_meta_tag(&key) { continue; } if !value.is_empty() { args.insert(key, serde_json::Value::String(value.to_string())); } } if args.is_empty() { args.insert( "content".to_string(), serde_json::Value::String(inner_content.to_string()), ); } } calls.push(ParsedToolCall { name: tool_name, arguments: serde_json::Value::Object(args), tool_call_id: None, }); } if calls.is_empty() { None } else { Some(calls) } } /// Parse MiniMax-style XML tool calls with attributed invoke/parameter tags. fn parse_minimax_invoke_calls(response: &str) -> Option<(String, Vec)> { let mut calls = Vec::new(); let mut text_parts = Vec::new(); let mut last_end = 0usize; for cap in MINIMAX_INVOKE_RE.captures_iter(response) { let Some(full_match) = cap.get(0) else { continue; }; let before = response[last_end..full_match.start()].trim(); if !before.is_empty() { text_parts.push(before.to_string()); } let name = cap .get(1) .or_else(|| cap.get(2)) .map(|m| m.as_str().trim()) .filter(|v| !v.is_empty()); let body = cap.get(3).map(|m| m.as_str()).unwrap_or("").trim(); last_end = full_match.end(); let Some(name) = name else { continue; }; let mut args = serde_json::Map::new(); for param_cap in MINIMAX_PARAMETER_RE.captures_iter(body) { let key = param_cap .get(1) .or_else(|| param_cap.get(2)) .map(|m| m.as_str().trim()) .unwrap_or_default(); if key.is_empty() { continue; } let value = param_cap .get(3) .map(|m| m.as_str().trim()) .unwrap_or_default(); if value.is_empty() { continue; } let parsed = extract_json_values(value).into_iter().next(); args.insert( key.to_string(), parsed.unwrap_or_else(|| serde_json::Value::String(value.to_string())), ); } if args.is_empty() { if let Some(first_json) = extract_json_values(body).into_iter().next() { match first_json { serde_json::Value::Object(obj) => args = obj, other => { args.insert("value".to_string(), other); } } } else if !body.is_empty() { args.insert( "content".to_string(), serde_json::Value::String(body.to_string()), ); } } calls.push(ParsedToolCall { name: name.to_string(), arguments: serde_json::Value::Object(args), tool_call_id: None, }); } if calls.is_empty() { return None; } let after = response[last_end..].trim(); if !after.is_empty() { text_parts.push(after.to_string()); } let text = text_parts .join("\n") .replace("", "") .replace("", "") .replace("", "") .replace("", "") .trim() .to_string(); Some((text, calls)) } const TOOL_CALL_OPEN_TAGS: [&str; 6] = [ "", "", "", "", "", "", ]; const TOOL_CALL_CLOSE_TAGS: [&str; 6] = [ "", "", "", "", "", "", ]; fn find_first_tag<'a>(haystack: &str, tags: &'a [&'a str]) -> Option<(usize, &'a str)> { tags.iter() .filter_map(|tag| haystack.find(tag).map(|idx| (idx, *tag))) .min_by_key(|(idx, _)| *idx) } fn matching_tool_call_close_tag(open_tag: &str) -> Option<&'static str> { match open_tag { "" => Some(""), "" => Some(""), "" => Some(""), "" => Some(""), "" => Some(""), "" => Some(""), _ => None, } } fn extract_first_json_value_with_end(input: &str) -> Option<(serde_json::Value, usize)> { let trimmed = input.trim_start(); let trim_offset = input.len().saturating_sub(trimmed.len()); for (byte_idx, ch) in trimmed.char_indices() { if ch != '{' && ch != '[' { continue; } let slice = &trimmed[byte_idx..]; let mut stream = serde_json::Deserializer::from_str(slice).into_iter::(); if let Some(Ok(value)) = stream.next() { let consumed = stream.byte_offset(); if consumed > 0 { return Some((value, trim_offset + byte_idx + consumed)); } } } None } fn strip_leading_close_tags(mut input: &str) -> &str { loop { let trimmed = input.trim_start(); if !trimmed.starts_with("') else { return ""; }; input = &trimmed[close_end + 1..]; } } /// Extract JSON values from a string. /// /// # Security Warning /// /// This function extracts ANY JSON objects/arrays from the input. It MUST only /// be used on content that is already trusted to be from the LLM, such as /// content inside `` tags where the LLM has explicitly indicated intent /// to make a tool call. Do NOT use this on raw user input or content that /// could contain prompt injection payloads. fn extract_json_values(input: &str) -> Vec { let mut values = Vec::new(); let trimmed = input.trim(); if trimmed.is_empty() { return values; } if let Ok(value) = serde_json::from_str::(trimmed) { values.push(value); return values; } let char_positions: Vec<(usize, char)> = trimmed.char_indices().collect(); let mut idx = 0; while idx < char_positions.len() { let (byte_idx, ch) = char_positions[idx]; if ch == '{' || ch == '[' { let slice = &trimmed[byte_idx..]; let mut stream = serde_json::Deserializer::from_str(slice).into_iter::(); if let Some(Ok(value)) = stream.next() { let consumed = stream.byte_offset(); if consumed > 0 { values.push(value); let next_byte = byte_idx + consumed; while idx < char_positions.len() && char_positions[idx].0 < next_byte { idx += 1; } continue; } } } idx += 1; } values } /// Find the end position of a JSON object by tracking balanced braces. fn find_json_end(input: &str) -> Option { let trimmed = input.trim_start(); let offset = input.len() - trimmed.len(); if !trimmed.starts_with('{') { return None; } let mut depth = 0; let mut in_string = false; let mut escape_next = false; for (i, ch) in trimmed.char_indices() { if escape_next { escape_next = false; continue; } match ch { '\\' if in_string => escape_next = true, '"' => in_string = !in_string, '{' if !in_string => depth += 1, '}' if !in_string => { depth -= 1; if depth == 0 { return Some(offset + i + ch.len_utf8()); } } _ => {} } } None } /// Parse XML attribute-style tool calls from response text. /// This handles MiniMax and similar providers that output: /// ```xml /// /// /// ls /// /// /// ``` fn parse_xml_attribute_tool_calls(response: &str) -> Vec { let mut calls = Vec::new(); // Regex to find ... blocks static INVOKE_RE: LazyLock = LazyLock::new(|| { Regex::new(r#"(?s)]*>(.*?)"#).unwrap() }); // Regex to find value static PARAM_RE: LazyLock = LazyLock::new(|| { Regex::new(r#"]*>([^<]*)"#).unwrap() }); for cap in INVOKE_RE.captures_iter(response) { let tool_name = cap.get(1).map(|m| m.as_str()).unwrap_or(""); let inner = cap.get(2).map(|m| m.as_str()).unwrap_or(""); if tool_name.is_empty() { continue; } let mut arguments = serde_json::Map::new(); for param_cap in PARAM_RE.captures_iter(inner) { let param_name = param_cap.get(1).map(|m| m.as_str()).unwrap_or(""); let param_value = param_cap.get(2).map(|m| m.as_str()).unwrap_or(""); if !param_name.is_empty() { arguments.insert( param_name.to_string(), serde_json::Value::String(param_value.to_string()), ); } } if !arguments.is_empty() { calls.push(ParsedToolCall { name: map_tool_name_alias(tool_name).to_string(), arguments: serde_json::Value::Object(arguments), tool_call_id: None, }); } } calls } /// Parse Perl/hash-ref style tool calls from response text. /// This handles formats like: /// ```text /// TOOL_CALL /// {tool => "shell", args => { /// --command "ls -la" /// --description "List current directory contents" /// }} /// /TOOL_CALL /// ``` fn parse_perl_style_tool_calls(response: &str) -> Vec { let mut calls = Vec::new(); // Regex to find TOOL_CALL blocks - handle double closing braces }} static PERL_RE: LazyLock = LazyLock::new(|| Regex::new(r"(?s)TOOL_CALL\s*\{(.+?)\}\}\s*/TOOL_CALL").unwrap()); // Regex to find tool => "name" in the content static TOOL_NAME_RE: LazyLock = LazyLock::new(|| Regex::new(r#"tool\s*=>\s*"([^"]+)""#).unwrap()); // Regex to find args => { ... } block static ARGS_BLOCK_RE: LazyLock = LazyLock::new(|| Regex::new(r"(?s)args\s*=>\s*\{(.+?)\}").unwrap()); // Regex to find --key "value" pairs static ARGS_RE: LazyLock = LazyLock::new(|| Regex::new(r#"--(\w+)\s+"([^"]+)""#).unwrap()); for cap in PERL_RE.captures_iter(response) { let content = cap.get(1).map(|m| m.as_str()).unwrap_or(""); // Extract tool name let tool_name = TOOL_NAME_RE .captures(content) .and_then(|c| c.get(1)) .map(|m| m.as_str()) .unwrap_or(""); if tool_name.is_empty() { continue; } // Extract args block let args_block = ARGS_BLOCK_RE .captures(content) .and_then(|c| c.get(1)) .map(|m| m.as_str()) .unwrap_or(""); let mut arguments = serde_json::Map::new(); for arg_cap in ARGS_RE.captures_iter(args_block) { let key = arg_cap.get(1).map(|m| m.as_str()).unwrap_or(""); let value = arg_cap.get(2).map(|m| m.as_str()).unwrap_or(""); if !key.is_empty() { arguments.insert( key.to_string(), serde_json::Value::String(value.to_string()), ); } } if !arguments.is_empty() { calls.push(ParsedToolCall { name: map_tool_name_alias(tool_name).to_string(), arguments: serde_json::Value::Object(arguments), tool_call_id: None, }); } } calls } /// Parse FunctionCall-style tool calls from response text. /// This handles formats like: /// ```text /// /// file_read /// path>/Users/kylelampa/Documents/zeroclaw/README.md /// /// ``` fn parse_function_call_tool_calls(response: &str) -> Vec { let mut calls = Vec::new(); // Regex to find blocks static FUNC_RE: LazyLock = LazyLock::new(|| { Regex::new(r"(?s)\s*(\w+)\s*([^<]+)\s*").unwrap() }); for cap in FUNC_RE.captures_iter(response) { let tool_name = cap.get(1).map(|m| m.as_str()).unwrap_or(""); let args_text = cap.get(2).map(|m| m.as_str()).unwrap_or(""); if tool_name.is_empty() { continue; } // Parse key>value pairs (e.g., path>/Users/.../file.txt) let mut arguments = serde_json::Map::new(); for line in args_text.lines() { let line = line.trim(); if let Some(pos) = line.find('>') { let key = line[..pos].trim(); let value = line[pos + 1..].trim(); if !key.is_empty() && !value.is_empty() { arguments.insert( key.to_string(), serde_json::Value::String(value.to_string()), ); } } } if !arguments.is_empty() { calls.push(ParsedToolCall { name: map_tool_name_alias(tool_name).to_string(), arguments: serde_json::Value::Object(arguments), tool_call_id: None, }); } } calls } /// Parse GLM-style tool calls from response text. /// Map tool name aliases from various LLM providers to ZeroClaw tool names. /// This handles variations like "fileread" -> "file_read", "bash" -> "shell", etc. fn map_tool_name_alias(tool_name: &str) -> &str { match tool_name { // Shell variations (including GLM aliases that map to shell) "shell" | "bash" | "sh" | "exec" | "command" | "cmd" | "browser_open" | "browser" | "web_search" => "shell", // Messaging variations "send_message" | "sendmessage" => "message_send", // File tool variations "fileread" | "file_read" | "readfile" | "read_file" | "file" => "file_read", "filewrite" | "file_write" | "writefile" | "write_file" => "file_write", "filelist" | "file_list" | "listfiles" | "list_files" => "file_list", // Memory variations "memoryrecall" | "memory_recall" | "recall" | "memrecall" => "memory_recall", "memorystore" | "memory_store" | "store" | "memstore" => "memory_store", "memoryforget" | "memory_forget" | "forget" | "memforget" => "memory_forget", // HTTP variations "http_request" | "http" | "fetch" | "curl" | "wget" => "http_request", _ => tool_name, } } fn build_curl_command(url: &str) -> Option { if !(url.starts_with("http://") || url.starts_with("https://")) { return None; } if url.chars().any(char::is_whitespace) { return None; } let escaped = url.replace('\'', r#"'\\''"#); Some(format!("curl -s '{}'", escaped)) } fn parse_glm_style_tool_calls(text: &str) -> Vec<(String, serde_json::Value, Option)> { let mut calls = Vec::new(); for line in text.lines() { let line = line.trim(); if line.is_empty() { continue; } // Format: tool_name/param>value or tool_name/{json} if let Some(pos) = line.find('/') { let tool_part = &line[..pos]; let rest = &line[pos + 1..]; if tool_part.chars().all(|c| c.is_alphanumeric() || c == '_') { let tool_name = map_tool_name_alias(tool_part); if let Some(gt_pos) = rest.find('>') { let param_name = rest[..gt_pos].trim(); let value = rest[gt_pos + 1..].trim(); let arguments = match tool_name { "shell" => { if param_name == "url" { let Some(command) = build_curl_command(value) else { continue; }; serde_json::json!({ "command": command }) } else if value.starts_with("http://") || value.starts_with("https://") { if let Some(command) = build_curl_command(value) { serde_json::json!({ "command": command }) } else { serde_json::json!({ "command": value }) } } else { serde_json::json!({ "command": value }) } } "http_request" => { serde_json::json!({"url": value, "method": "GET"}) } _ => serde_json::json!({ param_name: value }), }; calls.push((tool_name.to_string(), arguments, Some(line.to_string()))); continue; } if rest.starts_with('{') { if let Ok(json_args) = serde_json::from_str::(rest) { calls.push((tool_name.to_string(), json_args, Some(line.to_string()))); } } } } } calls } /// Return the canonical default parameter name for a tool. /// /// When a model emits a shortened call like `shell>uname -a` (without an /// explicit `/param_name`), we need to infer which parameter the value maps /// to. This function encodes the mapping for known ZeroClaw tools. fn default_param_for_tool(tool: &str) -> &'static str { match tool { "shell" | "bash" | "sh" | "exec" | "command" | "cmd" => "command", // All file tools default to "path" "file_read" | "fileread" | "readfile" | "read_file" | "file" | "file_write" | "filewrite" | "writefile" | "write_file" | "file_edit" | "fileedit" | "editfile" | "edit_file" | "file_list" | "filelist" | "listfiles" | "list_files" => "path", // Memory recall and forget both default to "query" "memory_recall" | "memoryrecall" | "recall" | "memrecall" | "memory_forget" | "memoryforget" | "forget" | "memforget" => "query", "memory_store" | "memorystore" | "store" | "memstore" => "content", // HTTP and browser tools default to "url" "http_request" | "http" | "fetch" | "curl" | "wget" | "browser_open" | "browser" | "web_search" => "url", _ => "input", } } /// Parse GLM-style shortened tool call bodies found inside `` tags. /// /// Handles three sub-formats that GLM-4.7 emits: /// /// 1. **Shortened**: `tool_name>value` — single value mapped via /// [`default_param_for_tool`]. /// 2. **YAML-like multi-line**: `tool_name>\nkey: value\nkey: value` — each /// subsequent `key: value` line becomes a parameter. /// 3. **Attribute-style**: `tool_name key="value" [/]>` — XML-like attributes. /// /// Returns `None` if the body does not match any of these formats. fn parse_glm_shortened_body(body: &str) -> Option { let body = body.trim(); if body.is_empty() { return None; } let function_style = body.find('(').and_then(|open| { if body.ends_with(')') && open > 0 { Some((body[..open].trim(), body[open + 1..body.len() - 1].trim())) } else { None } }); // Check attribute-style FIRST: `tool_name key="value" />` // Must come before `>` check because `/>` contains `>` and would // misparse the tool name in the first branch. let (tool_raw, value_part) = if let Some((tool, args)) = function_style { (tool, args) } else if body.contains("=\"") { // Attribute-style: split at first whitespace to get tool name let split_pos = body.find(|c: char| c.is_whitespace()).unwrap_or(body.len()); let tool = body[..split_pos].trim(); let attrs = body[split_pos..] .trim() .trim_end_matches("/>") .trim_end_matches('>') .trim_end_matches('/') .trim(); (tool, attrs) } else if let Some(gt_pos) = body.find('>') { // GLM shortened: `tool_name>value` let tool = body[..gt_pos].trim(); let value = body[gt_pos + 1..].trim(); // Strip trailing self-close markers that some models emit let value = value.trim_end_matches("/>").trim_end_matches('/').trim(); (tool, value) } else { return None; }; // Validate tool name: must be alphanumeric + underscore only let tool_raw = tool_raw.trim_end_matches(|c: char| c.is_whitespace()); if tool_raw.is_empty() || !tool_raw.chars().all(|c| c.is_alphanumeric() || c == '_') { return None; } let tool_name = map_tool_name_alias(tool_raw); // Try attribute-style: `key="value" key2="value2"` if value_part.contains("=\"") { let mut args = serde_json::Map::new(); // Simple attribute parser: key="value" pairs let mut rest = value_part; while let Some(eq_pos) = rest.find("=\"") { let key_start = rest[..eq_pos] .rfind(|c: char| c.is_whitespace()) .map(|p| p + 1) .unwrap_or(0); let key = rest[key_start..eq_pos] .trim() .trim_matches(|c: char| c == ',' || c == ';'); let after_quote = &rest[eq_pos + 2..]; if let Some(end_quote) = after_quote.find('"') { let value = &after_quote[..end_quote]; if !key.is_empty() { args.insert( key.to_string(), serde_json::Value::String(value.to_string()), ); } rest = &after_quote[end_quote + 1..]; } else { break; } } if !args.is_empty() { return Some(ParsedToolCall { name: tool_name.to_string(), arguments: serde_json::Value::Object(args), tool_call_id: None, }); } } // Try YAML-style multi-line: each line is `key: value` if value_part.contains('\n') { let mut args = serde_json::Map::new(); for line in value_part.lines() { let line = line.trim(); if line.is_empty() { continue; } if let Some(colon_pos) = line.find(':') { let key = line[..colon_pos].trim(); let value = line[colon_pos + 1..].trim(); if !key.is_empty() && !value.is_empty() { // Normalize boolean-like values let json_value = match value { "true" | "yes" => serde_json::Value::Bool(true), "false" | "no" => serde_json::Value::Bool(false), _ => serde_json::Value::String(value.to_string()), }; args.insert(key.to_string(), json_value); } } } if !args.is_empty() { return Some(ParsedToolCall { name: tool_name.to_string(), arguments: serde_json::Value::Object(args), tool_call_id: None, }); } } // Single-value shortened: `tool>value` if !value_part.is_empty() { let param = default_param_for_tool(tool_raw); let arguments = match tool_name { "shell" => { if value_part.starts_with("http://") || value_part.starts_with("https://") { if let Some(cmd) = build_curl_command(value_part) { serde_json::json!({ "command": cmd }) } else { serde_json::json!({ "command": value_part }) } } else { serde_json::json!({ "command": value_part }) } } "http_request" => serde_json::json!({"url": value_part, "method": "GET"}), _ => serde_json::json!({ param: value_part }), }; return Some(ParsedToolCall { name: tool_name.to_string(), arguments, tool_call_id: None, }); } None } // ── Tool-Call Parsing ───────────────────────────────────────────────────── // LLM responses may contain tool calls in multiple formats depending on // the provider. Parsing follows a priority chain: // 1. OpenAI-style JSON with `tool_calls` array (native API) // 2. XML tags: , , , // 3. Markdown code blocks with `tool_call` language // 4. GLM-style line-based format (e.g. `shell/command>ls`) // SECURITY: We never fall back to extracting arbitrary JSON from the // response body, because that would enable prompt-injection attacks where // malicious content in emails/files/web pages mimics a tool call. /// Parse tool calls from an LLM response that uses XML-style function calling. /// /// Expected format (common with system-prompt-guided tool use): /// ```text /// /// {"name": "shell", "arguments": {"command": "ls"}} /// /// ``` /// /// Also accepts common tag variants (``, ``) for model /// compatibility. /// /// Also supports JSON with `tool_calls` array from OpenAI-format responses. fn parse_tool_calls(response: &str) -> (String, Vec) { let mut text_parts = Vec::new(); let mut calls = Vec::new(); let mut remaining = response; // First, try to parse as OpenAI-style JSON response with tool_calls array // This handles providers like Minimax that return tool_calls in native JSON format if let Ok(json_value) = serde_json::from_str::(response.trim()) { calls = parse_tool_calls_from_json_value(&json_value); if !calls.is_empty() { // If we found tool_calls, extract any content field as text if let Some(content) = json_value.get("content").and_then(|v| v.as_str()) { if !content.trim().is_empty() { text_parts.push(content.trim().to_string()); } } return (text_parts.join("\n"), calls); } } if let Some((minimax_text, minimax_calls)) = parse_minimax_invoke_calls(response) { if !minimax_calls.is_empty() { return (minimax_text, minimax_calls); } } // Fall back to XML-style tool-call tag parsing. while let Some((start, open_tag)) = find_first_tag(remaining, &TOOL_CALL_OPEN_TAGS) { // Everything before the tag is text let before = &remaining[..start]; if !before.trim().is_empty() { text_parts.push(before.trim().to_string()); } let Some(close_tag) = matching_tool_call_close_tag(open_tag) else { break; }; let after_open = &remaining[start + open_tag.len()..]; if let Some(close_idx) = after_open.find(close_tag) { let inner = &after_open[..close_idx]; let mut parsed_any = false; // Try JSON format first let json_values = extract_json_values(inner); for value in json_values { let parsed_calls = parse_tool_calls_from_json_value(&value); if !parsed_calls.is_empty() { parsed_any = true; calls.extend(parsed_calls); } } // If JSON parsing failed, try XML format (DeepSeek/GLM style) if !parsed_any { if let Some(xml_calls) = parse_xml_tool_calls(inner) { calls.extend(xml_calls); parsed_any = true; } } if !parsed_any { // GLM-style shortened body: `shell>uname -a` or `shell\ncommand: date` if let Some(glm_call) = parse_glm_shortened_body(inner) { calls.push(glm_call); parsed_any = true; } } if !parsed_any { tracing::warn!( "Malformed : expected tool-call object in tag body (JSON/XML/GLM)" ); } remaining = &after_open[close_idx + close_tag.len()..]; } else { // Matching close tag not found — try cross-alias close tags first. // Models sometimes mix open/close tag aliases (e.g. ...). let mut resolved = false; if let Some((cross_idx, cross_tag)) = find_first_tag(after_open, &TOOL_CALL_CLOSE_TAGS) { let inner = &after_open[..cross_idx]; let mut parsed_any = false; // Try JSON let json_values = extract_json_values(inner); for value in json_values { let parsed_calls = parse_tool_calls_from_json_value(&value); if !parsed_calls.is_empty() { parsed_any = true; calls.extend(parsed_calls); } } // Try XML if !parsed_any { if let Some(xml_calls) = parse_xml_tool_calls(inner) { calls.extend(xml_calls); parsed_any = true; } } // Try GLM shortened body if !parsed_any { if let Some(glm_call) = parse_glm_shortened_body(inner) { calls.push(glm_call); parsed_any = true; } } if parsed_any { remaining = &after_open[cross_idx + cross_tag.len()..]; resolved = true; } } if resolved { continue; } // No cross-alias close tag resolved — fall back to JSON recovery // from unclosed tags (brace-balancing). if let Some(json_end) = find_json_end(after_open) { if let Ok(value) = serde_json::from_str::(&after_open[..json_end]) { let parsed_calls = parse_tool_calls_from_json_value(&value); if !parsed_calls.is_empty() { calls.extend(parsed_calls); remaining = strip_leading_close_tags(&after_open[json_end..]); continue; } } } if let Some((value, consumed_end)) = extract_first_json_value_with_end(after_open) { let parsed_calls = parse_tool_calls_from_json_value(&value); if !parsed_calls.is_empty() { calls.extend(parsed_calls); remaining = strip_leading_close_tags(&after_open[consumed_end..]); continue; } } // Last resort: try GLM shortened body on everything after the open tag. // The model may have emitted `shell>ls` with no close tag at all. let glm_input = after_open.trim(); if let Some(glm_call) = parse_glm_shortened_body(glm_input) { calls.push(glm_call); remaining = ""; continue; } remaining = &remaining[start..]; break; } } // If XML tags found nothing, try markdown code blocks with tool_call language. // Models behind OpenRouter sometimes output ```tool_call ... ``` or hybrid // ```tool_call ... instead of structured API calls or XML tags. if calls.is_empty() { static MD_TOOL_CALL_RE: LazyLock = LazyLock::new(|| { Regex::new( r"(?s)```(?:tool[_-]?call|invoke)\s*\n(.*?)(?:```||||)", ) .unwrap() }); let mut md_text_parts: Vec = Vec::new(); let mut last_end = 0; for cap in MD_TOOL_CALL_RE.captures_iter(response) { let full_match = cap.get(0).unwrap(); let before = &response[last_end..full_match.start()]; if !before.trim().is_empty() { md_text_parts.push(before.trim().to_string()); } let inner = &cap[1]; let json_values = extract_json_values(inner); for value in json_values { let parsed_calls = parse_tool_calls_from_json_value(&value); calls.extend(parsed_calls); } last_end = full_match.end(); } if !calls.is_empty() { let after = &response[last_end..]; if !after.trim().is_empty() { md_text_parts.push(after.trim().to_string()); } text_parts = md_text_parts; remaining = ""; } } // Try ```tool format used by some providers (e.g., xAI grok) // Example: ```tool file_write\n{"path": "...", "content": "..."}\n``` if calls.is_empty() { static MD_TOOL_NAME_RE: LazyLock = LazyLock::new(|| Regex::new(r"(?s)```tool\s+(\w+)\s*\n(.*?)(?:```|$)").unwrap()); let mut md_text_parts: Vec = Vec::new(); let mut last_end = 0; for cap in MD_TOOL_NAME_RE.captures_iter(response) { let full_match = cap.get(0).unwrap(); let before = &response[last_end..full_match.start()]; if !before.trim().is_empty() { md_text_parts.push(before.trim().to_string()); } let tool_name = &cap[1]; let inner = &cap[2]; // Try to parse the inner content as JSON arguments let json_values = extract_json_values(inner); if json_values.is_empty() { // Log a warning if we found a tool block but couldn't parse arguments tracing::warn!( tool_name = %tool_name, inner = %inner.chars().take(100).collect::(), "Found ```tool block but could not parse JSON arguments" ); } else { for value in json_values { let arguments = if value.is_object() { value } else { serde_json::Value::Object(serde_json::Map::new()) }; calls.push(ParsedToolCall { name: tool_name.to_string(), arguments, tool_call_id: None, }); } } last_end = full_match.end(); } if !calls.is_empty() { let after = &response[last_end..]; if !after.trim().is_empty() { md_text_parts.push(after.trim().to_string()); } text_parts = md_text_parts; remaining = ""; } } // XML attribute-style tool calls: // // // ls // // if calls.is_empty() { let xml_calls = parse_xml_attribute_tool_calls(remaining); if !xml_calls.is_empty() { let mut cleaned_text = remaining.to_string(); for call in xml_calls { calls.push(call); // Try to remove the XML from text if let Some(start) = cleaned_text.find("") { if let Some(end) = cleaned_text.find("") { let end_pos = end + "".len(); if end_pos <= cleaned_text.len() { cleaned_text = format!("{}{}", &cleaned_text[..start], &cleaned_text[end_pos..]); } } } } if !cleaned_text.trim().is_empty() { text_parts.push(cleaned_text.trim().to_string()); } remaining = ""; } } // Perl/hash-ref style tool calls: // TOOL_CALL // {tool => "shell", args => { // --command "ls -la" // --description "List current directory contents" // }} // /TOOL_CALL if calls.is_empty() { let perl_calls = parse_perl_style_tool_calls(remaining); if !perl_calls.is_empty() { let mut cleaned_text = remaining.to_string(); for call in perl_calls { calls.push(call); // Try to remove the TOOL_CALL block from text while let Some(start) = cleaned_text.find("TOOL_CALL") { if let Some(end) = cleaned_text.find("/TOOL_CALL") { let end_pos = end + "/TOOL_CALL".len(); if end_pos <= cleaned_text.len() { cleaned_text = format!("{}{}", &cleaned_text[..start], &cleaned_text[end_pos..]); } } else { break; } } } if !cleaned_text.trim().is_empty() { text_parts.push(cleaned_text.trim().to_string()); } remaining = ""; } } // // file_read // path>/Users/... // if calls.is_empty() { let func_calls = parse_function_call_tool_calls(remaining); if !func_calls.is_empty() { let mut cleaned_text = remaining.to_string(); for call in func_calls { calls.push(call); // Try to remove the FunctionCall block from text while let Some(start) = cleaned_text.find("") { if let Some(end) = cleaned_text.find("") { let end_pos = end + "".len(); if end_pos <= cleaned_text.len() { cleaned_text = format!("{}{}", &cleaned_text[..start], &cleaned_text[end_pos..]); } } else { break; } } } if !cleaned_text.trim().is_empty() { text_parts.push(cleaned_text.trim().to_string()); } remaining = ""; } } // GLM-style tool calls (browser_open/url>https://..., shell/command>ls, etc.) if calls.is_empty() { let glm_calls = parse_glm_style_tool_calls(remaining); if !glm_calls.is_empty() { let mut cleaned_text = remaining.to_string(); for (name, args, raw) in &glm_calls { calls.push(ParsedToolCall { name: name.clone(), arguments: args.clone(), tool_call_id: None, }); if let Some(r) = raw { cleaned_text = cleaned_text.replace(r, ""); } } if !cleaned_text.trim().is_empty() { text_parts.push(cleaned_text.trim().to_string()); } remaining = ""; } } // SECURITY: We do NOT fall back to extracting arbitrary JSON from the response // here. That would enable prompt injection attacks where malicious content // (e.g., in emails, files, or web pages) could include JSON that mimics a // tool call. Tool calls MUST be explicitly wrapped in either: // 1. OpenAI-style JSON with a "tool_calls" array // 2. ZeroClaw tool-call tags (, , ) // 3. Markdown code blocks with tool_call/toolcall/tool-call language // 4. Explicit GLM line-based call formats (e.g. `shell/command>...`) // This ensures only the LLM's intentional tool calls are executed. // Remaining text after last tool call if !remaining.trim().is_empty() { text_parts.push(remaining.trim().to_string()); } (text_parts.join("\n"), calls) } fn detect_tool_call_parse_issue(response: &str, parsed_calls: &[ParsedToolCall]) -> Option { if !parsed_calls.is_empty() { return None; } let trimmed = response.trim(); if trimmed.is_empty() { return None; } let looks_like_tool_payload = trimmed.contains(" pattern || trimmed.contains("\"tool_calls\"") || trimmed.contains("TOOL_CALL") || trimmed.contains(""); if looks_like_tool_payload { Some("response resembled a tool-call payload but no valid tool call could be parsed".into()) } else { None } } fn parse_structured_tool_calls(tool_calls: &[ToolCall]) -> Vec { tool_calls .iter() .map(|call| ParsedToolCall { name: call.name.clone(), arguments: serde_json::from_str::(&call.arguments) .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new())), tool_call_id: Some(call.id.clone()), }) .collect() } /// Build assistant history entry in JSON format for native tool-call APIs. /// `convert_messages` in the OpenRouter provider parses this JSON to reconstruct /// the proper `NativeMessage` with structured `tool_calls`. fn build_native_assistant_history( text: &str, tool_calls: &[ToolCall], reasoning_content: Option<&str>, ) -> String { let calls_json: Vec = tool_calls .iter() .map(|tc| { serde_json::json!({ "id": tc.id, "name": tc.name, "arguments": tc.arguments, }) }) .collect(); let content = if text.trim().is_empty() { serde_json::Value::Null } else { serde_json::Value::String(text.trim().to_string()) }; let mut obj = serde_json::json!({ "content": content, "tool_calls": calls_json, }); if let Some(rc) = reasoning_content { obj.as_object_mut().unwrap().insert( "reasoning_content".to_string(), serde_json::Value::String(rc.to_string()), ); } obj.to_string() } fn build_native_assistant_history_from_parsed_calls( text: &str, tool_calls: &[ParsedToolCall], reasoning_content: Option<&str>, ) -> Option { let calls_json = tool_calls .iter() .map(|tc| { Some(serde_json::json!({ "id": tc.tool_call_id.clone()?, "name": tc.name, "arguments": serde_json::to_string(&tc.arguments).unwrap_or_else(|_| "{}".to_string()), })) }) .collect::>>()?; let content = if text.trim().is_empty() { serde_json::Value::Null } else { serde_json::Value::String(text.trim().to_string()) }; let mut obj = serde_json::json!({ "content": content, "tool_calls": calls_json, }); if let Some(rc) = reasoning_content { obj.as_object_mut().unwrap().insert( "reasoning_content".to_string(), serde_json::Value::String(rc.to_string()), ); } Some(obj.to_string()) } fn build_assistant_history_with_tool_calls(text: &str, tool_calls: &[ToolCall]) -> String { let mut parts = Vec::new(); if !text.trim().is_empty() { parts.push(text.trim().to_string()); } for call in tool_calls { let arguments = serde_json::from_str::(&call.arguments) .unwrap_or_else(|_| serde_json::Value::String(call.arguments.clone())); let payload = serde_json::json!({ "id": call.id, "name": call.name, "arguments": arguments, }); parts.push(format!("\n{payload}\n")); } parts.join("\n") } #[derive(Debug, Clone)] struct ParsedToolCall { name: String, arguments: serde_json::Value, tool_call_id: Option, } #[derive(Debug)] pub(crate) struct ToolLoopCancelled; impl std::fmt::Display for ToolLoopCancelled { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("tool loop cancelled") } } impl std::error::Error for ToolLoopCancelled {} pub(crate) fn is_tool_loop_cancelled(err: &anyhow::Error) -> bool { err.chain().any(|source| source.is::()) } /// Execute a single turn of the agent loop: send messages, parse tool calls, /// execute tools, and loop until the LLM produces a final text response. /// When `silent` is true, suppresses stdout (for channel use). #[allow(clippy::too_many_arguments)] pub(crate) async fn agent_turn( provider: &dyn Provider, history: &mut Vec, tools_registry: &[Box], observer: &dyn Observer, provider_name: &str, model: &str, temperature: f64, silent: bool, multimodal_config: &crate::config::MultimodalConfig, max_tool_iterations: usize, ) -> Result { run_tool_call_loop( provider, history, tools_registry, observer, provider_name, model, temperature, silent, None, "channel", multimodal_config, max_tool_iterations, None, None, None, &[], ) .await } async fn execute_one_tool( call_name: &str, call_arguments: serde_json::Value, tools_registry: &[Box], observer: &dyn Observer, cancellation_token: Option<&CancellationToken>, ) -> Result { observer.record_event(&ObserverEvent::ToolCallStart { tool: call_name.to_string(), }); let start = Instant::now(); let Some(tool) = find_tool(tools_registry, call_name) else { let reason = format!("Unknown tool: {call_name}"); let duration = start.elapsed(); observer.record_event(&ObserverEvent::ToolCall { tool: call_name.to_string(), duration, success: false, }); return Ok(ToolExecutionOutcome { output: reason.clone(), success: false, error_reason: Some(scrub_credentials(&reason)), duration, }); }; let tool_future = tool.execute(call_arguments); let tool_result = if let Some(token) = cancellation_token { tokio::select! { () = token.cancelled() => return Err(ToolLoopCancelled.into()), result = tool_future => result, } } else { tool_future.await }; match tool_result { Ok(r) => { let duration = start.elapsed(); observer.record_event(&ObserverEvent::ToolCall { tool: call_name.to_string(), duration, success: r.success, }); if r.success { Ok(ToolExecutionOutcome { output: scrub_credentials(&r.output), success: true, error_reason: None, duration, }) } else { let reason = r.error.unwrap_or(r.output); Ok(ToolExecutionOutcome { output: format!("Error: {reason}"), success: false, error_reason: Some(scrub_credentials(&reason)), duration, }) } } Err(e) => { let duration = start.elapsed(); observer.record_event(&ObserverEvent::ToolCall { tool: call_name.to_string(), duration, success: false, }); let reason = format!("Error executing {call_name}: {e}"); Ok(ToolExecutionOutcome { output: reason.clone(), success: false, error_reason: Some(scrub_credentials(&reason)), duration, }) } } } struct ToolExecutionOutcome { output: String, success: bool, error_reason: Option, duration: Duration, } fn should_execute_tools_in_parallel( tool_calls: &[ParsedToolCall], approval: Option<&ApprovalManager>, ) -> bool { if tool_calls.len() <= 1 { return false; } if let Some(mgr) = approval { if tool_calls.iter().any(|call| mgr.needs_approval(&call.name)) { // Approval-gated calls must keep sequential handling so the caller can // enforce CLI prompt/deny policy consistently. return false; } } true } async fn execute_tools_parallel( tool_calls: &[ParsedToolCall], tools_registry: &[Box], observer: &dyn Observer, cancellation_token: Option<&CancellationToken>, ) -> Result> { let futures: Vec<_> = tool_calls .iter() .map(|call| { execute_one_tool( &call.name, call.arguments.clone(), tools_registry, observer, cancellation_token, ) }) .collect(); let results = futures_util::future::join_all(futures).await; results.into_iter().collect() } async fn execute_tools_sequential( tool_calls: &[ParsedToolCall], tools_registry: &[Box], observer: &dyn Observer, cancellation_token: Option<&CancellationToken>, ) -> Result> { let mut outcomes = Vec::with_capacity(tool_calls.len()); for call in tool_calls { outcomes.push( execute_one_tool( &call.name, call.arguments.clone(), tools_registry, observer, cancellation_token, ) .await?, ); } Ok(outcomes) } // ── Agent Tool-Call Loop ────────────────────────────────────────────────── // Core agentic iteration: send conversation to the LLM, parse any tool // calls from the response, execute them, append results to history, and // repeat until the LLM produces a final text-only answer. // // Loop invariant: at the start of each iteration, `history` contains the // full conversation so far (system prompt + user messages + prior tool // results). The loop exits when: // • the LLM returns no tool calls (final answer), or // • max_iterations is reached (runaway safety), or // • the cancellation token fires (external abort). /// Execute a single turn of the agent loop: send messages, parse tool calls, /// execute tools, and loop until the LLM produces a final text response. #[allow(clippy::too_many_arguments)] pub(crate) async fn run_tool_call_loop( provider: &dyn Provider, history: &mut Vec, tools_registry: &[Box], observer: &dyn Observer, provider_name: &str, model: &str, temperature: f64, silent: bool, approval: Option<&ApprovalManager>, channel_name: &str, multimodal_config: &crate::config::MultimodalConfig, max_tool_iterations: usize, cancellation_token: Option, on_delta: Option>, hooks: Option<&crate::hooks::HookRunner>, excluded_tools: &[String], ) -> Result { let max_iterations = if max_tool_iterations == 0 { DEFAULT_MAX_TOOL_ITERATIONS } else { max_tool_iterations }; let tool_specs: Vec = tools_registry .iter() .filter(|tool| !excluded_tools.iter().any(|ex| ex == tool.name())) .map(|tool| tool.spec()) .collect(); let use_native_tools = provider.supports_native_tools() && !tool_specs.is_empty(); let turn_id = Uuid::new_v4().to_string(); let mut seen_tool_signatures: HashSet<(String, String)> = HashSet::new(); for iteration in 0..max_iterations { if cancellation_token .as_ref() .is_some_and(CancellationToken::is_cancelled) { return Err(ToolLoopCancelled.into()); } let image_marker_count = multimodal::count_image_markers(history); if image_marker_count > 0 && !provider.supports_vision() { return Err(ProviderCapabilityError { provider: provider_name.to_string(), capability: "vision".to_string(), message: format!( "received {image_marker_count} image marker(s), but this provider does not support vision input" ), } .into()); } let prepared_messages = multimodal::prepare_messages_for_provider(history, multimodal_config).await?; // ── Progress: LLM thinking ──────────────────────────── if let Some(ref tx) = on_delta { let phase = if iteration == 0 { "\u{1f914} Thinking...\n".to_string() } else { format!("\u{1f914} Thinking (round {})...\n", iteration + 1) }; let _ = tx.send(phase).await; } observer.record_event(&ObserverEvent::LlmRequest { provider: provider_name.to_string(), model: model.to_string(), messages_count: history.len(), }); runtime_trace::record_event( "llm_request", Some(channel_name), Some(provider_name), Some(model), Some(&turn_id), None, None, serde_json::json!({ "iteration": iteration + 1, "messages_count": history.len(), }), ); let llm_started_at = Instant::now(); // Fire void hook before LLM call if let Some(hooks) = hooks { hooks.fire_llm_input(history, model).await; } // Unified path via Provider::chat so provider-specific native tool logic // (OpenAI/Anthropic/OpenRouter/compatible adapters) is honored. let request_tools = if use_native_tools { Some(tool_specs.as_slice()) } else { None }; let chat_future = provider.chat( ChatRequest { messages: &prepared_messages.messages, tools: request_tools, }, model, temperature, ); let chat_result = if let Some(token) = cancellation_token.as_ref() { tokio::select! { () = token.cancelled() => return Err(ToolLoopCancelled.into()), result = chat_future => result, } } else { chat_future.await }; let (response_text, parsed_text, tool_calls, assistant_history_content, native_tool_calls) = match chat_result { Ok(resp) => { let (resp_input_tokens, resp_output_tokens) = resp .usage .as_ref() .map(|u| (u.input_tokens, u.output_tokens)) .unwrap_or((None, None)); observer.record_event(&ObserverEvent::LlmResponse { provider: provider_name.to_string(), model: model.to_string(), duration: llm_started_at.elapsed(), success: true, error_message: None, input_tokens: resp_input_tokens, output_tokens: resp_output_tokens, }); let response_text = resp.text_or_empty().to_string(); // First try native structured tool calls (OpenAI-format). // Fall back to text-based parsing (XML tags, markdown blocks, // GLM format) only if the provider returned no native calls — // this ensures we support both native and prompt-guided models. let mut calls = parse_structured_tool_calls(&resp.tool_calls); let mut parsed_text = String::new(); if calls.is_empty() { let (fallback_text, fallback_calls) = parse_tool_calls(&response_text); if !fallback_text.is_empty() { parsed_text = fallback_text; } calls = fallback_calls; } if let Some(parse_issue) = detect_tool_call_parse_issue(&response_text, &calls) { runtime_trace::record_event( "tool_call_parse_issue", Some(channel_name), Some(provider_name), Some(model), Some(&turn_id), Some(false), Some(&parse_issue), serde_json::json!({ "iteration": iteration + 1, "response_excerpt": truncate_with_ellipsis( &scrub_credentials(&response_text), 600 ), }), ); } runtime_trace::record_event( "llm_response", Some(channel_name), Some(provider_name), Some(model), Some(&turn_id), Some(true), None, serde_json::json!({ "iteration": iteration + 1, "duration_ms": llm_started_at.elapsed().as_millis(), "input_tokens": resp_input_tokens, "output_tokens": resp_output_tokens, "raw_response": scrub_credentials(&response_text), "native_tool_calls": resp.tool_calls.len(), "parsed_tool_calls": calls.len(), }), ); // Preserve native tool call IDs in assistant history so role=tool // follow-up messages can reference the exact call id. let reasoning_content = resp.reasoning_content.clone(); let assistant_history_content = if resp.tool_calls.is_empty() { if use_native_tools { build_native_assistant_history_from_parsed_calls( &response_text, &calls, reasoning_content.as_deref(), ) .unwrap_or_else(|| response_text.clone()) } else { response_text.clone() } } else { build_native_assistant_history( &response_text, &resp.tool_calls, reasoning_content.as_deref(), ) }; let native_calls = resp.tool_calls; ( response_text, parsed_text, calls, assistant_history_content, native_calls, ) } Err(e) => { let safe_error = crate::providers::sanitize_api_error(&e.to_string()); observer.record_event(&ObserverEvent::LlmResponse { provider: provider_name.to_string(), model: model.to_string(), duration: llm_started_at.elapsed(), success: false, error_message: Some(safe_error.clone()), input_tokens: None, output_tokens: None, }); runtime_trace::record_event( "llm_response", Some(channel_name), Some(provider_name), Some(model), Some(&turn_id), Some(false), Some(&safe_error), serde_json::json!({ "iteration": iteration + 1, "duration_ms": llm_started_at.elapsed().as_millis(), }), ); return Err(e); } }; let display_text = if parsed_text.is_empty() { response_text.clone() } else { parsed_text }; // ── Progress: LLM responded ───────────────────────────── if let Some(ref tx) = on_delta { let llm_secs = llm_started_at.elapsed().as_secs(); if !tool_calls.is_empty() { let _ = tx .send(format!( "\u{1f4ac} Got {} tool call(s) ({llm_secs}s)\n", tool_calls.len() )) .await; } } if tool_calls.is_empty() { runtime_trace::record_event( "turn_final_response", Some(channel_name), Some(provider_name), Some(model), Some(&turn_id), Some(true), None, serde_json::json!({ "iteration": iteration + 1, "text": scrub_credentials(&display_text), }), ); // No tool calls — this is the final response. // If a streaming sender is provided, relay the text in small chunks // so the channel can progressively update the draft message. if let Some(ref tx) = on_delta { // Clear accumulated progress lines before streaming the final answer. let _ = tx.send(DRAFT_CLEAR_SENTINEL.to_string()).await; // Split on whitespace boundaries, accumulating chunks of at least // STREAM_CHUNK_MIN_CHARS characters for progressive draft updates. let mut chunk = String::new(); for word in display_text.split_inclusive(char::is_whitespace) { if cancellation_token .as_ref() .is_some_and(CancellationToken::is_cancelled) { return Err(ToolLoopCancelled.into()); } chunk.push_str(word); if chunk.len() >= STREAM_CHUNK_MIN_CHARS && tx.send(std::mem::take(&mut chunk)).await.is_err() { break; // receiver dropped } } if !chunk.is_empty() { let _ = tx.send(chunk).await; } } history.push(ChatMessage::assistant(response_text.clone())); return Ok(display_text); } // Print any text the LLM produced alongside tool calls (unless silent) if !silent && !display_text.is_empty() { print!("{display_text}"); let _ = std::io::stdout().flush(); } // Execute tool calls and build results. `individual_results` tracks per-call output so // native-mode history can emit one role=tool message per tool call with the correct ID. // // When multiple tool calls are present and interactive CLI approval is not needed, run // tool executions concurrently for lower wall-clock latency. let mut tool_results = String::new(); let mut individual_results: Vec<(Option, String)> = Vec::new(); let mut ordered_results: Vec, ToolExecutionOutcome)>> = (0..tool_calls.len()).map(|_| None).collect(); let allow_parallel_execution = should_execute_tools_in_parallel(&tool_calls, approval); let mut executable_indices: Vec = Vec::new(); let mut executable_calls: Vec = Vec::new(); for (idx, call) in tool_calls.iter().enumerate() { // ── Hook: before_tool_call (modifying) ────────── let mut tool_name = call.name.clone(); let mut tool_args = call.arguments.clone(); if let Some(hooks) = hooks { match hooks .run_before_tool_call(tool_name.clone(), tool_args.clone()) .await { crate::hooks::HookResult::Cancel(reason) => { tracing::info!(tool = %call.name, %reason, "tool call cancelled by hook"); let cancelled = format!("Cancelled by hook: {reason}"); runtime_trace::record_event( "tool_call_result", Some(channel_name), Some(provider_name), Some(model), Some(&turn_id), Some(false), Some(&cancelled), serde_json::json!({ "iteration": iteration + 1, "tool": call.name, "arguments": scrub_credentials(&tool_args.to_string()), }), ); ordered_results[idx] = Some(( call.name.clone(), call.tool_call_id.clone(), ToolExecutionOutcome { output: cancelled, success: false, error_reason: Some(scrub_credentials(&reason)), duration: Duration::ZERO, }, )); continue; } crate::hooks::HookResult::Continue((name, args)) => { tool_name = name; tool_args = args; } } } // ── Approval hook ──────────────────────────────── if let Some(mgr) = approval { if mgr.needs_approval(&tool_name) { let request = ApprovalRequest { tool_name: tool_name.clone(), arguments: tool_args.clone(), }; // Only prompt interactively on CLI; auto-approve on other channels. let decision = if channel_name == "cli" { mgr.prompt_cli(&request) } else { ApprovalResponse::Yes }; mgr.record_decision(&tool_name, &tool_args, decision, channel_name); if decision == ApprovalResponse::No { let denied = "Denied by user.".to_string(); runtime_trace::record_event( "tool_call_result", Some(channel_name), Some(provider_name), Some(model), Some(&turn_id), Some(false), Some(&denied), serde_json::json!({ "iteration": iteration + 1, "tool": tool_name.clone(), "arguments": scrub_credentials(&tool_args.to_string()), }), ); ordered_results[idx] = Some(( tool_name.clone(), call.tool_call_id.clone(), ToolExecutionOutcome { output: denied.clone(), success: false, error_reason: Some(denied), duration: Duration::ZERO, }, )); continue; } } } let signature = tool_call_signature(&tool_name, &tool_args); if !seen_tool_signatures.insert(signature) { let duplicate = format!( "Skipped duplicate tool call '{tool_name}' with identical arguments in this turn." ); runtime_trace::record_event( "tool_call_result", Some(channel_name), Some(provider_name), Some(model), Some(&turn_id), Some(false), Some(&duplicate), serde_json::json!({ "iteration": iteration + 1, "tool": tool_name.clone(), "arguments": scrub_credentials(&tool_args.to_string()), "deduplicated": true, }), ); ordered_results[idx] = Some(( tool_name.clone(), call.tool_call_id.clone(), ToolExecutionOutcome { output: duplicate.clone(), success: false, error_reason: Some(duplicate), duration: Duration::ZERO, }, )); continue; } runtime_trace::record_event( "tool_call_start", Some(channel_name), Some(provider_name), Some(model), Some(&turn_id), None, None, serde_json::json!({ "iteration": iteration + 1, "tool": tool_name.clone(), "arguments": scrub_credentials(&tool_args.to_string()), }), ); // ── Progress: tool start ──────────────────────────── if let Some(ref tx) = on_delta { let hint = truncate_tool_args_for_progress(&tool_name, &tool_args, 60); let progress = if hint.is_empty() { format!("\u{23f3} {}\n", tool_name) } else { format!("\u{23f3} {}: {hint}\n", tool_name) }; tracing::debug!(tool = %tool_name, "Sending progress start to draft"); let _ = tx.send(progress).await; } executable_indices.push(idx); executable_calls.push(ParsedToolCall { name: tool_name, arguments: tool_args, tool_call_id: call.tool_call_id.clone(), }); } let executed_outcomes = if allow_parallel_execution && executable_calls.len() > 1 { execute_tools_parallel( &executable_calls, tools_registry, observer, cancellation_token.as_ref(), ) .await? } else { execute_tools_sequential( &executable_calls, tools_registry, observer, cancellation_token.as_ref(), ) .await? }; for ((idx, call), outcome) in executable_indices .iter() .zip(executable_calls.iter()) .zip(executed_outcomes.into_iter()) { runtime_trace::record_event( "tool_call_result", Some(channel_name), Some(provider_name), Some(model), Some(&turn_id), Some(outcome.success), outcome.error_reason.as_deref(), serde_json::json!({ "iteration": iteration + 1, "tool": call.name.clone(), "duration_ms": outcome.duration.as_millis(), "output": scrub_credentials(&outcome.output), }), ); // ── Hook: after_tool_call (void) ───────────────── if let Some(hooks) = hooks { let tool_result_obj = crate::tools::ToolResult { success: outcome.success, output: outcome.output.clone(), error: None, }; hooks .fire_after_tool_call(&call.name, &tool_result_obj, outcome.duration) .await; } // ── Progress: tool completion ─────────────────────── if let Some(ref tx) = on_delta { let secs = outcome.duration.as_secs(); let icon = if outcome.success { "\u{2705}" } else { "\u{274c}" }; tracing::debug!(tool = %call.name, secs, "Sending progress complete to draft"); let _ = tx.send(format!("{icon} {} ({secs}s)\n", call.name)).await; } ordered_results[*idx] = Some((call.name.clone(), call.tool_call_id.clone(), outcome)); } for entry in ordered_results { if let Some((tool_name, tool_call_id, outcome)) = entry { individual_results.push((tool_call_id, outcome.output.clone())); let _ = writeln!( tool_results, "\n{}\n", tool_name, outcome.output ); } } // Add assistant message with tool calls + tool results to history. // Native mode: use JSON-structured messages so convert_messages() can // reconstruct proper OpenAI-format tool_calls and tool result messages. // Prompt mode: use XML-based text format as before. history.push(ChatMessage::assistant(assistant_history_content)); if native_tool_calls.is_empty() { let all_results_have_ids = use_native_tools && !individual_results.is_empty() && individual_results .iter() .all(|(tool_call_id, _)| tool_call_id.is_some()); if all_results_have_ids { for (tool_call_id, result) in &individual_results { let tool_msg = serde_json::json!({ "tool_call_id": tool_call_id, "content": result, }); history.push(ChatMessage::tool(tool_msg.to_string())); } } else { history.push(ChatMessage::user(format!("[Tool results]\n{tool_results}"))); } } else { for (native_call, (_, result)) in native_tool_calls.iter().zip(individual_results.iter()) { let tool_msg = serde_json::json!({ "tool_call_id": native_call.id, "content": result, }); history.push(ChatMessage::tool(tool_msg.to_string())); } } } runtime_trace::record_event( "tool_loop_exhausted", Some(channel_name), Some(provider_name), Some(model), Some(&turn_id), Some(false), Some("agent exceeded maximum tool iterations"), serde_json::json!({ "max_iterations": max_iterations, }), ); anyhow::bail!("Agent exceeded maximum tool iterations ({max_iterations})") } /// Build the tool instruction block for the system prompt so the LLM knows /// how to invoke tools. pub(crate) fn build_tool_instructions(tools_registry: &[Box]) -> String { let mut instructions = String::new(); instructions.push_str("\n## Tool Use Protocol\n\n"); instructions.push_str("To use a tool, wrap a JSON object in tags:\n\n"); instructions.push_str("```\n\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n\n```\n\n"); instructions.push_str( "CRITICAL: Output actual tags—never describe steps or give examples.\n\n", ); instructions.push_str("Example: User says \"what's the date?\". You MUST respond with:\n\n{\"name\":\"shell\",\"arguments\":{\"command\":\"date\"}}\n\n\n"); instructions.push_str("You may use multiple tool calls in a single response. "); instructions.push_str("After tool execution, results appear in tags. "); instructions .push_str("Continue reasoning with the results until you can give a final answer.\n\n"); instructions.push_str("### Available Tools\n\n"); for tool in tools_registry { let _ = writeln!( instructions, "**{}**: {}\nParameters: `{}`\n", tool.name(), tool.description(), tool.parameters_schema() ); } instructions } // ── CLI Entrypoint ─────────────────────────────────────────────────────── // Wires up all subsystems (observer, runtime, security, memory, tools, // provider, hardware RAG, peripherals) and enters either single-shot or // interactive REPL mode. The interactive loop manages history compaction // and hard trimming to keep the context window bounded. #[allow(clippy::too_many_lines)] pub async fn run( config: Config, message: Option, provider_override: Option, model_override: Option, temperature: f64, peripheral_overrides: Vec, interactive: bool, ) -> Result { // ── Wire up agnostic subsystems ────────────────────────────── let base_observer = observability::create_observer(&config.observability); let observer: Arc = Arc::from(base_observer); let runtime: Arc = Arc::from(runtime::create_runtime(&config.runtime)?); let security = Arc::new(SecurityPolicy::from_config( &config.autonomy, &config.workspace_dir, )); // ── Memory (the brain) ──────────────────────────────────────── let mem: Arc = Arc::from(memory::create_memory_with_storage( &config.memory, Some(&config.storage.provider.config), &config.workspace_dir, config.api_key.as_deref(), )?); tracing::info!(backend = mem.name(), "Memory initialized"); // ── Peripherals (merge peripheral tools into registry) ─ if !peripheral_overrides.is_empty() { tracing::info!( peripherals = ?peripheral_overrides, "Peripheral overrides from CLI (config boards take precedence)" ); } // ── Tools (including memory tools and peripherals) ──────────── let (composio_key, composio_entity_id) = if config.composio.enabled { ( config.composio.api_key.as_deref(), Some(config.composio.entity_id.as_str()), ) } else { (None, None) }; let mut tools_registry = tools::all_tools_with_runtime( Arc::new(config.clone()), &security, runtime, mem.clone(), composio_key, composio_entity_id, &config.browser, &config.http_request, &config.web_fetch, &config.workspace_dir, &config.agents, config.api_key.as_deref(), &config, ); let peripheral_tools: Vec> = crate::peripherals::create_peripheral_tools(&config.peripherals).await?; if !peripheral_tools.is_empty() { tracing::info!(count = peripheral_tools.len(), "Peripheral tools added"); tools_registry.extend(peripheral_tools); } // ── Resolve provider ───────────────────────────────────────── let provider_name = provider_override .as_deref() .or(config.default_provider.as_deref()) .unwrap_or("openrouter"); let model_name = model_override .as_deref() .or(config.default_model.as_deref()) .unwrap_or("anthropic/claude-sonnet-4"); let provider_runtime_options = providers::ProviderRuntimeOptions { auth_profile_override: None, provider_api_url: config.api_url.clone(), zeroclaw_dir: config.config_path.parent().map(std::path::PathBuf::from), secrets_encrypt: config.secrets.encrypt, reasoning_enabled: config.runtime.reasoning_enabled, }; let provider: Box = providers::create_routed_provider_with_options( provider_name, config.api_key.as_deref(), config.api_url.as_deref(), &config.reliability, &config.model_routes, model_name, &provider_runtime_options, )?; observer.record_event(&ObserverEvent::AgentStart { provider: provider_name.to_string(), model: model_name.to_string(), }); // ── Hardware RAG (datasheet retrieval when peripherals + datasheet_dir) ── let hardware_rag: Option = config .peripherals .datasheet_dir .as_ref() .filter(|d| !d.trim().is_empty()) .map(|dir| crate::rag::HardwareRag::load(&config.workspace_dir, dir.trim())) .and_then(Result::ok) .filter(|r: &crate::rag::HardwareRag| !r.is_empty()); if let Some(ref rag) = hardware_rag { tracing::info!(chunks = rag.len(), "Hardware RAG loaded"); } let board_names: Vec = config .peripherals .boards .iter() .map(|b| b.board.clone()) .collect(); // ── Build system prompt from workspace MD files (OpenClaw framework) ── let skills = crate::skills::load_skills_with_config(&config.workspace_dir, &config); let mut tool_descs: Vec<(&str, &str)> = vec![ ( "shell", "Execute terminal commands. Use when: running local checks, build/test commands, diagnostics. Don't use when: a safer dedicated tool exists, or command is destructive without approval.", ), ( "file_read", "Read file contents. Use when: inspecting project files, configs, logs. Don't use when: a targeted search is enough.", ), ( "file_write", "Write file contents. Use when: applying focused edits, scaffolding files, updating docs/code. Don't use when: side effects are unclear or file ownership is uncertain.", ), ( "memory_store", "Save to memory. Use when: preserving durable preferences, decisions, key context. Don't use when: information is transient/noisy/sensitive without need.", ), ( "memory_recall", "Search memory. Use when: retrieving prior decisions, user preferences, historical context. Don't use when: answer is already in current context.", ), ( "memory_forget", "Delete a memory entry. Use when: memory is incorrect/stale or explicitly requested for removal. Don't use when: impact is uncertain.", ), ]; tool_descs.push(( "cron_add", "Create a cron job. Supports schedule kinds: cron, at, every; and job types: shell or agent.", )); tool_descs.push(( "cron_list", "List all cron jobs with schedule, status, and metadata.", )); tool_descs.push(("cron_remove", "Remove a cron job by job_id.")); tool_descs.push(( "cron_update", "Patch a cron job (schedule, enabled, command/prompt, model, delivery, session_target).", )); tool_descs.push(( "cron_run", "Force-run a cron job immediately and record a run history entry.", )); tool_descs.push(("cron_runs", "Show recent run history for a cron job.")); tool_descs.push(( "screenshot", "Capture a screenshot of the current screen. Returns file path and base64-encoded PNG. Use when: visual verification, UI inspection, debugging displays.", )); tool_descs.push(( "image_info", "Read image file metadata (format, dimensions, size) and optionally base64-encode it. Use when: inspecting images, preparing visual data for analysis.", )); if config.browser.enabled { tool_descs.push(( "browser_open", "Open approved HTTPS URLs in system browser (allowlist-only, no scraping)", )); } if config.composio.enabled { tool_descs.push(( "composio", "Execute actions on 1000+ apps via Composio (Gmail, Notion, GitHub, Slack, etc.). Use action='list' to discover, 'execute' to run (optionally with connected_account_id), 'connect' to OAuth.", )); } tool_descs.push(( "schedule", "Manage scheduled tasks (create/list/get/cancel/pause/resume). Supports recurring cron and one-shot delays.", )); tool_descs.push(( "model_routing_config", "Configure default model, scenario routing, and delegate agents. Use for natural-language requests like: 'set conversation to kimi and coding to gpt-5.3-codex'.", )); if !config.agents.is_empty() { tool_descs.push(( "delegate", "Delegate a sub-task to a specialized agent. Use when: task needs different model/capability, or to parallelize work.", )); } if config.peripherals.enabled && !config.peripherals.boards.is_empty() { tool_descs.push(( "gpio_read", "Read GPIO pin value (0 or 1) on connected hardware (STM32, Arduino). Use when: checking sensor/button state, LED status.", )); tool_descs.push(( "gpio_write", "Set GPIO pin high (1) or low (0) on connected hardware. Use when: turning LED on/off, controlling actuators.", )); tool_descs.push(( "arduino_upload", "Upload agent-generated Arduino sketch. Use when: user asks for 'make a heart', 'blink pattern', or custom LED behavior on Arduino. You write the full .ino code; ZeroClaw compiles and uploads it. Pin 13 = built-in LED on Uno.", )); tool_descs.push(( "hardware_memory_map", "Return flash and RAM address ranges for connected hardware. Use when: user asks for 'upper and lower memory addresses', 'memory map', or 'readable addresses'.", )); tool_descs.push(( "hardware_board_info", "Return full board info (chip, architecture, memory map) for connected hardware. Use when: user asks for 'board info', 'what board do I have', 'connected hardware', 'chip info', or 'what hardware'.", )); tool_descs.push(( "hardware_memory_read", "Read actual memory/register values from Nucleo via USB. Use when: user asks to 'read register values', 'read memory', 'dump lower memory 0-126', 'give address and value'. Params: address (hex, default 0x20000000), length (bytes, default 128).", )); tool_descs.push(( "hardware_capabilities", "Query connected hardware for reported GPIO pins and LED pin. Use when: user asks what pins are available.", )); } let bootstrap_max_chars = if config.agent.compact_context { Some(6000) } else { None }; let native_tools = provider.supports_native_tools(); let mut system_prompt = crate::channels::build_system_prompt_with_mode( &config.workspace_dir, model_name, &tool_descs, &skills, Some(&config.identity), bootstrap_max_chars, native_tools, config.skills.prompt_injection_mode, ); // Append structured tool-use instructions with schemas (only for non-native providers) if !native_tools { system_prompt.push_str(&build_tool_instructions(&tools_registry)); } // ── Approval manager (supervised mode) ─────────────────────── let approval_manager = if interactive { Some(ApprovalManager::from_config(&config.autonomy)) } else { None }; let channel_name = if interactive { "cli" } else { "daemon" }; // ── Execute ────────────────────────────────────────────────── let start = Instant::now(); let mut final_output = String::new(); if let Some(msg) = message { // Auto-save user message to memory (skip short/trivial messages) if config.memory.auto_save && msg.chars().count() >= AUTOSAVE_MIN_MESSAGE_CHARS { let user_key = autosave_memory_key("user_msg"); let _ = mem .store(&user_key, &msg, MemoryCategory::Conversation, None) .await; } // Inject memory + hardware RAG context into user message let mem_context = build_context(mem.as_ref(), &msg, config.memory.min_relevance_score).await; let rag_limit = if config.agent.compact_context { 2 } else { 5 }; let hw_context = hardware_rag .as_ref() .map(|r| build_hardware_context(r, &msg, &board_names, rag_limit)) .unwrap_or_default(); let context = format!("{mem_context}{hw_context}"); let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z"); let enriched = if context.is_empty() { format!("[{now}] {msg}") } else { format!("{context}[{now}] {msg}") }; let mut history = vec![ ChatMessage::system(&system_prompt), ChatMessage::user(&enriched), ]; let response = run_tool_call_loop( provider.as_ref(), &mut history, &tools_registry, observer.as_ref(), provider_name, model_name, temperature, false, approval_manager.as_ref(), channel_name, &config.multimodal, config.agent.max_tool_iterations, None, None, None, &[], ) .await?; final_output = response.clone(); println!("{response}"); observer.record_event(&ObserverEvent::TurnComplete); } else { println!("🦀 ZeroClaw Interactive Mode"); println!("Type /help for commands.\n"); let cli = crate::channels::CliChannel::new(); // Persistent conversation history across turns let mut history = vec![ChatMessage::system(&system_prompt)]; loop { print!("> "); let _ = std::io::stdout().flush(); let mut input = String::new(); match std::io::stdin().read_line(&mut input) { Ok(0) => break, Ok(_) => {} Err(e) => { eprintln!("\nError reading input: {e}\n"); break; } } let user_input = input.trim().to_string(); if user_input.is_empty() { continue; } match user_input.as_str() { "/quit" | "/exit" => break, "/help" => { println!("Available commands:"); println!(" /help Show this help message"); println!(" /clear /new Clear conversation history"); println!(" /quit /exit Exit interactive mode\n"); continue; } "/clear" | "/new" => { println!( "This will clear the current conversation and delete all session memory." ); println!("Core memories (long-term facts/preferences) will be preserved."); print!("Continue? [y/N] "); let _ = std::io::stdout().flush(); let mut confirm = String::new(); if std::io::stdin().read_line(&mut confirm).is_err() { continue; } if !matches!(confirm.trim().to_lowercase().as_str(), "y" | "yes") { println!("Cancelled.\n"); continue; } history.clear(); history.push(ChatMessage::system(&system_prompt)); // Clear conversation and daily memory let mut cleared = 0; for category in [MemoryCategory::Conversation, MemoryCategory::Daily] { let entries = mem.list(Some(&category), None).await.unwrap_or_default(); for entry in entries { if mem.forget(&entry.key).await.unwrap_or(false) { cleared += 1; } } } if cleared > 0 { println!("Conversation cleared ({cleared} memory entries removed).\n"); } else { println!("Conversation cleared.\n"); } continue; } _ => {} } // Auto-save conversation turns (skip short/trivial messages) if config.memory.auto_save && user_input.chars().count() >= AUTOSAVE_MIN_MESSAGE_CHARS { let user_key = autosave_memory_key("user_msg"); let _ = mem .store(&user_key, &user_input, MemoryCategory::Conversation, None) .await; } // Inject memory + hardware RAG context into user message let mem_context = build_context(mem.as_ref(), &user_input, config.memory.min_relevance_score).await; let rag_limit = if config.agent.compact_context { 2 } else { 5 }; let hw_context = hardware_rag .as_ref() .map(|r| build_hardware_context(r, &user_input, &board_names, rag_limit)) .unwrap_or_default(); let context = format!("{mem_context}{hw_context}"); let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z"); let enriched = if context.is_empty() { format!("[{now}] {user_input}") } else { format!("{context}[{now}] {user_input}") }; history.push(ChatMessage::user(&enriched)); let response = match run_tool_call_loop( provider.as_ref(), &mut history, &tools_registry, observer.as_ref(), provider_name, model_name, temperature, false, approval_manager.as_ref(), channel_name, &config.multimodal, config.agent.max_tool_iterations, None, None, None, &[], ) .await { Ok(resp) => resp, Err(e) => { eprintln!("\nError: {e}\n"); continue; } }; final_output = response.clone(); if let Err(e) = crate::channels::Channel::send( &cli, &crate::channels::traits::SendMessage::new(format!("\n{response}\n"), "user"), ) .await { eprintln!("\nError sending CLI response: {e}\n"); } observer.record_event(&ObserverEvent::TurnComplete); // Auto-compaction before hard trimming to preserve long-context signal. if let Ok(compacted) = auto_compact_history( &mut history, provider.as_ref(), model_name, config.agent.max_history_messages, ) .await { if compacted { println!("🧹 Auto-compaction complete"); } } // Hard cap as a safety net. trim_history(&mut history, config.agent.max_history_messages); } } let duration = start.elapsed(); observer.record_event(&ObserverEvent::AgentEnd { provider: provider_name.to_string(), model: model_name.to_string(), duration, tokens_used: None, cost_usd: None, }); Ok(final_output) } /// Process a single message through the full agent (with tools, peripherals, memory). /// Used by channels (Telegram, Discord, etc.) to enable hardware and tool use. pub async fn process_message(config: Config, message: &str) -> Result { let observer: Arc = Arc::from(observability::create_observer(&config.observability)); let runtime: Arc = Arc::from(runtime::create_runtime(&config.runtime)?); let security = Arc::new(SecurityPolicy::from_config( &config.autonomy, &config.workspace_dir, )); let mem: Arc = Arc::from(memory::create_memory_with_storage( &config.memory, Some(&config.storage.provider.config), &config.workspace_dir, config.api_key.as_deref(), )?); let (composio_key, composio_entity_id) = if config.composio.enabled { ( config.composio.api_key.as_deref(), Some(config.composio.entity_id.as_str()), ) } else { (None, None) }; let mut tools_registry = tools::all_tools_with_runtime( Arc::new(config.clone()), &security, runtime, mem.clone(), composio_key, composio_entity_id, &config.browser, &config.http_request, &config.web_fetch, &config.workspace_dir, &config.agents, config.api_key.as_deref(), &config, ); let peripheral_tools: Vec> = crate::peripherals::create_peripheral_tools(&config.peripherals).await?; tools_registry.extend(peripheral_tools); let provider_name = config.default_provider.as_deref().unwrap_or("openrouter"); let model_name = config .default_model .clone() .unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into()); let provider_runtime_options = providers::ProviderRuntimeOptions { auth_profile_override: None, provider_api_url: config.api_url.clone(), zeroclaw_dir: config.config_path.parent().map(std::path::PathBuf::from), secrets_encrypt: config.secrets.encrypt, reasoning_enabled: config.runtime.reasoning_enabled, }; let provider: Box = providers::create_routed_provider_with_options( provider_name, config.api_key.as_deref(), config.api_url.as_deref(), &config.reliability, &config.model_routes, &model_name, &provider_runtime_options, )?; let hardware_rag: Option = config .peripherals .datasheet_dir .as_ref() .filter(|d| !d.trim().is_empty()) .map(|dir| crate::rag::HardwareRag::load(&config.workspace_dir, dir.trim())) .and_then(Result::ok) .filter(|r: &crate::rag::HardwareRag| !r.is_empty()); let board_names: Vec = config .peripherals .boards .iter() .map(|b| b.board.clone()) .collect(); let skills = crate::skills::load_skills_with_config(&config.workspace_dir, &config); let mut tool_descs: Vec<(&str, &str)> = vec![ ("shell", "Execute terminal commands."), ("file_read", "Read file contents."), ("file_write", "Write file contents."), ("memory_store", "Save to memory."), ("memory_recall", "Search memory."), ("memory_forget", "Delete a memory entry."), ( "model_routing_config", "Configure default model, scenario routing, and delegate agents.", ), ("screenshot", "Capture a screenshot."), ("image_info", "Read image metadata."), ]; if config.browser.enabled { tool_descs.push(("browser_open", "Open approved URLs in browser.")); } if config.composio.enabled { tool_descs.push(("composio", "Execute actions on 1000+ apps via Composio.")); } if config.peripherals.enabled && !config.peripherals.boards.is_empty() { tool_descs.push(("gpio_read", "Read GPIO pin value on connected hardware.")); tool_descs.push(( "gpio_write", "Set GPIO pin high or low on connected hardware.", )); tool_descs.push(( "arduino_upload", "Upload Arduino sketch. Use for 'make a heart', custom patterns. You write full .ino code; ZeroClaw uploads it.", )); tool_descs.push(( "hardware_memory_map", "Return flash and RAM address ranges. Use when user asks for memory addresses or memory map.", )); tool_descs.push(( "hardware_board_info", "Return full board info (chip, architecture, memory map). Use when user asks for board info, what board, connected hardware, or chip info.", )); tool_descs.push(( "hardware_memory_read", "Read actual memory/register values from Nucleo. Use when user asks to read registers, read memory, dump lower memory 0-126, or give address and value.", )); tool_descs.push(( "hardware_capabilities", "Query connected hardware for reported GPIO pins and LED pin. Use when user asks what pins are available.", )); } let bootstrap_max_chars = if config.agent.compact_context { Some(6000) } else { None }; let native_tools = provider.supports_native_tools(); let mut system_prompt = crate::channels::build_system_prompt_with_mode( &config.workspace_dir, &model_name, &tool_descs, &skills, Some(&config.identity), bootstrap_max_chars, native_tools, config.skills.prompt_injection_mode, ); if !native_tools { system_prompt.push_str(&build_tool_instructions(&tools_registry)); } let mem_context = build_context(mem.as_ref(), message, config.memory.min_relevance_score).await; let rag_limit = if config.agent.compact_context { 2 } else { 5 }; let hw_context = hardware_rag .as_ref() .map(|r| build_hardware_context(r, message, &board_names, rag_limit)) .unwrap_or_default(); let context = format!("{mem_context}{hw_context}"); let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z"); let enriched = if context.is_empty() { format!("[{now}] {message}") } else { format!("{context}[{now}] {message}") }; let mut history = vec![ ChatMessage::system(&system_prompt), ChatMessage::user(&enriched), ]; agent_turn( provider.as_ref(), &mut history, &tools_registry, observer.as_ref(), provider_name, &model_name, config.default_temperature, true, &config.multimodal, config.agent.max_tool_iterations, ) .await } #[cfg(test)] mod tests { use super::*; use async_trait::async_trait; use base64::{engine::general_purpose::STANDARD, Engine as _}; use std::collections::VecDeque; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; #[test] fn test_scrub_credentials() { let input = "API_KEY=sk-1234567890abcdef; token: 1234567890; password=\"secret123456\""; let scrubbed = scrub_credentials(input); assert!(scrubbed.contains("API_KEY=sk-1*[REDACTED]")); assert!(scrubbed.contains("token: 1234*[REDACTED]")); assert!(scrubbed.contains("password=\"secr*[REDACTED]\"")); assert!(!scrubbed.contains("abcdef")); assert!(!scrubbed.contains("secret123456")); } #[test] fn test_scrub_credentials_json() { let input = r#"{"api_key": "sk-1234567890", "other": "public"}"#; let scrubbed = scrub_credentials(input); assert!(scrubbed.contains("\"api_key\": \"sk-1*[REDACTED]\"")); assert!(scrubbed.contains("public")); } use crate::memory::{Memory, MemoryCategory, SqliteMemory}; use crate::observability::NoopObserver; use crate::providers::traits::ProviderCapabilities; use crate::providers::ChatResponse; use tempfile::TempDir; struct NonVisionProvider { calls: Arc, } #[async_trait] impl Provider for NonVisionProvider { async fn chat_with_system( &self, _system_prompt: Option<&str>, _message: &str, _model: &str, _temperature: f64, ) -> anyhow::Result { self.calls.fetch_add(1, Ordering::SeqCst); Ok("ok".to_string()) } } struct VisionProvider { calls: Arc, } #[async_trait] impl Provider for VisionProvider { fn capabilities(&self) -> ProviderCapabilities { ProviderCapabilities { native_tool_calling: false, vision: true, } } async fn chat_with_system( &self, _system_prompt: Option<&str>, _message: &str, _model: &str, _temperature: f64, ) -> anyhow::Result { self.calls.fetch_add(1, Ordering::SeqCst); Ok("ok".to_string()) } async fn chat( &self, request: ChatRequest<'_>, _model: &str, _temperature: f64, ) -> anyhow::Result { self.calls.fetch_add(1, Ordering::SeqCst); let marker_count = crate::multimodal::count_image_markers(request.messages); if marker_count == 0 { anyhow::bail!("expected image markers in request messages"); } if request.tools.is_some() { anyhow::bail!("no tools should be attached for this test"); } Ok(ChatResponse { text: Some("vision-ok".to_string()), tool_calls: Vec::new(), usage: None, reasoning_content: None, }) } } struct ScriptedProvider { responses: Arc>>, capabilities: ProviderCapabilities, } impl ScriptedProvider { fn from_text_responses(responses: Vec<&str>) -> Self { let scripted = responses .into_iter() .map(|text| ChatResponse { text: Some(text.to_string()), tool_calls: Vec::new(), usage: None, reasoning_content: None, }) .collect(); Self { responses: Arc::new(Mutex::new(scripted)), capabilities: ProviderCapabilities::default(), } } fn with_native_tool_support(mut self) -> Self { self.capabilities.native_tool_calling = true; self } } #[async_trait] impl Provider for ScriptedProvider { fn capabilities(&self) -> ProviderCapabilities { self.capabilities.clone() } async fn chat_with_system( &self, _system_prompt: Option<&str>, _message: &str, _model: &str, _temperature: f64, ) -> anyhow::Result { anyhow::bail!("chat_with_system should not be used in scripted provider tests"); } async fn chat( &self, _request: ChatRequest<'_>, _model: &str, _temperature: f64, ) -> anyhow::Result { let mut responses = self .responses .lock() .expect("responses lock should be valid"); responses .pop_front() .ok_or_else(|| anyhow::anyhow!("scripted provider exhausted responses")) } } struct CountingTool { name: String, invocations: Arc, } impl CountingTool { fn new(name: &str, invocations: Arc) -> Self { Self { name: name.to_string(), invocations, } } } #[async_trait] impl Tool for CountingTool { fn name(&self) -> &str { &self.name } fn description(&self) -> &str { "Counts executions for loop-stability tests" } fn parameters_schema(&self) -> serde_json::Value { serde_json::json!({ "type": "object", "properties": { "value": { "type": "string" } } }) } async fn execute( &self, args: serde_json::Value, ) -> anyhow::Result { self.invocations.fetch_add(1, Ordering::SeqCst); let value = args .get("value") .and_then(serde_json::Value::as_str) .unwrap_or_default(); Ok(crate::tools::ToolResult { success: true, output: format!("counted:{value}"), error: None, }) } } struct DelayTool { name: String, delay_ms: u64, active: Arc, max_active: Arc, } impl DelayTool { fn new( name: &str, delay_ms: u64, active: Arc, max_active: Arc, ) -> Self { Self { name: name.to_string(), delay_ms, active, max_active, } } } #[async_trait] impl Tool for DelayTool { fn name(&self) -> &str { &self.name } fn description(&self) -> &str { "Delay tool for testing parallel tool execution" } fn parameters_schema(&self) -> serde_json::Value { serde_json::json!({ "type": "object", "properties": { "value": { "type": "string" } }, "required": ["value"] }) } async fn execute( &self, args: serde_json::Value, ) -> anyhow::Result { let now_active = self.active.fetch_add(1, Ordering::SeqCst) + 1; self.max_active.fetch_max(now_active, Ordering::SeqCst); tokio::time::sleep(Duration::from_millis(self.delay_ms)).await; self.active.fetch_sub(1, Ordering::SeqCst); let value = args .get("value") .and_then(serde_json::Value::as_str) .unwrap_or_default() .to_string(); Ok(crate::tools::ToolResult { success: true, output: format!("ok:{value}"), error: None, }) } } #[tokio::test] async fn run_tool_call_loop_returns_structured_error_for_non_vision_provider() { let calls = Arc::new(AtomicUsize::new(0)); let provider = NonVisionProvider { calls: Arc::clone(&calls), }; let mut history = vec![ChatMessage::user( "please inspect [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(), )]; let tools_registry: Vec> = Vec::new(); let observer = NoopObserver; let err = run_tool_call_loop( &provider, &mut history, &tools_registry, &observer, "mock-provider", "mock-model", 0.0, true, None, "cli", &crate::config::MultimodalConfig::default(), 3, None, None, None, &[], ) .await .expect_err("provider without vision support should fail"); assert!(err.to_string().contains("provider_capability_error")); assert!(err.to_string().contains("capability=vision")); assert_eq!(calls.load(Ordering::SeqCst), 0); } #[tokio::test] async fn run_tool_call_loop_rejects_oversized_image_payload() { let calls = Arc::new(AtomicUsize::new(0)); let provider = VisionProvider { calls: Arc::clone(&calls), }; let oversized_payload = STANDARD.encode(vec![0_u8; (1024 * 1024) + 1]); let mut history = vec![ChatMessage::user(format!( "[IMAGE:data:image/png;base64,{oversized_payload}]" ))]; let tools_registry: Vec> = Vec::new(); let observer = NoopObserver; let multimodal = crate::config::MultimodalConfig { max_images: 4, max_image_size_mb: 1, allow_remote_fetch: false, }; let err = run_tool_call_loop( &provider, &mut history, &tools_registry, &observer, "mock-provider", "mock-model", 0.0, true, None, "cli", &multimodal, 3, None, None, None, &[], ) .await .expect_err("oversized payload must fail"); assert!(err .to_string() .contains("multimodal image size limit exceeded")); assert_eq!(calls.load(Ordering::SeqCst), 0); } #[tokio::test] async fn run_tool_call_loop_accepts_valid_multimodal_request_flow() { let calls = Arc::new(AtomicUsize::new(0)); let provider = VisionProvider { calls: Arc::clone(&calls), }; let mut history = vec![ChatMessage::user( "Analyze this [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(), )]; let tools_registry: Vec> = Vec::new(); let observer = NoopObserver; let result = run_tool_call_loop( &provider, &mut history, &tools_registry, &observer, "mock-provider", "mock-model", 0.0, true, None, "cli", &crate::config::MultimodalConfig::default(), 3, None, None, None, &[], ) .await .expect("valid multimodal payload should pass"); assert_eq!(result, "vision-ok"); assert_eq!(calls.load(Ordering::SeqCst), 1); } #[test] fn should_execute_tools_in_parallel_returns_false_for_single_call() { let calls = vec![ParsedToolCall { name: "file_read".to_string(), arguments: serde_json::json!({"path": "a.txt"}), tool_call_id: None, }]; assert!(!should_execute_tools_in_parallel(&calls, None)); } #[test] fn should_execute_tools_in_parallel_returns_false_when_approval_is_required() { let calls = vec![ ParsedToolCall { name: "shell".to_string(), arguments: serde_json::json!({"command": "pwd"}), tool_call_id: None, }, ParsedToolCall { name: "http_request".to_string(), arguments: serde_json::json!({"url": "https://example.com"}), tool_call_id: None, }, ]; let approval_cfg = crate::config::AutonomyConfig::default(); let approval_mgr = ApprovalManager::from_config(&approval_cfg); assert!(!should_execute_tools_in_parallel( &calls, Some(&approval_mgr) )); } #[test] fn should_execute_tools_in_parallel_returns_true_when_cli_has_no_interactive_approvals() { let calls = vec![ ParsedToolCall { name: "shell".to_string(), arguments: serde_json::json!({"command": "pwd"}), tool_call_id: None, }, ParsedToolCall { name: "http_request".to_string(), arguments: serde_json::json!({"url": "https://example.com"}), tool_call_id: None, }, ]; let approval_cfg = crate::config::AutonomyConfig { level: crate::security::AutonomyLevel::Full, ..crate::config::AutonomyConfig::default() }; let approval_mgr = ApprovalManager::from_config(&approval_cfg); assert!(should_execute_tools_in_parallel( &calls, Some(&approval_mgr) )); } #[tokio::test] async fn run_tool_call_loop_executes_multiple_tools_with_ordered_results() { let provider = ScriptedProvider::from_text_responses(vec![ r#" {"name":"delay_a","arguments":{"value":"A"}} {"name":"delay_b","arguments":{"value":"B"}} "#, "done", ]); let active = Arc::new(AtomicUsize::new(0)); let max_active = Arc::new(AtomicUsize::new(0)); let tools_registry: Vec> = vec![ Box::new(DelayTool::new( "delay_a", 200, Arc::clone(&active), Arc::clone(&max_active), )), Box::new(DelayTool::new( "delay_b", 200, Arc::clone(&active), Arc::clone(&max_active), )), ]; let approval_cfg = crate::config::AutonomyConfig { level: crate::security::AutonomyLevel::Full, ..crate::config::AutonomyConfig::default() }; let approval_mgr = ApprovalManager::from_config(&approval_cfg); let mut history = vec![ ChatMessage::system("test-system"), ChatMessage::user("run tool calls"), ]; let observer = NoopObserver; let result = run_tool_call_loop( &provider, &mut history, &tools_registry, &observer, "mock-provider", "mock-model", 0.0, true, Some(&approval_mgr), "telegram", &crate::config::MultimodalConfig::default(), 4, None, None, None, &[], ) .await .expect("parallel execution should complete"); assert_eq!(result, "done"); assert!( max_active.load(Ordering::SeqCst) >= 1, "tools should execute successfully" ); let tool_results_message = history .iter() .find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]")) .expect("tool results message should be present"); let idx_a = tool_results_message .content .find("name=\"delay_a\"") .expect("delay_a result should be present"); let idx_b = tool_results_message .content .find("name=\"delay_b\"") .expect("delay_b result should be present"); assert!( idx_a < idx_b, "tool results should preserve input order for tool call mapping" ); } #[tokio::test] async fn run_tool_call_loop_deduplicates_repeated_tool_calls() { let provider = ScriptedProvider::from_text_responses(vec![ r#" {"name":"count_tool","arguments":{"value":"A"}} {"name":"count_tool","arguments":{"value":"A"}} "#, "done", ]); let invocations = Arc::new(AtomicUsize::new(0)); let tools_registry: Vec> = vec![Box::new(CountingTool::new( "count_tool", Arc::clone(&invocations), ))]; let mut history = vec![ ChatMessage::system("test-system"), ChatMessage::user("run tool calls"), ]; let observer = NoopObserver; let result = run_tool_call_loop( &provider, &mut history, &tools_registry, &observer, "mock-provider", "mock-model", 0.0, true, None, "cli", &crate::config::MultimodalConfig::default(), 4, None, None, None, &[], ) .await .expect("loop should finish after deduplicating repeated calls"); assert_eq!(result, "done"); assert_eq!( invocations.load(Ordering::SeqCst), 1, "duplicate tool call with same args should not execute twice" ); let tool_results = history .iter() .find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]")) .expect("prompt-mode tool result payload should be present"); assert!(tool_results.content.contains("counted:A")); assert!(tool_results.content.contains("Skipped duplicate tool call")); } #[tokio::test] async fn run_tool_call_loop_native_mode_preserves_fallback_tool_call_ids() { let provider = ScriptedProvider::from_text_responses(vec![ r#"{"content":"Need to call tool","tool_calls":[{"id":"call_abc","name":"count_tool","arguments":"{\"value\":\"X\"}"}]}"#, "done", ]) .with_native_tool_support(); let invocations = Arc::new(AtomicUsize::new(0)); let tools_registry: Vec> = vec![Box::new(CountingTool::new( "count_tool", Arc::clone(&invocations), ))]; let mut history = vec![ ChatMessage::system("test-system"), ChatMessage::user("run tool calls"), ]; let observer = NoopObserver; let result = run_tool_call_loop( &provider, &mut history, &tools_registry, &observer, "mock-provider", "mock-model", 0.0, true, None, "cli", &crate::config::MultimodalConfig::default(), 4, None, None, None, &[], ) .await .expect("native fallback id flow should complete"); assert_eq!(result, "done"); assert_eq!(invocations.load(Ordering::SeqCst), 1); assert!( history.iter().any(|msg| { msg.role == "tool" && msg.content.contains("\"tool_call_id\":\"call_abc\"") }), "tool result should preserve parsed fallback tool_call_id in native mode" ); assert!( history .iter() .all(|msg| !(msg.role == "user" && msg.content.starts_with("[Tool results]"))), "native mode should use role=tool history instead of prompt fallback wrapper" ); } #[test] fn parse_tool_calls_extracts_single_call() { let response = r#"Let me check that. {"name": "shell", "arguments": {"command": "ls -la"}} "#; let (text, calls) = parse_tool_calls(response); assert_eq!(text, "Let me check that."); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "shell"); assert_eq!( calls[0].arguments.get("command").unwrap().as_str().unwrap(), "ls -la" ); } #[test] fn parse_tool_calls_extracts_multiple_calls() { let response = r#" {"name": "file_read", "arguments": {"path": "a.txt"}} {"name": "file_read", "arguments": {"path": "b.txt"}} "#; let (_, calls) = parse_tool_calls(response); assert_eq!(calls.len(), 2); assert_eq!(calls[0].name, "file_read"); assert_eq!(calls[1].name, "file_read"); } #[test] fn parse_tool_calls_returns_text_only_when_no_calls() { let response = "Just a normal response with no tools."; let (text, calls) = parse_tool_calls(response); assert_eq!(text, "Just a normal response with no tools."); assert!(calls.is_empty()); } #[test] fn parse_tool_calls_handles_malformed_json() { let response = r#" not valid json Some text after."#; let (text, calls) = parse_tool_calls(response); assert!(calls.is_empty()); assert!(text.contains("Some text after.")); } #[test] fn parse_tool_calls_text_before_and_after() { let response = r#"Before text. {"name": "shell", "arguments": {"command": "echo hi"}} After text."#; let (text, calls) = parse_tool_calls(response); assert!(text.contains("Before text.")); assert!(text.contains("After text.")); assert_eq!(calls.len(), 1); } #[test] fn parse_tool_calls_handles_openai_format() { // OpenAI-style response with tool_calls array let response = r#"{"content": "Let me check that for you.", "tool_calls": [{"type": "function", "function": {"name": "shell", "arguments": "{\"command\": \"ls -la\"}"}}]}"#; let (text, calls) = parse_tool_calls(response); assert_eq!(text, "Let me check that for you."); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "shell"); assert_eq!( calls[0].arguments.get("command").unwrap().as_str().unwrap(), "ls -la" ); } #[test] fn parse_tool_calls_handles_openai_format_multiple_calls() { let response = r#"{"tool_calls": [{"type": "function", "function": {"name": "file_read", "arguments": "{\"path\": \"a.txt\"}"}}, {"type": "function", "function": {"name": "file_read", "arguments": "{\"path\": \"b.txt\"}"}}]}"#; let (_, calls) = parse_tool_calls(response); assert_eq!(calls.len(), 2); assert_eq!(calls[0].name, "file_read"); assert_eq!(calls[1].name, "file_read"); } #[test] fn parse_tool_calls_openai_format_without_content() { // Some providers don't include content field with tool_calls let response = r#"{"tool_calls": [{"type": "function", "function": {"name": "memory_recall", "arguments": "{}"}}]}"#; let (text, calls) = parse_tool_calls(response); assert!(text.is_empty()); // No content field assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "memory_recall"); } #[test] fn parse_tool_calls_preserves_openai_tool_call_ids() { let response = r#"{"tool_calls":[{"id":"call_42","function":{"name":"shell","arguments":"{\"command\":\"pwd\"}"}}]}"#; let (_, calls) = parse_tool_calls(response); assert_eq!(calls.len(), 1); assert_eq!(calls[0].tool_call_id.as_deref(), Some("call_42")); } #[test] fn parse_tool_calls_handles_markdown_json_inside_tool_call_tag() { let response = r#" ```json {"name": "file_write", "arguments": {"path": "test.py", "content": "print('ok')"}} ``` "#; let (text, calls) = parse_tool_calls(response); assert!(text.is_empty()); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "file_write"); assert_eq!( calls[0].arguments.get("path").unwrap().as_str().unwrap(), "test.py" ); } #[test] fn parse_tool_calls_handles_noisy_tool_call_tag_body() { let response = r#" I will now call the tool with this payload: {"name": "shell", "arguments": {"command": "pwd"}} "#; let (text, calls) = parse_tool_calls(response); assert!(text.is_empty()); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "shell"); assert_eq!( calls[0].arguments.get("command").unwrap().as_str().unwrap(), "pwd" ); } #[test] fn parse_tool_calls_handles_tool_call_inline_attributes_with_send_message_alias() { let response = r#"send_message channel="user_channel" message="Hello! How can I assist you today?""#; let (text, calls) = parse_tool_calls(response); assert!(text.is_empty()); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "message_send"); assert_eq!( calls[0].arguments.get("channel").unwrap().as_str().unwrap(), "user_channel" ); assert_eq!( calls[0].arguments.get("message").unwrap().as_str().unwrap(), "Hello! How can I assist you today?" ); } #[test] fn parse_tool_calls_handles_tool_call_function_style_arguments() { let response = r#"message_send(channel="general", message="test")"#; let (text, calls) = parse_tool_calls(response); assert!(text.is_empty()); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "message_send"); assert_eq!( calls[0].arguments.get("channel").unwrap().as_str().unwrap(), "general" ); assert_eq!( calls[0].arguments.get("message").unwrap().as_str().unwrap(), "test" ); } #[test] fn parse_tool_calls_handles_xml_nested_tool_payload() { let response = r#" project roadmap "#; let (text, calls) = parse_tool_calls(response); assert!(text.is_empty()); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "memory_recall"); assert_eq!( calls[0].arguments.get("query").unwrap().as_str().unwrap(), "project roadmap" ); } #[test] fn parse_tool_calls_ignores_xml_thinking_wrapper() { let response = r#" Need to inspect memory first recent deploy notes "#; let (text, calls) = parse_tool_calls(response); assert!(text.is_empty()); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "memory_recall"); assert_eq!( calls[0].arguments.get("query").unwrap().as_str().unwrap(), "recent deploy notes" ); } #[test] fn parse_tool_calls_handles_xml_with_json_arguments() { let response = r#" {"command":"pwd"} "#; let (text, calls) = parse_tool_calls(response); assert!(text.is_empty()); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "shell"); assert_eq!( calls[0].arguments.get("command").unwrap().as_str().unwrap(), "pwd" ); } #[test] fn parse_tool_calls_handles_markdown_tool_call_fence() { let response = r#"I'll check that. ```tool_call {"name": "shell", "arguments": {"command": "pwd"}} ``` Done."#; let (text, calls) = parse_tool_calls(response); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "shell"); assert_eq!( calls[0].arguments.get("command").unwrap().as_str().unwrap(), "pwd" ); assert!(text.contains("I'll check that.")); assert!(text.contains("Done.")); assert!(!text.contains("```tool_call")); } #[test] fn parse_tool_calls_handles_markdown_tool_call_hybrid_close_tag() { let response = r#"Preface ```tool-call {"name": "shell", "arguments": {"command": "date"}} Tail"#; let (text, calls) = parse_tool_calls(response); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "shell"); assert_eq!( calls[0].arguments.get("command").unwrap().as_str().unwrap(), "date" ); assert!(text.contains("Preface")); assert!(text.contains("Tail")); assert!(!text.contains("```tool-call")); } #[test] fn parse_tool_calls_handles_markdown_invoke_fence() { let response = r#"Checking. ```invoke {"name": "shell", "arguments": {"command": "date"}} ``` Done."#; let (text, calls) = parse_tool_calls(response); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "shell"); assert_eq!( calls[0].arguments.get("command").unwrap().as_str().unwrap(), "date" ); assert!(text.contains("Checking.")); assert!(text.contains("Done.")); } #[test] fn parse_tool_calls_handles_tool_name_fence_format() { // Issue #1420: xAI grok models use ```tool format let response = r#"I'll write a test file. ```tool file_write {"path": "/home/user/test.txt", "content": "Hello world"} ``` Done."#; let (text, calls) = parse_tool_calls(response); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "file_write"); assert_eq!( calls[0].arguments.get("path").unwrap().as_str().unwrap(), "/home/user/test.txt" ); assert!(text.contains("I'll write a test file.")); assert!(text.contains("Done.")); } #[test] fn parse_tool_calls_handles_tool_name_fence_shell() { // Issue #1420: Test shell command in ```tool shell format let response = r#"```tool shell {"command": "ls -la"} ```"#; let (_text, calls) = parse_tool_calls(response); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "shell"); assert_eq!( calls[0].arguments.get("command").unwrap().as_str().unwrap(), "ls -la" ); } #[test] fn parse_tool_calls_handles_multiple_tool_name_fences() { // Multiple tool calls in ```tool format let response = r#"First, I'll write a file. ```tool file_write {"path": "/tmp/a.txt", "content": "A"} ``` Then read it. ```tool file_read {"path": "/tmp/a.txt"} ``` Done."#; let (text, calls) = parse_tool_calls(response); assert_eq!(calls.len(), 2); assert_eq!(calls[0].name, "file_write"); assert_eq!(calls[1].name, "file_read"); assert!(text.contains("First, I'll write a file.")); assert!(text.contains("Then read it.")); assert!(text.contains("Done.")); } #[test] fn parse_tool_calls_handles_toolcall_tag_alias() { let response = r#" {"name": "shell", "arguments": {"command": "date"}} "#; let (text, calls) = parse_tool_calls(response); assert!(text.is_empty()); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "shell"); assert_eq!( calls[0].arguments.get("command").unwrap().as_str().unwrap(), "date" ); } #[test] fn parse_tool_calls_handles_tool_dash_call_tag_alias() { let response = r#" {"name": "shell", "arguments": {"command": "whoami"}} "#; let (text, calls) = parse_tool_calls(response); assert!(text.is_empty()); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "shell"); assert_eq!( calls[0].arguments.get("command").unwrap().as_str().unwrap(), "whoami" ); } #[test] fn parse_tool_calls_handles_invoke_tag_alias() { let response = r#" {"name": "shell", "arguments": {"command": "uptime"}} "#; let (text, calls) = parse_tool_calls(response); assert!(text.is_empty()); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "shell"); assert_eq!( calls[0].arguments.get("command").unwrap().as_str().unwrap(), "uptime" ); } #[test] fn parse_tool_calls_handles_minimax_invoke_parameter_format() { let response = r#" sqlite3 /tmp/test.db ".tables" "#; let (text, calls) = parse_tool_calls(response); assert!(text.is_empty()); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "shell"); assert_eq!( calls[0].arguments.get("command").unwrap().as_str().unwrap(), r#"sqlite3 /tmp/test.db ".tables""# ); } #[test] fn parse_tool_calls_handles_minimax_invoke_with_surrounding_text() { let response = r#"Preface https://example.com GET Tail"#; let (text, calls) = parse_tool_calls(response); assert!(text.contains("Preface")); assert!(text.contains("Tail")); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "http_request"); assert_eq!( calls[0].arguments.get("url").unwrap().as_str().unwrap(), "https://example.com" ); assert_eq!( calls[0].arguments.get("method").unwrap().as_str().unwrap(), "GET" ); } #[test] fn parse_tool_calls_handles_minimax_toolcall_alias_and_cross_close_tag() { let response = r#" {"name":"shell","arguments":{"command":"date"}} "#; let (text, calls) = parse_tool_calls(response); assert!(text.is_empty()); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "shell"); assert_eq!( calls[0].arguments.get("command").unwrap().as_str().unwrap(), "date" ); } #[test] fn parse_tool_calls_handles_perl_style_tool_call_blocks() { let response = r#"TOOL_CALL {tool => "shell", args => { --command "uname -a" }}} /TOOL_CALL"#; let calls = parse_perl_style_tool_calls(response); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "shell"); assert_eq!( calls[0].arguments.get("command").unwrap().as_str().unwrap(), "uname -a" ); } #[test] fn parse_tool_calls_recovers_unclosed_tool_call_with_json() { let response = r#"I will call the tool now. {"name": "shell", "arguments": {"command": "uptime -p"}}"#; let (text, calls) = parse_tool_calls(response); assert!(text.contains("I will call the tool now.")); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "shell"); assert_eq!( calls[0].arguments.get("command").unwrap().as_str().unwrap(), "uptime -p" ); } #[test] fn parse_tool_calls_recovers_mismatched_close_tag() { let response = r#" {"name": "shell", "arguments": {"command": "uptime"}} "#; let (text, calls) = parse_tool_calls(response); assert!(text.is_empty()); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "shell"); assert_eq!( calls[0].arguments.get("command").unwrap().as_str().unwrap(), "uptime" ); } #[test] fn parse_tool_calls_recovers_cross_alias_closing_tags() { let response = r#" {"name": "shell", "arguments": {"command": "date"}} "#; let (text, calls) = parse_tool_calls(response); assert!(text.is_empty()); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "shell"); } #[test] fn parse_tool_calls_rejects_raw_tool_json_without_tags() { // SECURITY: Raw JSON without explicit wrappers should NOT be parsed // This prevents prompt injection attacks where malicious content // could include JSON that mimics a tool call. let response = r#"Sure, creating the file now. {"name": "file_write", "arguments": {"path": "hello.py", "content": "print('hello')"}}"#; let (text, calls) = parse_tool_calls(response); assert!(text.contains("Sure, creating the file now.")); assert_eq!( calls.len(), 0, "Raw JSON without wrappers should not be parsed" ); } #[test] fn build_tool_instructions_includes_all_tools() { use crate::security::SecurityPolicy; let security = Arc::new(SecurityPolicy::from_config( &crate::config::AutonomyConfig::default(), std::path::Path::new("/tmp"), )); let tools = tools::default_tools(security); let instructions = build_tool_instructions(&tools); assert!(instructions.contains("## Tool Use Protocol")); assert!(instructions.contains("")); assert!(instructions.contains("shell")); assert!(instructions.contains("file_read")); assert!(instructions.contains("file_write")); } #[test] fn tools_to_openai_format_produces_valid_schema() { use crate::security::SecurityPolicy; let security = Arc::new(SecurityPolicy::from_config( &crate::config::AutonomyConfig::default(), std::path::Path::new("/tmp"), )); let tools = tools::default_tools(security); let formatted = tools_to_openai_format(&tools); assert!(!formatted.is_empty()); for tool_json in &formatted { assert_eq!(tool_json["type"], "function"); assert!(tool_json["function"]["name"].is_string()); assert!(tool_json["function"]["description"].is_string()); assert!(!tool_json["function"]["name"].as_str().unwrap().is_empty()); } // Verify known tools are present let names: Vec<&str> = formatted .iter() .filter_map(|t| t["function"]["name"].as_str()) .collect(); assert!(names.contains(&"shell")); assert!(names.contains(&"file_read")); } #[test] fn trim_history_preserves_system_prompt() { let mut history = vec![ChatMessage::system("system prompt")]; for i in 0..DEFAULT_MAX_HISTORY_MESSAGES + 20 { history.push(ChatMessage::user(format!("msg {i}"))); } let original_len = history.len(); assert!(original_len > DEFAULT_MAX_HISTORY_MESSAGES + 1); trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES); // System prompt preserved assert_eq!(history[0].role, "system"); assert_eq!(history[0].content, "system prompt"); // Trimmed to limit assert_eq!(history.len(), DEFAULT_MAX_HISTORY_MESSAGES + 1); // +1 for system // Most recent messages preserved let last = &history[history.len() - 1]; assert_eq!( last.content, format!("msg {}", DEFAULT_MAX_HISTORY_MESSAGES + 19) ); } #[test] fn trim_history_noop_when_within_limit() { let mut history = vec![ ChatMessage::system("sys"), ChatMessage::user("hello"), ChatMessage::assistant("hi"), ]; trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES); assert_eq!(history.len(), 3); } #[test] fn build_compaction_transcript_formats_roles() { let messages = vec![ ChatMessage::user("I like dark mode"), ChatMessage::assistant("Got it"), ]; let transcript = build_compaction_transcript(&messages); assert!(transcript.contains("USER: I like dark mode")); assert!(transcript.contains("ASSISTANT: Got it")); } #[test] fn apply_compaction_summary_replaces_old_segment() { let mut history = vec![ ChatMessage::system("sys"), ChatMessage::user("old 1"), ChatMessage::assistant("old 2"), ChatMessage::user("recent 1"), ChatMessage::assistant("recent 2"), ]; apply_compaction_summary(&mut history, 1, 3, "- user prefers concise replies"); assert_eq!(history.len(), 4); assert!(history[1].content.contains("Compaction summary")); assert!(history[2].content.contains("recent 1")); assert!(history[3].content.contains("recent 2")); } #[test] fn autosave_memory_key_has_prefix_and_uniqueness() { let key1 = autosave_memory_key("user_msg"); let key2 = autosave_memory_key("user_msg"); assert!(key1.starts_with("user_msg_")); assert!(key2.starts_with("user_msg_")); assert_ne!(key1, key2); } #[tokio::test] async fn autosave_memory_keys_preserve_multiple_turns() { let tmp = TempDir::new().unwrap(); let mem = SqliteMemory::new(tmp.path()).unwrap(); let key1 = autosave_memory_key("user_msg"); let key2 = autosave_memory_key("user_msg"); mem.store(&key1, "I'm Paul", MemoryCategory::Conversation, None) .await .unwrap(); mem.store(&key2, "I'm 45", MemoryCategory::Conversation, None) .await .unwrap(); assert_eq!(mem.count().await.unwrap(), 2); let recalled = mem.recall("45", 5, None).await.unwrap(); assert!(recalled.iter().any(|entry| entry.content.contains("45"))); } #[tokio::test] async fn build_context_ignores_legacy_assistant_autosave_entries() { let tmp = TempDir::new().unwrap(); let mem = SqliteMemory::new(tmp.path()).unwrap(); mem.store( "assistant_resp_poisoned", "User suffered a fabricated event", MemoryCategory::Daily, None, ) .await .unwrap(); mem.store( "user_msg_real", "User asked for concise status updates", MemoryCategory::Conversation, None, ) .await .unwrap(); let context = build_context(&mem, "status updates", 0.0).await; assert!(context.contains("user_msg_real")); assert!(!context.contains("assistant_resp_poisoned")); assert!(!context.contains("fabricated event")); } // ═══════════════════════════════════════════════════════════════════════ // Recovery Tests - Tool Call Parsing Edge Cases // ═══════════════════════════════════════════════════════════════════════ #[test] fn parse_tool_calls_handles_empty_tool_result() { // Recovery: Empty tool_result tag should be handled gracefully let response = r#"I'll run that command. Done."#; let (text, calls) = parse_tool_calls(response); assert!(text.contains("Done.")); assert!(calls.is_empty()); } #[test] fn parse_arguments_value_handles_null() { // Recovery: null arguments are returned as-is (Value::Null) let value = serde_json::json!(null); let result = parse_arguments_value(Some(&value)); assert!(result.is_null()); } #[test] fn parse_tool_calls_handles_empty_tool_calls_array() { // Recovery: Empty tool_calls array returns original response (no tool parsing) let response = r#"{"content": "Hello", "tool_calls": []}"#; let (text, calls) = parse_tool_calls(response); // When tool_calls is empty, the entire JSON is returned as text assert!(text.contains("Hello")); assert!(calls.is_empty()); } #[test] fn detect_tool_call_parse_issue_flags_malformed_payloads() { let response = "{\"name\":\"shell\",\"arguments\":{\"command\":\"pwd\"}"; let issue = detect_tool_call_parse_issue(response, &[]); assert!( issue.is_some(), "malformed tool payload should be flagged for diagnostics" ); } #[test] fn detect_tool_call_parse_issue_ignores_normal_text() { let issue = detect_tool_call_parse_issue("Thanks, done.", &[]); assert!(issue.is_none()); } #[test] fn parse_tool_calls_handles_whitespace_only_name() { // Recovery: Whitespace-only tool name should return None let value = serde_json::json!({"function": {"name": " ", "arguments": {}}}); let result = parse_tool_call_value(&value); assert!(result.is_none()); } #[test] fn parse_tool_calls_handles_empty_string_arguments() { // Recovery: Empty string arguments should be handled let value = serde_json::json!({"name": "test", "arguments": ""}); let result = parse_tool_call_value(&value); assert!(result.is_some()); assert_eq!(result.unwrap().name, "test"); } // ═══════════════════════════════════════════════════════════════════════ // Recovery Tests - History Management // ═══════════════════════════════════════════════════════════════════════ #[test] fn trim_history_with_no_system_prompt() { // Recovery: History without system prompt should trim correctly let mut history = vec![]; for i in 0..DEFAULT_MAX_HISTORY_MESSAGES + 20 { history.push(ChatMessage::user(format!("msg {i}"))); } trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES); assert_eq!(history.len(), DEFAULT_MAX_HISTORY_MESSAGES); } #[test] fn trim_history_preserves_role_ordering() { // Recovery: After trimming, role ordering should remain consistent let mut history = vec![ChatMessage::system("system")]; for i in 0..DEFAULT_MAX_HISTORY_MESSAGES + 10 { history.push(ChatMessage::user(format!("user {i}"))); history.push(ChatMessage::assistant(format!("assistant {i}"))); } trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES); assert_eq!(history[0].role, "system"); assert_eq!(history[history.len() - 1].role, "assistant"); } #[test] fn trim_history_with_only_system_prompt() { // Recovery: Only system prompt should not be trimmed let mut history = vec![ChatMessage::system("system prompt")]; trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES); assert_eq!(history.len(), 1); } // ═══════════════════════════════════════════════════════════════════════ // Recovery Tests - Arguments Parsing // ═══════════════════════════════════════════════════════════════════════ #[test] fn parse_arguments_value_handles_invalid_json_string() { // Recovery: Invalid JSON string should return empty object let value = serde_json::Value::String("not valid json".to_string()); let result = parse_arguments_value(Some(&value)); assert!(result.is_object()); assert!(result.as_object().unwrap().is_empty()); } #[test] fn parse_arguments_value_handles_none() { // Recovery: None arguments should return empty object let result = parse_arguments_value(None); assert!(result.is_object()); assert!(result.as_object().unwrap().is_empty()); } // ═══════════════════════════════════════════════════════════════════════ // Recovery Tests - JSON Extraction // ═══════════════════════════════════════════════════════════════════════ #[test] fn extract_json_values_handles_empty_string() { // Recovery: Empty input should return empty vec let result = extract_json_values(""); assert!(result.is_empty()); } #[test] fn extract_json_values_handles_whitespace_only() { // Recovery: Whitespace only should return empty vec let result = extract_json_values(" \n\t "); assert!(result.is_empty()); } #[test] fn extract_json_values_handles_multiple_objects() { // Recovery: Multiple JSON objects should all be extracted let input = r#"{"a": 1}{"b": 2}{"c": 3}"#; let result = extract_json_values(input); assert_eq!(result.len(), 3); } #[test] fn extract_json_values_handles_arrays() { // Recovery: JSON arrays should be extracted let input = r#"[1, 2, 3]{"key": "value"}"#; let result = extract_json_values(input); assert_eq!(result.len(), 2); } // ═══════════════════════════════════════════════════════════════════════ // Recovery Tests - Constants Validation // ═══════════════════════════════════════════════════════════════════════ const _: () = { assert!(DEFAULT_MAX_TOOL_ITERATIONS > 0); assert!(DEFAULT_MAX_TOOL_ITERATIONS <= 100); assert!(DEFAULT_MAX_HISTORY_MESSAGES > 0); assert!(DEFAULT_MAX_HISTORY_MESSAGES <= 1000); }; #[test] fn constants_bounds_are_compile_time_checked() { // Bounds are enforced by the const assertions above. } // ═══════════════════════════════════════════════════════════════════════ // Recovery Tests - Tool Call Value Parsing // ═══════════════════════════════════════════════════════════════════════ #[test] fn parse_tool_call_value_handles_missing_name_field() { // Recovery: Missing name field should return None let value = serde_json::json!({"function": {"arguments": {}}}); let result = parse_tool_call_value(&value); assert!(result.is_none()); } #[test] fn parse_tool_call_value_handles_top_level_name() { // Recovery: Tool call with name at top level (non-OpenAI format) let value = serde_json::json!({"name": "test_tool", "arguments": {}}); let result = parse_tool_call_value(&value); assert!(result.is_some()); assert_eq!(result.unwrap().name, "test_tool"); } #[test] fn parse_tool_call_value_accepts_top_level_parameters_alias() { let value = serde_json::json!({ "name": "schedule", "parameters": {"action": "create", "message": "test"} }); let result = parse_tool_call_value(&value).expect("tool call should parse"); assert_eq!(result.name, "schedule"); assert_eq!( result.arguments.get("action").and_then(|v| v.as_str()), Some("create") ); } #[test] fn parse_tool_call_value_accepts_function_parameters_alias() { let value = serde_json::json!({ "function": { "name": "shell", "parameters": {"command": "date"} } }); let result = parse_tool_call_value(&value).expect("tool call should parse"); assert_eq!(result.name, "shell"); assert_eq!( result.arguments.get("command").and_then(|v| v.as_str()), Some("date") ); } #[test] fn parse_tool_call_value_preserves_tool_call_id_aliases() { let value = serde_json::json!({ "call_id": "legacy_1", "function": { "name": "shell", "arguments": {"command": "date"} } }); let result = parse_tool_call_value(&value).expect("tool call should parse"); assert_eq!(result.tool_call_id.as_deref(), Some("legacy_1")); } #[test] fn parse_tool_calls_from_json_value_handles_empty_array() { // Recovery: Empty tool_calls array should return empty vec let value = serde_json::json!({"tool_calls": []}); let result = parse_tool_calls_from_json_value(&value); assert!(result.is_empty()); } #[test] fn parse_tool_calls_from_json_value_handles_missing_tool_calls() { // Recovery: Missing tool_calls field should fall through let value = serde_json::json!({"name": "test", "arguments": {}}); let result = parse_tool_calls_from_json_value(&value); assert_eq!(result.len(), 1); } #[test] fn parse_tool_calls_from_json_value_handles_top_level_array() { // Recovery: Top-level array of tool calls let value = serde_json::json!([ {"name": "tool_a", "arguments": {}}, {"name": "tool_b", "arguments": {}} ]); let result = parse_tool_calls_from_json_value(&value); assert_eq!(result.len(), 2); } // ═══════════════════════════════════════════════════════════════════════ // GLM-Style Tool Call Parsing // ═══════════════════════════════════════════════════════════════════════ #[test] fn parse_glm_style_browser_open_url() { let response = "browser_open/url>https://example.com"; let calls = parse_glm_style_tool_calls(response); assert_eq!(calls.len(), 1); assert_eq!(calls[0].0, "shell"); assert!(calls[0].1["command"].as_str().unwrap().contains("curl")); assert!(calls[0].1["command"] .as_str() .unwrap() .contains("example.com")); } #[test] fn parse_glm_style_shell_command() { let response = "shell/command>ls -la"; let calls = parse_glm_style_tool_calls(response); assert_eq!(calls.len(), 1); assert_eq!(calls[0].0, "shell"); assert_eq!(calls[0].1["command"], "ls -la"); } #[test] fn parse_glm_style_http_request() { let response = "http_request/url>https://api.example.com/data"; let calls = parse_glm_style_tool_calls(response); assert_eq!(calls.len(), 1); assert_eq!(calls[0].0, "http_request"); assert_eq!(calls[0].1["url"], "https://api.example.com/data"); assert_eq!(calls[0].1["method"], "GET"); } #[test] fn parse_glm_style_plain_url() { let response = "https://example.com/api"; let calls = parse_glm_style_tool_calls(response); assert!(calls.is_empty()); } #[test] fn parse_glm_style_ignores_urls_in_text() { let response = "Google homepage:\nhttps://www.google.com"; let calls = parse_glm_style_tool_calls(response); assert!(calls.is_empty()); } #[test] fn parse_tool_calls_does_not_convert_plain_url_to_shell() { let response = "Google homepage:\nhttps://www.google.com"; let (_text, calls) = parse_tool_calls(response); assert!(calls.is_empty()); } #[test] fn parse_glm_style_json_args() { let response = r#"shell/{"command": "echo hello"}"#; let calls = parse_glm_style_tool_calls(response); assert_eq!(calls.len(), 1); assert_eq!(calls[0].0, "shell"); assert_eq!(calls[0].1["command"], "echo hello"); } #[test] fn parse_glm_style_multiple_calls() { let response = r#"shell/command>ls browser_open/url>https://example.com"#; let calls = parse_glm_style_tool_calls(response); assert_eq!(calls.len(), 2); } #[test] fn parse_glm_style_tool_call_integration() { // Integration test: GLM format should be parsed in parse_tool_calls let response = "Checking...\nbrowser_open/url>https://example.com\nDone"; let (text, calls) = parse_tool_calls(response); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "shell"); assert!(text.contains("Checking")); assert!(text.contains("Done")); } #[test] fn parse_glm_style_rejects_non_http_url_param() { let response = "browser_open/url>javascript:alert(1)"; let calls = parse_glm_style_tool_calls(response); assert!(calls.is_empty()); } #[test] fn parse_tool_calls_handles_unclosed_tool_call_tag() { let response = "{\"name\":\"shell\",\"arguments\":{\"command\":\"pwd\"}}\nDone"; let (text, calls) = parse_tool_calls(response); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "shell"); assert_eq!(calls[0].arguments["command"], "pwd"); assert_eq!(text, "Done"); } // ───────────────────────────────────────────────────────────────────── // TG4 (inline): parse_tool_calls robustness — malformed/edge-case inputs // Prevents: Pattern 4 issues #746, #418, #777, #848 // ───────────────────────────────────────────────────────────────────── #[test] fn parse_tool_calls_empty_input_returns_empty() { let (text, calls) = parse_tool_calls(""); assert!(calls.is_empty(), "empty input should produce no tool calls"); assert!(text.is_empty(), "empty input should produce no text"); } #[test] fn parse_tool_calls_whitespace_only_returns_empty_calls() { let (text, calls) = parse_tool_calls(" \n\t "); assert!(calls.is_empty()); assert!(text.is_empty() || text.trim().is_empty()); } #[test] fn parse_tool_calls_nested_xml_tags_handled() { // Double-wrapped tool call should still parse the inner call let response = r#"{"name":"echo","arguments":{"msg":"hi"}}"#; let (_text, calls) = parse_tool_calls(response); // Should find at least one tool call assert!( !calls.is_empty(), "nested XML tags should still yield at least one tool call" ); } #[test] fn parse_tool_calls_truncated_json_no_panic() { // Incomplete JSON inside tool_call tags let response = r#"{"name":"shell","arguments":{"command":"ls""#; let (_text, _calls) = parse_tool_calls(response); // Should not panic — graceful handling of truncated JSON } #[test] fn parse_tool_calls_empty_json_object_in_tag() { let response = "{}"; let (_text, calls) = parse_tool_calls(response); // Empty JSON object has no name field — should not produce valid tool call assert!( calls.is_empty(), "empty JSON object should not produce a tool call" ); } #[test] fn parse_tool_calls_closing_tag_only_returns_text() { let response = "Some text more text"; let (text, calls) = parse_tool_calls(response); assert!( calls.is_empty(), "closing tag only should not produce calls" ); assert!( !text.is_empty(), "text around orphaned closing tag should be preserved" ); } #[test] fn parse_tool_calls_very_large_arguments_no_panic() { let large_arg = "x".repeat(100_000); let response = format!( r#"{{"name":"echo","arguments":{{"message":"{}"}}}}"#, large_arg ); let (_text, calls) = parse_tool_calls(&response); assert_eq!(calls.len(), 1, "large arguments should still parse"); assert_eq!(calls[0].name, "echo"); } #[test] fn parse_tool_calls_special_characters_in_arguments() { let response = r#"{"name":"echo","arguments":{"message":"hello \"world\" <>&'\n\t"}}"#; let (_text, calls) = parse_tool_calls(response); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "echo"); } #[test] fn parse_tool_calls_text_with_embedded_json_not_extracted() { // Raw JSON without any tags should NOT be extracted as a tool call let response = r#"Here is some data: {"name":"echo","arguments":{"message":"hi"}} end."#; let (_text, calls) = parse_tool_calls(response); assert!( calls.is_empty(), "raw JSON in text without tags should not be extracted" ); } #[test] fn parse_tool_calls_multiple_formats_mixed() { // Mix of text and properly tagged tool call let response = r#"I'll help you with that. {"name":"shell","arguments":{"command":"echo hello"}} Let me check the result."#; let (text, calls) = parse_tool_calls(response); assert_eq!( calls.len(), 1, "should extract one tool call from mixed content" ); assert_eq!(calls[0].name, "shell"); assert!( text.contains("help you"), "text before tool call should be preserved" ); } // ───────────────────────────────────────────────────────────────────── // TG4 (inline): scrub_credentials edge cases // ───────────────────────────────────────────────────────────────────── #[test] fn scrub_credentials_empty_input() { let result = scrub_credentials(""); assert_eq!(result, ""); } #[test] fn scrub_credentials_no_sensitive_data() { let input = "normal text without any secrets"; let result = scrub_credentials(input); assert_eq!( result, input, "non-sensitive text should pass through unchanged" ); } #[test] fn scrub_credentials_short_values_not_redacted() { // Values shorter than 8 chars should not be redacted let input = r#"api_key="short""#; let result = scrub_credentials(input); assert_eq!(result, input, "short values should not be redacted"); } // ───────────────────────────────────────────────────────────────────── // TG4 (inline): trim_history edge cases // ───────────────────────────────────────────────────────────────────── #[test] fn trim_history_empty_history() { let mut history: Vec = vec![]; trim_history(&mut history, 10); assert!(history.is_empty()); } #[test] fn trim_history_system_only() { let mut history = vec![crate::providers::ChatMessage::system("system prompt")]; trim_history(&mut history, 10); assert_eq!(history.len(), 1); assert_eq!(history[0].role, "system"); } #[test] fn trim_history_exactly_at_limit() { let mut history = vec![ crate::providers::ChatMessage::system("system"), crate::providers::ChatMessage::user("msg 1"), crate::providers::ChatMessage::assistant("reply 1"), ]; trim_history(&mut history, 2); // 2 non-system messages = exactly at limit assert_eq!(history.len(), 3, "should not trim when exactly at limit"); } #[test] fn trim_history_removes_oldest_non_system() { let mut history = vec![ crate::providers::ChatMessage::system("system"), crate::providers::ChatMessage::user("old msg"), crate::providers::ChatMessage::assistant("old reply"), crate::providers::ChatMessage::user("new msg"), crate::providers::ChatMessage::assistant("new reply"), ]; trim_history(&mut history, 2); assert_eq!(history.len(), 3); // system + 2 kept assert_eq!(history[0].role, "system"); assert_eq!(history[1].content, "new msg"); } /// When `build_system_prompt_with_mode` is called with `native_tools = true`, /// the output must contain ZERO XML protocol artifacts. In the native path /// `build_tool_instructions` is never called, so the system prompt alone /// must be clean of XML tool-call protocol. #[test] fn native_tools_system_prompt_contains_zero_xml() { use crate::channels::build_system_prompt_with_mode; let tool_summaries: Vec<(&str, &str)> = vec![ ("shell", "Execute shell commands"), ("file_read", "Read files"), ]; let system_prompt = build_system_prompt_with_mode( std::path::Path::new("/tmp"), "test-model", &tool_summaries, &[], // no skills None, // no identity config None, // no bootstrap_max_chars true, // native_tools crate::config::SkillsPromptInjectionMode::Full, ); // Must contain zero XML protocol artifacts assert!( !system_prompt.contains(""), "Native prompt must not contain " ); assert!( !system_prompt.contains(""), "Native prompt must not contain " ); assert!( !system_prompt.contains(""), "Native prompt must not contain " ); assert!( !system_prompt.contains(""), "Native prompt must not contain " ); assert!( !system_prompt.contains("## Tool Use Protocol"), "Native prompt must not contain XML protocol header" ); // Positive: native prompt should still list tools and contain task instructions assert!( system_prompt.contains("shell"), "Native prompt must list tool names" ); assert!( system_prompt.contains("## Your Task"), "Native prompt should contain task instructions" ); } // ── Cross-Alias & GLM Shortened Body Tests ────────────────────────── #[test] fn parse_tool_calls_cross_alias_close_tag_with_json() { // opened but closed with — JSON body let input = r#"{"name": "shell", "arguments": {"command": "ls"}}"#; let (text, calls) = parse_tool_calls(input); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "shell"); assert_eq!(calls[0].arguments["command"], "ls"); assert!(text.is_empty()); } #[test] fn parse_tool_calls_cross_alias_close_tag_with_glm_shortened() { // shell>uname -a — GLM shortened inside cross-alias tags let input = "shell>uname -a"; let (text, calls) = parse_tool_calls(input); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "shell"); assert_eq!(calls[0].arguments["command"], "uname -a"); assert!(text.is_empty()); } #[test] fn parse_tool_calls_glm_shortened_body_in_matched_tags() { // shell>pwd — GLM shortened in matched tags let input = "shell>pwd"; let (text, calls) = parse_tool_calls(input); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "shell"); assert_eq!(calls[0].arguments["command"], "pwd"); assert!(text.is_empty()); } #[test] fn parse_tool_calls_glm_yaml_style_in_tags() { // shell>\ncommand: date\napproved: true let input = "shell>\ncommand: date\napproved: true"; let (text, calls) = parse_tool_calls(input); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "shell"); assert_eq!(calls[0].arguments["command"], "date"); assert_eq!(calls[0].arguments["approved"], true); assert!(text.is_empty()); } #[test] fn parse_tool_calls_attribute_style_in_tags() { // shell command="date" /> let input = r#"shell command="date" />"#; let (text, calls) = parse_tool_calls(input); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "shell"); assert_eq!(calls[0].arguments["command"], "date"); assert!(text.is_empty()); } #[test] fn parse_tool_calls_file_read_shortened_in_cross_alias() { // file_read path=".env" /> let input = r#"file_read path=".env" />"#; let (text, calls) = parse_tool_calls(input); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "file_read"); assert_eq!(calls[0].arguments["path"], ".env"); assert!(text.is_empty()); } #[test] fn parse_tool_calls_unclosed_glm_shortened_no_close_tag() { // shell>ls -la (no close tag at all) let input = "shell>ls -la"; let (text, calls) = parse_tool_calls(input); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "shell"); assert_eq!(calls[0].arguments["command"], "ls -la"); assert!(text.is_empty()); } #[test] fn parse_tool_calls_text_before_cross_alias() { // Text before and after cross-alias tool call let input = "Let me check that.\nshell>uname -a\nDone."; let (text, calls) = parse_tool_calls(input); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "shell"); assert_eq!(calls[0].arguments["command"], "uname -a"); assert!(text.contains("Let me check that.")); assert!(text.contains("Done.")); } #[test] fn parse_glm_shortened_body_url_to_curl() { // URL values for shell should be wrapped in curl let call = parse_glm_shortened_body("shell>https://example.com/api").unwrap(); assert_eq!(call.name, "shell"); let cmd = call.arguments["command"].as_str().unwrap(); assert!(cmd.contains("curl")); assert!(cmd.contains("example.com")); } #[test] fn parse_glm_shortened_body_browser_open_maps_to_shell_command() { // browser_open aliases to shell, and shortened calls must still emit // shell's canonical "command" argument. let call = parse_glm_shortened_body("browser_open>https://example.com").unwrap(); assert_eq!(call.name, "shell"); let cmd = call.arguments["command"].as_str().unwrap(); assert!(cmd.contains("curl")); assert!(cmd.contains("example.com")); } #[test] fn parse_glm_shortened_body_memory_recall() { // memory_recall>some query — default param is "query" let call = parse_glm_shortened_body("memory_recall>recent meetings").unwrap(); assert_eq!(call.name, "memory_recall"); assert_eq!(call.arguments["query"], "recent meetings"); } #[test] fn parse_glm_shortened_body_function_style_alias_maps_to_message_send() { let call = parse_glm_shortened_body(r#"sendmessage(channel="alerts", message="hi")"#).unwrap(); assert_eq!(call.name, "message_send"); assert_eq!(call.arguments["channel"], "alerts"); assert_eq!(call.arguments["message"], "hi"); } #[test] fn map_tool_name_alias_direct_coverage() { assert_eq!(map_tool_name_alias("bash"), "shell"); assert_eq!(map_tool_name_alias("filelist"), "file_list"); assert_eq!(map_tool_name_alias("memorystore"), "memory_store"); assert_eq!(map_tool_name_alias("memoryforget"), "memory_forget"); assert_eq!(map_tool_name_alias("http"), "http_request"); assert_eq!( map_tool_name_alias("totally_unknown_tool"), "totally_unknown_tool" ); } #[test] fn default_param_for_tool_coverage() { assert_eq!(default_param_for_tool("shell"), "command"); assert_eq!(default_param_for_tool("bash"), "command"); assert_eq!(default_param_for_tool("file_read"), "path"); assert_eq!(default_param_for_tool("memory_recall"), "query"); assert_eq!(default_param_for_tool("memory_store"), "content"); assert_eq!(default_param_for_tool("http_request"), "url"); assert_eq!(default_param_for_tool("browser_open"), "url"); assert_eq!(default_param_for_tool("unknown_tool"), "input"); } #[test] fn parse_glm_shortened_body_rejects_empty() { assert!(parse_glm_shortened_body("").is_none()); assert!(parse_glm_shortened_body(" ").is_none()); } #[test] fn parse_glm_shortened_body_rejects_invalid_tool_name() { // Tool names with special characters should be rejected assert!(parse_glm_shortened_body("not-a-tool>value").is_none()); assert!(parse_glm_shortened_body("tool name>value").is_none()); } // ═══════════════════════════════════════════════════════════════════════ // reasoning_content pass-through tests for history builders // ═══════════════════════════════════════════════════════════════════════ #[test] fn build_native_assistant_history_includes_reasoning_content() { let calls = vec![ToolCall { id: "call_1".into(), name: "shell".into(), arguments: "{}".into(), }]; let result = build_native_assistant_history("answer", &calls, Some("thinking step")); let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); assert_eq!(parsed["content"].as_str(), Some("answer")); assert_eq!(parsed["reasoning_content"].as_str(), Some("thinking step")); assert!(parsed["tool_calls"].is_array()); } #[test] fn build_native_assistant_history_omits_reasoning_content_when_none() { let calls = vec![ToolCall { id: "call_1".into(), name: "shell".into(), arguments: "{}".into(), }]; let result = build_native_assistant_history("answer", &calls, None); let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); assert_eq!(parsed["content"].as_str(), Some("answer")); assert!(parsed.get("reasoning_content").is_none()); } #[test] fn build_native_assistant_history_from_parsed_calls_includes_reasoning_content() { let calls = vec![ParsedToolCall { name: "shell".into(), arguments: serde_json::json!({"command": "pwd"}), tool_call_id: Some("call_2".into()), }]; let result = build_native_assistant_history_from_parsed_calls( "answer", &calls, Some("deep thought"), ); assert!(result.is_some()); let parsed: serde_json::Value = serde_json::from_str(result.as_deref().unwrap()).unwrap(); assert_eq!(parsed["content"].as_str(), Some("answer")); assert_eq!(parsed["reasoning_content"].as_str(), Some("deep thought")); assert!(parsed["tool_calls"].is_array()); } #[test] fn build_native_assistant_history_from_parsed_calls_omits_reasoning_content_when_none() { let calls = vec![ParsedToolCall { name: "shell".into(), arguments: serde_json::json!({"command": "pwd"}), tool_call_id: Some("call_2".into()), }]; let result = build_native_assistant_history_from_parsed_calls("answer", &calls, None); assert!(result.is_some()); let parsed: serde_json::Value = serde_json::from_str(result.as_deref().unwrap()).unwrap(); assert_eq!(parsed["content"].as_str(), Some("answer")); assert!(parsed.get("reasoning_content").is_none()); } }