Skip to main content

construct/agent/
loop_.rs

1use crate::approval::{ApprovalManager, ApprovalRequest, ApprovalResponse};
2use crate::config::Config;
3use crate::cost::types::BudgetCheck;
4use crate::i18n::ToolDescriptions;
5use crate::memory::{self, Memory, MemoryCategory, decay};
6use crate::multimodal;
7use crate::observability::{self, Observer, ObserverEvent, runtime_trace};
8use crate::providers::traits::StreamEvent;
9use crate::providers::{
10    self, ChatMessage, ChatRequest, Provider, ProviderCapabilityError, ToolCall,
11};
12use crate::runtime;
13use crate::security::{AutonomyLevel, SecurityPolicy};
14use crate::tools::{self, Tool};
15use crate::util::truncate_with_ellipsis;
16use anyhow::Result;
17use futures_util::StreamExt;
18use regex::{Regex, RegexSet};
19use std::collections::HashSet;
20use std::fmt::Write;
21use std::io::Write as _;
22use std::path::PathBuf;
23use std::sync::{Arc, LazyLock, Mutex};
24use std::time::{Duration, Instant};
25use tokio_util::sync::CancellationToken;
26use uuid::Uuid;
27
28// Cost tracking moved to `super::cost`.
29pub(crate) use super::cost::{
30    TOOL_LOOP_COST_TRACKING_CONTEXT, ToolLoopCostTrackingContext, check_tool_loop_budget,
31    record_tool_loop_cost_usage,
32};
33
34/// Minimum characters per chunk when relaying LLM text to a streaming draft.
35const STREAM_CHUNK_MIN_CHARS: usize = 80;
36/// Rolling window size for detecting streamed tool-call payload markers.
37const STREAM_TOOL_MARKER_WINDOW_CHARS: usize = 512;
38
39/// Default maximum agentic tool-use iterations per user message to prevent runaway loops.
40/// Used as a safe fallback when `max_tool_iterations` is unset or configured as zero.
41const DEFAULT_MAX_TOOL_ITERATIONS: usize = 10;
42
43/// Resolve the effective max tool iterations.
44///
45/// When the operator is enabled and has a non-zero `max_tool_iterations`,
46/// that value takes precedence over the agent-level default (operator tasks
47/// are inherently multi-step: research → plan → spawn → monitor).
48pub(crate) fn effective_max_tool_iterations(config: &crate::config::Config) -> usize {
49    if config.operator.enabled && config.operator.max_tool_iterations > 0 {
50        config.operator.max_tool_iterations
51    } else {
52        config.agent.max_tool_iterations
53    }
54}
55
56// History management moved to `super::history`.
57pub(crate) use super::history::{
58    emergency_history_trim, estimate_history_tokens, fast_trim_tool_results,
59    load_interactive_session_history, save_interactive_session_history, trim_history,
60    truncate_tool_result,
61};
62
63/// Minimum user-message length (in chars) for auto-save to memory.
64/// Matches the channel-side constant in `channels/mod.rs`.
65const AUTOSAVE_MIN_MESSAGE_CHARS: usize = 20;
66
67/// Callback type for checking if model has been switched during tool execution.
68/// Returns Some((provider, model)) if a switch was requested, None otherwise.
69pub type ModelSwitchCallback = Arc<Mutex<Option<(String, String)>>>;
70
71/// Global model switch request state - used for runtime model switching via model_switch tool.
72/// This is set by the model_switch tool and checked by the agent loop.
73#[allow(clippy::type_complexity)]
74static MODEL_SWITCH_REQUEST: LazyLock<Arc<Mutex<Option<(String, String)>>>> =
75    LazyLock::new(|| Arc::new(Mutex::new(None)));
76
77/// Get the global model switch request state
78pub fn get_model_switch_state() -> ModelSwitchCallback {
79    Arc::clone(&MODEL_SWITCH_REQUEST)
80}
81
82/// Clear any pending model switch request
83pub fn clear_model_switch_request() {
84    if let Ok(guard) = MODEL_SWITCH_REQUEST.lock() {
85        let mut guard = guard;
86        *guard = None;
87    }
88}
89
90fn glob_match(pattern: &str, name: &str) -> bool {
91    match pattern.find('*') {
92        None => pattern == name,
93        Some(star) => {
94            let prefix = &pattern[..star];
95            let suffix = &pattern[star + 1..];
96            name.starts_with(prefix)
97                && name.ends_with(suffix)
98                && name.len() >= prefix.len() + suffix.len()
99        }
100    }
101}
102
103/// Check whether a tool name matches any entry in the exclusion list.
104///
105/// Supports exact matches and prefix patterns: an entry ending with `*`
106/// matches any tool name that starts with the prefix before the `*`.
107/// Example: `"construct-operator__*"` excludes all operator MCP tools.
108fn is_tool_excluded(tool_name: &str, excluded: &[String]) -> bool {
109    excluded.iter().any(|ex| {
110        if let Some(prefix) = ex.strip_suffix('*') {
111            tool_name.starts_with(prefix)
112        } else {
113            ex == tool_name
114        }
115    })
116}
117
118/// Returns the subset of `tool_specs` that should be sent to the LLM for this turn.
119///
120/// Rules (mirrors NullClaw `filterToolSpecsForTurn`):
121/// - Built-in tools (names that do not start with `"mcp_"`) always pass through.
122/// - When `groups` is empty, all tools pass through (backward compatible default).
123/// - An MCP tool is included if at least one group matches it:
124///   - `always` group: included unconditionally if any pattern matches the tool name.
125///   - `dynamic` group: included if any pattern matches AND the user message contains
126///     at least one keyword (case-insensitive substring).
127pub(crate) fn filter_tool_specs_for_turn(
128    tool_specs: Vec<crate::tools::ToolSpec>,
129    groups: &[crate::config::schema::ToolFilterGroup],
130    user_message: &str,
131) -> Vec<crate::tools::ToolSpec> {
132    use crate::config::schema::ToolFilterGroupMode;
133
134    if groups.is_empty() {
135        return tool_specs;
136    }
137
138    let msg_lower = user_message.to_ascii_lowercase();
139
140    tool_specs
141        .into_iter()
142        .filter(|spec| {
143            // Built-in tools always pass through.
144            if !spec.name.starts_with("mcp_") {
145                return true;
146            }
147            // MCP tool: include if any active group matches.
148            groups.iter().any(|group| {
149                let pattern_matches = group.tools.iter().any(|pat| glob_match(pat, &spec.name));
150                if !pattern_matches {
151                    return false;
152                }
153                match group.mode {
154                    ToolFilterGroupMode::Always => true,
155                    ToolFilterGroupMode::Dynamic => group
156                        .keywords
157                        .iter()
158                        .any(|kw| msg_lower.contains(&kw.to_ascii_lowercase())),
159                }
160            })
161        })
162        .collect()
163}
164
165/// Filters a tool spec list by an optional capability allowlist.
166///
167/// When `allowed` is `None`, all specs pass through unchanged.
168/// When `allowed` is `Some(list)`, only specs whose name appears in the list
169/// are retained. Unknown names in the allowlist are silently ignored.
170pub(crate) fn filter_by_allowed_tools(
171    specs: Vec<crate::tools::ToolSpec>,
172    allowed: Option<&[String]>,
173) -> Vec<crate::tools::ToolSpec> {
174    match allowed {
175        None => specs,
176        Some(list) => specs
177            .into_iter()
178            .filter(|spec| list.iter().any(|name| name == &spec.name))
179            .collect(),
180    }
181}
182
183/// Computes the list of MCP tool names that should be excluded for a given turn
184/// based on `tool_filter_groups` and the user message.
185///
186/// Returns an empty `Vec` when `groups` is empty (no filtering).
187fn compute_excluded_mcp_tools(
188    tools_registry: &[Box<dyn Tool>],
189    groups: &[crate::config::schema::ToolFilterGroup],
190    user_message: &str,
191) -> Vec<String> {
192    if groups.is_empty() {
193        return Vec::new();
194    }
195    let filtered_specs = filter_tool_specs_for_turn(
196        tools_registry.iter().map(|t| t.spec()).collect(),
197        groups,
198        user_message,
199    );
200    let included: HashSet<&str> = filtered_specs.iter().map(|s| s.name.as_str()).collect();
201    tools_registry
202        .iter()
203        .filter(|t| t.name().starts_with("mcp_") && !included.contains(t.name()))
204        .map(|t| t.name().to_string())
205        .collect()
206}
207
208static SENSITIVE_KEY_PATTERNS: LazyLock<RegexSet> = LazyLock::new(|| {
209    RegexSet::new([
210        r"(?i)token",
211        r"(?i)api[_-]?key",
212        r"(?i)password",
213        r"(?i)secret",
214        r"(?i)user[_-]?key",
215        r"(?i)bearer",
216        r"(?i)credential",
217    ])
218    .unwrap()
219});
220
221static SENSITIVE_KV_REGEX: LazyLock<Regex> = LazyLock::new(|| {
222    Regex::new(r#"(?i)(token|api[_-]?key|password|secret|user[_-]?key|bearer|credential)["']?\s*[:=]\s*(?:"([^"]{8,})"|'([^']{8,})'|([a-zA-Z0-9_\-\.]{8,}))"#).unwrap()
223});
224
225/// Scrub credentials from tool output to prevent accidental exfiltration.
226/// Replaces known credential patterns with a redacted placeholder while preserving
227/// a small prefix for context.
228pub(crate) fn scrub_credentials(input: &str) -> String {
229    SENSITIVE_KV_REGEX
230        .replace_all(input, |caps: &regex::Captures| {
231            let full_match = &caps[0];
232            let key = &caps[1];
233            let val = caps
234                .get(2)
235                .or(caps.get(3))
236                .or(caps.get(4))
237                .map(|m| m.as_str())
238                .unwrap_or("");
239
240            // Preserve first 4 chars for context, then redact.
241            // Use char_indices to find the byte offset of the 4th character
242            // so we never slice in the middle of a multi-byte UTF-8 sequence.
243            let prefix = if val.len() > 4 {
244                val.char_indices()
245                    .nth(4)
246                    .map(|(byte_idx, _)| &val[..byte_idx])
247                    .unwrap_or(val)
248            } else {
249                ""
250            };
251
252            if full_match.contains(':') {
253                if full_match.contains('"') {
254                    format!("\"{}\": \"{}*[REDACTED]\"", key, prefix)
255                } else {
256                    format!("{}: {}*[REDACTED]", key, prefix)
257                }
258            } else if full_match.contains('=') {
259                if full_match.contains('"') {
260                    format!("{}=\"{}*[REDACTED]\"", key, prefix)
261                } else {
262                    format!("{}={}*[REDACTED]", key, prefix)
263                }
264            } else {
265                format!("{}: {}*[REDACTED]", key, prefix)
266            }
267        })
268        .to_string()
269}
270
271/// Default trigger for auto-compaction when non-system message count exceeds this threshold.
272/// Prefer passing the config-driven value via `run_tool_call_loop`; this constant is only
273/// used when callers omit the parameter.
274/// Minimum interval between progress sends to avoid flooding the draft channel.
275pub(crate) const PROGRESS_MIN_INTERVAL_MS: u64 = 500;
276
277/// Structured event sent through the draft channel so channels can
278/// differentiate between status/progress updates and actual response content.
279#[derive(Debug, Clone)]
280pub enum DraftEvent {
281    /// Clear accumulated draft content (e.g. before streaming a new response).
282    Clear,
283    /// Progress / status text — channels can show this in a status bar
284    /// rather than in the message body (e.g. "🤔 Thinking...", "⏳ shell_command").
285    Progress(String),
286    /// Actual response content delta to append to the draft message.
287    Content(String),
288}
289
290tokio::task_local! {
291    pub(crate) static TOOL_CHOICE_OVERRIDE: Option<String>;
292}
293
294/// Convert a tool registry to OpenAI function-calling format for native tool support.
295fn tools_to_openai_format(tools_registry: &[Box<dyn Tool>]) -> Vec<serde_json::Value> {
296    tools_registry
297        .iter()
298        .map(|tool| {
299            serde_json::json!({
300                "type": "function",
301                "function": {
302                    "name": tool.name(),
303                    "description": tool.description(),
304                    "parameters": tool.parameters_schema()
305                }
306            })
307        })
308        .collect()
309}
310
311fn autosave_memory_key(prefix: &str) -> String {
312    format!("{prefix}_{}", Uuid::new_v4())
313}
314
315/// Build context preamble by searching memory for relevant entries.
316/// Entries with a hybrid score below `min_relevance_score` are dropped to
317/// prevent unrelated memories from bleeding into the conversation.
318/// Core memories are exempt from time decay (evergreen).
319async fn build_context(
320    mem: &dyn Memory,
321    user_msg: &str,
322    min_relevance_score: f64,
323    session_id: Option<&str>,
324) -> String {
325    let mut context = String::new();
326
327    // Pull relevant memories for this message
328    if let Ok(mut entries) = mem.recall(user_msg, 5, session_id, None, None).await {
329        // Apply time decay: older non-Core memories score lower
330        decay::apply_time_decay(&mut entries, decay::DEFAULT_HALF_LIFE_DAYS);
331
332        let relevant: Vec<_> = entries
333            .iter()
334            .filter(|e| match e.score {
335                Some(score) => score >= min_relevance_score,
336                None => true,
337            })
338            .collect();
339
340        if !relevant.is_empty() {
341            context.push_str("[Memory context]\n");
342            for entry in &relevant {
343                if memory::is_assistant_autosave_key(&entry.key) {
344                    continue;
345                }
346                if memory::should_skip_autosave_content(&entry.content) {
347                    continue;
348                }
349                // Skip entries containing tool_result blocks — they can leak
350                // stale tool output from previous heartbeat ticks into new
351                // sessions, presenting the LLM with orphan tool_result data.
352                if entry.content.contains("<tool_result") {
353                    continue;
354                }
355                let _ = writeln!(context, "- {}: {}", entry.key, entry.content);
356            }
357            if context == "[Memory context]\n" {
358                context.clear();
359            } else {
360                context.push_str("[/Memory context]\n\n");
361            }
362        }
363    }
364
365    context
366}
367
368/// Build hardware datasheet context from RAG when peripherals are enabled.
369/// Includes pin-alias lookup (e.g. "red_led" → 13) when query matches, plus retrieved chunks.
370fn build_hardware_context(
371    rag: &crate::rag::HardwareRag,
372    user_msg: &str,
373    boards: &[String],
374    chunk_limit: usize,
375) -> String {
376    if rag.is_empty() || boards.is_empty() {
377        return String::new();
378    }
379
380    let mut context = String::new();
381
382    // Pin aliases: when user says "red led", inject "red_led: 13" for matching boards
383    let pin_ctx = rag.pin_alias_context(user_msg, boards);
384    if !pin_ctx.is_empty() {
385        context.push_str(&pin_ctx);
386    }
387
388    let chunks = rag.retrieve(user_msg, boards, chunk_limit);
389    if chunks.is_empty() && pin_ctx.is_empty() {
390        return String::new();
391    }
392
393    if !chunks.is_empty() {
394        context.push_str("[Hardware documentation]\n");
395    }
396    for chunk in chunks {
397        let board_tag = chunk.board.as_deref().unwrap_or("generic");
398        let _ = writeln!(
399            context,
400            "--- {} ({}) ---\n{}\n",
401            chunk.source, board_tag, chunk.content
402        );
403    }
404    context.push('\n');
405    context
406}
407
408// Tool execution moved to `super::tool_execution`.
409pub(crate) use super::tool_execution::{
410    ToolExecutionOutcome, execute_tools_parallel, execute_tools_sequential,
411    should_execute_tools_in_parallel,
412};
413
414fn parse_arguments_value(raw: Option<&serde_json::Value>) -> serde_json::Value {
415    match raw {
416        Some(serde_json::Value::String(s)) => serde_json::from_str::<serde_json::Value>(s)
417            .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new())),
418        Some(value) => value.clone(),
419        None => serde_json::Value::Object(serde_json::Map::new()),
420    }
421}
422
423fn parse_tool_call_id(
424    root: &serde_json::Value,
425    function: Option<&serde_json::Value>,
426) -> Option<String> {
427    function
428        .and_then(|func| func.get("id"))
429        .or_else(|| root.get("id"))
430        .or_else(|| root.get("tool_call_id"))
431        .or_else(|| root.get("call_id"))
432        .and_then(serde_json::Value::as_str)
433        .map(str::trim)
434        .filter(|id| !id.is_empty())
435        .map(ToString::to_string)
436}
437
438fn canonicalize_json_for_tool_signature(value: &serde_json::Value) -> serde_json::Value {
439    match value {
440        serde_json::Value::Object(map) => {
441            let mut keys: Vec<String> = map.keys().cloned().collect();
442            keys.sort_unstable();
443            let mut ordered = serde_json::Map::new();
444            for key in keys {
445                if let Some(child) = map.get(&key) {
446                    ordered.insert(key, canonicalize_json_for_tool_signature(child));
447                }
448            }
449            serde_json::Value::Object(ordered)
450        }
451        serde_json::Value::Array(items) => serde_json::Value::Array(
452            items
453                .iter()
454                .map(canonicalize_json_for_tool_signature)
455                .collect(),
456        ),
457        _ => value.clone(),
458    }
459}
460
461fn parse_tool_call_value(value: &serde_json::Value) -> Option<ParsedToolCall> {
462    if let Some(function) = value.get("function") {
463        let tool_call_id = parse_tool_call_id(value, Some(function));
464        let name = function
465            .get("name")
466            .and_then(|v| v.as_str())
467            .unwrap_or("")
468            .trim()
469            .to_string();
470        if !name.is_empty() {
471            let arguments = parse_arguments_value(
472                function
473                    .get("arguments")
474                    .or_else(|| function.get("parameters")),
475            );
476            return Some(ParsedToolCall {
477                name,
478                arguments,
479                tool_call_id,
480            });
481        }
482    }
483
484    let tool_call_id = parse_tool_call_id(value, None);
485    let name = value
486        .get("name")
487        .and_then(|v| v.as_str())
488        .unwrap_or("")
489        .trim()
490        .to_string();
491
492    if name.is_empty() {
493        return None;
494    }
495
496    let arguments =
497        parse_arguments_value(value.get("arguments").or_else(|| value.get("parameters")));
498    Some(ParsedToolCall {
499        name,
500        arguments,
501        tool_call_id,
502    })
503}
504
505fn parse_tool_calls_from_json_value(value: &serde_json::Value) -> Vec<ParsedToolCall> {
506    let mut calls = Vec::new();
507
508    if let Some(tool_calls) = value.get("tool_calls").and_then(|v| v.as_array()) {
509        for call in tool_calls {
510            if let Some(parsed) = parse_tool_call_value(call) {
511                calls.push(parsed);
512            }
513        }
514
515        if !calls.is_empty() {
516            return calls;
517        }
518    }
519
520    if let Some(array) = value.as_array() {
521        for item in array {
522            if let Some(parsed) = parse_tool_call_value(item) {
523                calls.push(parsed);
524            }
525        }
526        return calls;
527    }
528
529    if let Some(parsed) = parse_tool_call_value(value) {
530        calls.push(parsed);
531    }
532
533    calls
534}
535
536fn is_xml_meta_tag(tag: &str) -> bool {
537    let normalized = tag.to_ascii_lowercase();
538    matches!(
539        normalized.as_str(),
540        "tool_call"
541            | "toolcall"
542            | "tool-call"
543            | "invoke"
544            | "thinking"
545            | "thought"
546            | "analysis"
547            | "reasoning"
548            | "reflection"
549    )
550}
551
552/// Match opening XML tags: `<tag_name>`.  Does NOT use backreferences.
553static XML_OPEN_TAG_RE: LazyLock<Regex> =
554    LazyLock::new(|| Regex::new(r"<([a-zA-Z_][a-zA-Z0-9_-]*)>").unwrap());
555
556/// MiniMax XML invoke format:
557/// `<invoke name="shell"><parameter name="command">pwd</parameter></invoke>`
558static MINIMAX_INVOKE_RE: LazyLock<Regex> = LazyLock::new(|| {
559    Regex::new(r#"(?is)<invoke\b[^>]*\bname\s*=\s*(?:"([^"]+)"|'([^']+)')[^>]*>(.*?)</invoke>"#)
560        .unwrap()
561});
562
563static MINIMAX_PARAMETER_RE: LazyLock<Regex> = LazyLock::new(|| {
564    Regex::new(
565        r#"(?is)<parameter\b[^>]*\bname\s*=\s*(?:"([^"]+)"|'([^']+)')[^>]*>(.*?)</parameter>"#,
566    )
567    .unwrap()
568});
569
570/// Extracts all `<tag>…</tag>` pairs from `input`, returning `(tag_name, inner_content)`.
571/// Handles matching closing tags without regex backreferences.
572fn extract_xml_pairs(input: &str) -> Vec<(&str, &str)> {
573    let mut results = Vec::new();
574    let mut search_start = 0;
575    while let Some(open_cap) = XML_OPEN_TAG_RE.captures(&input[search_start..]) {
576        let full_open = open_cap.get(0).unwrap();
577        let tag_name = open_cap.get(1).unwrap().as_str();
578        let open_end = search_start + full_open.end();
579
580        let closing_tag = format!("</{tag_name}>");
581        if let Some(close_pos) = input[open_end..].find(&closing_tag) {
582            let inner = &input[open_end..open_end + close_pos];
583            results.push((tag_name, inner.trim()));
584            search_start = open_end + close_pos + closing_tag.len();
585        } else {
586            search_start = open_end;
587        }
588    }
589    results
590}
591
592/// Parse XML-style tool calls in `<tool_call>` bodies.
593/// Supports both nested argument tags and JSON argument payloads:
594/// - `<memory_recall><query>...</query></memory_recall>`
595/// - `<shell>{"command":"pwd"}</shell>`
596fn parse_xml_tool_calls(xml_content: &str) -> Option<Vec<ParsedToolCall>> {
597    let mut calls = Vec::new();
598    let trimmed = xml_content.trim();
599
600    if !trimmed.starts_with('<') || !trimmed.contains('>') {
601        return None;
602    }
603
604    for (tool_name_str, inner_content) in extract_xml_pairs(trimmed) {
605        let tool_name = tool_name_str.to_string();
606        if is_xml_meta_tag(&tool_name) {
607            continue;
608        }
609
610        if inner_content.is_empty() {
611            continue;
612        }
613
614        let mut args = serde_json::Map::new();
615
616        if let Some(first_json) = extract_json_values(inner_content).into_iter().next() {
617            match first_json {
618                serde_json::Value::Object(object_args) => {
619                    args = object_args;
620                }
621                other => {
622                    args.insert("value".to_string(), other);
623                }
624            }
625        } else {
626            for (key_str, value) in extract_xml_pairs(inner_content) {
627                let key = key_str.to_string();
628                if is_xml_meta_tag(&key) {
629                    continue;
630                }
631                if !value.is_empty() {
632                    args.insert(key, serde_json::Value::String(value.to_string()));
633                }
634            }
635
636            if args.is_empty() {
637                args.insert(
638                    "content".to_string(),
639                    serde_json::Value::String(inner_content.to_string()),
640                );
641            }
642        }
643
644        calls.push(ParsedToolCall {
645            name: tool_name,
646            arguments: serde_json::Value::Object(args),
647            tool_call_id: None,
648        });
649    }
650
651    if calls.is_empty() { None } else { Some(calls) }
652}
653
654/// Parse MiniMax-style XML tool calls with attributed invoke/parameter tags.
655fn parse_minimax_invoke_calls(response: &str) -> Option<(String, Vec<ParsedToolCall>)> {
656    let mut calls = Vec::new();
657    let mut text_parts = Vec::new();
658    let mut last_end = 0usize;
659
660    for cap in MINIMAX_INVOKE_RE.captures_iter(response) {
661        let Some(full_match) = cap.get(0) else {
662            continue;
663        };
664
665        let before = response[last_end..full_match.start()].trim();
666        if !before.is_empty() {
667            text_parts.push(before.to_string());
668        }
669
670        let name = cap
671            .get(1)
672            .or_else(|| cap.get(2))
673            .map(|m| m.as_str().trim())
674            .filter(|v| !v.is_empty());
675        let body = cap.get(3).map(|m| m.as_str()).unwrap_or("").trim();
676        last_end = full_match.end();
677
678        let Some(name) = name else {
679            continue;
680        };
681
682        let mut args = serde_json::Map::new();
683        for param_cap in MINIMAX_PARAMETER_RE.captures_iter(body) {
684            let key = param_cap
685                .get(1)
686                .or_else(|| param_cap.get(2))
687                .map(|m| m.as_str().trim())
688                .unwrap_or_default();
689            if key.is_empty() {
690                continue;
691            }
692            let value = param_cap
693                .get(3)
694                .map(|m| m.as_str().trim())
695                .unwrap_or_default();
696            if value.is_empty() {
697                continue;
698            }
699
700            let parsed = extract_json_values(value).into_iter().next();
701            args.insert(
702                key.to_string(),
703                parsed.unwrap_or_else(|| serde_json::Value::String(value.to_string())),
704            );
705        }
706
707        if args.is_empty() {
708            if let Some(first_json) = extract_json_values(body).into_iter().next() {
709                match first_json {
710                    serde_json::Value::Object(obj) => args = obj,
711                    other => {
712                        args.insert("value".to_string(), other);
713                    }
714                }
715            } else if !body.is_empty() {
716                args.insert(
717                    "content".to_string(),
718                    serde_json::Value::String(body.to_string()),
719                );
720            }
721        }
722
723        calls.push(ParsedToolCall {
724            name: name.to_string(),
725            arguments: serde_json::Value::Object(args),
726            tool_call_id: None,
727        });
728    }
729
730    if calls.is_empty() {
731        return None;
732    }
733
734    let after = response[last_end..].trim();
735    if !after.is_empty() {
736        text_parts.push(after.to_string());
737    }
738
739    let text = text_parts
740        .join("\n")
741        .replace("<minimax:tool_call>", "")
742        .replace("</minimax:tool_call>", "")
743        .replace("<minimax:toolcall>", "")
744        .replace("</minimax:toolcall>", "")
745        .trim()
746        .to_string();
747
748    Some((text, calls))
749}
750
751const TOOL_CALL_OPEN_TAGS: [&str; 6] = [
752    "<tool_call>",
753    "<toolcall>",
754    "<tool-call>",
755    "<invoke>",
756    "<minimax:tool_call>",
757    "<minimax:toolcall>",
758];
759
760const TOOL_CALL_CLOSE_TAGS: [&str; 6] = [
761    "</tool_call>",
762    "</toolcall>",
763    "</tool-call>",
764    "</invoke>",
765    "</minimax:tool_call>",
766    "</minimax:toolcall>",
767];
768
769fn find_first_tag<'a>(haystack: &str, tags: &'a [&'a str]) -> Option<(usize, &'a str)> {
770    tags.iter()
771        .filter_map(|tag| haystack.find(tag).map(|idx| (idx, *tag)))
772        .min_by_key(|(idx, _)| *idx)
773}
774
775fn extract_first_json_value_with_end(input: &str) -> Option<(serde_json::Value, usize)> {
776    let trimmed = input.trim_start();
777    let trim_offset = input.len().saturating_sub(trimmed.len());
778
779    for (byte_idx, ch) in trimmed.char_indices() {
780        if ch != '{' && ch != '[' {
781            continue;
782        }
783
784        let slice = &trimmed[byte_idx..];
785        let mut stream = serde_json::Deserializer::from_str(slice).into_iter::<serde_json::Value>();
786        if let Some(Ok(value)) = stream.next() {
787            let consumed = stream.byte_offset();
788            if consumed > 0 {
789                return Some((value, trim_offset + byte_idx + consumed));
790            }
791        }
792    }
793
794    None
795}
796
797fn strip_leading_close_tags(mut input: &str) -> &str {
798    loop {
799        let trimmed = input.trim_start();
800        if !trimmed.starts_with("</") {
801            return trimmed;
802        }
803
804        let Some(close_end) = trimmed.find('>') else {
805            return "";
806        };
807        input = &trimmed[close_end + 1..];
808    }
809}
810
811/// Extract JSON values from a string.
812///
813/// # Security Warning
814///
815/// This function extracts ANY JSON objects/arrays from the input. It MUST only
816/// be used on content that is already trusted to be from the LLM, such as
817/// content inside `<invoke>` tags where the LLM has explicitly indicated intent
818/// to make a tool call. Do NOT use this on raw user input or content that
819/// could contain prompt injection payloads.
820fn extract_json_values(input: &str) -> Vec<serde_json::Value> {
821    let mut values = Vec::new();
822    let trimmed = input.trim();
823    if trimmed.is_empty() {
824        return values;
825    }
826
827    if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
828        values.push(value);
829        return values;
830    }
831
832    let char_positions: Vec<(usize, char)> = trimmed.char_indices().collect();
833    let mut idx = 0;
834    while idx < char_positions.len() {
835        let (byte_idx, ch) = char_positions[idx];
836        if ch == '{' || ch == '[' {
837            let slice = &trimmed[byte_idx..];
838            let mut stream =
839                serde_json::Deserializer::from_str(slice).into_iter::<serde_json::Value>();
840            if let Some(Ok(value)) = stream.next() {
841                let consumed = stream.byte_offset();
842                if consumed > 0 {
843                    values.push(value);
844                    let next_byte = byte_idx + consumed;
845                    while idx < char_positions.len() && char_positions[idx].0 < next_byte {
846                        idx += 1;
847                    }
848                    continue;
849                }
850            }
851        }
852        idx += 1;
853    }
854
855    values
856}
857
858/// Find the end position of a JSON object by tracking balanced braces.
859fn find_json_end(input: &str) -> Option<usize> {
860    let trimmed = input.trim_start();
861    let offset = input.len() - trimmed.len();
862
863    if !trimmed.starts_with('{') {
864        return None;
865    }
866
867    let mut depth = 0;
868    let mut in_string = false;
869    let mut escape_next = false;
870
871    for (i, ch) in trimmed.char_indices() {
872        if escape_next {
873            escape_next = false;
874            continue;
875        }
876
877        match ch {
878            '\\' if in_string => escape_next = true,
879            '"' => in_string = !in_string,
880            '{' if !in_string => depth += 1,
881            '}' if !in_string => {
882                depth -= 1;
883                if depth == 0 {
884                    return Some(offset + i + ch.len_utf8());
885                }
886            }
887            _ => {}
888        }
889    }
890
891    None
892}
893
894/// Parse XML attribute-style tool calls from response text.
895/// This handles MiniMax and similar providers that output:
896/// ```xml
897/// <minimax:toolcall>
898/// <invoke name="shell">
899/// <parameter name="command">ls</parameter>
900/// </invoke>
901/// </minimax:toolcall>
902/// ```
903fn parse_xml_attribute_tool_calls(response: &str) -> Vec<ParsedToolCall> {
904    let mut calls = Vec::new();
905
906    // Regex to find <invoke name="toolname">...</invoke> blocks
907    static INVOKE_RE: LazyLock<Regex> = LazyLock::new(|| {
908        Regex::new(r#"(?s)<invoke\s+name="([^"]+)"[^>]*>(.*?)</invoke>"#).unwrap()
909    });
910
911    // Regex to find <parameter name="paramname">value</parameter>
912    static PARAM_RE: LazyLock<Regex> = LazyLock::new(|| {
913        Regex::new(r#"<parameter\s+name="([^"]+)"[^>]*>([^<]*)</parameter>"#).unwrap()
914    });
915
916    for cap in INVOKE_RE.captures_iter(response) {
917        let tool_name = cap.get(1).map(|m| m.as_str()).unwrap_or("");
918        let inner = cap.get(2).map(|m| m.as_str()).unwrap_or("");
919
920        if tool_name.is_empty() {
921            continue;
922        }
923
924        let mut arguments = serde_json::Map::new();
925
926        for param_cap in PARAM_RE.captures_iter(inner) {
927            let param_name = param_cap.get(1).map(|m| m.as_str()).unwrap_or("");
928            let param_value = param_cap.get(2).map(|m| m.as_str()).unwrap_or("");
929
930            if !param_name.is_empty() {
931                arguments.insert(
932                    param_name.to_string(),
933                    serde_json::Value::String(param_value.to_string()),
934                );
935            }
936        }
937
938        if !arguments.is_empty() {
939            calls.push(ParsedToolCall {
940                name: map_tool_name_alias(tool_name).to_string(),
941                arguments: serde_json::Value::Object(arguments),
942                tool_call_id: None,
943            });
944        }
945    }
946
947    calls
948}
949
950/// Parse Perl/hash-ref style tool calls from response text.
951/// This handles formats like:
952/// ```text
953/// TOOL_CALL
954/// {tool => "shell", args => {
955///   --command "ls -la"
956///   --description "List current directory contents"
957/// }}
958/// /TOOL_CALL
959/// ```
960/// Also handles the square bracket variant emitted by models like MiniMax 2.7:
961/// ```text
962/// [TOOL_CALL]{tool => "shell", args => {--command "echo hello"}}[/TOOL_CALL]
963/// ```
964fn parse_perl_style_tool_calls(response: &str) -> Vec<ParsedToolCall> {
965    let mut calls = Vec::new();
966
967    // Regex to find TOOL_CALL blocks - handle double closing braces }}
968    // Matches both `TOOL_CALL { ... }} /TOOL_CALL` and `[TOOL_CALL]{ ... }}[/TOOL_CALL]`
969    static PERL_RE: LazyLock<Regex> = LazyLock::new(|| {
970        Regex::new(r"(?s)(?:\[TOOL_CALL\]|TOOL_CALL)\s*\{(.+?)\}\}\s*(?:\[/TOOL_CALL\]|/TOOL_CALL)")
971            .unwrap()
972    });
973
974    // Regex to find tool => "name" in the content
975    static TOOL_NAME_RE: LazyLock<Regex> =
976        LazyLock::new(|| Regex::new(r#"tool\s*=>\s*"([^"]+)""#).unwrap());
977
978    // Regex to find args => { ... } block.
979    // The closing brace is optional: in the square bracket variant [TOOL_CALL]{...}}[/TOOL_CALL]
980    // the outer regex may consume the inner closing brace, so the args content may run to end of string.
981    static ARGS_BLOCK_RE: LazyLock<Regex> =
982        LazyLock::new(|| Regex::new(r"(?s)args\s*=>\s*\{(.+?)(?:\}|$)").unwrap());
983
984    // Regex to find --key "value" pairs
985    static ARGS_RE: LazyLock<Regex> =
986        LazyLock::new(|| Regex::new(r#"--(\w+)\s+"([^"]+)""#).unwrap());
987
988    for cap in PERL_RE.captures_iter(response) {
989        let content = cap.get(1).map(|m| m.as_str()).unwrap_or("");
990
991        // Extract tool name
992        let tool_name = TOOL_NAME_RE
993            .captures(content)
994            .and_then(|c| c.get(1))
995            .map(|m| m.as_str())
996            .unwrap_or("");
997
998        if tool_name.is_empty() {
999            continue;
1000        }
1001
1002        // Extract args block
1003        let args_block = ARGS_BLOCK_RE
1004            .captures(content)
1005            .and_then(|c| c.get(1))
1006            .map(|m| m.as_str())
1007            .unwrap_or("");
1008
1009        let mut arguments = serde_json::Map::new();
1010
1011        for arg_cap in ARGS_RE.captures_iter(args_block) {
1012            let key = arg_cap.get(1).map(|m| m.as_str()).unwrap_or("");
1013            let value = arg_cap.get(2).map(|m| m.as_str()).unwrap_or("");
1014
1015            if !key.is_empty() {
1016                arguments.insert(
1017                    key.to_string(),
1018                    serde_json::Value::String(value.to_string()),
1019                );
1020            }
1021        }
1022
1023        if !arguments.is_empty() {
1024            calls.push(ParsedToolCall {
1025                name: map_tool_name_alias(tool_name).to_string(),
1026                arguments: serde_json::Value::Object(arguments),
1027                tool_call_id: None,
1028            });
1029        }
1030    }
1031
1032    calls
1033}
1034
1035/// Parse FunctionCall-style tool calls from response text.
1036/// This handles formats like:
1037/// ```text
1038/// <FunctionCall>
1039/// file_read
1040/// <code>path>/Users/kylelampa/Documents/construct/README.md</code>
1041/// </FunctionCall>
1042/// ```
1043fn parse_function_call_tool_calls(response: &str) -> Vec<ParsedToolCall> {
1044    let mut calls = Vec::new();
1045
1046    // Regex to find <FunctionCall> blocks
1047    static FUNC_RE: LazyLock<Regex> = LazyLock::new(|| {
1048        Regex::new(r"(?s)<FunctionCall>\s*(\w+)\s*<code>([^<]+)</code>\s*</FunctionCall>").unwrap()
1049    });
1050
1051    for cap in FUNC_RE.captures_iter(response) {
1052        let tool_name = cap.get(1).map(|m| m.as_str()).unwrap_or("");
1053        let args_text = cap.get(2).map(|m| m.as_str()).unwrap_or("");
1054
1055        if tool_name.is_empty() {
1056            continue;
1057        }
1058
1059        // Parse key>value pairs (e.g., path>/Users/.../file.txt)
1060        let mut arguments = serde_json::Map::new();
1061        for line in args_text.lines() {
1062            let line = line.trim();
1063            if let Some(pos) = line.find('>') {
1064                let key = line[..pos].trim();
1065                let value = line[pos + 1..].trim();
1066                if !key.is_empty() && !value.is_empty() {
1067                    arguments.insert(
1068                        key.to_string(),
1069                        serde_json::Value::String(value.to_string()),
1070                    );
1071                }
1072            }
1073        }
1074
1075        if !arguments.is_empty() {
1076            calls.push(ParsedToolCall {
1077                name: map_tool_name_alias(tool_name).to_string(),
1078                arguments: serde_json::Value::Object(arguments),
1079                tool_call_id: None,
1080            });
1081        }
1082    }
1083
1084    calls
1085}
1086
1087/// Parse GLM-style tool calls from response text.
1088/// Map tool name aliases from various LLM providers to Construct tool names.
1089/// This handles variations like "fileread" -> "file_read", "bash" -> "shell", etc.
1090fn map_tool_name_alias(tool_name: &str) -> &str {
1091    match tool_name {
1092        // Shell variations (including GLM aliases that map to shell)
1093        "shell" | "bash" | "sh" | "exec" | "command" | "cmd" | "browser_open" | "browser"
1094        | "web_search" => "shell",
1095        // Messaging variations
1096        "send_message" | "sendmessage" => "message_send",
1097        // File tool variations
1098        "fileread" | "file_read" | "readfile" | "read_file" | "file" => "file_read",
1099        "filewrite" | "file_write" | "writefile" | "write_file" => "file_write",
1100        "filelist" | "file_list" | "listfiles" | "list_files" => "file_list",
1101        // Memory variations
1102        "memoryrecall" | "memory_recall" | "recall" | "memrecall" => "memory_recall",
1103        "memorystore" | "memory_store" | "store" | "memstore" => "memory_store",
1104        "memoryforget" | "memory_forget" | "forget" | "memforget" => "memory_forget",
1105        // HTTP variations
1106        "http_request" | "http" | "fetch" | "curl" | "wget" => "http_request",
1107        _ => tool_name,
1108    }
1109}
1110
1111fn build_curl_command(url: &str) -> Option<String> {
1112    if !(url.starts_with("http://") || url.starts_with("https://")) {
1113        return None;
1114    }
1115
1116    if url.chars().any(char::is_whitespace) {
1117        return None;
1118    }
1119
1120    let escaped = url.replace('\'', r#"'\\''"#);
1121    Some(format!("curl -s '{}'", escaped))
1122}
1123
1124fn parse_glm_style_tool_calls(text: &str) -> Vec<(String, serde_json::Value, Option<String>)> {
1125    let mut calls = Vec::new();
1126
1127    for line in text.lines() {
1128        let line = line.trim();
1129        if line.is_empty() {
1130            continue;
1131        }
1132
1133        // Format: tool_name/param>value or tool_name/{json}
1134        if let Some(pos) = line.find('/') {
1135            let tool_part = &line[..pos];
1136            let rest = &line[pos + 1..];
1137
1138            if tool_part.chars().all(|c| c.is_alphanumeric() || c == '_') {
1139                let tool_name = map_tool_name_alias(tool_part);
1140
1141                if let Some(gt_pos) = rest.find('>') {
1142                    let param_name = rest[..gt_pos].trim();
1143                    let value = rest[gt_pos + 1..].trim();
1144
1145                    let arguments = match tool_name {
1146                        "shell" => {
1147                            if param_name == "url" {
1148                                let Some(command) = build_curl_command(value) else {
1149                                    continue;
1150                                };
1151                                serde_json::json!({ "command": command })
1152                            } else if value.starts_with("http://") || value.starts_with("https://")
1153                            {
1154                                if let Some(command) = build_curl_command(value) {
1155                                    serde_json::json!({ "command": command })
1156                                } else {
1157                                    serde_json::json!({ "command": value })
1158                                }
1159                            } else {
1160                                serde_json::json!({ "command": value })
1161                            }
1162                        }
1163                        "http_request" => {
1164                            serde_json::json!({"url": value, "method": "GET"})
1165                        }
1166                        _ => serde_json::json!({ param_name: value }),
1167                    };
1168
1169                    calls.push((tool_name.to_string(), arguments, Some(line.to_string())));
1170                    continue;
1171                }
1172
1173                if rest.starts_with('{') {
1174                    if let Ok(json_args) = serde_json::from_str::<serde_json::Value>(rest) {
1175                        calls.push((tool_name.to_string(), json_args, Some(line.to_string())));
1176                    }
1177                }
1178            }
1179        }
1180    }
1181
1182    calls
1183}
1184
1185/// Return the canonical default parameter name for a tool.
1186///
1187/// When a model emits a shortened call like `shell>uname -a` (without an
1188/// explicit `/param_name`), we need to infer which parameter the value maps
1189/// to. This function encodes the mapping for known Construct tools.
1190fn default_param_for_tool(tool: &str) -> &'static str {
1191    match tool {
1192        "shell" | "bash" | "sh" | "exec" | "command" | "cmd" => "command",
1193        // All file tools default to "path"
1194        "file_read" | "fileread" | "readfile" | "read_file" | "file" | "file_write"
1195        | "filewrite" | "writefile" | "write_file" | "file_edit" | "fileedit" | "editfile"
1196        | "edit_file" | "file_list" | "filelist" | "listfiles" | "list_files" => "path",
1197        // Memory recall/forget and web search tools all default to "query"
1198        "memory_recall" | "memoryrecall" | "recall" | "memrecall" | "memory_forget"
1199        | "memoryforget" | "forget" | "memforget" | "web_search_tool" | "web_search"
1200        | "websearch" | "search" => "query",
1201        "memory_store" | "memorystore" | "store" | "memstore" => "content",
1202        // HTTP and browser tools default to "url"
1203        "http_request" | "http" | "fetch" | "curl" | "wget" | "browser_open" | "browser" => "url",
1204        _ => "input",
1205    }
1206}
1207
1208/// Parse GLM-style shortened tool call bodies found inside `<tool_call>` tags.
1209///
1210/// Handles three sub-formats that GLM-4.7 emits:
1211///
1212/// 1. **Shortened**: `tool_name>value` — single value mapped via
1213///    [`default_param_for_tool`].
1214/// 2. **YAML-like multi-line**: `tool_name>\nkey: value\nkey: value` — each
1215///    subsequent `key: value` line becomes a parameter.
1216/// 3. **Attribute-style**: `tool_name key="value" [/]>` — XML-like attributes.
1217///
1218/// Returns `None` if the body does not match any of these formats.
1219fn parse_glm_shortened_body(body: &str) -> Option<ParsedToolCall> {
1220    let body = body.trim();
1221    if body.is_empty() {
1222        return None;
1223    }
1224
1225    let function_style = body.find('(').and_then(|open| {
1226        if body.ends_with(')') && open > 0 {
1227            Some((body[..open].trim(), body[open + 1..body.len() - 1].trim()))
1228        } else {
1229            None
1230        }
1231    });
1232
1233    // Check attribute-style FIRST: `tool_name key="value" />`
1234    // Must come before `>` check because `/>` contains `>` and would
1235    // misparse the tool name in the first branch.
1236    let (tool_raw, value_part) = if let Some((tool, args)) = function_style {
1237        (tool, args)
1238    } else if body.contains("=\"") {
1239        // Attribute-style: split at first whitespace to get tool name
1240        let split_pos = body.find(|c: char| c.is_whitespace()).unwrap_or(body.len());
1241        let tool = body[..split_pos].trim();
1242        let attrs = body[split_pos..]
1243            .trim()
1244            .trim_end_matches("/>")
1245            .trim_end_matches('>')
1246            .trim_end_matches('/')
1247            .trim();
1248        (tool, attrs)
1249    } else if let Some(gt_pos) = body.find('>') {
1250        // GLM shortened: `tool_name>value`
1251        let tool = body[..gt_pos].trim();
1252        let value = body[gt_pos + 1..].trim();
1253        // Strip trailing self-close markers that some models emit
1254        let value = value.trim_end_matches("/>").trim_end_matches('/').trim();
1255        (tool, value)
1256    } else {
1257        return None;
1258    };
1259
1260    // Validate tool name: must be alphanumeric + underscore only
1261    let tool_raw = tool_raw.trim_end_matches(|c: char| c.is_whitespace());
1262    if tool_raw.is_empty() || !tool_raw.chars().all(|c| c.is_alphanumeric() || c == '_') {
1263        return None;
1264    }
1265
1266    let tool_name = map_tool_name_alias(tool_raw);
1267
1268    // Try attribute-style: `key="value" key2="value2"`
1269    if value_part.contains("=\"") {
1270        let mut args = serde_json::Map::new();
1271        // Simple attribute parser: key="value" pairs
1272        let mut rest = value_part;
1273        while let Some(eq_pos) = rest.find("=\"") {
1274            let key_start = rest[..eq_pos]
1275                .rfind(|c: char| c.is_whitespace())
1276                .map(|p| p + 1)
1277                .unwrap_or(0);
1278            let key = rest[key_start..eq_pos]
1279                .trim()
1280                .trim_matches(|c: char| c == ',' || c == ';');
1281            let after_quote = &rest[eq_pos + 2..];
1282            if let Some(end_quote) = after_quote.find('"') {
1283                let value = &after_quote[..end_quote];
1284                if !key.is_empty() {
1285                    args.insert(
1286                        key.to_string(),
1287                        serde_json::Value::String(value.to_string()),
1288                    );
1289                }
1290                rest = &after_quote[end_quote + 1..];
1291            } else {
1292                break;
1293            }
1294        }
1295        if !args.is_empty() {
1296            return Some(ParsedToolCall {
1297                name: tool_name.to_string(),
1298                arguments: serde_json::Value::Object(args),
1299                tool_call_id: None,
1300            });
1301        }
1302    }
1303
1304    // Try YAML-style multi-line: each line is `key: value`
1305    if value_part.contains('\n') {
1306        let mut args = serde_json::Map::new();
1307        for line in value_part.lines() {
1308            let line = line.trim();
1309            if line.is_empty() {
1310                continue;
1311            }
1312            if let Some(colon_pos) = line.find(':') {
1313                let key = line[..colon_pos].trim();
1314                let value = line[colon_pos + 1..].trim();
1315                if !key.is_empty() && !value.is_empty() {
1316                    // Normalize boolean-like values
1317                    let json_value = match value {
1318                        "true" | "yes" => serde_json::Value::Bool(true),
1319                        "false" | "no" => serde_json::Value::Bool(false),
1320                        _ => serde_json::Value::String(value.to_string()),
1321                    };
1322                    args.insert(key.to_string(), json_value);
1323                }
1324            }
1325        }
1326        if !args.is_empty() {
1327            return Some(ParsedToolCall {
1328                name: tool_name.to_string(),
1329                arguments: serde_json::Value::Object(args),
1330                tool_call_id: None,
1331            });
1332        }
1333    }
1334
1335    // Single-value shortened: `tool>value`
1336    if !value_part.is_empty() {
1337        let param = default_param_for_tool(tool_raw);
1338        let arguments = match tool_name {
1339            "shell" => {
1340                if value_part.starts_with("http://") || value_part.starts_with("https://") {
1341                    if let Some(cmd) = build_curl_command(value_part) {
1342                        serde_json::json!({ "command": cmd })
1343                    } else {
1344                        serde_json::json!({ "command": value_part })
1345                    }
1346                } else {
1347                    serde_json::json!({ "command": value_part })
1348                }
1349            }
1350            "http_request" => serde_json::json!({"url": value_part, "method": "GET"}),
1351            _ => serde_json::json!({ param: value_part }),
1352        };
1353        return Some(ParsedToolCall {
1354            name: tool_name.to_string(),
1355            arguments,
1356            tool_call_id: None,
1357        });
1358    }
1359
1360    None
1361}
1362
1363// ── Tool-Call Parsing ─────────────────────────────────────────────────────
1364// LLM responses may contain tool calls in multiple formats depending on
1365// the provider. Parsing follows a priority chain:
1366//   1. OpenAI-style JSON with `tool_calls` array (native API)
1367//   2. XML tags: <tool_call>, <toolcall>, <tool-call>, <invoke>
1368//   3. Markdown code blocks with `tool_call` language
1369//   4. GLM-style line-based format (e.g. `shell/command>ls`)
1370// SECURITY: We never fall back to extracting arbitrary JSON from the
1371// response body, because that would enable prompt-injection attacks where
1372// malicious content in emails/files/web pages mimics a tool call.
1373
1374/// Parse tool calls from an LLM response that uses XML-style function calling.
1375///
1376/// Expected format (common with system-prompt-guided tool use):
1377/// ```text
1378/// <tool_call>
1379/// {"name": "shell", "arguments": {"command": "ls"}}
1380/// </tool_call>
1381/// ```
1382///
1383/// Also accepts common tag variants (`<toolcall>`, `<tool-call>`) for model
1384/// compatibility.
1385///
1386/// Also supports JSON with `tool_calls` array from OpenAI-format responses.
1387fn parse_tool_calls(response: &str) -> (String, Vec<ParsedToolCall>) {
1388    // Strip `<think>...</think>` blocks before parsing.  Qwen and other
1389    // reasoning models embed chain-of-thought inline in the response text;
1390    // these tags can interfere with `<tool_call>` extraction and must be
1391    // removed first.
1392    let cleaned = strip_think_tags(response);
1393    let response = cleaned.as_str();
1394
1395    let mut text_parts = Vec::new();
1396    let mut calls = Vec::new();
1397    let mut remaining = response;
1398
1399    // First, try to parse as OpenAI-style JSON response with tool_calls array
1400    // This handles providers like Minimax that return tool_calls in native JSON format
1401    if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(response.trim()) {
1402        calls = parse_tool_calls_from_json_value(&json_value);
1403        if !calls.is_empty() {
1404            // If we found tool_calls, extract any content field as text
1405            if let Some(content) = json_value.get("content").and_then(|v| v.as_str()) {
1406                if !content.trim().is_empty() {
1407                    text_parts.push(content.trim().to_string());
1408                }
1409            }
1410            return (text_parts.join("\n"), calls);
1411        }
1412    }
1413
1414    if let Some((minimax_text, minimax_calls)) = parse_minimax_invoke_calls(response) {
1415        if !minimax_calls.is_empty() {
1416            return (minimax_text, minimax_calls);
1417        }
1418    }
1419
1420    // Fall back to XML-style tool-call tag parsing.
1421    while let Some((start, open_tag)) = find_first_tag(remaining, &TOOL_CALL_OPEN_TAGS) {
1422        // Everything before the tag is text
1423        let before = &remaining[..start];
1424        if !before.trim().is_empty() {
1425            text_parts.push(before.trim().to_string());
1426        }
1427
1428        let Some(close_tag) = (match open_tag {
1429            "<tool_call>" => Some("</tool_call>"),
1430            "<toolcall>" => Some("</toolcall>"),
1431            "<tool-call>" => Some("</tool-call>"),
1432            "<invoke>" => Some("</invoke>"),
1433            "<minimax:tool_call>" => Some("</minimax:tool_call>"),
1434            "<minimax:toolcall>" => Some("</minimax:toolcall>"),
1435            _ => None,
1436        }) else {
1437            break;
1438        };
1439
1440        let after_open = &remaining[start + open_tag.len()..];
1441        if let Some(close_idx) = after_open.find(close_tag) {
1442            let inner = &after_open[..close_idx];
1443            let mut parsed_any = false;
1444
1445            // Try JSON format first
1446            let json_values = extract_json_values(inner);
1447            for value in json_values {
1448                let parsed_calls = parse_tool_calls_from_json_value(&value);
1449                if !parsed_calls.is_empty() {
1450                    parsed_any = true;
1451                    calls.extend(parsed_calls);
1452                }
1453            }
1454
1455            // If JSON parsing failed, try XML format (DeepSeek/GLM style)
1456            if !parsed_any {
1457                if let Some(xml_calls) = parse_xml_tool_calls(inner) {
1458                    calls.extend(xml_calls);
1459                    parsed_any = true;
1460                }
1461            }
1462
1463            if !parsed_any {
1464                // GLM-style shortened body: `shell>uname -a` or `shell\ncommand: date`
1465                if let Some(glm_call) = parse_glm_shortened_body(inner) {
1466                    calls.push(glm_call);
1467                    parsed_any = true;
1468                }
1469            }
1470
1471            if !parsed_any {
1472                tracing::warn!(
1473                    "Malformed <tool_call>: expected tool-call object in tag body (JSON/XML/GLM)"
1474                );
1475            }
1476
1477            remaining = &after_open[close_idx + close_tag.len()..];
1478        } else {
1479            // Matching close tag not found — try cross-alias close tags first.
1480            // Models sometimes mix open/close tag aliases (e.g. <tool_call>...</invoke>).
1481            let mut resolved = false;
1482            if let Some((cross_idx, cross_tag)) = find_first_tag(after_open, &TOOL_CALL_CLOSE_TAGS)
1483            {
1484                let inner = &after_open[..cross_idx];
1485                let mut parsed_any = false;
1486
1487                // Try JSON
1488                let json_values = extract_json_values(inner);
1489                for value in json_values {
1490                    let parsed_calls = parse_tool_calls_from_json_value(&value);
1491                    if !parsed_calls.is_empty() {
1492                        parsed_any = true;
1493                        calls.extend(parsed_calls);
1494                    }
1495                }
1496
1497                // Try XML
1498                if !parsed_any {
1499                    if let Some(xml_calls) = parse_xml_tool_calls(inner) {
1500                        calls.extend(xml_calls);
1501                        parsed_any = true;
1502                    }
1503                }
1504
1505                // Try GLM shortened body
1506                if !parsed_any {
1507                    if let Some(glm_call) = parse_glm_shortened_body(inner) {
1508                        calls.push(glm_call);
1509                        parsed_any = true;
1510                    }
1511                }
1512
1513                if parsed_any {
1514                    remaining = &after_open[cross_idx + cross_tag.len()..];
1515                    resolved = true;
1516                }
1517            }
1518
1519            if resolved {
1520                continue;
1521            }
1522
1523            // No cross-alias close tag resolved — fall back to JSON recovery
1524            // from unclosed tags (brace-balancing).
1525            if let Some(json_end) = find_json_end(after_open) {
1526                if let Ok(value) =
1527                    serde_json::from_str::<serde_json::Value>(&after_open[..json_end])
1528                {
1529                    let parsed_calls = parse_tool_calls_from_json_value(&value);
1530                    if !parsed_calls.is_empty() {
1531                        calls.extend(parsed_calls);
1532                        remaining = strip_leading_close_tags(&after_open[json_end..]);
1533                        continue;
1534                    }
1535                }
1536            }
1537
1538            if let Some((value, consumed_end)) = extract_first_json_value_with_end(after_open) {
1539                let parsed_calls = parse_tool_calls_from_json_value(&value);
1540                if !parsed_calls.is_empty() {
1541                    calls.extend(parsed_calls);
1542                    remaining = strip_leading_close_tags(&after_open[consumed_end..]);
1543                    continue;
1544                }
1545            }
1546
1547            // Last resort: try GLM shortened body on everything after the open tag.
1548            // The model may have emitted `<tool_call>shell>ls` with no close tag at all.
1549            let glm_input = after_open.trim();
1550            if let Some(glm_call) = parse_glm_shortened_body(glm_input) {
1551                calls.push(glm_call);
1552                remaining = "";
1553                continue;
1554            }
1555
1556            remaining = &remaining[start..];
1557            break;
1558        }
1559    }
1560
1561    // If XML tags found nothing, try markdown code blocks with tool_call language.
1562    // Models behind OpenRouter sometimes output ```tool_call ... ``` or hybrid
1563    // ```tool_call ... </tool_call> instead of structured API calls or XML tags.
1564    if calls.is_empty() {
1565        static MD_TOOL_CALL_RE: LazyLock<Regex> = LazyLock::new(|| {
1566            Regex::new(
1567                r"(?s)```(?:tool[_-]?call|invoke)\s*\n(.*?)(?:```|</tool[_-]?call>|</toolcall>|</invoke>|</minimax:toolcall>)",
1568            )
1569            .unwrap()
1570        });
1571        let mut md_text_parts: Vec<String> = Vec::new();
1572        let mut last_end = 0;
1573
1574        for cap in MD_TOOL_CALL_RE.captures_iter(response) {
1575            let full_match = cap.get(0).unwrap();
1576            let before = &response[last_end..full_match.start()];
1577            if !before.trim().is_empty() {
1578                md_text_parts.push(before.trim().to_string());
1579            }
1580            let inner = &cap[1];
1581            let json_values = extract_json_values(inner);
1582            for value in json_values {
1583                let parsed_calls = parse_tool_calls_from_json_value(&value);
1584                calls.extend(parsed_calls);
1585            }
1586            last_end = full_match.end();
1587        }
1588
1589        if !calls.is_empty() {
1590            let after = &response[last_end..];
1591            if !after.trim().is_empty() {
1592                md_text_parts.push(after.trim().to_string());
1593            }
1594            text_parts = md_text_parts;
1595            remaining = "";
1596        }
1597    }
1598
1599    // Try ```tool <name> format used by some providers (e.g., xAI grok)
1600    // Example: ```tool file_write\n{"path": "...", "content": "..."}\n```
1601    if calls.is_empty() {
1602        static MD_TOOL_NAME_RE: LazyLock<Regex> =
1603            LazyLock::new(|| Regex::new(r"(?s)```tool\s+(\w+)\s*\n(.*?)(?:```|$)").unwrap());
1604        let mut md_text_parts: Vec<String> = Vec::new();
1605        let mut last_end = 0;
1606
1607        for cap in MD_TOOL_NAME_RE.captures_iter(response) {
1608            let full_match = cap.get(0).unwrap();
1609            let before = &response[last_end..full_match.start()];
1610            if !before.trim().is_empty() {
1611                md_text_parts.push(before.trim().to_string());
1612            }
1613            let tool_name = &cap[1];
1614            let inner = &cap[2];
1615
1616            // Try to parse the inner content as JSON arguments
1617            let json_values = extract_json_values(inner);
1618            if json_values.is_empty() {
1619                // Log a warning if we found a tool block but couldn't parse arguments
1620                tracing::warn!(
1621                    tool_name = %tool_name,
1622                    inner = %inner.chars().take(100).collect::<String>(),
1623                    "Found ```tool <name> block but could not parse JSON arguments"
1624                );
1625            } else {
1626                for value in json_values {
1627                    let arguments = if value.is_object() {
1628                        value
1629                    } else {
1630                        serde_json::Value::Object(serde_json::Map::new())
1631                    };
1632                    calls.push(ParsedToolCall {
1633                        name: tool_name.to_string(),
1634                        arguments,
1635                        tool_call_id: None,
1636                    });
1637                }
1638            }
1639            last_end = full_match.end();
1640        }
1641
1642        if !calls.is_empty() {
1643            let after = &response[last_end..];
1644            if !after.trim().is_empty() {
1645                md_text_parts.push(after.trim().to_string());
1646            }
1647            text_parts = md_text_parts;
1648            remaining = "";
1649        }
1650    }
1651
1652    // XML attribute-style tool calls:
1653    // <minimax:toolcall>
1654    // <invoke name="shell">
1655    // <parameter name="command">ls</parameter>
1656    // </invoke>
1657    // </minimax:toolcall>
1658    if calls.is_empty() {
1659        let xml_calls = parse_xml_attribute_tool_calls(remaining);
1660        if !xml_calls.is_empty() {
1661            let mut cleaned_text = remaining.to_string();
1662            for call in xml_calls {
1663                calls.push(call);
1664                // Try to remove the XML from text
1665                if let Some(start) = cleaned_text.find("<minimax:toolcall>") {
1666                    if let Some(end) = cleaned_text.find("</minimax:toolcall>") {
1667                        let end_pos = end + "</minimax:toolcall>".len();
1668                        if end_pos <= cleaned_text.len() {
1669                            cleaned_text =
1670                                format!("{}{}", &cleaned_text[..start], &cleaned_text[end_pos..]);
1671                        }
1672                    }
1673                }
1674            }
1675            if !cleaned_text.trim().is_empty() {
1676                text_parts.push(cleaned_text.trim().to_string());
1677            }
1678            remaining = "";
1679        }
1680    }
1681
1682    // Perl/hash-ref style tool calls:
1683    // TOOL_CALL
1684    // {tool => "shell", args => {
1685    //   --command "ls -la"
1686    //   --description "List current directory contents"
1687    // }}
1688    // /TOOL_CALL
1689    if calls.is_empty() {
1690        let perl_calls = parse_perl_style_tool_calls(remaining);
1691        if !perl_calls.is_empty() {
1692            let mut cleaned_text = remaining.to_string();
1693            for call in perl_calls {
1694                calls.push(call);
1695                // Try to remove the TOOL_CALL block from text
1696                while let Some(start) = cleaned_text.find("TOOL_CALL") {
1697                    if let Some(end) = cleaned_text.find("/TOOL_CALL") {
1698                        let end_pos = end + "/TOOL_CALL".len();
1699                        if end_pos <= cleaned_text.len() {
1700                            cleaned_text =
1701                                format!("{}{}", &cleaned_text[..start], &cleaned_text[end_pos..]);
1702                        }
1703                    } else {
1704                        break;
1705                    }
1706                }
1707            }
1708            if !cleaned_text.trim().is_empty() {
1709                text_parts.push(cleaned_text.trim().to_string());
1710            }
1711            remaining = "";
1712        }
1713    }
1714
1715    // <FunctionCall>
1716    // file_read
1717    // <code>path>/Users/...</code>
1718    // </FunctionCall>
1719    if calls.is_empty() {
1720        let func_calls = parse_function_call_tool_calls(remaining);
1721        if !func_calls.is_empty() {
1722            let mut cleaned_text = remaining.to_string();
1723            for call in func_calls {
1724                calls.push(call);
1725                // Try to remove the FunctionCall block from text
1726                while let Some(start) = cleaned_text.find("<FunctionCall>") {
1727                    if let Some(end) = cleaned_text.find("</FunctionCall>") {
1728                        let end_pos = end + "</FunctionCall>".len();
1729                        if end_pos <= cleaned_text.len() {
1730                            cleaned_text =
1731                                format!("{}{}", &cleaned_text[..start], &cleaned_text[end_pos..]);
1732                        }
1733                    } else {
1734                        break;
1735                    }
1736                }
1737            }
1738            if !cleaned_text.trim().is_empty() {
1739                text_parts.push(cleaned_text.trim().to_string());
1740            }
1741            remaining = "";
1742        }
1743    }
1744
1745    // GLM-style tool calls (browser_open/url>https://..., shell/command>ls, etc.)
1746    if calls.is_empty() {
1747        let glm_calls = parse_glm_style_tool_calls(remaining);
1748        if !glm_calls.is_empty() {
1749            let mut cleaned_text = remaining.to_string();
1750            for (name, args, raw) in &glm_calls {
1751                calls.push(ParsedToolCall {
1752                    name: name.clone(),
1753                    arguments: args.clone(),
1754                    tool_call_id: None,
1755                });
1756                if let Some(r) = raw {
1757                    cleaned_text = cleaned_text.replace(r, "");
1758                }
1759            }
1760            if !cleaned_text.trim().is_empty() {
1761                text_parts.push(cleaned_text.trim().to_string());
1762            }
1763            remaining = "";
1764        }
1765    }
1766
1767    // SECURITY: We do NOT fall back to extracting arbitrary JSON from the response
1768    // here. That would enable prompt injection attacks where malicious content
1769    // (e.g., in emails, files, or web pages) could include JSON that mimics a
1770    // tool call. Tool calls MUST be explicitly wrapped in either:
1771    // 1. OpenAI-style JSON with a "tool_calls" array
1772    // 2. Construct tool-call tags (<tool_call>, <toolcall>, <tool-call>)
1773    // 3. Markdown code blocks with tool_call/toolcall/tool-call language
1774    // 4. Explicit GLM line-based call formats (e.g. `shell/command>...`)
1775    // This ensures only the LLM's intentional tool calls are executed.
1776
1777    // Remaining text after last tool call
1778    if !remaining.trim().is_empty() {
1779        text_parts.push(remaining.trim().to_string());
1780    }
1781
1782    (text_parts.join("\n"), calls)
1783}
1784
1785/// Remove `<think>...</think>` blocks from model output.
1786/// Qwen and other reasoning models embed chain-of-thought inline in the
1787/// response text using `<think>` tags.  These must be removed before parsing
1788/// tool-call tags or displaying output.
1789fn strip_think_tags(s: &str) -> String {
1790    let mut result = String::with_capacity(s.len());
1791    let mut rest = s;
1792    loop {
1793        if let Some(start) = rest.find("<think>") {
1794            result.push_str(&rest[..start]);
1795            if let Some(end) = rest[start..].find("</think>") {
1796                rest = &rest[start + end + "</think>".len()..];
1797            } else {
1798                // Unclosed tag: drop the rest to avoid leaking partial reasoning.
1799                break;
1800            }
1801        } else {
1802            result.push_str(rest);
1803            break;
1804        }
1805    }
1806    result.trim().to_string()
1807}
1808
1809/// Strip prompt-guided tool artifacts from visible output while preserving
1810/// raw model text in history for future turns.
1811fn strip_tool_result_blocks(text: &str) -> String {
1812    static TOOL_RESULT_RE: LazyLock<Regex> =
1813        LazyLock::new(|| Regex::new(r"(?s)<tool_result[^>]*>.*?</tool_result>").unwrap());
1814    static THINKING_RE: LazyLock<Regex> =
1815        LazyLock::new(|| Regex::new(r"(?s)<thinking>.*?</thinking>").unwrap());
1816    static THINK_RE: LazyLock<Regex> =
1817        LazyLock::new(|| Regex::new(r"(?s)<think>.*?</think>").unwrap());
1818    static TOOL_RESULTS_PREFIX_RE: LazyLock<Regex> =
1819        LazyLock::new(|| Regex::new(r"(?m)^\[Tool results\]\s*\n?").unwrap());
1820    static EXCESS_BLANK_LINES_RE: LazyLock<Regex> =
1821        LazyLock::new(|| Regex::new(r"\n{3,}").unwrap());
1822
1823    let result = TOOL_RESULT_RE.replace_all(text, "");
1824    let result = THINKING_RE.replace_all(&result, "");
1825    let result = THINK_RE.replace_all(&result, "");
1826    let result = TOOL_RESULTS_PREFIX_RE.replace_all(&result, "");
1827    let result = EXCESS_BLANK_LINES_RE.replace_all(result.trim(), "\n\n");
1828
1829    result.trim().to_string()
1830}
1831
1832fn detect_tool_call_parse_issue(response: &str, parsed_calls: &[ParsedToolCall]) -> Option<String> {
1833    if !parsed_calls.is_empty() {
1834        return None;
1835    }
1836
1837    let trimmed = response.trim();
1838    if trimmed.is_empty() {
1839        return None;
1840    }
1841
1842    let looks_like_tool_payload = trimmed.contains("<tool_call")
1843        || trimmed.contains("<toolcall")
1844        || trimmed.contains("<tool-call")
1845        || trimmed.contains("```tool_call")
1846        || trimmed.contains("```toolcall")
1847        || trimmed.contains("```tool-call")
1848        || trimmed.contains("```tool file_")
1849        || trimmed.contains("```tool shell")
1850        || trimmed.contains("```tool web_")
1851        || trimmed.contains("```tool memory_")
1852        || trimmed.contains("```tool ") // Generic ```tool <name> pattern
1853        || trimmed.contains("\"tool_calls\"")
1854        || trimmed.contains("TOOL_CALL")
1855        || trimmed.contains("[TOOL_CALL]")
1856        || trimmed.contains("<FunctionCall>");
1857
1858    if looks_like_tool_payload {
1859        Some("response resembled a tool-call payload but no valid tool call could be parsed".into())
1860    } else {
1861        None
1862    }
1863}
1864
1865/// Build assistant history entry in JSON format for native tool-call APIs.
1866/// `convert_messages` in the OpenRouter provider parses this JSON to reconstruct
1867/// the proper `NativeMessage` with structured `tool_calls`.
1868fn build_native_assistant_history(
1869    text: &str,
1870    tool_calls: &[ToolCall],
1871    reasoning_content: Option<&str>,
1872) -> String {
1873    let calls_json: Vec<serde_json::Value> = tool_calls
1874        .iter()
1875        .map(|tc| {
1876            serde_json::json!({
1877                "id": tc.id,
1878                "name": tc.name,
1879                "arguments": tc.arguments,
1880            })
1881        })
1882        .collect();
1883
1884    let content = if text.trim().is_empty() {
1885        serde_json::Value::Null
1886    } else {
1887        serde_json::Value::String(text.trim().to_string())
1888    };
1889
1890    let mut obj = serde_json::json!({
1891        "content": content,
1892        "tool_calls": calls_json,
1893    });
1894
1895    if let Some(rc) = reasoning_content {
1896        obj.as_object_mut().unwrap().insert(
1897            "reasoning_content".to_string(),
1898            serde_json::Value::String(rc.to_string()),
1899        );
1900    }
1901
1902    obj.to_string()
1903}
1904
1905fn build_native_assistant_history_from_parsed_calls(
1906    text: &str,
1907    tool_calls: &[ParsedToolCall],
1908    reasoning_content: Option<&str>,
1909) -> Option<String> {
1910    let calls_json = tool_calls
1911        .iter()
1912        .map(|tc| {
1913            Some(serde_json::json!({
1914                "id": tc.tool_call_id.clone()?,
1915                "name": tc.name,
1916                "arguments": serde_json::to_string(&tc.arguments).unwrap_or_else(|_| "{}".to_string()),
1917            }))
1918        })
1919        .collect::<Option<Vec<_>>>()?;
1920
1921    let content = if text.trim().is_empty() {
1922        serde_json::Value::Null
1923    } else {
1924        serde_json::Value::String(text.trim().to_string())
1925    };
1926
1927    let mut obj = serde_json::json!({
1928        "content": content,
1929        "tool_calls": calls_json,
1930    });
1931
1932    if let Some(rc) = reasoning_content {
1933        obj.as_object_mut().unwrap().insert(
1934            "reasoning_content".to_string(),
1935            serde_json::Value::String(rc.to_string()),
1936        );
1937    }
1938
1939    Some(obj.to_string())
1940}
1941
1942fn resolve_display_text(
1943    response_text: &str,
1944    parsed_text: &str,
1945    has_tool_calls: bool,
1946    has_native_tool_calls: bool,
1947) -> String {
1948    if has_tool_calls {
1949        if !parsed_text.is_empty() {
1950            return parsed_text.to_string();
1951        }
1952        if has_native_tool_calls {
1953            return response_text.to_string();
1954        }
1955        return String::new();
1956    }
1957
1958    if parsed_text.is_empty() {
1959        response_text.to_string()
1960    } else {
1961        parsed_text.to_string()
1962    }
1963}
1964
1965#[derive(Debug, Clone)]
1966pub(crate) struct ParsedToolCall {
1967    pub(crate) name: String,
1968    pub(crate) arguments: serde_json::Value,
1969    pub(crate) tool_call_id: Option<String>,
1970}
1971
1972#[derive(Debug)]
1973pub(crate) struct ToolLoopCancelled;
1974
1975impl std::fmt::Display for ToolLoopCancelled {
1976    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1977        f.write_str("tool loop cancelled")
1978    }
1979}
1980
1981impl std::error::Error for ToolLoopCancelled {}
1982
1983pub(crate) fn is_tool_loop_cancelled(err: &anyhow::Error) -> bool {
1984    err.chain().any(|source| source.is::<ToolLoopCancelled>())
1985}
1986
1987#[derive(Debug)]
1988pub(crate) struct ModelSwitchRequested {
1989    pub provider: String,
1990    pub model: String,
1991}
1992
1993impl std::fmt::Display for ModelSwitchRequested {
1994    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1995        write!(
1996            f,
1997            "model switch requested to {} {}",
1998            self.provider, self.model
1999        )
2000    }
2001}
2002
2003impl std::error::Error for ModelSwitchRequested {}
2004
2005pub(crate) fn is_model_switch_requested(err: &anyhow::Error) -> Option<(String, String)> {
2006    err.chain()
2007        .filter_map(|source| source.downcast_ref::<ModelSwitchRequested>())
2008        .map(|e| (e.provider.clone(), e.model.clone()))
2009        .next()
2010}
2011
2012#[derive(Debug, Default)]
2013struct StreamedChatOutcome {
2014    response_text: String,
2015    tool_calls: Vec<ToolCall>,
2016    forwarded_live_deltas: bool,
2017}
2018
2019async fn consume_provider_streaming_response(
2020    provider: &dyn Provider,
2021    messages: &[ChatMessage],
2022    request_tools: Option<&[crate::tools::ToolSpec]>,
2023    model: &str,
2024    temperature: f64,
2025    cancellation_token: Option<&CancellationToken>,
2026    on_delta: Option<&tokio::sync::mpsc::Sender<DraftEvent>>,
2027) -> Result<StreamedChatOutcome> {
2028    let mut provider_stream = provider.stream_chat(
2029        ChatRequest {
2030            messages,
2031            tools: request_tools,
2032        },
2033        model,
2034        temperature,
2035        crate::providers::traits::StreamOptions::new(true),
2036    );
2037    let mut outcome = StreamedChatOutcome::default();
2038    let mut delta_sender = on_delta;
2039    let mut suppress_forwarding = false;
2040    let mut marker_window = String::new();
2041
2042    loop {
2043        let next_chunk = if let Some(token) = cancellation_token {
2044            tokio::select! {
2045                () = token.cancelled() => return Err(ToolLoopCancelled.into()),
2046                chunk = provider_stream.next() => chunk,
2047            }
2048        } else {
2049            provider_stream.next().await
2050        };
2051
2052        let Some(event_result) = next_chunk else {
2053            break;
2054        };
2055
2056        let event = event_result.map_err(|err| anyhow::anyhow!("provider stream error: {err}"))?;
2057        match event {
2058            StreamEvent::Final => break,
2059            StreamEvent::ToolCall(tool_call) => {
2060                outcome.tool_calls.push(tool_call);
2061                suppress_forwarding = true;
2062                if outcome.forwarded_live_deltas {
2063                    if let Some(tx) = delta_sender {
2064                        let _ = tx.send(DraftEvent::Clear).await;
2065                    }
2066                    outcome.forwarded_live_deltas = false;
2067                }
2068            }
2069            StreamEvent::PreExecutedToolCall { .. } | StreamEvent::PreExecutedToolResult { .. } => {
2070                // Pre-executed tool events are for observability only.
2071                // They are forwarded to the gateway via turn_streamed but
2072                // do not affect the agent's tool dispatch loop.
2073            }
2074            StreamEvent::Usage(_) => {
2075                // Usage is recorded by the tool-call loop from the non-streaming
2076                // fallback ChatResponse; streamed usage is consumed by turn_streamed.
2077            }
2078            StreamEvent::TextDelta(chunk) => {
2079                if chunk.delta.is_empty() {
2080                    continue;
2081                }
2082
2083                outcome.response_text.push_str(&chunk.delta);
2084                marker_window.push_str(&chunk.delta);
2085
2086                if marker_window.len() > STREAM_TOOL_MARKER_WINDOW_CHARS {
2087                    let keep_from = marker_window.len() - STREAM_TOOL_MARKER_WINDOW_CHARS;
2088                    let boundary = marker_window
2089                        .char_indices()
2090                        .find(|(idx, _)| *idx >= keep_from)
2091                        .map_or(0, |(idx, _)| idx);
2092                    marker_window.drain(..boundary);
2093                }
2094
2095                if !suppress_forwarding && {
2096                    let lowered = marker_window.to_ascii_lowercase();
2097                    lowered.contains("<tool_call")
2098                        || lowered.contains("<toolcall")
2099                        || lowered.contains("\"tool_calls\"")
2100                } {
2101                    suppress_forwarding = true;
2102                    if outcome.forwarded_live_deltas {
2103                        if let Some(tx) = delta_sender {
2104                            let _ = tx.send(DraftEvent::Clear).await;
2105                        }
2106                        outcome.forwarded_live_deltas = false;
2107                    }
2108                }
2109
2110                if suppress_forwarding {
2111                    continue;
2112                }
2113
2114                if let Some(tx) = delta_sender {
2115                    if !outcome.forwarded_live_deltas {
2116                        let _ = tx.send(DraftEvent::Clear).await;
2117                        outcome.forwarded_live_deltas = true;
2118                    }
2119                    if tx.send(DraftEvent::Content(chunk.delta)).await.is_err() {
2120                        delta_sender = None;
2121                    }
2122                }
2123            }
2124        }
2125    }
2126
2127    Ok(outcome)
2128}
2129
2130/// Execute a single turn of the agent loop: send messages, parse tool calls,
2131/// execute tools, and loop until the LLM produces a final text response.
2132/// When `silent` is true, suppresses stdout (for channel use).
2133#[allow(clippy::too_many_arguments)]
2134pub(crate) async fn agent_turn(
2135    provider: &dyn Provider,
2136    history: &mut Vec<ChatMessage>,
2137    tools_registry: &[Box<dyn Tool>],
2138    observer: &dyn Observer,
2139    provider_name: &str,
2140    model: &str,
2141    temperature: f64,
2142    silent: bool,
2143    channel_name: &str,
2144    channel_reply_target: Option<&str>,
2145    multimodal_config: &crate::config::MultimodalConfig,
2146    max_tool_iterations: usize,
2147    approval: Option<&ApprovalManager>,
2148    excluded_tools: &[String],
2149    dedup_exempt_tools: &[String],
2150    activated_tools: Option<&std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>>,
2151    model_switch_callback: Option<ModelSwitchCallback>,
2152) -> Result<String> {
2153    run_tool_call_loop(
2154        provider,
2155        history,
2156        tools_registry,
2157        observer,
2158        provider_name,
2159        model,
2160        temperature,
2161        silent,
2162        approval,
2163        channel_name,
2164        channel_reply_target,
2165        multimodal_config,
2166        max_tool_iterations,
2167        None,
2168        None,
2169        None,
2170        excluded_tools,
2171        dedup_exempt_tools,
2172        activated_tools,
2173        model_switch_callback,
2174        &crate::config::PacingConfig::default(),
2175        0,    // max_tool_result_chars: 0 = disabled (legacy callers)
2176        0,    // context_token_budget: 0 = disabled (legacy callers)
2177        None, // shared_budget: no shared budget for legacy callers
2178    )
2179    .await
2180}
2181
2182fn maybe_inject_channel_delivery_defaults(
2183    tool_name: &str,
2184    tool_args: &mut serde_json::Value,
2185    channel_name: &str,
2186    channel_reply_target: Option<&str>,
2187) {
2188    if tool_name != "cron_add" {
2189        return;
2190    }
2191
2192    if !matches!(
2193        channel_name,
2194        "telegram" | "discord" | "slack" | "mattermost" | "matrix"
2195    ) {
2196        return;
2197    }
2198
2199    let Some(reply_target) = channel_reply_target
2200        .map(str::trim)
2201        .filter(|value| !value.is_empty())
2202    else {
2203        return;
2204    };
2205
2206    let Some(args) = tool_args.as_object_mut() else {
2207        return;
2208    };
2209
2210    let is_agent_job = args
2211        .get("job_type")
2212        .and_then(serde_json::Value::as_str)
2213        .is_some_and(|job_type| job_type.eq_ignore_ascii_case("agent"))
2214        || args
2215            .get("prompt")
2216            .and_then(serde_json::Value::as_str)
2217            .is_some_and(|prompt| !prompt.trim().is_empty());
2218    if !is_agent_job {
2219        return;
2220    }
2221
2222    let default_delivery = || {
2223        serde_json::json!({
2224            "mode": "announce",
2225            "channel": channel_name,
2226            "to": reply_target,
2227        })
2228    };
2229
2230    match args.get_mut("delivery") {
2231        None => {
2232            args.insert("delivery".to_string(), default_delivery());
2233        }
2234        Some(serde_json::Value::Null) => {
2235            *args.get_mut("delivery").expect("delivery key exists") = default_delivery();
2236        }
2237        Some(serde_json::Value::Object(delivery)) => {
2238            if delivery
2239                .get("mode")
2240                .and_then(serde_json::Value::as_str)
2241                .is_some_and(|mode| mode.eq_ignore_ascii_case("none"))
2242            {
2243                return;
2244            }
2245
2246            delivery
2247                .entry("mode".to_string())
2248                .or_insert_with(|| serde_json::Value::String("announce".to_string()));
2249
2250            let needs_channel = delivery
2251                .get("channel")
2252                .and_then(serde_json::Value::as_str)
2253                .is_none_or(|value| value.trim().is_empty());
2254            if needs_channel {
2255                delivery.insert(
2256                    "channel".to_string(),
2257                    serde_json::Value::String(channel_name.to_string()),
2258                );
2259            }
2260
2261            let needs_target = delivery
2262                .get("to")
2263                .and_then(serde_json::Value::as_str)
2264                .is_none_or(|value| value.trim().is_empty());
2265            if needs_target {
2266                delivery.insert(
2267                    "to".to_string(),
2268                    serde_json::Value::String(reply_target.to_string()),
2269                );
2270            }
2271        }
2272        Some(_) => {}
2273    }
2274}
2275
2276// ── Agent Tool-Call Loop ──────────────────────────────────────────────────
2277// Core agentic iteration: send conversation to the LLM, parse any tool
2278// calls from the response, execute them, append results to history, and
2279// repeat until the LLM produces a final text-only answer.
2280//
2281// Loop invariant: at the start of each iteration, `history` contains the
2282// full conversation so far (system prompt + user messages + prior tool
2283// results). The loop exits when:
2284//   • the LLM returns no tool calls (final answer), or
2285//   • max_iterations is reached (runaway safety), or
2286//   • the cancellation token fires (external abort).
2287
2288/// Execute a single turn of the agent loop: send messages, parse tool calls,
2289/// execute tools, and loop until the LLM produces a final text response.
2290#[allow(clippy::too_many_arguments)]
2291pub(crate) async fn run_tool_call_loop(
2292    provider: &dyn Provider,
2293    history: &mut Vec<ChatMessage>,
2294    tools_registry: &[Box<dyn Tool>],
2295    observer: &dyn Observer,
2296    provider_name: &str,
2297    model: &str,
2298    temperature: f64,
2299    silent: bool,
2300    approval: Option<&ApprovalManager>,
2301    channel_name: &str,
2302    channel_reply_target: Option<&str>,
2303    multimodal_config: &crate::config::MultimodalConfig,
2304    max_tool_iterations: usize,
2305    cancellation_token: Option<CancellationToken>,
2306    on_delta: Option<tokio::sync::mpsc::Sender<DraftEvent>>,
2307    hooks: Option<&crate::hooks::HookRunner>,
2308    excluded_tools: &[String],
2309    dedup_exempt_tools: &[String],
2310    activated_tools: Option<&std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>>,
2311    model_switch_callback: Option<ModelSwitchCallback>,
2312    pacing: &crate::config::PacingConfig,
2313    max_tool_result_chars: usize,
2314    context_token_budget: usize,
2315    shared_budget: Option<Arc<std::sync::atomic::AtomicUsize>>,
2316) -> Result<String> {
2317    let max_iterations = if max_tool_iterations == 0 {
2318        DEFAULT_MAX_TOOL_ITERATIONS
2319    } else {
2320        max_tool_iterations
2321    };
2322
2323    let turn_id = Uuid::new_v4().to_string();
2324    let loop_started_at = Instant::now();
2325    let loop_ignore_tools: HashSet<&str> = pacing
2326        .loop_ignore_tools
2327        .iter()
2328        .map(String::as_str)
2329        .collect();
2330    let mut consecutive_identical_outputs: usize = 0;
2331    let mut last_tool_output_hash: Option<u64> = None;
2332
2333    let mut loop_detector = crate::agent::loop_detector::LoopDetector::new(
2334        crate::agent::loop_detector::LoopDetectorConfig {
2335            enabled: pacing.loop_detection_enabled,
2336            window_size: pacing.loop_detection_window_size,
2337            max_repeats: pacing.loop_detection_max_repeats,
2338        },
2339    );
2340
2341    for iteration in 0..max_iterations {
2342        let mut seen_tool_signatures: HashSet<(String, String)> = HashSet::new();
2343
2344        if cancellation_token
2345            .as_ref()
2346            .is_some_and(CancellationToken::is_cancelled)
2347        {
2348            return Err(ToolLoopCancelled.into());
2349        }
2350
2351        // Shared iteration budget: parent + subagents share a global counter
2352        if let Some(ref budget) = shared_budget {
2353            let remaining = budget.load(std::sync::atomic::Ordering::Relaxed);
2354            if remaining == 0 {
2355                tracing::warn!("Shared iteration budget exhausted at iteration {iteration}");
2356                break;
2357            }
2358            budget.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
2359        }
2360
2361        // Preemptive context management: trim history before it overflows
2362        if context_token_budget > 0 {
2363            let estimated = estimate_history_tokens(history);
2364            if estimated > context_token_budget {
2365                tracing::info!(
2366                    estimated,
2367                    budget = context_token_budget,
2368                    iteration = iteration + 1,
2369                    "Preemptive context trim: estimated tokens exceed budget"
2370                );
2371                let chars_saved = fast_trim_tool_results(history, 4);
2372                if chars_saved > 0 {
2373                    tracing::info!(chars_saved, "Preemptive fast-trim applied");
2374                }
2375                // If still over budget, use the history pruner for deeper cleanup
2376                let recheck = estimate_history_tokens(history);
2377                if recheck > context_token_budget {
2378                    let stats = crate::agent::history_pruner::prune_history(
2379                        history,
2380                        &crate::agent::history_pruner::HistoryPrunerConfig {
2381                            enabled: true,
2382                            max_tokens: context_token_budget,
2383                            keep_recent: 4,
2384                            collapse_tool_results: true,
2385                        },
2386                    );
2387                    if stats.dropped_messages > 0 || stats.collapsed_pairs > 0 {
2388                        tracing::info!(
2389                            collapsed = stats.collapsed_pairs,
2390                            dropped = stats.dropped_messages,
2391                            "Preemptive history prune applied"
2392                        );
2393                    }
2394                }
2395            }
2396        }
2397
2398        // Check if model switch was requested via model_switch tool
2399        if let Some(ref callback) = model_switch_callback {
2400            if let Ok(guard) = callback.lock() {
2401                if let Some((new_provider, new_model)) = guard.as_ref() {
2402                    if new_provider != provider_name || new_model != model {
2403                        tracing::info!(
2404                            "Model switch detected: {} {} -> {} {}",
2405                            provider_name,
2406                            model,
2407                            new_provider,
2408                            new_model
2409                        );
2410                        return Err(ModelSwitchRequested {
2411                            provider: new_provider.clone(),
2412                            model: new_model.clone(),
2413                        }
2414                        .into());
2415                    }
2416                }
2417            }
2418        }
2419
2420        // Rebuild tool_specs each iteration so newly activated deferred tools appear.
2421        let mut tool_specs: Vec<crate::tools::ToolSpec> = tools_registry
2422            .iter()
2423            .filter(|tool| !is_tool_excluded(tool.name(), excluded_tools))
2424            .map(|tool| tool.spec())
2425            .collect();
2426        if let Some(at) = activated_tools {
2427            for spec in at.lock().unwrap().tool_specs() {
2428                if !is_tool_excluded(&spec.name, excluded_tools) {
2429                    tool_specs.push(spec);
2430                }
2431            }
2432        }
2433        let use_native_tools = provider.supports_native_tools() && !tool_specs.is_empty();
2434        // Also enable prompt-guided tool injection for non-native providers.
2435        // Provider::chat() will inject tool instructions into the system prompt
2436        // and the fallback parse_tool_calls() parser handles <tool_call> responses.
2437        let send_tools = !tool_specs.is_empty();
2438
2439        let image_marker_count = multimodal::count_image_markers(history);
2440
2441        // ── Vision provider routing ──────────────────────────
2442        // When the default provider lacks vision support but a dedicated
2443        // vision_provider is configured, create it on demand and use it
2444        // for this iteration.  Otherwise, preserve the original error.
2445        let vision_provider_box: Option<Box<dyn Provider>> = if image_marker_count > 0
2446            && !provider.supports_vision()
2447        {
2448            if let Some(ref vp) = multimodal_config.vision_provider {
2449                let vp_instance = providers::create_provider(vp, None)
2450                    .map_err(|e| anyhow::anyhow!("failed to create vision provider '{vp}': {e}"))?;
2451                if !vp_instance.supports_vision() {
2452                    return Err(ProviderCapabilityError {
2453                        provider: vp.clone(),
2454                        capability: "vision".to_string(),
2455                        message: format!(
2456                            "configured vision_provider '{vp}' does not support vision input"
2457                        ),
2458                    }
2459                    .into());
2460                }
2461                Some(vp_instance)
2462            } else {
2463                return Err(ProviderCapabilityError {
2464                        provider: provider_name.to_string(),
2465                        capability: "vision".to_string(),
2466                        message: format!(
2467                            "received {image_marker_count} image marker(s), but this provider does not support vision input"
2468                        ),
2469                    }
2470                    .into());
2471            }
2472        } else {
2473            None
2474        };
2475
2476        let (active_provider, active_provider_name, active_model): (&dyn Provider, &str, &str) =
2477            if let Some(ref vp_box) = vision_provider_box {
2478                let vp_name = multimodal_config
2479                    .vision_provider
2480                    .as_deref()
2481                    .unwrap_or(provider_name);
2482                let vm = multimodal_config.vision_model.as_deref().unwrap_or(model);
2483                (vp_box.as_ref(), vp_name, vm)
2484            } else {
2485                (provider, provider_name, model)
2486            };
2487
2488        let prepared_messages =
2489            multimodal::prepare_messages_for_provider(history, multimodal_config).await?;
2490
2491        // ── Progress: LLM thinking ────────────────────────────
2492        if let Some(ref tx) = on_delta {
2493            let phase = if iteration == 0 {
2494                "\u{1f914} Thinking...\n".to_string()
2495            } else {
2496                format!("\u{1f914} Thinking (round {})...\n", iteration + 1)
2497            };
2498            let _ = tx.send(DraftEvent::Progress(phase)).await;
2499        }
2500
2501        observer.record_event(&ObserverEvent::LlmRequest {
2502            provider: active_provider_name.to_string(),
2503            model: active_model.to_string(),
2504            messages_count: history.len(),
2505        });
2506        runtime_trace::record_event(
2507            "llm_request",
2508            Some(channel_name),
2509            Some(active_provider_name),
2510            Some(active_model),
2511            Some(&turn_id),
2512            None,
2513            None,
2514            serde_json::json!({
2515                "iteration": iteration + 1,
2516                "messages_count": history.len(),
2517            }),
2518        );
2519
2520        let llm_started_at = Instant::now();
2521
2522        // Fire void hook before LLM call
2523        if let Some(hooks) = hooks {
2524            hooks.fire_llm_input(history, model).await;
2525        }
2526
2527        // Budget enforcement — block if limit exceeded (no-op when not scoped)
2528        if let Some(BudgetCheck::Exceeded {
2529            current_usd,
2530            limit_usd,
2531            period,
2532        }) = check_tool_loop_budget()
2533        {
2534            return Err(anyhow::anyhow!(
2535                "Budget exceeded: ${:.4} of ${:.2} {:?} limit. Cannot make further API calls until the budget resets.",
2536                current_usd,
2537                limit_usd,
2538                period
2539            ));
2540        }
2541
2542        // Unified path via Provider::chat so provider-specific native tool logic
2543        // (OpenAI/Anthropic/OpenRouter/compatible adapters) is honored.
2544        let request_tools = if send_tools {
2545            Some(tool_specs.as_slice())
2546        } else {
2547            None
2548        };
2549        let should_consume_provider_stream = on_delta.is_some()
2550            && provider.supports_streaming()
2551            && (request_tools.is_none() || provider.supports_streaming_tool_events());
2552        tracing::debug!(
2553            has_on_delta = on_delta.is_some(),
2554            supports_streaming = provider.supports_streaming(),
2555            should_consume_provider_stream,
2556            "Streaming decision for iteration {}",
2557            iteration + 1,
2558        );
2559        let mut streamed_live_deltas = false;
2560
2561        let chat_result = if should_consume_provider_stream {
2562            match consume_provider_streaming_response(
2563                active_provider,
2564                &prepared_messages.messages,
2565                request_tools,
2566                active_model,
2567                temperature,
2568                cancellation_token.as_ref(),
2569                on_delta.as_ref(),
2570            )
2571            .await
2572            {
2573                Ok(streamed) => {
2574                    streamed_live_deltas = streamed.forwarded_live_deltas;
2575                    Ok(crate::providers::ChatResponse {
2576                        text: Some(streamed.response_text),
2577                        tool_calls: streamed.tool_calls,
2578                        usage: None,
2579                        reasoning_content: None,
2580                    })
2581                }
2582                Err(stream_err) => {
2583                    tracing::warn!(
2584                        provider = active_provider_name,
2585                        model = active_model,
2586                        iteration = iteration + 1,
2587                        "provider streaming failed, falling back to non-streaming chat: {stream_err}"
2588                    );
2589                    runtime_trace::record_event(
2590                        "llm_stream_fallback",
2591                        Some(channel_name),
2592                        Some(active_provider_name),
2593                        Some(active_model),
2594                        Some(&turn_id),
2595                        Some(false),
2596                        Some("provider stream failed; fallback to non-streaming chat"),
2597                        serde_json::json!({
2598                            "iteration": iteration + 1,
2599                            "error": scrub_credentials(&stream_err.to_string()),
2600                        }),
2601                    );
2602                    if let Some(ref tx) = on_delta {
2603                        let _ = tx.send(DraftEvent::Clear).await;
2604                    }
2605                    {
2606                        let chat_future = active_provider.chat(
2607                            ChatRequest {
2608                                messages: &prepared_messages.messages,
2609                                tools: request_tools,
2610                            },
2611                            active_model,
2612                            temperature,
2613                        );
2614                        if let Some(token) = cancellation_token.as_ref() {
2615                            tokio::select! {
2616                                () = token.cancelled() => Err(ToolLoopCancelled.into()),
2617                                result = chat_future => result,
2618                            }
2619                        } else {
2620                            chat_future.await
2621                        }
2622                    }
2623                }
2624            }
2625        } else {
2626            // Non-streaming path: wrap with optional per-step timeout from
2627            // pacing config to catch hung model responses.
2628            let chat_future = active_provider.chat(
2629                ChatRequest {
2630                    messages: &prepared_messages.messages,
2631                    tools: request_tools,
2632                },
2633                active_model,
2634                temperature,
2635            );
2636
2637            match pacing.step_timeout_secs {
2638                Some(step_secs) if step_secs > 0 => {
2639                    let step_timeout = Duration::from_secs(step_secs);
2640                    if let Some(token) = cancellation_token.as_ref() {
2641                        tokio::select! {
2642                            () = token.cancelled() => return Err(ToolLoopCancelled.into()),
2643                            result = tokio::time::timeout(step_timeout, chat_future) => {
2644                                match result {
2645                                    Ok(inner) => inner,
2646                                    Err(_) => anyhow::bail!(
2647                                        "LLM inference step timed out after {step_secs}s (step_timeout_secs)"
2648                                    ),
2649                                }
2650                            },
2651                        }
2652                    } else {
2653                        match tokio::time::timeout(step_timeout, chat_future).await {
2654                            Ok(inner) => inner,
2655                            Err(_) => anyhow::bail!(
2656                                "LLM inference step timed out after {step_secs}s (step_timeout_secs)"
2657                            ),
2658                        }
2659                    }
2660                }
2661                _ => {
2662                    if let Some(token) = cancellation_token.as_ref() {
2663                        tokio::select! {
2664                            () = token.cancelled() => return Err(ToolLoopCancelled.into()),
2665                            result = chat_future => result,
2666                        }
2667                    } else {
2668                        chat_future.await
2669                    }
2670                }
2671            }
2672        };
2673
2674        let (
2675            response_text,
2676            parsed_text,
2677            tool_calls,
2678            assistant_history_content,
2679            native_tool_calls,
2680            _parse_issue_detected,
2681            response_streamed_live,
2682        ) = match chat_result {
2683            Ok(resp) => {
2684                let (resp_input_tokens, resp_output_tokens) = resp
2685                    .usage
2686                    .as_ref()
2687                    .map(|u| (u.input_tokens, u.output_tokens))
2688                    .unwrap_or((None, None));
2689
2690                observer.record_event(&ObserverEvent::LlmResponse {
2691                    provider: provider_name.to_string(),
2692                    model: model.to_string(),
2693                    duration: llm_started_at.elapsed(),
2694                    success: true,
2695                    error_message: None,
2696                    input_tokens: resp_input_tokens,
2697                    output_tokens: resp_output_tokens,
2698                });
2699
2700                // Record cost via task-local tracker (no-op when not scoped).
2701                // Always record — even when the provider omits usage — so request_count
2702                // on the cost page reflects every turn. Zero-token requests emit a warn
2703                // inside record_tool_loop_cost_usage so the gap is visible in logs.
2704                let usage_for_cost = resp
2705                    .usage
2706                    .clone()
2707                    .unwrap_or_else(crate::providers::traits::TokenUsage::default);
2708                let _ = record_tool_loop_cost_usage(provider_name, model, &usage_for_cost);
2709
2710                let response_text = resp.text_or_empty().to_string();
2711                // First try native structured tool calls (OpenAI-format).
2712                // Fall back to text-based parsing (XML tags, markdown blocks,
2713                // GLM format) only if the provider returned no native calls —
2714                // this ensures we support both native and prompt-guided models.
2715                let mut calls: Vec<ParsedToolCall> = resp
2716                    .tool_calls
2717                    .iter()
2718                    .map(|call| ParsedToolCall {
2719                        name: call.name.clone(),
2720                        arguments: serde_json::from_str::<serde_json::Value>(&call.arguments)
2721                            .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new())),
2722                        tool_call_id: Some(call.id.clone()),
2723                    })
2724                    .collect();
2725                let mut parsed_text = String::new();
2726
2727                if calls.is_empty() {
2728                    let (fallback_text, fallback_calls) = parse_tool_calls(&response_text);
2729                    if !fallback_text.is_empty() {
2730                        parsed_text = fallback_text;
2731                    }
2732                    calls = fallback_calls;
2733                }
2734
2735                let parse_issue = detect_tool_call_parse_issue(&response_text, &calls);
2736                if let Some(ref issue) = parse_issue {
2737                    runtime_trace::record_event(
2738                        "tool_call_parse_issue",
2739                        Some(channel_name),
2740                        Some(provider_name),
2741                        Some(model),
2742                        Some(&turn_id),
2743                        Some(false),
2744                        Some(issue.as_str()),
2745                        serde_json::json!({
2746                            "iteration": iteration + 1,
2747                            "response_excerpt": truncate_with_ellipsis(
2748                                &scrub_credentials(&response_text),
2749                                600
2750                            ),
2751                        }),
2752                    );
2753                }
2754
2755                runtime_trace::record_event(
2756                    "llm_response",
2757                    Some(channel_name),
2758                    Some(provider_name),
2759                    Some(model),
2760                    Some(&turn_id),
2761                    Some(true),
2762                    None,
2763                    serde_json::json!({
2764                        "iteration": iteration + 1,
2765                        "duration_ms": llm_started_at.elapsed().as_millis(),
2766                        "input_tokens": resp_input_tokens,
2767                        "output_tokens": resp_output_tokens,
2768                        "raw_response": scrub_credentials(&response_text),
2769                        "native_tool_calls": resp.tool_calls.len(),
2770                        "parsed_tool_calls": calls.len(),
2771                    }),
2772                );
2773
2774                // Preserve native tool call IDs in assistant history so role=tool
2775                // follow-up messages can reference the exact call id.
2776                let reasoning_content = resp.reasoning_content.clone();
2777                let assistant_history_content = if resp.tool_calls.is_empty() {
2778                    if use_native_tools {
2779                        build_native_assistant_history_from_parsed_calls(
2780                            &response_text,
2781                            &calls,
2782                            reasoning_content.as_deref(),
2783                        )
2784                        .unwrap_or_else(|| response_text.clone())
2785                    } else {
2786                        response_text.clone()
2787                    }
2788                } else {
2789                    build_native_assistant_history(
2790                        &response_text,
2791                        &resp.tool_calls,
2792                        reasoning_content.as_deref(),
2793                    )
2794                };
2795
2796                let native_calls = resp.tool_calls;
2797                (
2798                    response_text,
2799                    parsed_text,
2800                    calls,
2801                    assistant_history_content,
2802                    native_calls,
2803                    parse_issue.is_some(),
2804                    streamed_live_deltas,
2805                )
2806            }
2807            Err(e) => {
2808                let safe_error = crate::providers::sanitize_api_error(&e.to_string());
2809                observer.record_event(&ObserverEvent::LlmResponse {
2810                    provider: provider_name.to_string(),
2811                    model: model.to_string(),
2812                    duration: llm_started_at.elapsed(),
2813                    success: false,
2814                    error_message: Some(safe_error.clone()),
2815                    input_tokens: None,
2816                    output_tokens: None,
2817                });
2818                runtime_trace::record_event(
2819                    "llm_response",
2820                    Some(channel_name),
2821                    Some(provider_name),
2822                    Some(model),
2823                    Some(&turn_id),
2824                    Some(false),
2825                    Some(&safe_error),
2826                    serde_json::json!({
2827                        "iteration": iteration + 1,
2828                        "duration_ms": llm_started_at.elapsed().as_millis(),
2829                    }),
2830                );
2831
2832                // Context overflow recovery: trim history and retry
2833                if crate::providers::reliable::is_context_window_exceeded(&e) {
2834                    tracing::warn!(
2835                        iteration = iteration + 1,
2836                        "Context window exceeded, attempting in-loop recovery"
2837                    );
2838
2839                    // Step 1: fast-trim old tool results (cheap)
2840                    let chars_saved = fast_trim_tool_results(history, 4);
2841                    if chars_saved > 0 {
2842                        tracing::info!(
2843                            chars_saved,
2844                            "Context recovery: trimmed old tool results, retrying"
2845                        );
2846                        continue;
2847                    }
2848
2849                    // Step 2: emergency drop oldest non-system messages
2850                    let dropped = emergency_history_trim(history, 4);
2851                    if dropped > 0 {
2852                        tracing::info!(dropped, "Context recovery: dropped old messages, retrying");
2853                        continue;
2854                    }
2855
2856                    // Nothing left to trim — truly unrecoverable
2857                    tracing::error!("Context overflow unrecoverable: no trimmable messages");
2858                }
2859
2860                return Err(e);
2861            }
2862        };
2863
2864        let display_text = if parsed_text.is_empty() {
2865            response_text.clone()
2866        } else {
2867            parsed_text
2868        };
2869
2870        // ── Progress: LLM responded ─────────────────────────────
2871        if let Some(ref tx) = on_delta {
2872            let llm_secs = llm_started_at.elapsed().as_secs();
2873            if !tool_calls.is_empty() {
2874                let _ = tx
2875                    .send(DraftEvent::Progress(format!(
2876                        "\u{1f4ac} Got {} tool call(s) ({llm_secs}s)\n",
2877                        tool_calls.len()
2878                    )))
2879                    .await;
2880            }
2881        }
2882
2883        if tool_calls.is_empty() {
2884            runtime_trace::record_event(
2885                "turn_final_response",
2886                Some(channel_name),
2887                Some(provider_name),
2888                Some(model),
2889                Some(&turn_id),
2890                Some(true),
2891                None,
2892                serde_json::json!({
2893                    "iteration": iteration + 1,
2894                    "text": scrub_credentials(&display_text),
2895                }),
2896            );
2897            // No tool calls — this is the final response.
2898            // If a streaming sender is provided, relay the text in small chunks
2899            // so the channel can progressively update the draft message.
2900            if let Some(ref tx) = on_delta {
2901                let should_emit_post_hoc_chunks =
2902                    !response_streamed_live || display_text != response_text;
2903                if !should_emit_post_hoc_chunks {
2904                    history.push(ChatMessage::assistant(response_text.clone()));
2905                    return Ok(display_text);
2906                }
2907                // Clear accumulated progress lines before streaming the final answer.
2908                let _ = tx.send(DraftEvent::Clear).await;
2909                // Split on whitespace boundaries, accumulating chunks of at least
2910                // STREAM_CHUNK_MIN_CHARS characters for progressive draft updates.
2911                let mut chunk = String::new();
2912                for word in display_text.split_inclusive(char::is_whitespace) {
2913                    if cancellation_token
2914                        .as_ref()
2915                        .is_some_and(CancellationToken::is_cancelled)
2916                    {
2917                        return Err(ToolLoopCancelled.into());
2918                    }
2919                    chunk.push_str(word);
2920                    if chunk.len() >= STREAM_CHUNK_MIN_CHARS
2921                        && tx
2922                            .send(DraftEvent::Content(std::mem::take(&mut chunk)))
2923                            .await
2924                            .is_err()
2925                    {
2926                        break; // receiver dropped
2927                    }
2928                }
2929                if !chunk.is_empty() {
2930                    let _ = tx.send(DraftEvent::Content(chunk)).await;
2931                }
2932            }
2933            history.push(ChatMessage::assistant(response_text.clone()));
2934            return Ok(display_text);
2935        }
2936
2937        // Native tool-call providers can return assistant text separately from
2938        // the structured call payload; relay it to draft-capable channels.
2939        if !display_text.is_empty() {
2940            if !native_tool_calls.is_empty() {
2941                if let Some(ref tx) = on_delta {
2942                    let mut narration = display_text.clone();
2943                    if !narration.ends_with('\n') {
2944                        narration.push('\n');
2945                    }
2946                    let _ = tx.send(DraftEvent::Content(narration)).await;
2947                }
2948            }
2949            if !silent {
2950                print!("{display_text}");
2951                let _ = std::io::stdout().flush();
2952            }
2953        }
2954
2955        // Execute tool calls and build results. `individual_results` tracks per-call output so
2956        // native-mode history can emit one role=tool message per tool call with the correct ID.
2957        //
2958        // When multiple tool calls are present and interactive CLI approval is not needed, run
2959        // tool executions concurrently for lower wall-clock latency.
2960        let mut tool_results = String::new();
2961        let mut individual_results: Vec<(Option<String>, String)> = Vec::new();
2962        let mut ordered_results: Vec<Option<(String, Option<String>, ToolExecutionOutcome)>> =
2963            (0..tool_calls.len()).map(|_| None).collect();
2964        let allow_parallel_execution = should_execute_tools_in_parallel(&tool_calls, approval);
2965        let mut executable_indices: Vec<usize> = Vec::new();
2966        let mut executable_calls: Vec<ParsedToolCall> = Vec::new();
2967
2968        for (idx, call) in tool_calls.iter().enumerate() {
2969            // ── Hook: before_tool_call (modifying) ──────────
2970            let mut tool_name = call.name.clone();
2971            let mut tool_args = call.arguments.clone();
2972            if let Some(hooks) = hooks {
2973                match hooks
2974                    .run_before_tool_call(tool_name.clone(), tool_args.clone())
2975                    .await
2976                {
2977                    crate::hooks::HookResult::Cancel(reason) => {
2978                        tracing::info!(tool = %call.name, %reason, "tool call cancelled by hook");
2979                        let cancelled = format!("Cancelled by hook: {reason}");
2980                        runtime_trace::record_event(
2981                            "tool_call_result",
2982                            Some(channel_name),
2983                            Some(provider_name),
2984                            Some(model),
2985                            Some(&turn_id),
2986                            Some(false),
2987                            Some(&cancelled),
2988                            serde_json::json!({
2989                                "iteration": iteration + 1,
2990                                "tool": call.name,
2991                                "arguments": scrub_credentials(&tool_args.to_string()),
2992                            }),
2993                        );
2994                        if let Some(ref tx) = on_delta {
2995                            let _ = tx
2996                                .send(DraftEvent::Progress(format!(
2997                                    "\u{274c} {}: {}\n",
2998                                    call.name,
2999                                    truncate_with_ellipsis(&scrub_credentials(&cancelled), 200)
3000                                )))
3001                                .await;
3002                        }
3003                        ordered_results[idx] = Some((
3004                            call.name.clone(),
3005                            call.tool_call_id.clone(),
3006                            ToolExecutionOutcome {
3007                                output: cancelled,
3008                                success: false,
3009                                error_reason: Some(scrub_credentials(&reason)),
3010                                duration: Duration::ZERO,
3011                            },
3012                        ));
3013                        continue;
3014                    }
3015                    crate::hooks::HookResult::Continue((name, args)) => {
3016                        tool_name = name;
3017                        tool_args = args;
3018                    }
3019                }
3020            }
3021
3022            maybe_inject_channel_delivery_defaults(
3023                &tool_name,
3024                &mut tool_args,
3025                channel_name,
3026                channel_reply_target,
3027            );
3028
3029            // ── Approval hook ────────────────────────────────
3030            if let Some(mgr) = approval {
3031                if mgr.needs_approval(&tool_name) {
3032                    let request = ApprovalRequest {
3033                        tool_name: tool_name.clone(),
3034                        arguments: tool_args.clone(),
3035                    };
3036
3037                    // Interactive CLI: prompt the operator.
3038                    // Non-interactive (channels): auto-deny since no operator
3039                    // is present to approve.
3040                    let decision = if mgr.is_non_interactive() {
3041                        ApprovalResponse::No
3042                    } else {
3043                        mgr.prompt_cli(&request)
3044                    };
3045
3046                    mgr.record_decision(&tool_name, &tool_args, decision, channel_name);
3047
3048                    if decision == ApprovalResponse::No {
3049                        let denied = "Denied by user.".to_string();
3050                        runtime_trace::record_event(
3051                            "tool_call_result",
3052                            Some(channel_name),
3053                            Some(provider_name),
3054                            Some(model),
3055                            Some(&turn_id),
3056                            Some(false),
3057                            Some(&denied),
3058                            serde_json::json!({
3059                                "iteration": iteration + 1,
3060                                "tool": tool_name.clone(),
3061                                "arguments": scrub_credentials(&tool_args.to_string()),
3062                            }),
3063                        );
3064                        if let Some(ref tx) = on_delta {
3065                            let _ = tx
3066                                .send(DraftEvent::Progress(format!(
3067                                    "\u{274c} {}: {}\n",
3068                                    tool_name, denied
3069                                )))
3070                                .await;
3071                        }
3072                        ordered_results[idx] = Some((
3073                            tool_name.clone(),
3074                            call.tool_call_id.clone(),
3075                            ToolExecutionOutcome {
3076                                output: denied.clone(),
3077                                success: false,
3078                                error_reason: Some(denied),
3079                                duration: Duration::ZERO,
3080                            },
3081                        ));
3082                        continue;
3083                    }
3084                }
3085            }
3086
3087            let signature = {
3088                let canonical_args = canonicalize_json_for_tool_signature(&tool_args);
3089                let args_json =
3090                    serde_json::to_string(&canonical_args).unwrap_or_else(|_| "{}".to_string());
3091                (tool_name.trim().to_ascii_lowercase(), args_json)
3092            };
3093            let dedup_exempt = dedup_exempt_tools.iter().any(|e| e == &tool_name);
3094            if !dedup_exempt && !seen_tool_signatures.insert(signature) {
3095                let duplicate = format!(
3096                    "Skipped duplicate tool call '{tool_name}' with identical arguments in this turn."
3097                );
3098                runtime_trace::record_event(
3099                    "tool_call_result",
3100                    Some(channel_name),
3101                    Some(provider_name),
3102                    Some(model),
3103                    Some(&turn_id),
3104                    Some(false),
3105                    Some(&duplicate),
3106                    serde_json::json!({
3107                        "iteration": iteration + 1,
3108                        "tool": tool_name.clone(),
3109                        "arguments": scrub_credentials(&tool_args.to_string()),
3110                        "deduplicated": true,
3111                    }),
3112                );
3113                if let Some(ref tx) = on_delta {
3114                    let _ = tx
3115                        .send(DraftEvent::Progress(format!(
3116                            "\u{274c} {}: {}\n",
3117                            tool_name, duplicate
3118                        )))
3119                        .await;
3120                }
3121                ordered_results[idx] = Some((
3122                    tool_name.clone(),
3123                    call.tool_call_id.clone(),
3124                    ToolExecutionOutcome {
3125                        output: duplicate.clone(),
3126                        success: false,
3127                        error_reason: Some(duplicate),
3128                        duration: Duration::ZERO,
3129                    },
3130                ));
3131                continue;
3132            }
3133
3134            runtime_trace::record_event(
3135                "tool_call_start",
3136                Some(channel_name),
3137                Some(provider_name),
3138                Some(model),
3139                Some(&turn_id),
3140                None,
3141                None,
3142                serde_json::json!({
3143                    "iteration": iteration + 1,
3144                    "tool": tool_name.clone(),
3145                    "arguments": scrub_credentials(&tool_args.to_string()),
3146                }),
3147            );
3148
3149            // ── Progress: tool start ────────────────────────────
3150            if let Some(ref tx) = on_delta {
3151                let progress = if let Some(suffix) = tool_name.strip_prefix("construct-operator__")
3152                {
3153                    // Operator tools get user-friendly progress messages
3154                    match suffix {
3155                        "create_agent" => {
3156                            let title = tool_args
3157                                .get("title")
3158                                .and_then(|v| v.as_str())
3159                                .unwrap_or("agent");
3160                            format!("\u{1f916} Spawning agent: {title}\n")
3161                        }
3162                        "wait_for_agent" => "\u{23f3} Waiting for agent to finish…\n".to_string(),
3163                        "send_agent_prompt" => {
3164                            "\u{1f4e8} Sending follow-up to agent…\n".to_string()
3165                        }
3166                        "get_agent_activity" => "\u{1f4cb} Collecting agent results…\n".to_string(),
3167                        "get_agent_status" => "\u{1f50d} Checking agent status…\n".to_string(),
3168                        "list_agents" => "\u{1f4cb} Listing active agents…\n".to_string(),
3169                        "search_agent_pool" | "list_agent_templates" => {
3170                            "\u{1f50d} Searching agent pool…\n".to_string()
3171                        }
3172                        "save_agent_template" => {
3173                            let name = tool_args
3174                                .get("name")
3175                                .and_then(|v| v.as_str())
3176                                .unwrap_or("template");
3177                            format!("\u{1f4be} Saving agent template: {name}\n")
3178                        }
3179                        "list_teams" | "search_teams" => "\u{1f50d} Searching teams…\n".to_string(),
3180                        "get_team" => "\u{1f4cb} Loading team details…\n".to_string(),
3181                        "spawn_team" => "\u{1f680} Deploying team…\n".to_string(),
3182                        "create_team" => {
3183                            let name = tool_args
3184                                .get("name")
3185                                .and_then(|v| v.as_str())
3186                                .unwrap_or("team");
3187                            format!("\u{1f4be} Creating team: {name}\n")
3188                        }
3189                        "get_budget_status" => "\u{1f4b0} Checking budget…\n".to_string(),
3190                        "save_plan" => "\u{1f4be} Saving execution plan…\n".to_string(),
3191                        "recall_plans" => "\u{1f50d} Searching past plans…\n".to_string(),
3192                        "create_goal" => {
3193                            let name = tool_args
3194                                .get("name")
3195                                .and_then(|v| v.as_str())
3196                                .unwrap_or("goal");
3197                            format!("\u{1f3af} Creating goal: {name}\n")
3198                        }
3199                        "get_goals" => "\u{1f3af} Loading goals…\n".to_string(),
3200                        "update_goal" => "\u{1f3af} Updating goal…\n".to_string(),
3201                        "record_agent_outcome" => {
3202                            "\u{1f4ca} Recording agent outcome…\n".to_string()
3203                        }
3204                        "get_agent_trust" => "\u{1f4ca} Checking trust scores…\n".to_string(),
3205                        "publish_to_clawhub" => "\u{1f4e4} Publishing to ClawHub…\n".to_string(),
3206                        "search_clawhub" => {
3207                            "\u{1f50d} Searching ClawHub marketplace…\n".to_string()
3208                        }
3209                        "install_from_clawhub" => {
3210                            "\u{1f4e5} Installing from ClawHub…\n".to_string()
3211                        }
3212                        "list_nodes" => "\u{1f310} Discovering connected nodes…\n".to_string(),
3213                        "invoke_node" => "\u{1f4e1} Invoking node capability…\n".to_string(),
3214                        "get_session_history" => "\u{1f4c3} Loading session history…\n".to_string(),
3215                        "archive_session" => "\u{1f4e6} Archiving session…\n".to_string(),
3216                        "capture_skill" => {
3217                            let name = tool_args
3218                                .get("name")
3219                                .and_then(|v| v.as_str())
3220                                .unwrap_or("skill");
3221                            format!("\u{1f4da} Capturing skill: {name}\n")
3222                        }
3223                        _ => format!("\u{2699}\u{fe0f} Operator: {suffix}\n"),
3224                    }
3225                } else {
3226                    let hint = {
3227                        let raw = match tool_name.as_str() {
3228                            "shell" => tool_args.get("command").and_then(|v| v.as_str()),
3229                            "file_read" | "file_write" => {
3230                                tool_args.get("path").and_then(|v| v.as_str())
3231                            }
3232                            _ => tool_args
3233                                .get("action")
3234                                .and_then(|v| v.as_str())
3235                                .or_else(|| tool_args.get("query").and_then(|v| v.as_str())),
3236                        };
3237                        match raw {
3238                            Some(s) => truncate_with_ellipsis(s, 60),
3239                            None => String::new(),
3240                        }
3241                    };
3242                    if hint.is_empty() {
3243                        format!("\u{23f3} {}\n", tool_name)
3244                    } else {
3245                        format!("\u{23f3} {}: {hint}\n", tool_name)
3246                    }
3247                };
3248                tracing::debug!(tool = %tool_name, "Sending progress start to draft");
3249                let _ = tx.send(DraftEvent::Progress(progress)).await;
3250            }
3251
3252            executable_indices.push(idx);
3253            executable_calls.push(ParsedToolCall {
3254                name: tool_name,
3255                arguments: tool_args,
3256                tool_call_id: call.tool_call_id.clone(),
3257            });
3258        }
3259
3260        let executed_outcomes = if allow_parallel_execution && executable_calls.len() > 1 {
3261            execute_tools_parallel(
3262                &executable_calls,
3263                tools_registry,
3264                activated_tools,
3265                observer,
3266                cancellation_token.as_ref(),
3267            )
3268            .await?
3269        } else {
3270            execute_tools_sequential(
3271                &executable_calls,
3272                tools_registry,
3273                activated_tools,
3274                observer,
3275                cancellation_token.as_ref(),
3276            )
3277            .await?
3278        };
3279
3280        for ((idx, call), outcome) in executable_indices
3281            .iter()
3282            .zip(executable_calls.iter())
3283            .zip(executed_outcomes.into_iter())
3284        {
3285            runtime_trace::record_event(
3286                "tool_call_result",
3287                Some(channel_name),
3288                Some(provider_name),
3289                Some(model),
3290                Some(&turn_id),
3291                Some(outcome.success),
3292                outcome.error_reason.as_deref(),
3293                serde_json::json!({
3294                    "iteration": iteration + 1,
3295                    "tool": call.name.clone(),
3296                    "duration_ms": outcome.duration.as_millis(),
3297                    "output": scrub_credentials(&outcome.output),
3298                }),
3299            );
3300
3301            // ── Hook: after_tool_call (void) ─────────────────
3302            if let Some(hooks) = hooks {
3303                let tool_result_obj = crate::tools::ToolResult {
3304                    success: outcome.success,
3305                    output: outcome.output.clone(),
3306                    error: None,
3307                };
3308                hooks
3309                    .fire_after_tool_call(&call.name, &tool_result_obj, outcome.duration)
3310                    .await;
3311            }
3312
3313            // ── Progress: tool completion ───────────────────────
3314            if let Some(ref tx) = on_delta {
3315                let secs = outcome.duration.as_secs();
3316                let progress_msg = if let Some(suffix) =
3317                    call.name.strip_prefix("construct-operator__")
3318                {
3319                    // Operator tools get user-friendly completion messages
3320                    if outcome.success {
3321                        match suffix {
3322                            "create_agent" => format!("\u{2705} Agent spawned ({secs}s)\n"),
3323                            "wait_for_agent" => format!("\u{2705} Agent finished ({secs}s)\n"),
3324                            "get_agent_activity" => {
3325                                format!("\u{2705} Results collected ({secs}s)\n")
3326                            }
3327                            "save_agent_template" => format!("\u{2705} Template saved ({secs}s)\n"),
3328                            "send_agent_prompt" => format!("\u{2705} Follow-up sent ({secs}s)\n"),
3329                            "search_agent_pool" | "list_agent_templates" => {
3330                                format!("\u{2705} Pool search done ({secs}s)\n")
3331                            }
3332                            "list_teams" | "search_teams" => {
3333                                format!("\u{2705} Team search done ({secs}s)\n")
3334                            }
3335                            "get_team" => format!("\u{2705} Team loaded ({secs}s)\n"),
3336                            "spawn_team" => format!("\u{2705} Team deployed ({secs}s)\n"),
3337                            "create_team" => format!("\u{2705} Team created ({secs}s)\n"),
3338                            "get_budget_status" => format!("\u{2705} Budget checked ({secs}s)\n"),
3339                            "save_plan" => format!("\u{2705} Plan saved ({secs}s)\n"),
3340                            "recall_plans" => format!("\u{2705} Plans retrieved ({secs}s)\n"),
3341                            "create_goal" => format!("\u{2705} Goal created ({secs}s)\n"),
3342                            "get_goals" => format!("\u{2705} Goals loaded ({secs}s)\n"),
3343                            "update_goal" => format!("\u{2705} Goal updated ({secs}s)\n"),
3344                            "record_agent_outcome" => {
3345                                format!("\u{2705} Outcome recorded ({secs}s)\n")
3346                            }
3347                            "get_agent_trust" => {
3348                                format!("\u{2705} Trust scores loaded ({secs}s)\n")
3349                            }
3350                            "capture_skill" => format!("\u{2705} Skill captured ({secs}s)\n"),
3351                            "publish_to_clawhub" => {
3352                                format!("\u{2705} Published to ClawHub ({secs}s)\n")
3353                            }
3354                            "search_clawhub" => {
3355                                format!("\u{2705} ClawHub search complete ({secs}s)\n")
3356                            }
3357                            "install_from_clawhub" => {
3358                                format!("\u{2705} Installed from ClawHub ({secs}s)\n")
3359                            }
3360                            "list_nodes" => format!("\u{2705} Nodes discovered ({secs}s)\n"),
3361                            "invoke_node" => {
3362                                format!("\u{2705} Node invocation complete ({secs}s)\n")
3363                            }
3364                            "get_session_history" => {
3365                                format!("\u{2705} Session history loaded ({secs}s)\n")
3366                            }
3367                            "archive_session" => format!("\u{2705} Session archived ({secs}s)\n"),
3368                            _ => format!("\u{2705} {suffix} ({secs}s)\n"),
3369                        }
3370                    } else {
3371                        let reason_hint = outcome.error_reason.as_deref().unwrap_or("failed");
3372                        format!(
3373                            "\u{274c} {suffix} ({secs}s): {}\n",
3374                            truncate_with_ellipsis(reason_hint, 200)
3375                        )
3376                    }
3377                } else if outcome.success {
3378                    format!("\u{2705} {} ({secs}s)\n", call.name)
3379                } else if let Some(ref reason) = outcome.error_reason {
3380                    format!(
3381                        "\u{274c} {} ({secs}s): {}\n",
3382                        call.name,
3383                        truncate_with_ellipsis(reason, 200)
3384                    )
3385                } else {
3386                    format!("\u{274c} {} ({secs}s)\n", call.name)
3387                };
3388                tracing::debug!(tool = %call.name, secs, "Sending progress complete to draft");
3389                let _ = tx.send(DraftEvent::Progress(progress_msg)).await;
3390            }
3391
3392            ordered_results[*idx] = Some((call.name.clone(), call.tool_call_id.clone(), outcome));
3393        }
3394
3395        // Collect tool results and build per-tool output for loop detection.
3396        // Only non-ignored tool outputs contribute to the identical-output hash.
3397        let mut detection_relevant_output = String::new();
3398        // Use enumerate *before* filter_map so result_index stays aligned with
3399        // tool_calls even when some ordered_results entries are None.
3400        for (result_index, (tool_name, tool_call_id, outcome)) in ordered_results
3401            .into_iter()
3402            .enumerate()
3403            .filter_map(|(i, opt)| opt.map(|v| (i, v)))
3404        {
3405            if !loop_ignore_tools.contains(tool_name.as_str()) {
3406                detection_relevant_output.push_str(&outcome.output);
3407
3408                // Feed the pattern-based loop detector with name + args + result.
3409                let args = tool_calls
3410                    .get(result_index)
3411                    .map(|c| &c.arguments)
3412                    .unwrap_or(&serde_json::Value::Null);
3413                let det_result = loop_detector.record(&tool_name, args, &outcome.output);
3414                match det_result {
3415                    crate::agent::loop_detector::LoopDetectionResult::Ok => {}
3416                    crate::agent::loop_detector::LoopDetectionResult::Warning(ref msg) => {
3417                        tracing::warn!(tool = %tool_name, %msg, "loop detector warning");
3418                        // Inject a system nudge so the LLM adjusts strategy.
3419                        history.push(ChatMessage::system(format!("[Loop Detection] {msg}")));
3420                    }
3421                    crate::agent::loop_detector::LoopDetectionResult::Block(ref msg) => {
3422                        tracing::warn!(tool = %tool_name, %msg, "loop detector blocked tool call");
3423                        // Replace the tool output with the block message.
3424                        // We still continue the loop so the LLM sees the block feedback.
3425                        history.push(ChatMessage::system(format!(
3426                            "[Loop Detection — BLOCKED] {msg}"
3427                        )));
3428                    }
3429                    crate::agent::loop_detector::LoopDetectionResult::Break(msg) => {
3430                        runtime_trace::record_event(
3431                            "loop_detector_circuit_breaker",
3432                            Some(channel_name),
3433                            Some(provider_name),
3434                            Some(model),
3435                            Some(&turn_id),
3436                            Some(false),
3437                            Some(&msg),
3438                            serde_json::json!({
3439                                "iteration": iteration + 1,
3440                                "tool": tool_name,
3441                            }),
3442                        );
3443                        anyhow::bail!("Agent loop aborted by loop detector: {msg}");
3444                    }
3445                }
3446            }
3447            let result_output = truncate_tool_result(&outcome.output, max_tool_result_chars);
3448            individual_results.push((tool_call_id, result_output.clone()));
3449            let _ = writeln!(
3450                tool_results,
3451                "<tool_result name=\"{}\">\n{}\n</tool_result>",
3452                tool_name, result_output
3453            );
3454        }
3455
3456        // ── Time-gated loop detection ──────────────────────────
3457        // When pacing.loop_detection_min_elapsed_secs is set, identical-output
3458        // loop detection activates after the task has been running that long.
3459        // This avoids false-positive aborts on long-running browser/research
3460        // workflows while keeping aggressive protection for quick tasks.
3461        // When not configured, identical-output detection is disabled (preserving
3462        // existing behavior where only max_iterations prevents runaway loops).
3463        let loop_detection_active = match pacing.loop_detection_min_elapsed_secs {
3464            Some(min_secs) => loop_started_at.elapsed() >= Duration::from_secs(min_secs),
3465            None => false, // disabled when not configured (backwards compatible)
3466        };
3467
3468        if loop_detection_active && !detection_relevant_output.is_empty() {
3469            use std::hash::{Hash, Hasher};
3470            let mut hasher = std::collections::hash_map::DefaultHasher::new();
3471            detection_relevant_output.hash(&mut hasher);
3472            let current_hash = hasher.finish();
3473
3474            if last_tool_output_hash == Some(current_hash) {
3475                consecutive_identical_outputs += 1;
3476            } else {
3477                consecutive_identical_outputs = 0;
3478                last_tool_output_hash = Some(current_hash);
3479            }
3480
3481            // Bail if we see 3+ consecutive identical tool outputs (clear runaway).
3482            if consecutive_identical_outputs >= 3 {
3483                runtime_trace::record_event(
3484                    "tool_loop_identical_output_abort",
3485                    Some(channel_name),
3486                    Some(provider_name),
3487                    Some(model),
3488                    Some(&turn_id),
3489                    Some(false),
3490                    Some("identical tool output detected 3 consecutive times"),
3491                    serde_json::json!({
3492                        "iteration": iteration + 1,
3493                        "consecutive_identical": consecutive_identical_outputs,
3494                    }),
3495                );
3496                anyhow::bail!(
3497                    "Agent loop aborted: identical tool output detected {} consecutive times",
3498                    consecutive_identical_outputs
3499                );
3500            }
3501        }
3502
3503        // Add assistant message with tool calls + tool results to history.
3504        // Native mode: use JSON-structured messages so convert_messages() can
3505        // reconstruct proper OpenAI-format tool_calls and tool result messages.
3506        // Prompt mode: use XML-based text format as before.
3507        history.push(ChatMessage::assistant(assistant_history_content));
3508        if native_tool_calls.is_empty() {
3509            let all_results_have_ids = use_native_tools
3510                && !individual_results.is_empty()
3511                && individual_results
3512                    .iter()
3513                    .all(|(tool_call_id, _)| tool_call_id.is_some());
3514            if all_results_have_ids {
3515                for (tool_call_id, result) in &individual_results {
3516                    let tool_msg = serde_json::json!({
3517                        "tool_call_id": tool_call_id,
3518                        "content": result,
3519                    });
3520                    history.push(ChatMessage::tool(tool_msg.to_string()));
3521                }
3522            } else {
3523                history.push(ChatMessage::user(format!("[Tool results]\n{tool_results}")));
3524            }
3525        } else {
3526            for (native_call, (_, result)) in
3527                native_tool_calls.iter().zip(individual_results.iter())
3528            {
3529                let tool_msg = serde_json::json!({
3530                    "tool_call_id": native_call.id,
3531                    "content": result,
3532                });
3533                history.push(ChatMessage::tool(tool_msg.to_string()));
3534            }
3535        }
3536    }
3537
3538    runtime_trace::record_event(
3539        "tool_loop_exhausted",
3540        Some(channel_name),
3541        Some(provider_name),
3542        Some(model),
3543        Some(&turn_id),
3544        Some(false),
3545        Some("agent exceeded maximum tool iterations"),
3546        serde_json::json!({
3547            "max_iterations": max_iterations,
3548        }),
3549    );
3550
3551    // Graceful shutdown: ask the LLM for a final summary without tools
3552    tracing::warn!(
3553        max_iterations,
3554        "Max iterations reached, requesting final summary"
3555    );
3556    history.push(ChatMessage::user(
3557        "You have reached the maximum number of tool iterations. \
3558         Please provide your best answer based on the work completed so far. \
3559         Summarize what you accomplished and what remains to be done."
3560            .to_string(),
3561    ));
3562
3563    let summary_request = crate::providers::ChatRequest {
3564        messages: history,
3565        tools: None, // No tools — force a text response
3566    };
3567    match provider.chat(summary_request, model, temperature).await {
3568        Ok(resp) => {
3569            let text = resp.text.unwrap_or_default();
3570            if text.is_empty() {
3571                anyhow::bail!("Agent exceeded maximum tool iterations ({max_iterations})")
3572            }
3573            Ok(text)
3574        }
3575        Err(e) => {
3576            tracing::warn!(error = %e, "Final summary LLM call failed, bailing");
3577            anyhow::bail!("Agent exceeded maximum tool iterations ({max_iterations})")
3578        }
3579    }
3580}
3581
3582/// Build the tool instruction block for the system prompt so the LLM knows
3583/// how to invoke tools.
3584pub(crate) fn build_tool_instructions(
3585    tools_registry: &[Box<dyn Tool>],
3586    tool_descriptions: Option<&ToolDescriptions>,
3587) -> String {
3588    let mut instructions = String::new();
3589    instructions.push_str("\n## Tool Use Protocol\n\n");
3590    instructions.push_str("To use a tool, wrap a JSON object in <tool_call></tool_call> tags:\n\n");
3591    instructions.push_str("```\n<tool_call>\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n```\n\n");
3592    instructions.push_str(
3593        "CRITICAL: Output actual <tool_call> tags—never describe steps or give examples.\n\n",
3594    );
3595    instructions.push_str("Example: User says \"what's the date?\". You MUST respond with:\n<tool_call>\n{\"name\":\"shell\",\"arguments\":{\"command\":\"date\"}}\n</tool_call>\n\n");
3596    instructions.push_str("You may use multiple tool calls in a single response. ");
3597    instructions.push_str("After tool execution, results appear in <tool_result> tags. ");
3598    instructions
3599        .push_str("Continue reasoning with the results until you can give a final answer.\n\n");
3600    instructions.push_str("### Available Tools\n\n");
3601
3602    for tool in tools_registry {
3603        let desc = tool_descriptions
3604            .and_then(|td| td.get(tool.name()))
3605            .unwrap_or_else(|| tool.description());
3606        let _ = writeln!(
3607            instructions,
3608            "**{}**: {}\nParameters: `{}`\n",
3609            tool.name(),
3610            desc,
3611            tool.parameters_schema()
3612        );
3613    }
3614
3615    instructions
3616}
3617
3618// ── CLI Entrypoint ───────────────────────────────────────────────────────
3619// Wires up all subsystems (observer, runtime, security, memory, tools,
3620// provider, hardware RAG, peripherals) and enters either single-shot or
3621// interactive REPL mode. The interactive loop manages history compaction
3622// and hard trimming to keep the context window bounded.
3623
3624#[allow(clippy::too_many_lines)]
3625pub async fn run(
3626    config: Config,
3627    message: Option<String>,
3628    provider_override: Option<String>,
3629    model_override: Option<String>,
3630    temperature: f64,
3631    peripheral_overrides: Vec<String>,
3632    interactive: bool,
3633    session_state_file: Option<PathBuf>,
3634    allowed_tools: Option<Vec<String>>,
3635) -> Result<String> {
3636    // ── Wire up agnostic subsystems ──────────────────────────────
3637    let base_observer = observability::create_observer(&config.observability);
3638    let observer: Arc<dyn Observer> = Arc::from(base_observer);
3639    let runtime: Arc<dyn runtime::RuntimeAdapter> =
3640        Arc::from(runtime::create_runtime(&config.runtime)?);
3641    let security = Arc::new(SecurityPolicy::from_config(
3642        &config.autonomy,
3643        &config.workspace_dir,
3644    ));
3645
3646    // ── Memory (the brain) ────────────────────────────────────────
3647    let mem: Arc<dyn Memory> = Arc::from(memory::create_memory_with_storage_and_routes(
3648        &config.memory,
3649        &config.embedding_routes,
3650        Some(&config.storage.provider.config),
3651        &config.workspace_dir,
3652        config.api_key.as_deref(),
3653    )?);
3654    tracing::info!(backend = mem.name(), "Memory initialized");
3655
3656    // ── Peripherals (merge peripheral tools into registry) ─
3657    if !peripheral_overrides.is_empty() {
3658        tracing::info!(
3659            peripherals = ?peripheral_overrides,
3660            "Peripheral overrides from CLI (config boards take precedence)"
3661        );
3662    }
3663
3664    // ── Tools (including memory tools and peripherals) ────────────
3665    let (composio_key, composio_entity_id) = if config.composio.enabled {
3666        (
3667            config.composio.api_key.as_deref(),
3668            Some(config.composio.entity_id.as_str()),
3669        )
3670    } else {
3671        (None, None)
3672    };
3673    let (
3674        mut tools_registry,
3675        delegate_handle,
3676        _reaction_handle,
3677        _channel_map_handle,
3678        _ask_user_handle,
3679        _escalate_handle,
3680    ) = tools::all_tools_with_runtime(
3681        Arc::new(config.clone()),
3682        &security,
3683        runtime,
3684        mem.clone(),
3685        composio_key,
3686        composio_entity_id,
3687        &config.browser,
3688        &config.http_request,
3689        &config.web_fetch,
3690        &config.workspace_dir,
3691        &config.agents,
3692        config.api_key.as_deref(),
3693        &config,
3694        None,
3695    );
3696
3697    let peripheral_tools: Vec<Box<dyn Tool>> =
3698        crate::peripherals::create_peripheral_tools(&config.peripherals).await?;
3699    if !peripheral_tools.is_empty() {
3700        tracing::info!(count = peripheral_tools.len(), "Peripheral tools added");
3701        tools_registry.extend(peripheral_tools);
3702    }
3703
3704    // ── Capability-based tool access control ─────────────────────
3705    // When `allowed_tools` is `Some(list)`, restrict the tool registry to only
3706    // those tools whose name appears in the list. Unknown names are silently
3707    // ignored. When `None`, all tools remain available (backward compatible).
3708    if let Some(ref allow_list) = allowed_tools {
3709        tools_registry.retain(|t| allow_list.iter().any(|name| name == t.name()));
3710        tracing::info!(
3711            allowed = allow_list.len(),
3712            retained = tools_registry.len(),
3713            "Applied capability-based tool access filter"
3714        );
3715    }
3716
3717    // ── Inject Kumiho memory MCP server (first-class, non-fatal) ──
3718    // Kumiho is Construct's only persistent memory store and is wired into every
3719    // non-internal agent unconditionally.  inject_kumiho() is idempotent.
3720    let config = crate::agent::kumiho::inject_kumiho(config, false);
3721
3722    // ── Inject Operator orchestration MCP server (first-class, non-fatal) ──
3723    let config = crate::agent::operator::inject_operator(config, false);
3724
3725    // ── Wire MCP tools (non-fatal) — CLI path ────────────────────
3726    // NOTE: MCP tools are injected after built-in tool filtering
3727    // (filter_primary_agent_tools_or_fail / agent.allowed_tools / agent.denied_tools).
3728    // MCP servers are user-declared external integrations; the built-in allow/deny
3729    // filter is not appropriate for them and would silently drop all MCP tools when
3730    // a restrictive allowlist is configured. Keep this block after any such filter call.
3731    //
3732    // When `deferred_loading` is enabled, MCP tools are NOT added to the registry
3733    // eagerly. Instead, a `tool_search` built-in is registered so the LLM can
3734    // fetch schemas on demand. This reduces context window waste.
3735    let mut deferred_section = String::new();
3736    let mut activated_handle: Option<
3737        std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>,
3738    > = None;
3739    if config.mcp.enabled && !config.mcp.servers.is_empty() {
3740        tracing::info!(
3741            "Initializing MCP client — {} server(s) configured",
3742            config.mcp.servers.len()
3743        );
3744        match crate::tools::McpRegistry::connect_all(&config.mcp.servers).await {
3745            Ok(registry) => {
3746                let registry = std::sync::Arc::new(registry);
3747                if config.mcp.deferred_loading {
3748                    // Hybrid path: eagerly load essential tools, defer the rest.
3749                    //
3750                    // Local models (Ollama) get the minimal local eager set
3751                    // because large tool sets cause hallucinated tool names.
3752                    // Cloud providers get the curated operator-seat eager set
3753                    // (operator essentials + Kumiho memory reflexes); the rest
3754                    // is discoverable via tool_search to keep per-turn input
3755                    // tokens bounded.
3756                    let early_provider = provider_override
3757                        .as_deref()
3758                        .or(config.default_provider.as_deref())
3759                        .unwrap_or("openrouter");
3760                    let is_local_provider = early_provider == "ollama";
3761                    let is_eager_tool = |name: &str| -> bool {
3762                        if is_local_provider {
3763                            crate::tools::mcp_deferred::is_local_model_eager_tool(name)
3764                        } else {
3765                            crate::tools::mcp_deferred::is_operator_seat_eager_tool(name)
3766                        }
3767                    };
3768
3769                    let all_names = registry.tool_names();
3770                    let mut eager_count = 0usize;
3771
3772                    for name in &all_names {
3773                        if is_eager_tool(name) {
3774                            if let Some(def) =
3775                                registry.get_tool_def(name).await
3776                            {
3777                                let wrapper: std::sync::Arc<dyn Tool> =
3778                                    std::sync::Arc::new(crate::tools::McpToolWrapper::new(
3779                                        name.clone(),
3780                                        def,
3781                                        std::sync::Arc::clone(&registry),
3782                                    ));
3783                                if let Some(ref handle) = delegate_handle {
3784                                    handle.write().push(std::sync::Arc::clone(&wrapper));
3785                                }
3786                                tools_registry.push(Box::new(crate::tools::ArcToolRef(wrapper)));
3787                                eager_count += 1;
3788                            }
3789                        }
3790                    }
3791
3792                    // Defer everything that wasn't eagerly loaded.
3793                    let deferred_set = crate::tools::DeferredMcpToolSet::from_registry_filtered(
3794                        std::sync::Arc::clone(&registry),
3795                        move |name: &str| {
3796                            if is_local_provider {
3797                                !crate::tools::mcp_deferred::is_local_model_eager_tool(name)
3798                            } else {
3799                                !crate::tools::mcp_deferred::is_operator_seat_eager_tool(name)
3800                            }
3801                        },
3802                    )
3803                    .await;
3804                    tracing::info!(
3805                        "MCP hybrid: {} eager tool(s), {} deferred stub(s) from {} server(s) (local_provider={})",
3806                        eager_count,
3807                        deferred_set.len(),
3808                        registry.server_count(),
3809                        is_local_provider,
3810                    );
3811                    deferred_section =
3812                        crate::tools::mcp_deferred::build_deferred_tools_section(&deferred_set);
3813                    let activated = std::sync::Arc::new(std::sync::Mutex::new(
3814                        crate::tools::ActivatedToolSet::new(),
3815                    ));
3816                    activated_handle = Some(std::sync::Arc::clone(&activated));
3817                    tools_registry.push(Box::new(crate::tools::ToolSearchTool::new(
3818                        deferred_set,
3819                        activated,
3820                    )));
3821                } else {
3822                    // Eager path: register all MCP tools directly
3823                    let names = registry.tool_names();
3824                    let mut registered = 0usize;
3825                    for name in names {
3826                        if let Some(def) = registry.get_tool_def(&name).await {
3827                            let wrapper: std::sync::Arc<dyn Tool> =
3828                                std::sync::Arc::new(crate::tools::McpToolWrapper::new(
3829                                    name,
3830                                    def,
3831                                    std::sync::Arc::clone(&registry),
3832                                ));
3833                            if let Some(ref handle) = delegate_handle {
3834                                handle.write().push(std::sync::Arc::clone(&wrapper));
3835                            }
3836                            tools_registry.push(Box::new(crate::tools::ArcToolRef(wrapper)));
3837                            registered += 1;
3838                        }
3839                    }
3840                    tracing::info!(
3841                        "MCP: {} tool(s) registered from {} server(s)",
3842                        registered,
3843                        registry.server_count()
3844                    );
3845                }
3846            }
3847            Err(e) => {
3848                tracing::error!("MCP registry failed to initialize: {e:#}");
3849            }
3850        }
3851    }
3852
3853    // ── Resolve provider ─────────────────────────────────────────
3854    let mut provider_name = provider_override
3855        .as_deref()
3856        .or(config.default_provider.as_deref())
3857        .unwrap_or("openrouter")
3858        .to_string();
3859
3860    let mut model_name = model_override
3861        .as_deref()
3862        .or(config.default_model.as_deref())
3863        .unwrap_or("anthropic/claude-sonnet-4")
3864        .to_string();
3865
3866    let provider_runtime_options = providers::provider_runtime_options_from_config(&config);
3867
3868    let mut provider: Box<dyn Provider> = providers::create_routed_provider_with_options(
3869        &provider_name,
3870        config.api_key.as_deref(),
3871        config.api_url.as_deref(),
3872        &config.reliability,
3873        &config.model_routes,
3874        &model_name,
3875        &provider_runtime_options,
3876    )?;
3877
3878    let model_switch_callback = get_model_switch_state();
3879
3880    observer.record_event(&ObserverEvent::AgentStart {
3881        provider: provider_name.to_string(),
3882        model: model_name.to_string(),
3883    });
3884
3885    // ── Hardware RAG (datasheet retrieval when peripherals + datasheet_dir) ──
3886    let hardware_rag: Option<crate::rag::HardwareRag> = config
3887        .peripherals
3888        .datasheet_dir
3889        .as_ref()
3890        .filter(|d| !d.trim().is_empty())
3891        .map(|dir| crate::rag::HardwareRag::load(&config.workspace_dir, dir.trim()))
3892        .and_then(Result::ok)
3893        .filter(|r: &crate::rag::HardwareRag| !r.is_empty());
3894    if let Some(ref rag) = hardware_rag {
3895        tracing::info!(chunks = rag.len(), "Hardware RAG loaded");
3896    }
3897
3898    let board_names: Vec<String> = config
3899        .peripherals
3900        .boards
3901        .iter()
3902        .map(|b| b.board.clone())
3903        .collect();
3904
3905    // ── Load locale-aware tool descriptions ────────────────────────
3906    let i18n_locale = config
3907        .locale
3908        .as_deref()
3909        .filter(|s| !s.is_empty())
3910        .map(ToString::to_string)
3911        .unwrap_or_else(crate::i18n::detect_locale);
3912    let i18n_search_dirs = crate::i18n::default_search_dirs(&config.workspace_dir);
3913    let i18n_descs = crate::i18n::ToolDescriptions::load(&i18n_locale, &i18n_search_dirs);
3914
3915    // ── Build system prompt from workspace MD files (OpenClaw framework) ──
3916    let skills = crate::skills::load_skills_with_config(&config.workspace_dir, &config);
3917
3918    // Register skill-defined tools as callable tool specs in the tool registry
3919    // so the LLM can invoke them via native function calling, not just XML prompts.
3920    tools::register_skill_tools(&mut tools_registry, &skills, security.clone());
3921
3922    let mut tool_descs: Vec<(&str, &str)> = vec![
3923        (
3924            "shell",
3925            "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.",
3926        ),
3927        (
3928            "file_read",
3929            "Read file contents. Use when: inspecting project files, configs, logs. Don't use when: a targeted search is enough.",
3930        ),
3931        (
3932            "file_write",
3933            "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.",
3934        ),
3935        (
3936            "memory_store",
3937            "Save to memory. Use when: preserving durable preferences, decisions, key context. Don't use when: information is transient/noisy/sensitive without need.",
3938        ),
3939        (
3940            "memory_recall",
3941            "Search memory. Use when: retrieving prior decisions, user preferences, historical context. Don't use when: answer is already in current context.",
3942        ),
3943        (
3944            "memory_forget",
3945            "Delete a memory entry. Use when: memory is incorrect/stale or explicitly requested for removal. Don't use when: impact is uncertain.",
3946        ),
3947    ];
3948    if matches!(
3949        config.skills.prompt_injection_mode,
3950        crate::config::SkillsPromptInjectionMode::Compact
3951    ) {
3952        tool_descs.push((
3953            "read_skill",
3954            "Load the full source for an available skill by name. Use when: compact mode only shows a summary and you need the complete skill instructions.",
3955        ));
3956    }
3957    tool_descs.push((
3958        "cron_add",
3959        "Create a cron job. Supports schedule kinds: cron, at, every; and job types: shell or agent.",
3960    ));
3961    tool_descs.push((
3962        "cron_list",
3963        "List all cron jobs with schedule, status, and metadata.",
3964    ));
3965    tool_descs.push(("cron_remove", "Remove a cron job by job_id."));
3966    tool_descs.push((
3967        "cron_update",
3968        "Patch a cron job (schedule, enabled, command/prompt, model, delivery, session_target).",
3969    ));
3970    tool_descs.push((
3971        "cron_run",
3972        "Force-run a cron job immediately and record a run history entry.",
3973    ));
3974    tool_descs.push(("cron_runs", "Show recent run history for a cron job."));
3975    tool_descs.push((
3976        "screenshot",
3977        "Capture a screenshot of the current screen. Returns file path and base64-encoded PNG. Use when: visual verification, UI inspection, debugging displays.",
3978    ));
3979    tool_descs.push((
3980        "image_info",
3981        "Read image file metadata (format, dimensions, size) and optionally base64-encode it. Use when: inspecting images, preparing visual data for analysis.",
3982    ));
3983    if config.browser.enabled {
3984        tool_descs.push((
3985            "browser_open",
3986            "Open approved HTTPS URLs in system browser (allowlist-only, no scraping)",
3987        ));
3988    }
3989    if config.composio.enabled {
3990        tool_descs.push((
3991            "composio",
3992            "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.",
3993        ));
3994    }
3995    tool_descs.push((
3996        "schedule",
3997        "Manage scheduled tasks (create/list/get/cancel/pause/resume). Supports recurring cron and one-shot delays.",
3998    ));
3999    tool_descs.push((
4000        "model_routing_config",
4001        "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'.",
4002    ));
4003    if !config.agents.is_empty() {
4004        tool_descs.push((
4005            "delegate",
4006            "Delegate a sub-task to a specialized agent. Use when: task needs different model/capability, or to parallelize work.",
4007        ));
4008    }
4009    if config.peripherals.enabled && !config.peripherals.boards.is_empty() {
4010        tool_descs.push((
4011            "gpio_read",
4012            "Read GPIO pin value (0 or 1) on connected hardware (STM32, Arduino). Use when: checking sensor/button state, LED status.",
4013        ));
4014        tool_descs.push((
4015            "gpio_write",
4016            "Set GPIO pin high (1) or low (0) on connected hardware. Use when: turning LED on/off, controlling actuators.",
4017        ));
4018        tool_descs.push((
4019            "arduino_upload",
4020            "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; Construct compiles and uploads it. Pin 13 = built-in LED on Uno.",
4021        ));
4022        tool_descs.push((
4023            "hardware_memory_map",
4024            "Return flash and RAM address ranges for connected hardware. Use when: user asks for 'upper and lower memory addresses', 'memory map', or 'readable addresses'.",
4025        ));
4026        tool_descs.push((
4027            "hardware_board_info",
4028            "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'.",
4029        ));
4030        tool_descs.push((
4031            "hardware_memory_read",
4032            "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).",
4033        ));
4034        tool_descs.push((
4035            "hardware_capabilities",
4036            "Query connected hardware for reported GPIO pins and LED pin. Use when: user asks what pins are available.",
4037        ));
4038    }
4039    let bootstrap_max_chars = if config.agent.compact_context {
4040        Some(6000)
4041    } else {
4042        None
4043    };
4044    let native_tools = provider.supports_native_tools();
4045    let mut system_prompt = crate::channels::build_system_prompt_with_mode_and_autonomy(
4046        &config.workspace_dir,
4047        &model_name,
4048        &tool_descs,
4049        &skills,
4050        Some(&config.identity),
4051        bootstrap_max_chars,
4052        Some(&config.autonomy),
4053        native_tools,
4054        config.skills.prompt_injection_mode,
4055        config.agent.compact_context,
4056        config.agent.max_system_prompt_chars,
4057    );
4058
4059    // Append structured tool-use instructions with schemas (only for non-native providers)
4060    if !native_tools {
4061        system_prompt.push_str(&build_tool_instructions(&tools_registry, Some(&i18n_descs)));
4062    }
4063
4064    // Append deferred MCP tool names so the LLM knows what is available
4065    if !deferred_section.is_empty() {
4066        system_prompt.push('\n');
4067        system_prompt.push_str(&deferred_section);
4068    }
4069
4070    // Append Kumiho memory session-bootstrap instructions
4071    crate::agent::kumiho::append_kumiho_bootstrap(&mut system_prompt, &config, false);
4072
4073    // Append Operator orchestration instructions
4074    crate::agent::operator::append_operator_prompt(&mut system_prompt, &config, false, &model_name);
4075
4076    // ── Approval manager (supervised mode) ───────────────────────
4077    let approval_manager = if interactive {
4078        let trust_tracker = std::sync::Arc::new(parking_lot::Mutex::new(
4079            crate::trust::TrustTracker::new(config.trust.clone()),
4080        ));
4081        Some(ApprovalManager::from_config(&config.autonomy).with_trust_tracker(trust_tracker))
4082    } else {
4083        None
4084    };
4085    let channel_name = if interactive { "cli" } else { "daemon" };
4086    let memory_session_id = session_state_file.as_deref().and_then(|path| {
4087        let raw = path.to_string_lossy().trim().to_string();
4088        if raw.is_empty() {
4089            None
4090        } else {
4091            Some(format!("cli:{raw}"))
4092        }
4093    });
4094
4095    // ── Execute ──────────────────────────────────────────────────
4096    let start = Instant::now();
4097
4098    let mut final_output = String::new();
4099
4100    // Save the base system prompt before any thinking modifications so
4101    // the interactive loop can restore it between turns.
4102    let base_system_prompt = system_prompt.clone();
4103
4104    if let Some(msg) = message {
4105        // ── Parse thinking directive from user message ─────────
4106        let (thinking_directive, effective_msg) =
4107            match crate::agent::thinking::parse_thinking_directive(&msg) {
4108                Some((level, remaining)) => {
4109                    tracing::info!(thinking_level = ?level, "Thinking directive parsed from message");
4110                    (Some(level), remaining)
4111                }
4112                None => (None, msg.clone()),
4113            };
4114        let thinking_level = crate::agent::thinking::resolve_thinking_level(
4115            thinking_directive,
4116            None,
4117            &config.agent.thinking,
4118        );
4119        let thinking_params = crate::agent::thinking::apply_thinking_level(thinking_level);
4120        let effective_temperature = crate::agent::thinking::clamp_temperature(
4121            temperature + thinking_params.temperature_adjustment,
4122        );
4123
4124        // Prepend thinking system prompt prefix when present.
4125        if let Some(ref prefix) = thinking_params.system_prompt_prefix {
4126            system_prompt = format!("{prefix}\n\n{system_prompt}");
4127        }
4128
4129        // Auto-save user message to memory (skip short/trivial messages)
4130        if config.memory.auto_save
4131            && effective_msg.chars().count() >= AUTOSAVE_MIN_MESSAGE_CHARS
4132            && !memory::should_skip_autosave_content(&effective_msg)
4133        {
4134            let user_key = autosave_memory_key("user_msg");
4135            let _ = mem
4136                .store(
4137                    &user_key,
4138                    &effective_msg,
4139                    MemoryCategory::Conversation,
4140                    memory_session_id.as_deref(),
4141                )
4142                .await;
4143        }
4144
4145        // Inject memory + hardware RAG context into user message
4146        let mem_context = build_context(
4147            mem.as_ref(),
4148            &effective_msg,
4149            config.memory.min_relevance_score,
4150            memory_session_id.as_deref(),
4151        )
4152        .await;
4153        let rag_limit = if config.agent.compact_context { 2 } else { 5 };
4154        let hw_context = hardware_rag
4155            .as_ref()
4156            .map(|r| build_hardware_context(r, &effective_msg, &board_names, rag_limit))
4157            .unwrap_or_default();
4158        let context = format!("{mem_context}{hw_context}");
4159        let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z");
4160        let enriched = if context.is_empty() {
4161            format!("[{now}] {effective_msg}")
4162        } else {
4163            format!("{context}[{now}] {effective_msg}")
4164        };
4165
4166        let mut history = vec![
4167            ChatMessage::system(&system_prompt),
4168            ChatMessage::user(&enriched),
4169        ];
4170
4171        // Prune history for token efficiency (when enabled).
4172        if config.agent.history_pruning.enabled {
4173            let _stats = crate::agent::history_pruner::prune_history(
4174                &mut history,
4175                &config.agent.history_pruning,
4176            );
4177        }
4178
4179        // Compute per-turn excluded MCP tools from tool_filter_groups.
4180        let excluded_tools = compute_excluded_mcp_tools(
4181            &tools_registry,
4182            &config.agent.tool_filter_groups,
4183            &effective_msg,
4184        );
4185
4186        #[allow(unused_assignments)]
4187        let mut response = String::new();
4188        loop {
4189            match run_tool_call_loop(
4190                provider.as_ref(),
4191                &mut history,
4192                &tools_registry,
4193                observer.as_ref(),
4194                &provider_name,
4195                &model_name,
4196                effective_temperature,
4197                false,
4198                approval_manager.as_ref(),
4199                channel_name,
4200                None,
4201                &config.multimodal,
4202                effective_max_tool_iterations(&config),
4203                None,
4204                None,
4205                None,
4206                &excluded_tools,
4207                &config.agent.tool_call_dedup_exempt,
4208                activated_handle.as_ref(),
4209                Some(model_switch_callback.clone()),
4210                &config.pacing,
4211                config.agent.max_tool_result_chars,
4212                config.agent.max_context_tokens,
4213                None, // shared_budget
4214            )
4215            .await
4216            {
4217                Ok(resp) => {
4218                    response = resp;
4219                    break;
4220                }
4221                Err(e) => {
4222                    if let Some((new_provider, new_model)) = is_model_switch_requested(&e) {
4223                        tracing::info!(
4224                            "Model switch requested, switching from {} {} to {} {}",
4225                            provider_name,
4226                            model_name,
4227                            new_provider,
4228                            new_model
4229                        );
4230
4231                        provider = providers::create_routed_provider_with_options(
4232                            &new_provider,
4233                            config.api_key.as_deref(),
4234                            config.api_url.as_deref(),
4235                            &config.reliability,
4236                            &config.model_routes,
4237                            &new_model,
4238                            &provider_runtime_options,
4239                        )?;
4240
4241                        provider_name = new_provider;
4242                        model_name = new_model;
4243
4244                        clear_model_switch_request();
4245
4246                        observer.record_event(&ObserverEvent::AgentStart {
4247                            provider: provider_name.to_string(),
4248                            model: model_name.to_string(),
4249                        });
4250
4251                        continue;
4252                    }
4253                    return Err(e);
4254                }
4255            }
4256        }
4257
4258        // After successful multi-step execution, attempt autonomous skill creation.
4259        #[cfg(feature = "skill-creation")]
4260        if config.skills.skill_creation.enabled {
4261            let tool_calls = crate::skills::creator::extract_tool_calls_from_history(&history);
4262            if tool_calls.len() >= 2 {
4263                let creator = crate::skills::creator::SkillCreator::new(
4264                    config.workspace_dir.clone(),
4265                    config.skills.skill_creation.clone(),
4266                );
4267                match creator.create_from_execution(&msg, &tool_calls, None).await {
4268                    Ok(Some(slug)) => {
4269                        tracing::info!(slug, "Auto-created skill from execution");
4270                    }
4271                    Ok(None) => {
4272                        tracing::debug!("Skill creation skipped (duplicate or disabled)");
4273                    }
4274                    Err(e) => tracing::warn!("Skill creation failed: {e}"),
4275                }
4276            }
4277        }
4278        final_output = response.clone();
4279        println!("{response}");
4280        observer.record_event(&ObserverEvent::TurnComplete);
4281    } else {
4282        println!("🦀 Construct Interactive Mode");
4283        println!("Type /help for commands.\n");
4284        let cli = crate::channels::CliChannel::new();
4285
4286        // Persistent conversation history across turns
4287        let mut history = if let Some(path) = session_state_file.as_deref() {
4288            load_interactive_session_history(path, &system_prompt)?
4289        } else {
4290            vec![ChatMessage::system(&system_prompt)]
4291        };
4292
4293        loop {
4294            print!("> ");
4295            let _ = std::io::stdout().flush();
4296
4297            // Read raw bytes to avoid UTF-8 validation errors when PTY
4298            // transport splits multi-byte characters at frame boundaries
4299            // (e.g. CJK input with spaces over kubectl exec / SSH).
4300            let mut raw = Vec::new();
4301            match std::io::BufRead::read_until(&mut std::io::stdin().lock(), b'\n', &mut raw) {
4302                Ok(0) => break,
4303                Ok(_) => {}
4304                Err(e) => {
4305                    eprintln!("\nError reading input: {e}\n");
4306                    break;
4307                }
4308            }
4309            let input = String::from_utf8_lossy(&raw).into_owned();
4310
4311            let user_input = input.trim().to_string();
4312            if user_input.is_empty() {
4313                continue;
4314            }
4315            match user_input.as_str() {
4316                "/quit" | "/exit" => break,
4317                "/help" => {
4318                    println!("Available commands:");
4319                    println!("  /help             Show this help message");
4320                    println!("  /clear /new       Clear conversation history");
4321                    println!("  /quit /exit       Exit interactive mode");
4322                    println!(
4323                        "  /think:<level>    Set reasoning depth (off|minimal|low|medium|high|max)\n"
4324                    );
4325                    continue;
4326                }
4327                "/clear" | "/new" => {
4328                    println!(
4329                        "This will clear the current conversation and delete all session memory."
4330                    );
4331                    println!("Core memories (long-term facts/preferences) will be preserved.");
4332                    print!("Continue? [y/N] ");
4333                    let _ = std::io::stdout().flush();
4334
4335                    let mut confirm_raw = Vec::new();
4336                    if std::io::BufRead::read_until(
4337                        &mut std::io::stdin().lock(),
4338                        b'\n',
4339                        &mut confirm_raw,
4340                    )
4341                    .is_err()
4342                    {
4343                        continue;
4344                    }
4345                    let confirm = String::from_utf8_lossy(&confirm_raw);
4346                    if !matches!(confirm.trim().to_lowercase().as_str(), "y" | "yes") {
4347                        println!("Cancelled.\n");
4348                        continue;
4349                    }
4350
4351                    history.clear();
4352                    history.push(ChatMessage::system(&system_prompt));
4353                    // Clear conversation and daily memory
4354                    let mut cleared = 0;
4355                    for category in [MemoryCategory::Conversation, MemoryCategory::Daily] {
4356                        let entries = mem.list(Some(&category), None).await.unwrap_or_default();
4357                        for entry in entries {
4358                            if mem.forget(&entry.key).await.unwrap_or(false) {
4359                                cleared += 1;
4360                            }
4361                        }
4362                    }
4363                    if cleared > 0 {
4364                        println!("Conversation cleared ({cleared} memory entries removed).\n");
4365                    } else {
4366                        println!("Conversation cleared.\n");
4367                    }
4368                    if let Some(path) = session_state_file.as_deref() {
4369                        save_interactive_session_history(path, &history)?;
4370                    }
4371                    continue;
4372                }
4373                _ => {}
4374            }
4375
4376            // ── Parse thinking directive from interactive input ───
4377            let (thinking_directive, effective_input) =
4378                match crate::agent::thinking::parse_thinking_directive(&user_input) {
4379                    Some((level, remaining)) => {
4380                        tracing::info!(thinking_level = ?level, "Thinking directive parsed");
4381                        (Some(level), remaining)
4382                    }
4383                    None => (None, user_input.clone()),
4384                };
4385            let thinking_level = crate::agent::thinking::resolve_thinking_level(
4386                thinking_directive,
4387                None,
4388                &config.agent.thinking,
4389            );
4390            let thinking_params = crate::agent::thinking::apply_thinking_level(thinking_level);
4391            let turn_temperature = crate::agent::thinking::clamp_temperature(
4392                temperature + thinking_params.temperature_adjustment,
4393            );
4394
4395            // For non-Medium levels, temporarily patch the system prompt with prefix.
4396            let turn_system_prompt;
4397            if let Some(ref prefix) = thinking_params.system_prompt_prefix {
4398                turn_system_prompt = format!("{prefix}\n\n{system_prompt}");
4399                // Update the system message in history for this turn.
4400                if let Some(sys_msg) = history.first_mut() {
4401                    if sys_msg.role == "system" {
4402                        sys_msg.content = turn_system_prompt.clone();
4403                    }
4404                }
4405            }
4406
4407            // Auto-save conversation turns (skip short/trivial messages)
4408            if config.memory.auto_save
4409                && effective_input.chars().count() >= AUTOSAVE_MIN_MESSAGE_CHARS
4410                && !memory::should_skip_autosave_content(&effective_input)
4411            {
4412                let user_key = autosave_memory_key("user_msg");
4413                let _ = mem
4414                    .store(
4415                        &user_key,
4416                        &effective_input,
4417                        MemoryCategory::Conversation,
4418                        memory_session_id.as_deref(),
4419                    )
4420                    .await;
4421            }
4422
4423            // Inject memory + hardware RAG context into user message
4424            let mem_context = build_context(
4425                mem.as_ref(),
4426                &effective_input,
4427                config.memory.min_relevance_score,
4428                memory_session_id.as_deref(),
4429            )
4430            .await;
4431            let rag_limit = if config.agent.compact_context { 2 } else { 5 };
4432            let hw_context = hardware_rag
4433                .as_ref()
4434                .map(|r| build_hardware_context(r, &effective_input, &board_names, rag_limit))
4435                .unwrap_or_default();
4436            let context = format!("{mem_context}{hw_context}");
4437            let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z");
4438            let enriched = if context.is_empty() {
4439                format!("[{now}] {effective_input}")
4440            } else {
4441                format!("{context}[{now}] {effective_input}")
4442            };
4443
4444            history.push(ChatMessage::user(&enriched));
4445
4446            // Compute per-turn excluded MCP tools from tool_filter_groups.
4447            let excluded_tools = compute_excluded_mcp_tools(
4448                &tools_registry,
4449                &config.agent.tool_filter_groups,
4450                &effective_input,
4451            );
4452
4453            // Set up streaming channel so tool progress and response
4454            // content are printed progressively instead of buffered.
4455            let (delta_tx, mut delta_rx) = tokio::sync::mpsc::channel::<DraftEvent>(64);
4456            let content_was_streamed =
4457                std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
4458            let content_streamed_flag = content_was_streamed.clone();
4459            let is_tty = std::io::IsTerminal::is_terminal(&std::io::stderr());
4460
4461            let consumer_handle = tokio::spawn(async move {
4462                use std::io::Write;
4463                while let Some(event) = delta_rx.recv().await {
4464                    match event {
4465                        DraftEvent::Clear => {
4466                            let _ = writeln!(std::io::stderr());
4467                        }
4468                        DraftEvent::Progress(text) => {
4469                            if is_tty {
4470                                let _ = write!(std::io::stderr(), "\x1b[2m{text}\x1b[0m");
4471                            } else {
4472                                let _ = write!(std::io::stderr(), "{text}");
4473                            }
4474                            let _ = std::io::stderr().flush();
4475                        }
4476                        DraftEvent::Content(text) => {
4477                            content_streamed_flag.store(true, std::sync::atomic::Ordering::Relaxed);
4478                            print!("{text}");
4479                            let _ = std::io::stdout().flush();
4480                        }
4481                    }
4482                }
4483            });
4484
4485            // Ctrl+C cancels the in-flight turn instead of killing the process.
4486            let cancel_token = CancellationToken::new();
4487            let cancel_token_clone = cancel_token.clone();
4488            let ctrlc_handle = tokio::spawn(async move {
4489                if tokio::signal::ctrl_c().await.is_ok() {
4490                    cancel_token_clone.cancel();
4491                }
4492            });
4493
4494            let response = loop {
4495                match run_tool_call_loop(
4496                    provider.as_ref(),
4497                    &mut history,
4498                    &tools_registry,
4499                    observer.as_ref(),
4500                    &provider_name,
4501                    &model_name,
4502                    turn_temperature,
4503                    true,
4504                    approval_manager.as_ref(),
4505                    channel_name,
4506                    None,
4507                    &config.multimodal,
4508                    effective_max_tool_iterations(&config),
4509                    Some(cancel_token.clone()),
4510                    Some(delta_tx.clone()),
4511                    None,
4512                    &excluded_tools,
4513                    &config.agent.tool_call_dedup_exempt,
4514                    activated_handle.as_ref(),
4515                    Some(model_switch_callback.clone()),
4516                    &config.pacing,
4517                    config.agent.max_tool_result_chars,
4518                    config.agent.max_context_tokens,
4519                    None, // shared_budget
4520                )
4521                .await
4522                {
4523                    Ok(resp) => break resp,
4524                    Err(e) => {
4525                        if is_tool_loop_cancelled(&e) {
4526                            eprintln!("\n\x1b[2m(cancelled)\x1b[0m");
4527                            break String::new();
4528                        }
4529                        if let Some((new_provider, new_model)) = is_model_switch_requested(&e) {
4530                            tracing::info!(
4531                                "Model switch requested, switching from {} {} to {} {}",
4532                                provider_name,
4533                                model_name,
4534                                new_provider,
4535                                new_model
4536                            );
4537
4538                            provider = providers::create_routed_provider_with_options(
4539                                &new_provider,
4540                                config.api_key.as_deref(),
4541                                config.api_url.as_deref(),
4542                                &config.reliability,
4543                                &config.model_routes,
4544                                &new_model,
4545                                &provider_runtime_options,
4546                            )?;
4547
4548                            provider_name = new_provider;
4549                            model_name = new_model;
4550
4551                            clear_model_switch_request();
4552
4553                            observer.record_event(&ObserverEvent::AgentStart {
4554                                provider: provider_name.to_string(),
4555                                model: model_name.to_string(),
4556                            });
4557
4558                            continue;
4559                        }
4560                        // Context overflow recovery: compress and retry
4561                        if crate::providers::reliable::is_context_window_exceeded(&e) {
4562                            tracing::warn!(
4563                                "Context overflow in interactive loop, attempting recovery"
4564                            );
4565                            let mut compressor =
4566                                crate::agent::context_compressor::ContextCompressor::new(
4567                                    config.agent.context_compression.clone(),
4568                                    config.agent.max_context_tokens,
4569                                )
4570                                .with_memory(mem.clone());
4571                            let error_msg = format!("{e}");
4572                            match compressor
4573                                .compress_on_error(
4574                                    &mut history,
4575                                    provider.as_ref(),
4576                                    &model_name,
4577                                    &error_msg,
4578                                )
4579                                .await
4580                            {
4581                                Ok(true) => {
4582                                    tracing::info!(
4583                                        "Context recovered via compression, retrying turn"
4584                                    );
4585                                    continue;
4586                                }
4587                                Ok(false) => {
4588                                    tracing::warn!("Compression ran but couldn't reduce enough");
4589                                }
4590                                Err(compress_err) => {
4591                                    tracing::warn!(
4592                                        error = %compress_err,
4593                                        "Compression failed during recovery"
4594                                    );
4595                                }
4596                            }
4597                        }
4598
4599                        eprintln!("\nError: {e}\n");
4600                        break String::new();
4601                    }
4602                }
4603            };
4604
4605            // Clean up: stop the Ctrl+C listener and flush streaming events.
4606            ctrlc_handle.abort();
4607            drop(delta_tx);
4608            let _ = consumer_handle.await;
4609
4610            final_output = response.clone();
4611            if content_was_streamed.load(std::sync::atomic::Ordering::Relaxed) {
4612                println!();
4613            } else if let Err(e) = crate::channels::Channel::send(
4614                &cli,
4615                &crate::channels::traits::SendMessage::new(format!("\n{response}\n"), "user"),
4616            )
4617            .await
4618            {
4619                eprintln!("\nError sending CLI response: {e}\n");
4620            }
4621            observer.record_event(&ObserverEvent::TurnComplete);
4622
4623            // Context compression before hard trimming to preserve long-context signal.
4624            {
4625                let compressor = crate::agent::context_compressor::ContextCompressor::new(
4626                    config.agent.context_compression.clone(),
4627                    config.agent.max_context_tokens,
4628                )
4629                .with_memory(mem.clone());
4630                match compressor
4631                    .compress_if_needed(&mut history, provider.as_ref(), &model_name)
4632                    .await
4633                {
4634                    Ok(result) if result.compressed => {
4635                        tracing::info!(
4636                            passes = result.passes_used,
4637                            before = result.tokens_before,
4638                            after = result.tokens_after,
4639                            "Context compression complete"
4640                        );
4641                    }
4642                    Ok(_) => {} // No compression needed
4643                    Err(e) => {
4644                        tracing::warn!(
4645                            error = %e,
4646                            "Context compression failed, falling back to history trim"
4647                        );
4648                        trim_history(&mut history, config.agent.max_history_messages / 2);
4649                    }
4650                }
4651            }
4652
4653            // Hard cap as a safety net.
4654            trim_history(&mut history, config.agent.max_history_messages);
4655
4656            // Restore base system prompt (remove per-turn thinking prefix).
4657            if thinking_params.system_prompt_prefix.is_some() {
4658                if let Some(sys_msg) = history.first_mut() {
4659                    if sys_msg.role == "system" {
4660                        sys_msg.content.clone_from(&base_system_prompt);
4661                    }
4662                }
4663            }
4664
4665            if let Some(path) = session_state_file.as_deref() {
4666                save_interactive_session_history(path, &history)?;
4667            }
4668        }
4669    }
4670
4671    let duration = start.elapsed();
4672    observer.record_event(&ObserverEvent::AgentEnd {
4673        provider: provider_name.to_string(),
4674        model: model_name.to_string(),
4675        duration,
4676        tokens_used: None,
4677        cost_usd: None,
4678    });
4679
4680    Ok(final_output)
4681}
4682
4683/// Process a single message through the full agent (with tools, peripherals, memory).
4684/// Used by channels (Telegram, Discord, etc.) to enable hardware and tool use.
4685pub async fn process_message(
4686    config: Config,
4687    message: &str,
4688    session_id: Option<&str>,
4689) -> Result<String> {
4690    let observer: Arc<dyn Observer> =
4691        Arc::from(observability::create_observer(&config.observability));
4692    let runtime: Arc<dyn runtime::RuntimeAdapter> =
4693        Arc::from(runtime::create_runtime(&config.runtime)?);
4694    let security = Arc::new(SecurityPolicy::from_config(
4695        &config.autonomy,
4696        &config.workspace_dir,
4697    ));
4698    let approval_manager = ApprovalManager::for_non_interactive(&config.autonomy);
4699    let mem: Arc<dyn Memory> = Arc::from(memory::create_memory_with_storage_and_routes(
4700        &config.memory,
4701        &config.embedding_routes,
4702        Some(&config.storage.provider.config),
4703        &config.workspace_dir,
4704        config.api_key.as_deref(),
4705    )?);
4706
4707    let (composio_key, composio_entity_id) = if config.composio.enabled {
4708        (
4709            config.composio.api_key.as_deref(),
4710            Some(config.composio.entity_id.as_str()),
4711        )
4712    } else {
4713        (None, None)
4714    };
4715    let (
4716        mut tools_registry,
4717        delegate_handle_pm,
4718        _reaction_handle_pm,
4719        _channel_map_handle_pm,
4720        _ask_user_handle_pm,
4721        _escalate_handle_pm,
4722    ) = tools::all_tools_with_runtime(
4723        Arc::new(config.clone()),
4724        &security,
4725        runtime,
4726        mem.clone(),
4727        composio_key,
4728        composio_entity_id,
4729        &config.browser,
4730        &config.http_request,
4731        &config.web_fetch,
4732        &config.workspace_dir,
4733        &config.agents,
4734        config.api_key.as_deref(),
4735        &config,
4736        None,
4737    );
4738    let peripheral_tools: Vec<Box<dyn Tool>> =
4739        crate::peripherals::create_peripheral_tools(&config.peripherals).await?;
4740    tools_registry.extend(peripheral_tools);
4741
4742    // ── Inject Kumiho memory MCP server (first-class, non-fatal) ──
4743    let config = crate::agent::kumiho::inject_kumiho(config, false);
4744
4745    // ── Inject Operator orchestration MCP server (first-class, non-fatal) ──
4746    let config = crate::agent::operator::inject_operator(config, false);
4747
4748    // ── Wire MCP tools (non-fatal) — process_message path ────────
4749    // NOTE: Same ordering contract as the CLI path above — MCP tools must be
4750    // injected after filter_primary_agent_tools_or_fail (or equivalent built-in
4751    // tool allow/deny filtering) to avoid MCP tools being silently dropped.
4752    let mut deferred_section = String::new();
4753    let mut activated_handle_pm: Option<
4754        std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>,
4755    > = None;
4756    if config.mcp.enabled && !config.mcp.servers.is_empty() {
4757        tracing::info!(
4758            "Initializing MCP client — {} server(s) configured",
4759            config.mcp.servers.len()
4760        );
4761        match crate::tools::McpRegistry::connect_all(&config.mcp.servers).await {
4762            Ok(registry) => {
4763                let registry = std::sync::Arc::new(registry);
4764                if config.mcp.deferred_loading {
4765                    // Hybrid: eagerly load operator tools, defer the rest.
4766                    let operator_prefix =
4767                        format!("{}__", crate::agent::operator::OPERATOR_SERVER_NAME);
4768                    let all_names = registry.tool_names();
4769                    let mut eager_count = 0usize;
4770
4771                    let is_eager = |name: &str| -> bool {
4772                        name.starts_with(&operator_prefix)
4773                            || name == "kumiho-memory__kumiho_memory_engage"
4774                            || name == "kumiho-memory__kumiho_memory_reflect"
4775                    };
4776
4777                    for name in &all_names {
4778                        if is_eager(name) {
4779                            if let Some(def) = registry.get_tool_def(name).await {
4780                                let wrapper: std::sync::Arc<dyn Tool> =
4781                                    std::sync::Arc::new(crate::tools::McpToolWrapper::new(
4782                                        name.clone(),
4783                                        def,
4784                                        std::sync::Arc::clone(&registry),
4785                                    ));
4786                                if let Some(ref handle) = delegate_handle_pm {
4787                                    handle.write().push(std::sync::Arc::clone(&wrapper));
4788                                }
4789                                tools_registry.push(Box::new(crate::tools::ArcToolRef(wrapper)));
4790                                eager_count += 1;
4791                            }
4792                        }
4793                    }
4794
4795                    let operator_pfx = operator_prefix.clone();
4796                    let deferred_set = crate::tools::DeferredMcpToolSet::from_registry_filtered(
4797                        std::sync::Arc::clone(&registry),
4798                        move |name: &str| {
4799                            !(name.starts_with(&operator_pfx)
4800                                || name == "kumiho-memory__kumiho_memory_engage"
4801                                || name == "kumiho-memory__kumiho_memory_reflect")
4802                        },
4803                    )
4804                    .await;
4805                    tracing::info!(
4806                        "MCP hybrid: {} eager tool(s) (operator + kumiho reflexes), {} deferred stub(s) from {} server(s)",
4807                        eager_count,
4808                        deferred_set.len(),
4809                        registry.server_count()
4810                    );
4811                    deferred_section =
4812                        crate::tools::mcp_deferred::build_deferred_tools_section(&deferred_set);
4813                    let activated = std::sync::Arc::new(std::sync::Mutex::new(
4814                        crate::tools::ActivatedToolSet::new(),
4815                    ));
4816                    activated_handle_pm = Some(std::sync::Arc::clone(&activated));
4817                    tools_registry.push(Box::new(crate::tools::ToolSearchTool::new(
4818                        deferred_set,
4819                        activated,
4820                    )));
4821                } else {
4822                    let names = registry.tool_names();
4823                    let mut registered = 0usize;
4824                    for name in names {
4825                        if let Some(def) = registry.get_tool_def(&name).await {
4826                            let wrapper: std::sync::Arc<dyn Tool> =
4827                                std::sync::Arc::new(crate::tools::McpToolWrapper::new(
4828                                    name,
4829                                    def,
4830                                    std::sync::Arc::clone(&registry),
4831                                ));
4832                            if let Some(ref handle) = delegate_handle_pm {
4833                                handle.write().push(std::sync::Arc::clone(&wrapper));
4834                            }
4835                            tools_registry.push(Box::new(crate::tools::ArcToolRef(wrapper)));
4836                            registered += 1;
4837                        }
4838                    }
4839                    tracing::info!(
4840                        "MCP: {} tool(s) registered from {} server(s)",
4841                        registered,
4842                        registry.server_count()
4843                    );
4844                }
4845            }
4846            Err(e) => {
4847                tracing::error!("MCP registry failed to initialize: {e:#}");
4848            }
4849        }
4850    }
4851
4852    let provider_name = config.default_provider.as_deref().unwrap_or("openrouter");
4853    let model_name = config
4854        .default_model
4855        .clone()
4856        .unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into());
4857    let provider_runtime_options = providers::provider_runtime_options_from_config(&config);
4858    let provider: Box<dyn Provider> = providers::create_routed_provider_with_options(
4859        provider_name,
4860        config.api_key.as_deref(),
4861        config.api_url.as_deref(),
4862        &config.reliability,
4863        &config.model_routes,
4864        &model_name,
4865        &provider_runtime_options,
4866    )?;
4867
4868    let hardware_rag: Option<crate::rag::HardwareRag> = config
4869        .peripherals
4870        .datasheet_dir
4871        .as_ref()
4872        .filter(|d| !d.trim().is_empty())
4873        .map(|dir| crate::rag::HardwareRag::load(&config.workspace_dir, dir.trim()))
4874        .and_then(Result::ok)
4875        .filter(|r: &crate::rag::HardwareRag| !r.is_empty());
4876    let board_names: Vec<String> = config
4877        .peripherals
4878        .boards
4879        .iter()
4880        .map(|b| b.board.clone())
4881        .collect();
4882
4883    // ── Load locale-aware tool descriptions ────────────────────────
4884    let i18n_locale = config
4885        .locale
4886        .as_deref()
4887        .filter(|s| !s.is_empty())
4888        .map(ToString::to_string)
4889        .unwrap_or_else(crate::i18n::detect_locale);
4890    let i18n_search_dirs = crate::i18n::default_search_dirs(&config.workspace_dir);
4891    let i18n_descs = crate::i18n::ToolDescriptions::load(&i18n_locale, &i18n_search_dirs);
4892
4893    let skills = crate::skills::load_skills_with_config(&config.workspace_dir, &config);
4894
4895    // Register skill-defined tools as callable tool specs (process_message path).
4896    tools::register_skill_tools(&mut tools_registry, &skills, security.clone());
4897
4898    let mut tool_descs: Vec<(&str, &str)> = vec![
4899        ("shell", "Execute terminal commands."),
4900        ("file_read", "Read file contents."),
4901        ("file_write", "Write file contents."),
4902        ("memory_store", "Save to memory."),
4903        ("memory_recall", "Search memory."),
4904        ("memory_forget", "Delete a memory entry."),
4905        (
4906            "model_routing_config",
4907            "Configure default model, scenario routing, and delegate agents.",
4908        ),
4909        ("screenshot", "Capture a screenshot."),
4910        ("image_info", "Read image metadata."),
4911    ];
4912    if matches!(
4913        config.skills.prompt_injection_mode,
4914        crate::config::SkillsPromptInjectionMode::Compact
4915    ) {
4916        tool_descs.push((
4917            "read_skill",
4918            "Load the full source for an available skill by name.",
4919        ));
4920    }
4921    if config.browser.enabled {
4922        tool_descs.push(("browser_open", "Open approved URLs in browser."));
4923    }
4924    if config.composio.enabled {
4925        tool_descs.push(("composio", "Execute actions on 1000+ apps via Composio."));
4926    }
4927    if config.peripherals.enabled && !config.peripherals.boards.is_empty() {
4928        tool_descs.push(("gpio_read", "Read GPIO pin value on connected hardware."));
4929        tool_descs.push((
4930            "gpio_write",
4931            "Set GPIO pin high or low on connected hardware.",
4932        ));
4933        tool_descs.push((
4934            "arduino_upload",
4935            "Upload Arduino sketch. Use for 'make a heart', custom patterns. You write full .ino code; Construct uploads it.",
4936        ));
4937        tool_descs.push((
4938            "hardware_memory_map",
4939            "Return flash and RAM address ranges. Use when user asks for memory addresses or memory map.",
4940        ));
4941        tool_descs.push((
4942            "hardware_board_info",
4943            "Return full board info (chip, architecture, memory map). Use when user asks for board info, what board, connected hardware, or chip info.",
4944        ));
4945        tool_descs.push((
4946            "hardware_memory_read",
4947            "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.",
4948        ));
4949        tool_descs.push((
4950            "hardware_capabilities",
4951            "Query connected hardware for reported GPIO pins and LED pin. Use when user asks what pins are available.",
4952        ));
4953    }
4954
4955    // Filter out tools excluded for non-CLI channels (gateway counts as non-CLI).
4956    // Skip when autonomy is `Full` — full-autonomy agents keep all tools.
4957    if config.autonomy.level != AutonomyLevel::Full {
4958        let excluded = &config.autonomy.non_cli_excluded_tools;
4959        if !excluded.is_empty() {
4960            tool_descs.retain(|(name, _)| !excluded.iter().any(|ex| ex == name));
4961        }
4962    }
4963
4964    let bootstrap_max_chars = if config.agent.compact_context {
4965        Some(6000)
4966    } else {
4967        None
4968    };
4969    let native_tools = provider.supports_native_tools();
4970    let mut system_prompt = crate::channels::build_system_prompt_with_mode_and_autonomy(
4971        &config.workspace_dir,
4972        &model_name,
4973        &tool_descs,
4974        &skills,
4975        Some(&config.identity),
4976        bootstrap_max_chars,
4977        Some(&config.autonomy),
4978        native_tools,
4979        config.skills.prompt_injection_mode,
4980        config.agent.compact_context,
4981        config.agent.max_system_prompt_chars,
4982    );
4983    if !native_tools {
4984        system_prompt.push_str(&build_tool_instructions(&tools_registry, Some(&i18n_descs)));
4985    }
4986    if !deferred_section.is_empty() {
4987        system_prompt.push('\n');
4988        system_prompt.push_str(&deferred_section);
4989    }
4990
4991    // Append Kumiho memory session-bootstrap instructions
4992    crate::agent::kumiho::append_kumiho_bootstrap(&mut system_prompt, &config, false);
4993
4994    // Append Operator orchestration instructions
4995    crate::agent::operator::append_operator_prompt(&mut system_prompt, &config, false, &model_name);
4996
4997    // ── Parse thinking directive from user message ─────────────
4998    let (thinking_directive, effective_message) =
4999        match crate::agent::thinking::parse_thinking_directive(message) {
5000            Some((level, remaining)) => {
5001                tracing::info!(thinking_level = ?level, "Thinking directive parsed from message");
5002                (Some(level), remaining)
5003            }
5004            None => (None, message.to_string()),
5005        };
5006    let thinking_level = crate::agent::thinking::resolve_thinking_level(
5007        thinking_directive,
5008        None,
5009        &config.agent.thinking,
5010    );
5011    let thinking_params = crate::agent::thinking::apply_thinking_level(thinking_level);
5012    let effective_temperature = crate::agent::thinking::clamp_temperature(
5013        config.default_temperature + thinking_params.temperature_adjustment,
5014    );
5015
5016    // Prepend thinking system prompt prefix when present.
5017    if let Some(ref prefix) = thinking_params.system_prompt_prefix {
5018        system_prompt = format!("{prefix}\n\n{system_prompt}");
5019    }
5020
5021    let effective_msg_ref = effective_message.as_str();
5022    let mem_context = build_context(
5023        mem.as_ref(),
5024        effective_msg_ref,
5025        config.memory.min_relevance_score,
5026        session_id,
5027    )
5028    .await;
5029    let rag_limit = if config.agent.compact_context { 2 } else { 5 };
5030    let hw_context = hardware_rag
5031        .as_ref()
5032        .map(|r| build_hardware_context(r, effective_msg_ref, &board_names, rag_limit))
5033        .unwrap_or_default();
5034    let context = format!("{mem_context}{hw_context}");
5035    let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z");
5036    let enriched = if context.is_empty() {
5037        format!("[{now}] {effective_message}")
5038    } else {
5039        format!("{context}[{now}] {effective_message}")
5040    };
5041
5042    let mut history = vec![
5043        ChatMessage::system(&system_prompt),
5044        ChatMessage::user(&enriched),
5045    ];
5046    let mut excluded_tools = compute_excluded_mcp_tools(
5047        &tools_registry,
5048        &config.agent.tool_filter_groups,
5049        effective_msg_ref,
5050    );
5051    if config.autonomy.level != AutonomyLevel::Full {
5052        excluded_tools.extend(config.autonomy.non_cli_excluded_tools.iter().cloned());
5053    }
5054
5055    agent_turn(
5056        provider.as_ref(),
5057        &mut history,
5058        &tools_registry,
5059        observer.as_ref(),
5060        provider_name,
5061        &model_name,
5062        effective_temperature,
5063        true,
5064        "daemon",
5065        None,
5066        &config.multimodal,
5067        config.agent.max_tool_iterations,
5068        Some(&approval_manager),
5069        &excluded_tools,
5070        &config.agent.tool_call_dedup_exempt,
5071        activated_handle_pm.as_ref(),
5072        None,
5073    )
5074    .await
5075}
5076
5077#[cfg(test)]
5078mod tests {
5079    use super::{
5080        emergency_history_trim, estimate_history_tokens, fast_trim_tool_results,
5081        load_interactive_session_history, save_interactive_session_history, truncate_tool_result,
5082    };
5083    use crate::agent::history::{DEFAULT_MAX_HISTORY_MESSAGES, InteractiveSessionState};
5084    use crate::agent::tool_execution::execute_one_tool;
5085    use crate::providers::ChatMessage;
5086    use tempfile::tempdir;
5087
5088    // ── truncate_tool_result tests ────────────────────────────────
5089
5090    #[test]
5091    fn truncate_tool_result_short_passthrough() {
5092        let output = "short output";
5093        assert_eq!(truncate_tool_result(output, 100), output);
5094    }
5095
5096    #[test]
5097    fn truncate_tool_result_exact_boundary() {
5098        let output = "a".repeat(100);
5099        assert_eq!(truncate_tool_result(&output, 100), output);
5100    }
5101
5102    #[test]
5103    fn truncate_tool_result_zero_disables() {
5104        let output = "a".repeat(200_000);
5105        assert_eq!(truncate_tool_result(&output, 0), output);
5106    }
5107
5108    #[test]
5109    fn truncate_tool_result_truncates_with_marker() {
5110        let output = "a".repeat(200);
5111        let result = truncate_tool_result(&output, 100);
5112        assert!(result.contains("[... "));
5113        assert!(result.contains("characters truncated ...]\n\n"));
5114        // Head should be ~2/3 of 100 = 66, tail ~1/3 = 34
5115        assert!(result.starts_with("aaa"));
5116        assert!(result.ends_with("aaa"));
5117        // Result should be shorter than original
5118        assert!(result.len() < output.len());
5119    }
5120
5121    #[test]
5122    fn truncate_tool_result_preserves_head_tail_ratio() {
5123        let output: String = (0u32..1000)
5124            .map(|i| char::from(b'a' + (i % 26) as u8))
5125            .collect();
5126        let result = truncate_tool_result(&output, 300);
5127        // Head = 2/3 of 300 = 200 chars, tail = 100 chars
5128        // Find the marker
5129        let marker_start = result.find("[... ").unwrap();
5130        let marker_end = result.find("characters truncated ...]\n\n").unwrap()
5131            + "characters truncated ...]\n\n".len();
5132        let head = &result[..marker_start - 2]; // subtract \n\n
5133        let tail = &result[marker_end..];
5134        assert!(
5135            head.len() >= 190 && head.len() <= 210,
5136            "head len={}",
5137            head.len()
5138        );
5139        assert!(
5140            tail.len() >= 90 && tail.len() <= 110,
5141            "tail len={}",
5142            tail.len()
5143        );
5144    }
5145
5146    #[test]
5147    fn truncate_tool_result_utf8_boundary_safety() {
5148        // Create string with multi-byte chars: each emoji is 4 bytes
5149        let output = "🦀".repeat(100); // 400 bytes
5150        // This should not panic even with a limit that falls mid-char
5151        let result = truncate_tool_result(&output, 50);
5152        assert!(result.contains("[... "));
5153        // Verify the result is valid UTF-8 (would panic otherwise)
5154        let _ = result.len();
5155    }
5156
5157    #[test]
5158    fn truncate_tool_result_very_small_max() {
5159        let output = "abcdefghijklmnopqrstuvwxyz";
5160        // With max=5, head=3 tail=2 — result includes marker overhead
5161        // but should not panic and should contain truncation marker
5162        let result = truncate_tool_result(output, 5);
5163        assert!(result.contains("[... "));
5164        // Head (3 chars) + tail (2 chars) from original should be preserved
5165        assert!(result.starts_with("abc"));
5166        assert!(result.ends_with("yz"));
5167    }
5168
5169    // ── fast_trim_tool_results tests ────────────────────────────
5170
5171    #[test]
5172    fn fast_trim_protects_recent_messages() {
5173        let mut history = vec![
5174            ChatMessage::system("sys"),
5175            ChatMessage::tool("a".repeat(5000)),
5176            ChatMessage::tool("b".repeat(5000)),
5177            ChatMessage::user("recent user msg"),
5178            ChatMessage::tool("c".repeat(5000)), // recent, should be protected
5179        ];
5180        // protect_last_n = 2 → last 2 messages protected
5181        let saved = fast_trim_tool_results(&mut history, 2);
5182        assert!(saved > 0);
5183        // First two tool messages should be trimmed
5184        assert!(history[1].content.len() <= 2100);
5185        assert!(history[2].content.len() <= 2100);
5186        // Last tool message (protected) should be unchanged
5187        assert_eq!(history[4].content.len(), 5000);
5188    }
5189
5190    #[test]
5191    fn fast_trim_skips_non_tool_messages() {
5192        let mut history = vec![
5193            ChatMessage::system("sys"),
5194            ChatMessage::user("a".repeat(5000)),
5195            ChatMessage::assistant("b".repeat(5000)),
5196        ];
5197        let saved = fast_trim_tool_results(&mut history, 0);
5198        assert_eq!(saved, 0);
5199        assert_eq!(history[1].content.len(), 5000);
5200        assert_eq!(history[2].content.len(), 5000);
5201    }
5202
5203    #[test]
5204    fn fast_trim_small_tool_results_unchanged() {
5205        let mut history = vec![
5206            ChatMessage::system("sys"),
5207            ChatMessage::tool("short result"),
5208        ];
5209        let saved = fast_trim_tool_results(&mut history, 0);
5210        assert_eq!(saved, 0);
5211        assert_eq!(history[1].content, "short result");
5212    }
5213
5214    // ── emergency_history_trim tests ──────────────────────────────
5215
5216    #[test]
5217    fn emergency_trim_preserves_system() {
5218        let mut history = vec![
5219            ChatMessage::system("sys"),
5220            ChatMessage::user("msg1"),
5221            ChatMessage::assistant("resp1"),
5222            ChatMessage::user("msg2"),
5223            ChatMessage::assistant("resp2"),
5224            ChatMessage::user("msg3"),
5225        ];
5226        let dropped = emergency_history_trim(&mut history, 2);
5227        assert!(dropped > 0);
5228        // System message should always be preserved
5229        assert_eq!(history[0].role, "system");
5230        assert_eq!(history[0].content, "sys");
5231        // Last 2 messages should be preserved
5232        let len = history.len();
5233        assert_eq!(history[len - 1].content, "msg3");
5234    }
5235
5236    #[test]
5237    fn emergency_trim_preserves_recent() {
5238        let mut history = vec![
5239            ChatMessage::system("sys"),
5240            ChatMessage::user("old1"),
5241            ChatMessage::user("old2"),
5242            ChatMessage::user("recent1"),
5243            ChatMessage::user("recent2"),
5244        ];
5245        let dropped = emergency_history_trim(&mut history, 2);
5246        assert!(dropped > 0);
5247        // Last 2 should be preserved
5248        let len = history.len();
5249        assert_eq!(history[len - 1].content, "recent2");
5250        assert_eq!(history[len - 2].content, "recent1");
5251    }
5252
5253    #[test]
5254    fn emergency_trim_nothing_to_drop() {
5255        let mut history = vec![
5256            ChatMessage::system("sys"),
5257            ChatMessage::user("only user msg"),
5258        ];
5259        // protect_last = 1, system is protected → only 1 droppable
5260        // target_drop = 2/3 = 0 → nothing dropped
5261        let dropped = emergency_history_trim(&mut history, 1);
5262        assert_eq!(dropped, 0);
5263    }
5264
5265    // ── estimate_history_tokens tests ─────────────────────────────
5266
5267    #[test]
5268    fn estimate_tokens_empty_history() {
5269        let history: Vec<ChatMessage> = vec![];
5270        assert_eq!(estimate_history_tokens(&history), 0);
5271    }
5272
5273    #[test]
5274    fn estimate_tokens_single_message() {
5275        // 40 chars → 40.div_ceil(4) + 4 = 10 + 4 = 14 tokens
5276        let msg = "a".repeat(40);
5277        let history = vec![ChatMessage::user(&msg)];
5278        let est = estimate_history_tokens(&history);
5279        assert_eq!(est, 14);
5280    }
5281
5282    #[test]
5283    fn estimate_tokens_multiple_messages() {
5284        let history = vec![
5285            ChatMessage::system("system prompt here"), // 18 chars → 18/4=4 +4=8 (div_ceil: 5+4=9)
5286            ChatMessage::user("hello"),                // 5 chars → 5/4=1 +4=5 (div_ceil: 2+4=6)
5287            ChatMessage::assistant("world"),           // 5 chars → 5/4=1 +4=5 (div_ceil: 2+4=6)
5288        ];
5289        let est = estimate_history_tokens(&history);
5290        // Each message: content_len.div_ceil(4) + 4
5291        // 18.div_ceil(4)=5, 5.div_ceil(4)=2, 5.div_ceil(4)=2 → 5+4 + 2+4 + 2+4 = 21
5292        assert_eq!(est, 21);
5293    }
5294
5295    #[test]
5296    fn estimate_tokens_large_tool_result() {
5297        let big = "x".repeat(40_000);
5298        let history = vec![ChatMessage::tool(&big)];
5299        let est = estimate_history_tokens(&history);
5300        // 40000.div_ceil(4) + 4 = 10000 + 4 = 10004
5301        assert_eq!(est, 10_004);
5302    }
5303
5304    // ── shared_budget tests ───────────────────────────────────────
5305
5306    #[test]
5307    fn shared_budget_decrement_logic() {
5308        use std::sync::Arc;
5309        use std::sync::atomic::{AtomicUsize, Ordering};
5310
5311        let budget = Arc::new(AtomicUsize::new(3));
5312
5313        // Simulate 3 iterations decrementing
5314        for i in 0..3 {
5315            let remaining = budget.load(Ordering::Relaxed);
5316            assert!(remaining > 0, "Budget should be >0 at iteration {i}");
5317            budget.fetch_sub(1, Ordering::Relaxed);
5318        }
5319
5320        // Budget should now be 0
5321        assert_eq!(budget.load(Ordering::Relaxed), 0);
5322    }
5323
5324    #[test]
5325    fn shared_budget_none_has_no_effect() {
5326        // When shared_budget is None, the check is simply skipped
5327        let budget: Option<Arc<std::sync::atomic::AtomicUsize>> = None;
5328        assert!(budget.is_none());
5329    }
5330
5331    // ── existing tests ────────────────────────────────────────────
5332
5333    #[test]
5334    fn interactive_session_state_round_trips_history() {
5335        let dir = tempdir().unwrap();
5336        let path = dir.path().join("session.json");
5337        let history = vec![
5338            ChatMessage::system("system"),
5339            ChatMessage::user("hello"),
5340            ChatMessage::assistant("hi"),
5341        ];
5342
5343        save_interactive_session_history(&path, &history).unwrap();
5344        let restored = load_interactive_session_history(&path, "fallback").unwrap();
5345
5346        assert_eq!(restored.len(), 3);
5347        assert_eq!(restored[0].role, "system");
5348        assert_eq!(restored[1].content, "hello");
5349        assert_eq!(restored[2].content, "hi");
5350    }
5351
5352    #[test]
5353    fn interactive_session_state_adds_missing_system_prompt() {
5354        let dir = tempdir().unwrap();
5355        let path = dir.path().join("session.json");
5356        let payload = serde_json::to_string_pretty(&InteractiveSessionState {
5357            version: 1,
5358            history: vec![ChatMessage::user("orphan")],
5359        })
5360        .unwrap();
5361        std::fs::write(&path, payload).unwrap();
5362
5363        let restored = load_interactive_session_history(&path, "fallback system").unwrap();
5364
5365        assert_eq!(restored[0].role, "system");
5366        assert_eq!(restored[0].content, "fallback system");
5367        assert_eq!(restored[1].content, "orphan");
5368    }
5369
5370    use super::*;
5371    use async_trait::async_trait;
5372    use base64::{Engine as _, engine::general_purpose::STANDARD};
5373    use std::collections::VecDeque;
5374    use std::sync::atomic::{AtomicUsize, Ordering};
5375    use std::sync::{Arc, Mutex};
5376    use std::time::Duration;
5377
5378    #[test]
5379    fn scrub_credentials_redacts_bearer_token() {
5380        let input = "API_KEY=sk-1234567890abcdef; token: 1234567890; password=\"secret123456\"";
5381        let scrubbed = scrub_credentials(input);
5382        assert!(scrubbed.contains("API_KEY=sk-1*[REDACTED]"));
5383        assert!(scrubbed.contains("token: 1234*[REDACTED]"));
5384        assert!(scrubbed.contains("password=\"secr*[REDACTED]\""));
5385        assert!(!scrubbed.contains("abcdef"));
5386        assert!(!scrubbed.contains("secret123456"));
5387    }
5388
5389    #[test]
5390    fn scrub_credentials_redacts_json_api_key() {
5391        let input = r#"{"api_key": "sk-1234567890", "other": "public"}"#;
5392        let scrubbed = scrub_credentials(input);
5393        assert!(scrubbed.contains("\"api_key\": \"sk-1*[REDACTED]\""));
5394        assert!(scrubbed.contains("public"));
5395    }
5396
5397    #[tokio::test]
5398    async fn execute_one_tool_does_not_panic_on_utf8_boundary() {
5399        let call_arguments = (0..600)
5400            .map(|n| serde_json::json!({ "content": format!("{}:tail", "a".repeat(n)) }))
5401            .find(|args| {
5402                let raw = args.to_string();
5403                raw.len() > 300 && !raw.is_char_boundary(300)
5404            })
5405            .expect("should produce a sample whose byte index 300 is not a char boundary");
5406
5407        let observer = NoopObserver;
5408        let result =
5409            execute_one_tool("unknown_tool", call_arguments, &[], None, &observer, None).await;
5410        assert!(result.is_ok(), "execute_one_tool should not panic or error");
5411
5412        let outcome = result.unwrap();
5413        assert!(!outcome.success);
5414        assert!(outcome.output.contains("Unknown tool: unknown_tool"));
5415    }
5416
5417    #[tokio::test]
5418    async fn execute_one_tool_resolves_unique_activated_tool_suffix() {
5419        let observer = NoopObserver;
5420        let invocations = Arc::new(AtomicUsize::new(0));
5421        let activated = Arc::new(std::sync::Mutex::new(crate::tools::ActivatedToolSet::new()));
5422        let activated_tool: Arc<dyn Tool> = Arc::new(CountingTool::new(
5423            "docker-mcp__extract_text",
5424            Arc::clone(&invocations),
5425        ));
5426        activated
5427            .lock()
5428            .unwrap()
5429            .activate("docker-mcp__extract_text".into(), activated_tool);
5430
5431        let outcome = execute_one_tool(
5432            "extract_text",
5433            serde_json::json!({ "value": "ok" }),
5434            &[],
5435            Some(&activated),
5436            &observer,
5437            None,
5438        )
5439        .await
5440        .expect("suffix alias should execute the unique activated tool");
5441
5442        assert!(outcome.success);
5443        assert_eq!(outcome.output, "counted:ok");
5444        assert_eq!(invocations.load(Ordering::SeqCst), 1);
5445    }
5446
5447    use crate::observability::NoopObserver;
5448    use crate::providers::ChatResponse;
5449    use crate::providers::router::{Route, RouterProvider};
5450    use crate::providers::traits::{ProviderCapabilities, StreamChunk, StreamEvent, StreamOptions};
5451    use tempfile::TempDir;
5452
5453    struct NonVisionProvider {
5454        calls: Arc<AtomicUsize>,
5455    }
5456
5457    #[async_trait]
5458    impl Provider for NonVisionProvider {
5459        async fn chat_with_system(
5460            &self,
5461            _system_prompt: Option<&str>,
5462            _message: &str,
5463            _model: &str,
5464            _temperature: f64,
5465        ) -> anyhow::Result<String> {
5466            self.calls.fetch_add(1, Ordering::SeqCst);
5467            Ok("ok".to_string())
5468        }
5469    }
5470
5471    struct VisionProvider {
5472        calls: Arc<AtomicUsize>,
5473    }
5474
5475    #[async_trait]
5476    impl Provider for VisionProvider {
5477        fn capabilities(&self) -> ProviderCapabilities {
5478            ProviderCapabilities {
5479                native_tool_calling: false,
5480                vision: true,
5481                prompt_caching: false,
5482            }
5483        }
5484
5485        async fn chat_with_system(
5486            &self,
5487            _system_prompt: Option<&str>,
5488            _message: &str,
5489            _model: &str,
5490            _temperature: f64,
5491        ) -> anyhow::Result<String> {
5492            self.calls.fetch_add(1, Ordering::SeqCst);
5493            Ok("ok".to_string())
5494        }
5495
5496        async fn chat(
5497            &self,
5498            request: ChatRequest<'_>,
5499            _model: &str,
5500            _temperature: f64,
5501        ) -> anyhow::Result<ChatResponse> {
5502            self.calls.fetch_add(1, Ordering::SeqCst);
5503            let marker_count = crate::multimodal::count_image_markers(request.messages);
5504            if marker_count == 0 {
5505                anyhow::bail!("expected image markers in request messages");
5506            }
5507
5508            if request.tools.is_some() {
5509                anyhow::bail!("no tools should be attached for this test");
5510            }
5511
5512            Ok(ChatResponse {
5513                text: Some("vision-ok".to_string()),
5514                tool_calls: Vec::new(),
5515                usage: None,
5516                reasoning_content: None,
5517            })
5518        }
5519    }
5520
5521    struct ScriptedProvider {
5522        responses: Arc<Mutex<VecDeque<ChatResponse>>>,
5523        capabilities: ProviderCapabilities,
5524    }
5525
5526    impl ScriptedProvider {
5527        fn from_text_responses(responses: Vec<&str>) -> Self {
5528            let scripted = responses
5529                .into_iter()
5530                .map(|text| ChatResponse {
5531                    text: Some(text.to_string()),
5532                    tool_calls: Vec::new(),
5533                    usage: None,
5534                    reasoning_content: None,
5535                })
5536                .collect();
5537            Self {
5538                responses: Arc::new(Mutex::new(scripted)),
5539                capabilities: ProviderCapabilities::default(),
5540            }
5541        }
5542
5543        fn with_native_tool_support(mut self) -> Self {
5544            self.capabilities.native_tool_calling = true;
5545            self
5546        }
5547    }
5548
5549    #[async_trait]
5550    impl Provider for ScriptedProvider {
5551        fn capabilities(&self) -> ProviderCapabilities {
5552            self.capabilities.clone()
5553        }
5554
5555        async fn chat_with_system(
5556            &self,
5557            _system_prompt: Option<&str>,
5558            _message: &str,
5559            _model: &str,
5560            _temperature: f64,
5561        ) -> anyhow::Result<String> {
5562            anyhow::bail!("chat_with_system should not be used in scripted provider tests");
5563        }
5564
5565        async fn chat(
5566            &self,
5567            _request: ChatRequest<'_>,
5568            _model: &str,
5569            _temperature: f64,
5570        ) -> anyhow::Result<ChatResponse> {
5571            let mut responses = self
5572                .responses
5573                .lock()
5574                .expect("responses lock should be valid");
5575            responses
5576                .pop_front()
5577                .ok_or_else(|| anyhow::anyhow!("scripted provider exhausted responses"))
5578        }
5579    }
5580
5581    struct StreamingScriptedProvider {
5582        responses: Arc<Mutex<VecDeque<String>>>,
5583        stream_calls: Arc<AtomicUsize>,
5584        chat_calls: Arc<AtomicUsize>,
5585    }
5586
5587    impl StreamingScriptedProvider {
5588        fn from_text_responses(responses: Vec<&str>) -> Self {
5589            Self {
5590                responses: Arc::new(Mutex::new(
5591                    responses.into_iter().map(ToString::to_string).collect(),
5592                )),
5593                stream_calls: Arc::new(AtomicUsize::new(0)),
5594                chat_calls: Arc::new(AtomicUsize::new(0)),
5595            }
5596        }
5597    }
5598
5599    #[async_trait]
5600    impl Provider for StreamingScriptedProvider {
5601        async fn chat_with_system(
5602            &self,
5603            _system_prompt: Option<&str>,
5604            _message: &str,
5605            _model: &str,
5606            _temperature: f64,
5607        ) -> anyhow::Result<String> {
5608            anyhow::bail!(
5609                "chat_with_system should not be used in streaming scripted provider tests"
5610            );
5611        }
5612
5613        async fn chat(
5614            &self,
5615            _request: ChatRequest<'_>,
5616            _model: &str,
5617            _temperature: f64,
5618        ) -> anyhow::Result<ChatResponse> {
5619            self.chat_calls.fetch_add(1, Ordering::SeqCst);
5620            anyhow::bail!("chat should not be called when streaming succeeds")
5621        }
5622
5623        fn supports_streaming(&self) -> bool {
5624            true
5625        }
5626
5627        fn stream_chat_with_history(
5628            &self,
5629            _messages: &[ChatMessage],
5630            _model: &str,
5631            _temperature: f64,
5632            options: StreamOptions,
5633        ) -> futures_util::stream::BoxStream<
5634            'static,
5635            crate::providers::traits::StreamResult<StreamChunk>,
5636        > {
5637            self.stream_calls.fetch_add(1, Ordering::SeqCst);
5638            if !options.enabled {
5639                return Box::pin(futures_util::stream::empty());
5640            }
5641
5642            let response = self
5643                .responses
5644                .lock()
5645                .expect("responses lock should be valid")
5646                .pop_front()
5647                .unwrap_or_default();
5648
5649            Box::pin(futures_util::stream::iter(vec![
5650                Ok(StreamChunk::delta(response)),
5651                Ok(StreamChunk::final_chunk()),
5652            ]))
5653        }
5654    }
5655
5656    enum NativeStreamTurn {
5657        ToolCall(ToolCall),
5658        Text(String),
5659    }
5660
5661    struct StreamingNativeToolEventProvider {
5662        turns: Arc<Mutex<VecDeque<NativeStreamTurn>>>,
5663        stream_calls: Arc<AtomicUsize>,
5664        stream_tool_requests: Arc<AtomicUsize>,
5665        chat_calls: Arc<AtomicUsize>,
5666    }
5667
5668    impl StreamingNativeToolEventProvider {
5669        fn with_turns(turns: Vec<NativeStreamTurn>) -> Self {
5670            Self {
5671                turns: Arc::new(Mutex::new(turns.into())),
5672                stream_calls: Arc::new(AtomicUsize::new(0)),
5673                stream_tool_requests: Arc::new(AtomicUsize::new(0)),
5674                chat_calls: Arc::new(AtomicUsize::new(0)),
5675            }
5676        }
5677    }
5678
5679    #[async_trait]
5680    impl Provider for StreamingNativeToolEventProvider {
5681        fn capabilities(&self) -> ProviderCapabilities {
5682            ProviderCapabilities {
5683                native_tool_calling: true,
5684                vision: false,
5685                prompt_caching: false,
5686            }
5687        }
5688
5689        async fn chat_with_system(
5690            &self,
5691            _system_prompt: Option<&str>,
5692            _message: &str,
5693            _model: &str,
5694            _temperature: f64,
5695        ) -> anyhow::Result<String> {
5696            anyhow::bail!(
5697                "chat_with_system should not be used in streaming native tool event provider tests"
5698            );
5699        }
5700
5701        async fn chat(
5702            &self,
5703            _request: ChatRequest<'_>,
5704            _model: &str,
5705            _temperature: f64,
5706        ) -> anyhow::Result<ChatResponse> {
5707            self.chat_calls.fetch_add(1, Ordering::SeqCst);
5708            anyhow::bail!("chat should not be called when native streaming events succeed")
5709        }
5710
5711        fn supports_streaming(&self) -> bool {
5712            true
5713        }
5714
5715        fn supports_streaming_tool_events(&self) -> bool {
5716            true
5717        }
5718
5719        fn stream_chat(
5720            &self,
5721            request: ChatRequest<'_>,
5722            _model: &str,
5723            _temperature: f64,
5724            options: StreamOptions,
5725        ) -> futures_util::stream::BoxStream<
5726            'static,
5727            crate::providers::traits::StreamResult<StreamEvent>,
5728        > {
5729            self.stream_calls.fetch_add(1, Ordering::SeqCst);
5730            if request.tools.is_some_and(|tools| !tools.is_empty()) {
5731                self.stream_tool_requests.fetch_add(1, Ordering::SeqCst);
5732            }
5733            if !options.enabled {
5734                return Box::pin(futures_util::stream::empty());
5735            }
5736
5737            let turn = self
5738                .turns
5739                .lock()
5740                .expect("turns lock should be valid")
5741                .pop_front()
5742                .expect("streaming turns should have scripted output");
5743            match turn {
5744                NativeStreamTurn::ToolCall(tool_call) => {
5745                    Box::pin(futures_util::stream::iter(vec![
5746                        Ok(StreamEvent::ToolCall(tool_call)),
5747                        Ok(StreamEvent::Final),
5748                    ]))
5749                }
5750                NativeStreamTurn::Text(text) => Box::pin(futures_util::stream::iter(vec![
5751                    Ok(StreamEvent::TextDelta(StreamChunk::delta(text))),
5752                    Ok(StreamEvent::Final),
5753                ])),
5754            }
5755        }
5756    }
5757
5758    struct RouteAwareStreamingProvider {
5759        response: String,
5760        stream_calls: Arc<AtomicUsize>,
5761        chat_calls: Arc<AtomicUsize>,
5762        last_model: Arc<Mutex<String>>,
5763    }
5764
5765    impl RouteAwareStreamingProvider {
5766        fn new(response: &str) -> Self {
5767            Self {
5768                response: response.to_string(),
5769                stream_calls: Arc::new(AtomicUsize::new(0)),
5770                chat_calls: Arc::new(AtomicUsize::new(0)),
5771                last_model: Arc::new(Mutex::new(String::new())),
5772            }
5773        }
5774    }
5775
5776    #[async_trait]
5777    impl Provider for RouteAwareStreamingProvider {
5778        async fn chat_with_system(
5779            &self,
5780            _system_prompt: Option<&str>,
5781            _message: &str,
5782            _model: &str,
5783            _temperature: f64,
5784        ) -> anyhow::Result<String> {
5785            anyhow::bail!("chat_with_system should not be used in route-aware stream tests");
5786        }
5787
5788        async fn chat(
5789            &self,
5790            _request: ChatRequest<'_>,
5791            _model: &str,
5792            _temperature: f64,
5793        ) -> anyhow::Result<ChatResponse> {
5794            self.chat_calls.fetch_add(1, Ordering::SeqCst);
5795            anyhow::bail!("chat should not be called when routed streaming succeeds")
5796        }
5797
5798        fn supports_streaming(&self) -> bool {
5799            true
5800        }
5801
5802        fn stream_chat_with_history(
5803            &self,
5804            _messages: &[ChatMessage],
5805            model: &str,
5806            _temperature: f64,
5807            options: StreamOptions,
5808        ) -> futures_util::stream::BoxStream<
5809            'static,
5810            crate::providers::traits::StreamResult<StreamChunk>,
5811        > {
5812            self.stream_calls.fetch_add(1, Ordering::SeqCst);
5813            *self
5814                .last_model
5815                .lock()
5816                .expect("last_model lock should be valid") = model.to_string();
5817            if !options.enabled {
5818                return Box::pin(futures_util::stream::empty());
5819            }
5820
5821            Box::pin(futures_util::stream::iter(vec![
5822                Ok(StreamChunk::delta(self.response.clone())),
5823                Ok(StreamChunk::final_chunk()),
5824            ]))
5825        }
5826    }
5827
5828    struct CountingTool {
5829        name: String,
5830        invocations: Arc<AtomicUsize>,
5831    }
5832
5833    impl CountingTool {
5834        fn new(name: &str, invocations: Arc<AtomicUsize>) -> Self {
5835            Self {
5836                name: name.to_string(),
5837                invocations,
5838            }
5839        }
5840    }
5841
5842    #[async_trait]
5843    impl Tool for CountingTool {
5844        fn name(&self) -> &str {
5845            &self.name
5846        }
5847
5848        fn description(&self) -> &str {
5849            "Counts executions for loop-stability tests"
5850        }
5851
5852        fn parameters_schema(&self) -> serde_json::Value {
5853            serde_json::json!({
5854                "type": "object",
5855                "properties": {
5856                    "value": { "type": "string" }
5857                }
5858            })
5859        }
5860
5861        async fn execute(
5862            &self,
5863            args: serde_json::Value,
5864        ) -> anyhow::Result<crate::tools::ToolResult> {
5865            self.invocations.fetch_add(1, Ordering::SeqCst);
5866            let value = args
5867                .get("value")
5868                .and_then(serde_json::Value::as_str)
5869                .unwrap_or_default();
5870            Ok(crate::tools::ToolResult {
5871                success: true,
5872                output: format!("counted:{value}"),
5873                error: None,
5874            })
5875        }
5876    }
5877
5878    struct RecordingArgsTool {
5879        name: String,
5880        recorded_args: Arc<Mutex<Vec<serde_json::Value>>>,
5881    }
5882
5883    impl RecordingArgsTool {
5884        fn new(name: &str, recorded_args: Arc<Mutex<Vec<serde_json::Value>>>) -> Self {
5885            Self {
5886                name: name.to_string(),
5887                recorded_args,
5888            }
5889        }
5890    }
5891
5892    #[async_trait]
5893    impl Tool for RecordingArgsTool {
5894        fn name(&self) -> &str {
5895            &self.name
5896        }
5897
5898        fn description(&self) -> &str {
5899            "Records tool arguments for regression tests"
5900        }
5901
5902        fn parameters_schema(&self) -> serde_json::Value {
5903            serde_json::json!({
5904                "type": "object",
5905                "properties": {
5906                    "prompt": { "type": "string" },
5907                    "schedule": { "type": "object" },
5908                    "delivery": { "type": "object" }
5909                }
5910            })
5911        }
5912
5913        async fn execute(
5914            &self,
5915            args: serde_json::Value,
5916        ) -> anyhow::Result<crate::tools::ToolResult> {
5917            self.recorded_args
5918                .lock()
5919                .expect("recorded args lock should be valid")
5920                .push(args.clone());
5921            Ok(crate::tools::ToolResult {
5922                success: true,
5923                output: args.to_string(),
5924                error: None,
5925            })
5926        }
5927    }
5928
5929    struct DelayTool {
5930        name: String,
5931        delay_ms: u64,
5932        active: Arc<AtomicUsize>,
5933        max_active: Arc<AtomicUsize>,
5934    }
5935
5936    impl DelayTool {
5937        fn new(
5938            name: &str,
5939            delay_ms: u64,
5940            active: Arc<AtomicUsize>,
5941            max_active: Arc<AtomicUsize>,
5942        ) -> Self {
5943            Self {
5944                name: name.to_string(),
5945                delay_ms,
5946                active,
5947                max_active,
5948            }
5949        }
5950    }
5951
5952    #[async_trait]
5953    impl Tool for DelayTool {
5954        fn name(&self) -> &str {
5955            &self.name
5956        }
5957
5958        fn description(&self) -> &str {
5959            "Delay tool for testing parallel tool execution"
5960        }
5961
5962        fn parameters_schema(&self) -> serde_json::Value {
5963            serde_json::json!({
5964                "type": "object",
5965                "properties": {
5966                    "value": { "type": "string" }
5967                },
5968                "required": ["value"]
5969            })
5970        }
5971
5972        async fn execute(
5973            &self,
5974            args: serde_json::Value,
5975        ) -> anyhow::Result<crate::tools::ToolResult> {
5976            let now_active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
5977            self.max_active.fetch_max(now_active, Ordering::SeqCst);
5978
5979            tokio::time::sleep(Duration::from_millis(self.delay_ms)).await;
5980
5981            self.active.fetch_sub(1, Ordering::SeqCst);
5982
5983            let value = args
5984                .get("value")
5985                .and_then(serde_json::Value::as_str)
5986                .unwrap_or_default()
5987                .to_string();
5988
5989            Ok(crate::tools::ToolResult {
5990                success: true,
5991                output: format!("ok:{value}"),
5992                error: None,
5993            })
5994        }
5995    }
5996
5997    /// A tool that always returns a failure with a given error reason.
5998    struct FailingTool {
5999        tool_name: String,
6000        error_reason: String,
6001    }
6002
6003    impl FailingTool {
6004        fn new(name: &str, error_reason: &str) -> Self {
6005            Self {
6006                tool_name: name.to_string(),
6007                error_reason: error_reason.to_string(),
6008            }
6009        }
6010    }
6011
6012    #[async_trait]
6013    impl Tool for FailingTool {
6014        fn name(&self) -> &str {
6015            &self.tool_name
6016        }
6017
6018        fn description(&self) -> &str {
6019            "A tool that always fails for testing failure surfacing"
6020        }
6021
6022        fn parameters_schema(&self) -> serde_json::Value {
6023            serde_json::json!({
6024                "type": "object",
6025                "properties": {
6026                    "command": { "type": "string" }
6027                }
6028            })
6029        }
6030
6031        async fn execute(
6032            &self,
6033            _args: serde_json::Value,
6034        ) -> anyhow::Result<crate::tools::ToolResult> {
6035            Ok(crate::tools::ToolResult {
6036                success: false,
6037                output: String::new(),
6038                error: Some(self.error_reason.clone()),
6039            })
6040        }
6041    }
6042
6043    #[tokio::test]
6044    async fn run_tool_call_loop_returns_structured_error_for_non_vision_provider() {
6045        let calls = Arc::new(AtomicUsize::new(0));
6046        let provider = NonVisionProvider {
6047            calls: Arc::clone(&calls),
6048        };
6049
6050        let mut history = vec![ChatMessage::user(
6051            "please inspect [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
6052        )];
6053        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6054        let observer = NoopObserver;
6055
6056        let err = run_tool_call_loop(
6057            &provider,
6058            &mut history,
6059            &tools_registry,
6060            &observer,
6061            "mock-provider",
6062            "mock-model",
6063            0.0,
6064            true,
6065            None,
6066            "cli",
6067            None,
6068            &crate::config::MultimodalConfig::default(),
6069            3,
6070            None,
6071            None,
6072            None,
6073            &[],
6074            &[],
6075            None,
6076            None,
6077            &crate::config::PacingConfig::default(),
6078            0,
6079            0,
6080            None,
6081        )
6082        .await
6083        .expect_err("provider without vision support should fail");
6084
6085        assert!(err.to_string().contains("provider_capability_error"));
6086        assert!(err.to_string().contains("capability=vision"));
6087        assert_eq!(calls.load(Ordering::SeqCst), 0);
6088    }
6089
6090    #[tokio::test]
6091    async fn run_tool_call_loop_rejects_oversized_image_payload() {
6092        let calls = Arc::new(AtomicUsize::new(0));
6093        let provider = VisionProvider {
6094            calls: Arc::clone(&calls),
6095        };
6096
6097        let oversized_payload = STANDARD.encode(vec![0_u8; (1024 * 1024) + 1]);
6098        let mut history = vec![ChatMessage::user(format!(
6099            "[IMAGE:data:image/png;base64,{oversized_payload}]"
6100        ))];
6101
6102        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6103        let observer = NoopObserver;
6104        let multimodal = crate::config::MultimodalConfig {
6105            max_images: 4,
6106            max_image_size_mb: 1,
6107            allow_remote_fetch: false,
6108            ..Default::default()
6109        };
6110
6111        let err = run_tool_call_loop(
6112            &provider,
6113            &mut history,
6114            &tools_registry,
6115            &observer,
6116            "mock-provider",
6117            "mock-model",
6118            0.0,
6119            true,
6120            None,
6121            "cli",
6122            None,
6123            &multimodal,
6124            3,
6125            None,
6126            None,
6127            None,
6128            &[],
6129            &[],
6130            None,
6131            None,
6132            &crate::config::PacingConfig::default(),
6133            0,
6134            0,
6135            None,
6136        )
6137        .await
6138        .expect_err("oversized payload must fail");
6139
6140        assert!(
6141            err.to_string()
6142                .contains("multimodal image size limit exceeded")
6143        );
6144        assert_eq!(calls.load(Ordering::SeqCst), 0);
6145    }
6146
6147    #[tokio::test]
6148    async fn run_tool_call_loop_accepts_valid_multimodal_request_flow() {
6149        let calls = Arc::new(AtomicUsize::new(0));
6150        let provider = VisionProvider {
6151            calls: Arc::clone(&calls),
6152        };
6153
6154        let mut history = vec![ChatMessage::user(
6155            "Analyze this [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
6156        )];
6157        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6158        let observer = NoopObserver;
6159
6160        let result = run_tool_call_loop(
6161            &provider,
6162            &mut history,
6163            &tools_registry,
6164            &observer,
6165            "mock-provider",
6166            "mock-model",
6167            0.0,
6168            true,
6169            None,
6170            "cli",
6171            None,
6172            &crate::config::MultimodalConfig::default(),
6173            3,
6174            None,
6175            None,
6176            None,
6177            &[],
6178            &[],
6179            None,
6180            None,
6181            &crate::config::PacingConfig::default(),
6182            0,
6183            0,
6184            None,
6185        )
6186        .await
6187        .expect("valid multimodal payload should pass");
6188
6189        assert_eq!(result, "vision-ok");
6190        assert_eq!(calls.load(Ordering::SeqCst), 1);
6191    }
6192
6193    /// When `vision_provider` is not set and the default provider lacks vision
6194    /// support, the original `ProviderCapabilityError` should be returned.
6195    #[tokio::test]
6196    async fn run_tool_call_loop_no_vision_provider_config_preserves_error() {
6197        let calls = Arc::new(AtomicUsize::new(0));
6198        let provider = NonVisionProvider {
6199            calls: Arc::clone(&calls),
6200        };
6201
6202        let mut history = vec![ChatMessage::user(
6203            "check [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
6204        )];
6205        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6206        let observer = NoopObserver;
6207
6208        let err = run_tool_call_loop(
6209            &provider,
6210            &mut history,
6211            &tools_registry,
6212            &observer,
6213            "mock-provider",
6214            "mock-model",
6215            0.0,
6216            true,
6217            None,
6218            "cli",
6219            None,
6220            &crate::config::MultimodalConfig::default(),
6221            3,
6222            None,
6223            None,
6224            None,
6225            &[],
6226            &[],
6227            None,
6228            None,
6229            &crate::config::PacingConfig::default(),
6230            0,
6231            0,
6232            None,
6233        )
6234        .await
6235        .expect_err("should fail without vision_provider config");
6236
6237        assert!(err.to_string().contains("capability=vision"));
6238        assert_eq!(calls.load(Ordering::SeqCst), 0);
6239    }
6240
6241    /// When `vision_provider` is set but the provider factory cannot resolve
6242    /// the name, a descriptive error should be returned (not the generic
6243    /// capability error).
6244    #[tokio::test]
6245    async fn run_tool_call_loop_vision_provider_creation_failure() {
6246        let calls = Arc::new(AtomicUsize::new(0));
6247        let provider = NonVisionProvider {
6248            calls: Arc::clone(&calls),
6249        };
6250
6251        let mut history = vec![ChatMessage::user(
6252            "inspect [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
6253        )];
6254        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6255        let observer = NoopObserver;
6256
6257        let multimodal = crate::config::MultimodalConfig {
6258            vision_provider: Some("nonexistent-provider-xyz".to_string()),
6259            vision_model: Some("some-model".to_string()),
6260            ..Default::default()
6261        };
6262
6263        let err = run_tool_call_loop(
6264            &provider,
6265            &mut history,
6266            &tools_registry,
6267            &observer,
6268            "mock-provider",
6269            "mock-model",
6270            0.0,
6271            true,
6272            None,
6273            "cli",
6274            None,
6275            &multimodal,
6276            3,
6277            None,
6278            None,
6279            None,
6280            &[],
6281            &[],
6282            None,
6283            None,
6284            &crate::config::PacingConfig::default(),
6285            0,
6286            0,
6287            None,
6288        )
6289        .await
6290        .expect_err("should fail when vision provider cannot be created");
6291
6292        assert!(
6293            err.to_string().contains("failed to create vision provider"),
6294            "expected creation failure error, got: {}",
6295            err
6296        );
6297        assert_eq!(calls.load(Ordering::SeqCst), 0);
6298    }
6299
6300    /// Messages without image markers should use the default provider even
6301    /// when `vision_provider` is configured.
6302    #[tokio::test]
6303    async fn run_tool_call_loop_no_images_uses_default_provider() {
6304        let provider = ScriptedProvider::from_text_responses(vec!["hello world"]);
6305
6306        let mut history = vec![ChatMessage::user("just text, no images".to_string())];
6307        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6308        let observer = NoopObserver;
6309
6310        let multimodal = crate::config::MultimodalConfig {
6311            vision_provider: Some("nonexistent-provider-xyz".to_string()),
6312            vision_model: Some("some-model".to_string()),
6313            ..Default::default()
6314        };
6315
6316        // Even though vision_provider points to a nonexistent provider, this
6317        // should succeed because there are no image markers to trigger routing.
6318        let result = run_tool_call_loop(
6319            &provider,
6320            &mut history,
6321            &tools_registry,
6322            &observer,
6323            "scripted",
6324            "scripted-model",
6325            0.0,
6326            true,
6327            None,
6328            "cli",
6329            None,
6330            &multimodal,
6331            3,
6332            None,
6333            None,
6334            None,
6335            &[],
6336            &[],
6337            None,
6338            None,
6339            &crate::config::PacingConfig::default(),
6340            0,
6341            0,
6342            None,
6343        )
6344        .await
6345        .expect("text-only messages should succeed with default provider");
6346
6347        assert_eq!(result, "hello world");
6348    }
6349
6350    /// When `vision_provider` is set but `vision_model` is not, the default
6351    /// model should be used as fallback for the vision provider.
6352    #[tokio::test]
6353    async fn run_tool_call_loop_vision_provider_without_model_falls_back() {
6354        let calls = Arc::new(AtomicUsize::new(0));
6355        let provider = NonVisionProvider {
6356            calls: Arc::clone(&calls),
6357        };
6358
6359        let mut history = vec![ChatMessage::user(
6360            "look [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
6361        )];
6362        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6363        let observer = NoopObserver;
6364
6365        // vision_provider set but vision_model is None — the code should
6366        // fall back to the default model. Since the provider name is invalid,
6367        // we just verify the error path references the correct provider.
6368        let multimodal = crate::config::MultimodalConfig {
6369            vision_provider: Some("nonexistent-provider-xyz".to_string()),
6370            vision_model: None,
6371            ..Default::default()
6372        };
6373
6374        let err = run_tool_call_loop(
6375            &provider,
6376            &mut history,
6377            &tools_registry,
6378            &observer,
6379            "mock-provider",
6380            "mock-model",
6381            0.0,
6382            true,
6383            None,
6384            "cli",
6385            None,
6386            &multimodal,
6387            3,
6388            None,
6389            None,
6390            None,
6391            &[],
6392            &[],
6393            None,
6394            None,
6395            &crate::config::PacingConfig::default(),
6396            0,
6397            0,
6398            None,
6399        )
6400        .await
6401        .expect_err("should fail due to nonexistent vision provider");
6402
6403        // Verify the routing was attempted (not the generic capability error).
6404        assert!(
6405            err.to_string().contains("failed to create vision provider"),
6406            "expected creation failure, got: {}",
6407            err
6408        );
6409    }
6410
6411    /// Empty `[IMAGE:]` markers (which are preserved as literal text by the
6412    /// parser) should not trigger vision provider routing.
6413    #[tokio::test]
6414    async fn run_tool_call_loop_empty_image_markers_use_default_provider() {
6415        let provider = ScriptedProvider::from_text_responses(vec!["handled"]);
6416
6417        let mut history = vec![ChatMessage::user(
6418            "empty marker [IMAGE:] should be ignored".to_string(),
6419        )];
6420        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6421        let observer = NoopObserver;
6422
6423        let multimodal = crate::config::MultimodalConfig {
6424            vision_provider: Some("nonexistent-provider-xyz".to_string()),
6425            ..Default::default()
6426        };
6427
6428        let result = run_tool_call_loop(
6429            &provider,
6430            &mut history,
6431            &tools_registry,
6432            &observer,
6433            "scripted",
6434            "scripted-model",
6435            0.0,
6436            true,
6437            None,
6438            "cli",
6439            None,
6440            &multimodal,
6441            3,
6442            None,
6443            None,
6444            None,
6445            &[],
6446            &[],
6447            None,
6448            None,
6449            &crate::config::PacingConfig::default(),
6450            0,
6451            0,
6452            None,
6453        )
6454        .await
6455        .expect("empty image markers should not trigger vision routing");
6456
6457        assert_eq!(result, "handled");
6458    }
6459
6460    /// Multiple image markers should still trigger vision routing when
6461    /// vision_provider is configured.
6462    #[tokio::test]
6463    async fn run_tool_call_loop_multiple_images_trigger_vision_routing() {
6464        let calls = Arc::new(AtomicUsize::new(0));
6465        let provider = NonVisionProvider {
6466            calls: Arc::clone(&calls),
6467        };
6468
6469        let mut history = vec![ChatMessage::user(
6470            "two images [IMAGE:data:image/png;base64,aQ==] and [IMAGE:data:image/png;base64,bQ==]"
6471                .to_string(),
6472        )];
6473        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6474        let observer = NoopObserver;
6475
6476        let multimodal = crate::config::MultimodalConfig {
6477            vision_provider: Some("nonexistent-provider-xyz".to_string()),
6478            vision_model: Some("llava:7b".to_string()),
6479            ..Default::default()
6480        };
6481
6482        let err = run_tool_call_loop(
6483            &provider,
6484            &mut history,
6485            &tools_registry,
6486            &observer,
6487            "mock-provider",
6488            "mock-model",
6489            0.0,
6490            true,
6491            None,
6492            "cli",
6493            None,
6494            &multimodal,
6495            3,
6496            None,
6497            None,
6498            None,
6499            &[],
6500            &[],
6501            None,
6502            None,
6503            &crate::config::PacingConfig::default(),
6504            0,
6505            0,
6506            None,
6507        )
6508        .await
6509        .expect_err("should attempt vision provider creation for multiple images");
6510
6511        assert!(
6512            err.to_string().contains("failed to create vision provider"),
6513            "expected creation failure for multiple images, got: {}",
6514            err
6515        );
6516    }
6517
6518    #[test]
6519    fn should_execute_tools_in_parallel_returns_false_for_single_call() {
6520        let calls = vec![ParsedToolCall {
6521            name: "file_read".to_string(),
6522            arguments: serde_json::json!({"path": "a.txt"}),
6523            tool_call_id: None,
6524        }];
6525
6526        assert!(!should_execute_tools_in_parallel(&calls, None));
6527    }
6528
6529    #[test]
6530    fn should_execute_tools_in_parallel_returns_false_when_approval_is_required() {
6531        let calls = vec![
6532            ParsedToolCall {
6533                name: "shell".to_string(),
6534                arguments: serde_json::json!({"command": "pwd"}),
6535                tool_call_id: None,
6536            },
6537            ParsedToolCall {
6538                name: "http_request".to_string(),
6539                arguments: serde_json::json!({"url": "https://example.com"}),
6540                tool_call_id: None,
6541            },
6542        ];
6543        let approval_cfg = crate::config::AutonomyConfig::default();
6544        let approval_mgr = ApprovalManager::from_config(&approval_cfg);
6545
6546        assert!(!should_execute_tools_in_parallel(
6547            &calls,
6548            Some(&approval_mgr)
6549        ));
6550    }
6551
6552    #[test]
6553    fn should_execute_tools_in_parallel_returns_true_when_cli_has_no_interactive_approvals() {
6554        let calls = vec![
6555            ParsedToolCall {
6556                name: "shell".to_string(),
6557                arguments: serde_json::json!({"command": "pwd"}),
6558                tool_call_id: None,
6559            },
6560            ParsedToolCall {
6561                name: "http_request".to_string(),
6562                arguments: serde_json::json!({"url": "https://example.com"}),
6563                tool_call_id: None,
6564            },
6565        ];
6566        let approval_cfg = crate::config::AutonomyConfig {
6567            level: crate::security::AutonomyLevel::Full,
6568            ..crate::config::AutonomyConfig::default()
6569        };
6570        let approval_mgr = ApprovalManager::from_config(&approval_cfg);
6571
6572        assert!(should_execute_tools_in_parallel(
6573            &calls,
6574            Some(&approval_mgr)
6575        ));
6576    }
6577
6578    #[tokio::test]
6579    async fn run_tool_call_loop_executes_multiple_tools_with_ordered_results() {
6580        let provider = ScriptedProvider::from_text_responses(vec![
6581            r#"<tool_call>
6582{"name":"delay_a","arguments":{"value":"A"}}
6583</tool_call>
6584<tool_call>
6585{"name":"delay_b","arguments":{"value":"B"}}
6586</tool_call>"#,
6587            "done",
6588        ]);
6589
6590        let active = Arc::new(AtomicUsize::new(0));
6591        let max_active = Arc::new(AtomicUsize::new(0));
6592        let tools_registry: Vec<Box<dyn Tool>> = vec![
6593            Box::new(DelayTool::new(
6594                "delay_a",
6595                200,
6596                Arc::clone(&active),
6597                Arc::clone(&max_active),
6598            )),
6599            Box::new(DelayTool::new(
6600                "delay_b",
6601                200,
6602                Arc::clone(&active),
6603                Arc::clone(&max_active),
6604            )),
6605        ];
6606
6607        let approval_cfg = crate::config::AutonomyConfig {
6608            level: crate::security::AutonomyLevel::Full,
6609            ..crate::config::AutonomyConfig::default()
6610        };
6611        let approval_mgr = ApprovalManager::from_config(&approval_cfg);
6612
6613        let mut history = vec![
6614            ChatMessage::system("test-system"),
6615            ChatMessage::user("run tool calls"),
6616        ];
6617        let observer = NoopObserver;
6618
6619        let result = run_tool_call_loop(
6620            &provider,
6621            &mut history,
6622            &tools_registry,
6623            &observer,
6624            "mock-provider",
6625            "mock-model",
6626            0.0,
6627            true,
6628            Some(&approval_mgr),
6629            "telegram",
6630            None,
6631            &crate::config::MultimodalConfig::default(),
6632            4,
6633            None,
6634            None,
6635            None,
6636            &[],
6637            &[],
6638            None,
6639            None,
6640            &crate::config::PacingConfig::default(),
6641            0,
6642            0,
6643            None,
6644        )
6645        .await
6646        .expect("parallel execution should complete");
6647
6648        assert_eq!(result, "done");
6649        assert!(
6650            max_active.load(Ordering::SeqCst) >= 1,
6651            "tools should execute successfully"
6652        );
6653
6654        let tool_results_message = history
6655            .iter()
6656            .find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]"))
6657            .expect("tool results message should be present");
6658        let idx_a = tool_results_message
6659            .content
6660            .find("name=\"delay_a\"")
6661            .expect("delay_a result should be present");
6662        let idx_b = tool_results_message
6663            .content
6664            .find("name=\"delay_b\"")
6665            .expect("delay_b result should be present");
6666        assert!(
6667            idx_a < idx_b,
6668            "tool results should preserve input order for tool call mapping"
6669        );
6670    }
6671
6672    #[tokio::test]
6673    async fn run_tool_call_loop_injects_channel_delivery_defaults_for_cron_add() {
6674        let provider = ScriptedProvider::from_text_responses(vec![
6675            r#"<tool_call>
6676{"name":"cron_add","arguments":{"job_type":"agent","prompt":"remind me later","schedule":{"kind":"every","every_ms":60000}}}
6677</tool_call>"#,
6678            "done",
6679        ]);
6680
6681        let recorded_args = Arc::new(Mutex::new(Vec::new()));
6682        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(RecordingArgsTool::new(
6683            "cron_add",
6684            Arc::clone(&recorded_args),
6685        ))];
6686
6687        let mut history = vec![
6688            ChatMessage::system("test-system"),
6689            ChatMessage::user("schedule a reminder"),
6690        ];
6691        let observer = NoopObserver;
6692
6693        let result = run_tool_call_loop(
6694            &provider,
6695            &mut history,
6696            &tools_registry,
6697            &observer,
6698            "mock-provider",
6699            "mock-model",
6700            0.0,
6701            true,
6702            None,
6703            "telegram",
6704            Some("chat-42"),
6705            &crate::config::MultimodalConfig::default(),
6706            4,
6707            None,
6708            None,
6709            None,
6710            &[],
6711            &[],
6712            None,
6713            None,
6714            &crate::config::PacingConfig::default(),
6715            0,
6716            0,
6717            None,
6718        )
6719        .await
6720        .expect("cron_add delivery defaults should be injected");
6721
6722        assert_eq!(result, "done");
6723
6724        let recorded = recorded_args
6725            .lock()
6726            .expect("recorded args lock should be valid");
6727        let delivery = recorded[0]["delivery"].clone();
6728        assert_eq!(
6729            delivery,
6730            serde_json::json!({
6731                "mode": "announce",
6732                "channel": "telegram",
6733                "to": "chat-42",
6734            })
6735        );
6736    }
6737
6738    #[tokio::test]
6739    async fn run_tool_call_loop_preserves_explicit_cron_delivery_none() {
6740        let provider = ScriptedProvider::from_text_responses(vec![
6741            r#"<tool_call>
6742{"name":"cron_add","arguments":{"job_type":"agent","prompt":"run silently","schedule":{"kind":"every","every_ms":60000},"delivery":{"mode":"none"}}}
6743</tool_call>"#,
6744            "done",
6745        ]);
6746
6747        let recorded_args = Arc::new(Mutex::new(Vec::new()));
6748        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(RecordingArgsTool::new(
6749            "cron_add",
6750            Arc::clone(&recorded_args),
6751        ))];
6752
6753        let mut history = vec![
6754            ChatMessage::system("test-system"),
6755            ChatMessage::user("schedule a quiet cron job"),
6756        ];
6757        let observer = NoopObserver;
6758
6759        let result = run_tool_call_loop(
6760            &provider,
6761            &mut history,
6762            &tools_registry,
6763            &observer,
6764            "mock-provider",
6765            "mock-model",
6766            0.0,
6767            true,
6768            None,
6769            "telegram",
6770            Some("chat-42"),
6771            &crate::config::MultimodalConfig::default(),
6772            4,
6773            None,
6774            None,
6775            None,
6776            &[],
6777            &[],
6778            None,
6779            None,
6780            &crate::config::PacingConfig::default(),
6781            0,
6782            0,
6783            None,
6784        )
6785        .await
6786        .expect("explicit delivery mode should be preserved");
6787
6788        assert_eq!(result, "done");
6789
6790        let recorded = recorded_args
6791            .lock()
6792            .expect("recorded args lock should be valid");
6793        assert_eq!(recorded[0]["delivery"], serde_json::json!({"mode": "none"}));
6794    }
6795
6796    #[tokio::test]
6797    async fn run_tool_call_loop_deduplicates_repeated_tool_calls() {
6798        let provider = ScriptedProvider::from_text_responses(vec![
6799            r#"<tool_call>
6800{"name":"count_tool","arguments":{"value":"A"}}
6801</tool_call>
6802<tool_call>
6803{"name":"count_tool","arguments":{"value":"A"}}
6804</tool_call>"#,
6805            "done",
6806        ]);
6807
6808        let invocations = Arc::new(AtomicUsize::new(0));
6809        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
6810            "count_tool",
6811            Arc::clone(&invocations),
6812        ))];
6813
6814        let mut history = vec![
6815            ChatMessage::system("test-system"),
6816            ChatMessage::user("run tool calls"),
6817        ];
6818        let observer = NoopObserver;
6819
6820        let result = run_tool_call_loop(
6821            &provider,
6822            &mut history,
6823            &tools_registry,
6824            &observer,
6825            "mock-provider",
6826            "mock-model",
6827            0.0,
6828            true,
6829            None,
6830            "cli",
6831            None,
6832            &crate::config::MultimodalConfig::default(),
6833            4,
6834            None,
6835            None,
6836            None,
6837            &[],
6838            &[],
6839            None,
6840            None,
6841            &crate::config::PacingConfig::default(),
6842            0,
6843            0,
6844            None,
6845        )
6846        .await
6847        .expect("loop should finish after deduplicating repeated calls");
6848
6849        assert_eq!(result, "done");
6850        assert_eq!(
6851            invocations.load(Ordering::SeqCst),
6852            1,
6853            "duplicate tool call with same args should not execute twice"
6854        );
6855
6856        let tool_results = history
6857            .iter()
6858            .find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]"))
6859            .expect("prompt-mode tool result payload should be present");
6860        assert!(tool_results.content.contains("counted:A"));
6861        assert!(tool_results.content.contains("Skipped duplicate tool call"));
6862    }
6863
6864    #[tokio::test]
6865    async fn run_tool_call_loop_allows_low_risk_shell_in_non_interactive_mode() {
6866        let provider = ScriptedProvider::from_text_responses(vec![
6867            r#"<tool_call>
6868{"name":"shell","arguments":{"command":"echo hello"}}
6869</tool_call>"#,
6870            "done",
6871        ]);
6872
6873        let tmp = TempDir::new().expect("temp dir");
6874        let security = Arc::new(crate::security::SecurityPolicy {
6875            autonomy: crate::security::AutonomyLevel::Supervised,
6876            workspace_dir: tmp.path().to_path_buf(),
6877            ..crate::security::SecurityPolicy::default()
6878        });
6879        let runtime: Arc<dyn crate::runtime::RuntimeAdapter> =
6880            Arc::new(crate::runtime::NativeRuntime::new());
6881        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(
6882            crate::tools::shell::ShellTool::new(security, runtime),
6883        )];
6884
6885        let mut history = vec![
6886            ChatMessage::system("test-system"),
6887            ChatMessage::user("run shell"),
6888        ];
6889        let observer = NoopObserver;
6890        let approval_mgr =
6891            ApprovalManager::for_non_interactive(&crate::config::AutonomyConfig::default());
6892
6893        let result = run_tool_call_loop(
6894            &provider,
6895            &mut history,
6896            &tools_registry,
6897            &observer,
6898            "mock-provider",
6899            "mock-model",
6900            0.0,
6901            true,
6902            Some(&approval_mgr),
6903            "telegram",
6904            None,
6905            &crate::config::MultimodalConfig::default(),
6906            4,
6907            None,
6908            None,
6909            None,
6910            &[],
6911            &[],
6912            None,
6913            None,
6914            &crate::config::PacingConfig::default(),
6915            0,
6916            0,
6917            None,
6918        )
6919        .await
6920        .expect("non-interactive shell should succeed for low-risk command");
6921
6922        assert_eq!(result, "done");
6923
6924        let tool_results = history
6925            .iter()
6926            .find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]"))
6927            .expect("tool results message should be present");
6928        assert!(tool_results.content.contains("hello"));
6929        assert!(!tool_results.content.contains("Denied by user."));
6930    }
6931
6932    #[tokio::test]
6933    async fn run_tool_call_loop_dedup_exempt_allows_repeated_calls() {
6934        let provider = ScriptedProvider::from_text_responses(vec![
6935            r#"<tool_call>
6936{"name":"count_tool","arguments":{"value":"A"}}
6937</tool_call>
6938<tool_call>
6939{"name":"count_tool","arguments":{"value":"A"}}
6940</tool_call>"#,
6941            "done",
6942        ]);
6943
6944        let invocations = Arc::new(AtomicUsize::new(0));
6945        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
6946            "count_tool",
6947            Arc::clone(&invocations),
6948        ))];
6949
6950        let mut history = vec![
6951            ChatMessage::system("test-system"),
6952            ChatMessage::user("run tool calls"),
6953        ];
6954        let observer = NoopObserver;
6955        let exempt = vec!["count_tool".to_string()];
6956
6957        let result = run_tool_call_loop(
6958            &provider,
6959            &mut history,
6960            &tools_registry,
6961            &observer,
6962            "mock-provider",
6963            "mock-model",
6964            0.0,
6965            true,
6966            None,
6967            "cli",
6968            None,
6969            &crate::config::MultimodalConfig::default(),
6970            4,
6971            None,
6972            None,
6973            None,
6974            &[],
6975            &exempt,
6976            None,
6977            None,
6978            &crate::config::PacingConfig::default(),
6979            0,
6980            0,
6981            None,
6982        )
6983        .await
6984        .expect("loop should finish with exempt tool executing twice");
6985
6986        assert_eq!(result, "done");
6987        assert_eq!(
6988            invocations.load(Ordering::SeqCst),
6989            2,
6990            "exempt tool should execute both duplicate calls"
6991        );
6992
6993        let tool_results = history
6994            .iter()
6995            .find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]"))
6996            .expect("prompt-mode tool result payload should be present");
6997        assert!(
6998            !tool_results.content.contains("Skipped duplicate tool call"),
6999            "exempt tool calls should not be suppressed"
7000        );
7001    }
7002
7003    #[tokio::test]
7004    async fn run_tool_call_loop_dedup_exempt_only_affects_listed_tools() {
7005        let provider = ScriptedProvider::from_text_responses(vec![
7006            r#"<tool_call>
7007{"name":"count_tool","arguments":{"value":"A"}}
7008</tool_call>
7009<tool_call>
7010{"name":"count_tool","arguments":{"value":"A"}}
7011</tool_call>
7012<tool_call>
7013{"name":"other_tool","arguments":{"value":"B"}}
7014</tool_call>
7015<tool_call>
7016{"name":"other_tool","arguments":{"value":"B"}}
7017</tool_call>"#,
7018            "done",
7019        ]);
7020
7021        let count_invocations = Arc::new(AtomicUsize::new(0));
7022        let other_invocations = Arc::new(AtomicUsize::new(0));
7023        let tools_registry: Vec<Box<dyn Tool>> = vec![
7024            Box::new(CountingTool::new(
7025                "count_tool",
7026                Arc::clone(&count_invocations),
7027            )),
7028            Box::new(CountingTool::new(
7029                "other_tool",
7030                Arc::clone(&other_invocations),
7031            )),
7032        ];
7033
7034        let mut history = vec![
7035            ChatMessage::system("test-system"),
7036            ChatMessage::user("run tool calls"),
7037        ];
7038        let observer = NoopObserver;
7039        let exempt = vec!["count_tool".to_string()];
7040
7041        let _result = run_tool_call_loop(
7042            &provider,
7043            &mut history,
7044            &tools_registry,
7045            &observer,
7046            "mock-provider",
7047            "mock-model",
7048            0.0,
7049            true,
7050            None,
7051            "cli",
7052            None,
7053            &crate::config::MultimodalConfig::default(),
7054            4,
7055            None,
7056            None,
7057            None,
7058            &[],
7059            &exempt,
7060            None,
7061            None,
7062            &crate::config::PacingConfig::default(),
7063            0,
7064            0,
7065            None,
7066        )
7067        .await
7068        .expect("loop should complete");
7069
7070        assert_eq!(
7071            count_invocations.load(Ordering::SeqCst),
7072            2,
7073            "exempt tool should execute both calls"
7074        );
7075        assert_eq!(
7076            other_invocations.load(Ordering::SeqCst),
7077            1,
7078            "non-exempt tool should still be deduped"
7079        );
7080    }
7081
7082    #[tokio::test]
7083    async fn run_tool_call_loop_native_mode_preserves_fallback_tool_call_ids() {
7084        let provider = ScriptedProvider::from_text_responses(vec![
7085            r#"{"content":"Need to call tool","tool_calls":[{"id":"call_abc","name":"count_tool","arguments":"{\"value\":\"X\"}"}]}"#,
7086            "done",
7087        ])
7088        .with_native_tool_support();
7089
7090        let invocations = Arc::new(AtomicUsize::new(0));
7091        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
7092            "count_tool",
7093            Arc::clone(&invocations),
7094        ))];
7095
7096        let mut history = vec![
7097            ChatMessage::system("test-system"),
7098            ChatMessage::user("run tool calls"),
7099        ];
7100        let observer = NoopObserver;
7101
7102        let result = run_tool_call_loop(
7103            &provider,
7104            &mut history,
7105            &tools_registry,
7106            &observer,
7107            "mock-provider",
7108            "mock-model",
7109            0.0,
7110            true,
7111            None,
7112            "cli",
7113            None,
7114            &crate::config::MultimodalConfig::default(),
7115            4,
7116            None,
7117            None,
7118            None,
7119            &[],
7120            &[],
7121            None,
7122            None,
7123            &crate::config::PacingConfig::default(),
7124            0,
7125            0,
7126            None,
7127        )
7128        .await
7129        .expect("native fallback id flow should complete");
7130
7131        assert_eq!(result, "done");
7132        assert_eq!(invocations.load(Ordering::SeqCst), 1);
7133        assert!(
7134            history.iter().any(|msg| {
7135                msg.role == "tool" && msg.content.contains("\"tool_call_id\":\"call_abc\"")
7136            }),
7137            "tool result should preserve parsed fallback tool_call_id in native mode"
7138        );
7139        assert!(
7140            history
7141                .iter()
7142                .all(|msg| !(msg.role == "user" && msg.content.starts_with("[Tool results]"))),
7143            "native mode should use role=tool history instead of prompt fallback wrapper"
7144        );
7145    }
7146
7147    #[tokio::test]
7148    async fn run_tool_call_loop_relays_native_tool_call_text_via_on_delta() {
7149        let provider = ScriptedProvider {
7150            responses: Arc::new(Mutex::new(VecDeque::from(vec![
7151                ChatResponse {
7152                    text: Some("Task started. Waiting 30 seconds before checking status.".into()),
7153                    tool_calls: vec![ToolCall {
7154                        id: "call_wait".into(),
7155                        name: "count_tool".into(),
7156                        arguments: r#"{"value":"A"}"#.into(),
7157                    }],
7158                    usage: None,
7159                    reasoning_content: None,
7160                },
7161                ChatResponse {
7162                    text: Some("Final answer".into()),
7163                    tool_calls: Vec::new(),
7164                    usage: None,
7165                    reasoning_content: None,
7166                },
7167            ]))),
7168            capabilities: ProviderCapabilities {
7169                native_tool_calling: true,
7170                ..ProviderCapabilities::default()
7171            },
7172        };
7173
7174        let invocations = Arc::new(AtomicUsize::new(0));
7175        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
7176            "count_tool",
7177            Arc::clone(&invocations),
7178        ))];
7179
7180        let mut history = vec![
7181            ChatMessage::system("test-system"),
7182            ChatMessage::user("run tool calls"),
7183        ];
7184        let observer = NoopObserver;
7185        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
7186
7187        let result = run_tool_call_loop(
7188            &provider,
7189            &mut history,
7190            &tools_registry,
7191            &observer,
7192            "mock-provider",
7193            "mock-model",
7194            0.0,
7195            true,
7196            None,
7197            "telegram",
7198            None,
7199            &crate::config::MultimodalConfig::default(),
7200            4,
7201            None,
7202            Some(tx),
7203            None,
7204            &[],
7205            &[],
7206            None,
7207            None,
7208            &crate::config::PacingConfig::default(),
7209            0,
7210            0,
7211            None,
7212        )
7213        .await
7214        .expect("native tool-call text should be relayed through on_delta");
7215
7216        let mut deltas: Vec<DraftEvent> = Vec::new();
7217        while let Some(delta) = rx.recv().await {
7218            deltas.push(delta);
7219        }
7220
7221        let explanation_idx = deltas
7222            .iter()
7223            .position(|delta| matches!(delta, DraftEvent::Content(t) if t == "Task started. Waiting 30 seconds before checking status.\n"))
7224            .expect("native assistant text should be relayed to on_delta");
7225        let clear_idx = deltas
7226            .iter()
7227            .position(|delta| matches!(delta, DraftEvent::Clear))
7228            .expect("final answer streaming should clear prior draft state");
7229
7230        assert!(
7231            deltas
7232                .iter()
7233                .any(|delta| matches!(delta, DraftEvent::Progress(t) if t.starts_with("\u{1f4ac} Got 1 tool call(s)"))),
7234            "tool-call progress line should still be relayed"
7235        );
7236        assert!(
7237            explanation_idx < clear_idx,
7238            "native assistant text should arrive before final-answer draft clearing"
7239        );
7240        assert_eq!(result, "Final answer");
7241        assert_eq!(invocations.load(Ordering::SeqCst), 1);
7242    }
7243
7244    #[tokio::test]
7245    async fn run_tool_call_loop_consumes_provider_stream_for_final_response() {
7246        let provider =
7247            StreamingScriptedProvider::from_text_responses(vec!["streamed final answer"]);
7248        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
7249        let mut history = vec![
7250            ChatMessage::system("test-system"),
7251            ChatMessage::user("say hi"),
7252        ];
7253        let observer = NoopObserver;
7254        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(32);
7255
7256        let result = run_tool_call_loop(
7257            &provider,
7258            &mut history,
7259            &tools_registry,
7260            &observer,
7261            "mock-provider",
7262            "mock-model",
7263            0.0,
7264            true,
7265            None,
7266            "telegram",
7267            None,
7268            &crate::config::MultimodalConfig::default(),
7269            4,
7270            None,
7271            Some(tx),
7272            None,
7273            &[],
7274            &[],
7275            None,
7276            None,
7277            &crate::config::PacingConfig::default(),
7278            0,
7279            0,
7280            None,
7281        )
7282        .await
7283        .expect("streaming provider should complete");
7284
7285        let mut visible_deltas = String::new();
7286        while let Some(delta) = rx.recv().await {
7287            match delta {
7288                DraftEvent::Clear => {
7289                    visible_deltas.clear();
7290                }
7291                DraftEvent::Progress(_) => {}
7292                DraftEvent::Content(text) => {
7293                    visible_deltas.push_str(&text);
7294                }
7295            }
7296        }
7297
7298        assert_eq!(result, "streamed final answer");
7299        assert_eq!(
7300            visible_deltas, "streamed final answer",
7301            "draft should receive upstream deltas once without post-hoc duplication"
7302        );
7303        assert_eq!(provider.stream_calls.load(Ordering::SeqCst), 1);
7304        assert_eq!(provider.chat_calls.load(Ordering::SeqCst), 0);
7305    }
7306
7307    #[tokio::test]
7308    async fn run_tool_call_loop_streaming_path_preserves_tool_loop_semantics() {
7309        let provider = StreamingScriptedProvider::from_text_responses(vec![
7310            r#"<tool_call>
7311{"name":"count_tool","arguments":{"value":"A"}}
7312</tool_call>"#,
7313            "done",
7314        ]);
7315        let invocations = Arc::new(AtomicUsize::new(0));
7316        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
7317            "count_tool",
7318            Arc::clone(&invocations),
7319        ))];
7320        let mut history = vec![
7321            ChatMessage::system("test-system"),
7322            ChatMessage::user("run tool calls"),
7323        ];
7324        let observer = NoopObserver;
7325        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(64);
7326
7327        let result = run_tool_call_loop(
7328            &provider,
7329            &mut history,
7330            &tools_registry,
7331            &observer,
7332            "mock-provider",
7333            "mock-model",
7334            0.0,
7335            true,
7336            None,
7337            "telegram",
7338            None,
7339            &crate::config::MultimodalConfig::default(),
7340            5,
7341            None,
7342            Some(tx),
7343            None,
7344            &[],
7345            &[],
7346            None,
7347            None,
7348            &crate::config::PacingConfig::default(),
7349            0,
7350            0,
7351            None,
7352        )
7353        .await
7354        .expect("streaming tool loop should execute tool and finish");
7355
7356        let mut visible_deltas = String::new();
7357        while let Some(delta) = rx.recv().await {
7358            match delta {
7359                DraftEvent::Clear => {
7360                    visible_deltas.clear();
7361                }
7362                DraftEvent::Progress(_) => {}
7363                DraftEvent::Content(text) => {
7364                    visible_deltas.push_str(&text);
7365                }
7366            }
7367        }
7368
7369        assert_eq!(result, "done");
7370        assert_eq!(invocations.load(Ordering::SeqCst), 1);
7371        assert_eq!(provider.stream_calls.load(Ordering::SeqCst), 2);
7372        assert_eq!(provider.chat_calls.load(Ordering::SeqCst), 0);
7373        assert_eq!(visible_deltas, "done");
7374        assert!(
7375            !visible_deltas.contains("<tool_call"),
7376            "draft text should not leak streamed tool payload markers"
7377        );
7378    }
7379
7380    #[tokio::test]
7381    async fn run_tool_call_loop_streams_native_tool_events_without_chat_fallback() {
7382        let provider = StreamingNativeToolEventProvider::with_turns(vec![
7383            NativeStreamTurn::ToolCall(ToolCall {
7384                id: "call_native_1".to_string(),
7385                name: "count_tool".to_string(),
7386                arguments: r#"{"value":"A"}"#.to_string(),
7387            }),
7388            NativeStreamTurn::Text("done".to_string()),
7389        ]);
7390        let invocations = Arc::new(AtomicUsize::new(0));
7391        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
7392            "count_tool",
7393            Arc::clone(&invocations),
7394        ))];
7395        let mut history = vec![
7396            ChatMessage::system("test-system"),
7397            ChatMessage::user("run native tools"),
7398        ];
7399        let observer = NoopObserver;
7400        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(64);
7401
7402        let result = run_tool_call_loop(
7403            &provider,
7404            &mut history,
7405            &tools_registry,
7406            &observer,
7407            "mock-provider",
7408            "mock-model",
7409            0.0,
7410            true,
7411            None,
7412            "telegram",
7413            None,
7414            &crate::config::MultimodalConfig::default(),
7415            5,
7416            None,
7417            Some(tx),
7418            None,
7419            &[],
7420            &[],
7421            None,
7422            None,
7423            &crate::config::PacingConfig::default(),
7424            0,
7425            0,
7426            None,
7427        )
7428        .await
7429        .expect("native streaming events should preserve tool loop semantics");
7430
7431        let mut visible_deltas = String::new();
7432        while let Some(delta) = rx.recv().await {
7433            match delta {
7434                DraftEvent::Clear => {
7435                    visible_deltas.clear();
7436                }
7437                DraftEvent::Progress(_) => {}
7438                DraftEvent::Content(text) => {
7439                    visible_deltas.push_str(&text);
7440                }
7441            }
7442        }
7443
7444        assert_eq!(result, "done");
7445        assert_eq!(invocations.load(Ordering::SeqCst), 1);
7446        assert_eq!(provider.stream_calls.load(Ordering::SeqCst), 2);
7447        assert_eq!(provider.stream_tool_requests.load(Ordering::SeqCst), 2);
7448        assert_eq!(provider.chat_calls.load(Ordering::SeqCst), 0);
7449        assert_eq!(visible_deltas, "done");
7450    }
7451
7452    #[tokio::test]
7453    async fn run_tool_call_loop_routed_streaming_uses_live_provider_deltas_once() {
7454        let default_provider = RouteAwareStreamingProvider::new("default answer");
7455        let default_stream_calls = Arc::clone(&default_provider.stream_calls);
7456        let default_chat_calls = Arc::clone(&default_provider.chat_calls);
7457
7458        let routed_provider = RouteAwareStreamingProvider::new("routed streamed answer");
7459        let routed_stream_calls = Arc::clone(&routed_provider.stream_calls);
7460        let routed_chat_calls = Arc::clone(&routed_provider.chat_calls);
7461        let routed_last_model = Arc::clone(&routed_provider.last_model);
7462
7463        let router = RouterProvider::new(
7464            vec![
7465                ("default".to_string(), Box::new(default_provider)),
7466                ("fast".to_string(), Box::new(routed_provider)),
7467            ],
7468            vec![(
7469                "fast".to_string(),
7470                Route {
7471                    provider_name: "fast".to_string(),
7472                    model: "routed-model".to_string(),
7473                },
7474            )],
7475            "default-model".to_string(),
7476        );
7477
7478        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
7479        let mut history = vec![
7480            ChatMessage::system("test-system"),
7481            ChatMessage::user("say hi"),
7482        ];
7483        let observer = NoopObserver;
7484        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(32);
7485
7486        let result = run_tool_call_loop(
7487            &router,
7488            &mut history,
7489            &tools_registry,
7490            &observer,
7491            "router",
7492            "hint:fast",
7493            0.0,
7494            true,
7495            None,
7496            "telegram",
7497            None,
7498            &crate::config::MultimodalConfig::default(),
7499            4,
7500            None,
7501            Some(tx),
7502            None,
7503            &[],
7504            &[],
7505            None,
7506            None,
7507            &crate::config::PacingConfig::default(),
7508            0,
7509            0,
7510            None,
7511        )
7512        .await
7513        .expect("routed streaming provider should complete");
7514
7515        let mut visible_deltas = String::new();
7516        while let Some(delta) = rx.recv().await {
7517            match delta {
7518                DraftEvent::Clear => {
7519                    visible_deltas.clear();
7520                }
7521                DraftEvent::Progress(_) => {}
7522                DraftEvent::Content(text) => {
7523                    visible_deltas.push_str(&text);
7524                }
7525            }
7526        }
7527
7528        assert_eq!(result, "routed streamed answer");
7529        assert_eq!(
7530            visible_deltas, "routed streamed answer",
7531            "routed draft should receive upstream deltas once without post-hoc duplication"
7532        );
7533        assert_eq!(default_stream_calls.load(Ordering::SeqCst), 0);
7534        assert_eq!(routed_stream_calls.load(Ordering::SeqCst), 1);
7535        assert_eq!(default_chat_calls.load(Ordering::SeqCst), 0);
7536        assert_eq!(routed_chat_calls.load(Ordering::SeqCst), 0);
7537        assert_eq!(
7538            routed_last_model
7539                .lock()
7540                .expect("routed_last_model lock should be valid")
7541                .as_str(),
7542            "routed-model"
7543        );
7544    }
7545
7546    #[test]
7547    fn agent_turn_executes_activated_tool_from_wrapper() {
7548        let runtime = tokio::runtime::Builder::new_current_thread()
7549            .enable_all()
7550            .build()
7551            .expect("test runtime should initialize");
7552
7553        runtime.block_on(async {
7554            let provider = ScriptedProvider::from_text_responses(vec![
7555                r#"<tool_call>
7556{"name":"pixel__get_api_health","arguments":{"value":"ok"}}
7557</tool_call>"#,
7558                "done",
7559            ]);
7560
7561            let invocations = Arc::new(AtomicUsize::new(0));
7562            let activated = Arc::new(std::sync::Mutex::new(crate::tools::ActivatedToolSet::new()));
7563            let activated_tool: Arc<dyn Tool> = Arc::new(CountingTool::new(
7564                "pixel__get_api_health",
7565                Arc::clone(&invocations),
7566            ));
7567            activated
7568                .lock()
7569                .unwrap()
7570                .activate("pixel__get_api_health".into(), activated_tool);
7571
7572            let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
7573            let mut history = vec![
7574                ChatMessage::system("test-system"),
7575                ChatMessage::user("use the activated MCP tool"),
7576            ];
7577            let observer = NoopObserver;
7578
7579            let result = agent_turn(
7580                &provider,
7581                &mut history,
7582                &tools_registry,
7583                &observer,
7584                "mock-provider",
7585                "mock-model",
7586                0.0,
7587                true,
7588                "daemon",
7589                None,
7590                &crate::config::MultimodalConfig::default(),
7591                4,
7592                None,
7593                &[],
7594                &[],
7595                Some(&activated),
7596                None,
7597            )
7598            .await
7599            .expect("wrapper path should execute activated tools");
7600
7601            assert_eq!(result, "done");
7602            assert_eq!(invocations.load(Ordering::SeqCst), 1);
7603        });
7604    }
7605
7606    #[test]
7607    fn resolve_display_text_hides_raw_payload_for_tool_only_turns() {
7608        let display = resolve_display_text(
7609            "<tool_call>{\"name\":\"memory_store\"}</tool_call>",
7610            "",
7611            true,
7612            false,
7613        );
7614        assert!(display.is_empty());
7615    }
7616
7617    #[test]
7618    fn resolve_display_text_keeps_plain_text_for_tool_turns() {
7619        let display = resolve_display_text(
7620            "<tool_call>{\"name\":\"shell\"}</tool_call>",
7621            "Let me check that.",
7622            true,
7623            false,
7624        );
7625        assert_eq!(display, "Let me check that.");
7626    }
7627
7628    #[test]
7629    fn resolve_display_text_uses_response_text_for_native_tool_turns() {
7630        let display = resolve_display_text("Task started.", "", true, true);
7631        assert_eq!(display, "Task started.");
7632    }
7633
7634    #[test]
7635    fn resolve_display_text_uses_response_text_for_final_turns() {
7636        let display = resolve_display_text("Final answer", "", false, false);
7637        assert_eq!(display, "Final answer");
7638    }
7639
7640    #[test]
7641    fn parse_tool_calls_extracts_single_call() {
7642        let response = r#"Let me check that.
7643<tool_call>
7644{"name": "shell", "arguments": {"command": "ls -la"}}
7645</tool_call>"#;
7646
7647        let (text, calls) = parse_tool_calls(response);
7648        assert_eq!(text, "Let me check that.");
7649        assert_eq!(calls.len(), 1);
7650        assert_eq!(calls[0].name, "shell");
7651        assert_eq!(
7652            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7653            "ls -la"
7654        );
7655    }
7656
7657    #[test]
7658    fn parse_tool_calls_extracts_multiple_calls() {
7659        let response = r#"<tool_call>
7660{"name": "file_read", "arguments": {"path": "a.txt"}}
7661</tool_call>
7662<tool_call>
7663{"name": "file_read", "arguments": {"path": "b.txt"}}
7664</tool_call>"#;
7665
7666        let (_, calls) = parse_tool_calls(response);
7667        assert_eq!(calls.len(), 2);
7668        assert_eq!(calls[0].name, "file_read");
7669        assert_eq!(calls[1].name, "file_read");
7670    }
7671
7672    #[test]
7673    fn parse_tool_calls_returns_text_only_when_no_calls() {
7674        let response = "Just a normal response with no tools.";
7675        let (text, calls) = parse_tool_calls(response);
7676        assert_eq!(text, "Just a normal response with no tools.");
7677        assert!(calls.is_empty());
7678    }
7679
7680    #[test]
7681    fn parse_tool_calls_handles_malformed_json() {
7682        let response = r#"<tool_call>
7683not valid json
7684</tool_call>
7685Some text after."#;
7686
7687        let (text, calls) = parse_tool_calls(response);
7688        assert!(calls.is_empty());
7689        assert!(text.contains("Some text after."));
7690    }
7691
7692    #[test]
7693    fn parse_tool_calls_text_before_and_after() {
7694        let response = r#"Before text.
7695<tool_call>
7696{"name": "shell", "arguments": {"command": "echo hi"}}
7697</tool_call>
7698After text."#;
7699
7700        let (text, calls) = parse_tool_calls(response);
7701        assert!(text.contains("Before text."));
7702        assert!(text.contains("After text."));
7703        assert_eq!(calls.len(), 1);
7704    }
7705
7706    #[test]
7707    fn parse_tool_calls_handles_openai_format() {
7708        // OpenAI-style response with tool_calls array
7709        let response = r#"{"content": "Let me check that for you.", "tool_calls": [{"type": "function", "function": {"name": "shell", "arguments": "{\"command\": \"ls -la\"}"}}]}"#;
7710
7711        let (text, calls) = parse_tool_calls(response);
7712        assert_eq!(text, "Let me check that for you.");
7713        assert_eq!(calls.len(), 1);
7714        assert_eq!(calls[0].name, "shell");
7715        assert_eq!(
7716            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7717            "ls -la"
7718        );
7719    }
7720
7721    #[test]
7722    fn parse_tool_calls_handles_openai_format_multiple_calls() {
7723        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\"}"}}]}"#;
7724
7725        let (_, calls) = parse_tool_calls(response);
7726        assert_eq!(calls.len(), 2);
7727        assert_eq!(calls[0].name, "file_read");
7728        assert_eq!(calls[1].name, "file_read");
7729    }
7730
7731    #[test]
7732    fn parse_tool_calls_openai_format_without_content() {
7733        // Some providers don't include content field with tool_calls
7734        let response = r#"{"tool_calls": [{"type": "function", "function": {"name": "memory_recall", "arguments": "{}"}}]}"#;
7735
7736        let (text, calls) = parse_tool_calls(response);
7737        assert!(text.is_empty()); // No content field
7738        assert_eq!(calls.len(), 1);
7739        assert_eq!(calls[0].name, "memory_recall");
7740    }
7741
7742    #[test]
7743    fn parse_tool_calls_preserves_openai_tool_call_ids() {
7744        let response = r#"{"tool_calls":[{"id":"call_42","function":{"name":"shell","arguments":"{\"command\":\"pwd\"}"}}]}"#;
7745        let (_, calls) = parse_tool_calls(response);
7746        assert_eq!(calls.len(), 1);
7747        assert_eq!(calls[0].tool_call_id.as_deref(), Some("call_42"));
7748    }
7749
7750    #[test]
7751    fn parse_tool_calls_handles_markdown_json_inside_tool_call_tag() {
7752        let response = r#"<tool_call>
7753```json
7754{"name": "file_write", "arguments": {"path": "test.py", "content": "print('ok')"}}
7755```
7756</tool_call>"#;
7757
7758        let (text, calls) = parse_tool_calls(response);
7759        assert!(text.is_empty());
7760        assert_eq!(calls.len(), 1);
7761        assert_eq!(calls[0].name, "file_write");
7762        assert_eq!(
7763            calls[0].arguments.get("path").unwrap().as_str().unwrap(),
7764            "test.py"
7765        );
7766    }
7767
7768    #[test]
7769    fn parse_tool_calls_handles_noisy_tool_call_tag_body() {
7770        let response = r#"<tool_call>
7771I will now call the tool with this payload:
7772{"name": "shell", "arguments": {"command": "pwd"}}
7773</tool_call>"#;
7774
7775        let (text, calls) = parse_tool_calls(response);
7776        assert!(text.is_empty());
7777        assert_eq!(calls.len(), 1);
7778        assert_eq!(calls[0].name, "shell");
7779        assert_eq!(
7780            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7781            "pwd"
7782        );
7783    }
7784
7785    #[test]
7786    fn parse_tool_calls_handles_tool_call_inline_attributes_with_send_message_alias() {
7787        let response = r#"<tool_call>send_message channel="user_channel" message="Hello! How can I assist you today?"</tool_call>"#;
7788
7789        let (text, calls) = parse_tool_calls(response);
7790        assert!(text.is_empty());
7791        assert_eq!(calls.len(), 1);
7792        assert_eq!(calls[0].name, "message_send");
7793        assert_eq!(
7794            calls[0].arguments.get("channel").unwrap().as_str().unwrap(),
7795            "user_channel"
7796        );
7797        assert_eq!(
7798            calls[0].arguments.get("message").unwrap().as_str().unwrap(),
7799            "Hello! How can I assist you today?"
7800        );
7801    }
7802
7803    #[test]
7804    fn parse_tool_calls_handles_tool_call_function_style_arguments() {
7805        let response = r#"<tool_call>message_send(channel="general", message="test")</tool_call>"#;
7806
7807        let (text, calls) = parse_tool_calls(response);
7808        assert!(text.is_empty());
7809        assert_eq!(calls.len(), 1);
7810        assert_eq!(calls[0].name, "message_send");
7811        assert_eq!(
7812            calls[0].arguments.get("channel").unwrap().as_str().unwrap(),
7813            "general"
7814        );
7815        assert_eq!(
7816            calls[0].arguments.get("message").unwrap().as_str().unwrap(),
7817            "test"
7818        );
7819    }
7820
7821    #[test]
7822    fn parse_tool_calls_handles_xml_nested_tool_payload() {
7823        let response = r#"<tool_call>
7824<memory_recall>
7825<query>project roadmap</query>
7826</memory_recall>
7827</tool_call>"#;
7828
7829        let (text, calls) = parse_tool_calls(response);
7830        assert!(text.is_empty());
7831        assert_eq!(calls.len(), 1);
7832        assert_eq!(calls[0].name, "memory_recall");
7833        assert_eq!(
7834            calls[0].arguments.get("query").unwrap().as_str().unwrap(),
7835            "project roadmap"
7836        );
7837    }
7838
7839    #[test]
7840    fn parse_tool_calls_ignores_xml_thinking_wrapper() {
7841        let response = r#"<tool_call>
7842<thinking>Need to inspect memory first</thinking>
7843<memory_recall>
7844<query>recent deploy notes</query>
7845</memory_recall>
7846</tool_call>"#;
7847
7848        let (text, calls) = parse_tool_calls(response);
7849        assert!(text.is_empty());
7850        assert_eq!(calls.len(), 1);
7851        assert_eq!(calls[0].name, "memory_recall");
7852        assert_eq!(
7853            calls[0].arguments.get("query").unwrap().as_str().unwrap(),
7854            "recent deploy notes"
7855        );
7856    }
7857
7858    #[test]
7859    fn parse_tool_calls_handles_xml_with_json_arguments() {
7860        let response = r#"<tool_call>
7861<shell>{"command":"pwd"}</shell>
7862</tool_call>"#;
7863
7864        let (text, calls) = parse_tool_calls(response);
7865        assert!(text.is_empty());
7866        assert_eq!(calls.len(), 1);
7867        assert_eq!(calls[0].name, "shell");
7868        assert_eq!(
7869            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7870            "pwd"
7871        );
7872    }
7873
7874    #[test]
7875    fn parse_tool_calls_handles_markdown_tool_call_fence() {
7876        let response = r#"I'll check that.
7877```tool_call
7878{"name": "shell", "arguments": {"command": "pwd"}}
7879```
7880Done."#;
7881
7882        let (text, calls) = parse_tool_calls(response);
7883        assert_eq!(calls.len(), 1);
7884        assert_eq!(calls[0].name, "shell");
7885        assert_eq!(
7886            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7887            "pwd"
7888        );
7889        assert!(text.contains("I'll check that."));
7890        assert!(text.contains("Done."));
7891        assert!(!text.contains("```tool_call"));
7892    }
7893
7894    #[test]
7895    fn parse_tool_calls_handles_markdown_tool_call_hybrid_close_tag() {
7896        let response = r#"Preface
7897```tool-call
7898{"name": "shell", "arguments": {"command": "date"}}
7899</tool_call>
7900Tail"#;
7901
7902        let (text, calls) = parse_tool_calls(response);
7903        assert_eq!(calls.len(), 1);
7904        assert_eq!(calls[0].name, "shell");
7905        assert_eq!(
7906            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7907            "date"
7908        );
7909        assert!(text.contains("Preface"));
7910        assert!(text.contains("Tail"));
7911        assert!(!text.contains("```tool-call"));
7912    }
7913
7914    #[test]
7915    fn parse_tool_calls_handles_markdown_invoke_fence() {
7916        let response = r#"Checking.
7917```invoke
7918{"name": "shell", "arguments": {"command": "date"}}
7919```
7920Done."#;
7921
7922        let (text, calls) = parse_tool_calls(response);
7923        assert_eq!(calls.len(), 1);
7924        assert_eq!(calls[0].name, "shell");
7925        assert_eq!(
7926            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7927            "date"
7928        );
7929        assert!(text.contains("Checking."));
7930        assert!(text.contains("Done."));
7931    }
7932
7933    #[test]
7934    fn parse_tool_calls_handles_tool_name_fence_format() {
7935        // Issue #1420: xAI grok models use ```tool <name> format
7936        let response = r#"I'll write a test file.
7937```tool file_write
7938{"path": "/home/user/test.txt", "content": "Hello world"}
7939```
7940Done."#;
7941
7942        let (text, calls) = parse_tool_calls(response);
7943        assert_eq!(calls.len(), 1);
7944        assert_eq!(calls[0].name, "file_write");
7945        assert_eq!(
7946            calls[0].arguments.get("path").unwrap().as_str().unwrap(),
7947            "/home/user/test.txt"
7948        );
7949        assert!(text.contains("I'll write a test file."));
7950        assert!(text.contains("Done."));
7951    }
7952
7953    #[test]
7954    fn parse_tool_calls_handles_tool_name_fence_shell() {
7955        // Issue #1420: Test shell command in ```tool shell format
7956        let response = r#"```tool shell
7957{"command": "ls -la"}
7958```"#;
7959
7960        let (_text, calls) = parse_tool_calls(response);
7961        assert_eq!(calls.len(), 1);
7962        assert_eq!(calls[0].name, "shell");
7963        assert_eq!(
7964            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7965            "ls -la"
7966        );
7967    }
7968
7969    #[test]
7970    fn parse_tool_calls_handles_multiple_tool_name_fences() {
7971        // Multiple tool calls in ```tool <name> format
7972        let response = r#"First, I'll write a file.
7973```tool file_write
7974{"path": "/tmp/a.txt", "content": "A"}
7975```
7976Then read it.
7977```tool file_read
7978{"path": "/tmp/a.txt"}
7979```
7980Done."#;
7981
7982        let (text, calls) = parse_tool_calls(response);
7983        assert_eq!(calls.len(), 2);
7984        assert_eq!(calls[0].name, "file_write");
7985        assert_eq!(calls[1].name, "file_read");
7986        assert!(text.contains("First, I'll write a file."));
7987        assert!(text.contains("Then read it."));
7988        assert!(text.contains("Done."));
7989    }
7990
7991    #[test]
7992    fn parse_tool_calls_handles_toolcall_tag_alias() {
7993        let response = r#"<toolcall>
7994{"name": "shell", "arguments": {"command": "date"}}
7995</toolcall>"#;
7996
7997        let (text, calls) = parse_tool_calls(response);
7998        assert!(text.is_empty());
7999        assert_eq!(calls.len(), 1);
8000        assert_eq!(calls[0].name, "shell");
8001        assert_eq!(
8002            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8003            "date"
8004        );
8005    }
8006
8007    #[test]
8008    fn parse_tool_calls_handles_tool_dash_call_tag_alias() {
8009        let response = r#"<tool-call>
8010{"name": "shell", "arguments": {"command": "whoami"}}
8011</tool-call>"#;
8012
8013        let (text, calls) = parse_tool_calls(response);
8014        assert!(text.is_empty());
8015        assert_eq!(calls.len(), 1);
8016        assert_eq!(calls[0].name, "shell");
8017        assert_eq!(
8018            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8019            "whoami"
8020        );
8021    }
8022
8023    #[test]
8024    fn parse_tool_calls_handles_invoke_tag_alias() {
8025        let response = r#"<invoke>
8026{"name": "shell", "arguments": {"command": "uptime"}}
8027</invoke>"#;
8028
8029        let (text, calls) = parse_tool_calls(response);
8030        assert!(text.is_empty());
8031        assert_eq!(calls.len(), 1);
8032        assert_eq!(calls[0].name, "shell");
8033        assert_eq!(
8034            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8035            "uptime"
8036        );
8037    }
8038
8039    #[test]
8040    fn parse_tool_calls_handles_minimax_invoke_parameter_format() {
8041        let response = r#"<minimax:tool_call>
8042<invoke name="shell">
8043<parameter name="command">sqlite3 /tmp/test.db ".tables"</parameter>
8044</invoke>
8045</minimax:tool_call>"#;
8046
8047        let (text, calls) = parse_tool_calls(response);
8048        assert!(text.is_empty());
8049        assert_eq!(calls.len(), 1);
8050        assert_eq!(calls[0].name, "shell");
8051        assert_eq!(
8052            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8053            r#"sqlite3 /tmp/test.db ".tables""#
8054        );
8055    }
8056
8057    #[test]
8058    fn parse_tool_calls_handles_minimax_invoke_with_surrounding_text() {
8059        let response = r#"Preface
8060<minimax:tool_call>
8061<invoke name='http_request'>
8062<parameter name='url'>https://example.com</parameter>
8063<parameter name='method'>GET</parameter>
8064</invoke>
8065</minimax:tool_call>
8066Tail"#;
8067
8068        let (text, calls) = parse_tool_calls(response);
8069        assert!(text.contains("Preface"));
8070        assert!(text.contains("Tail"));
8071        assert_eq!(calls.len(), 1);
8072        assert_eq!(calls[0].name, "http_request");
8073        assert_eq!(
8074            calls[0].arguments.get("url").unwrap().as_str().unwrap(),
8075            "https://example.com"
8076        );
8077        assert_eq!(
8078            calls[0].arguments.get("method").unwrap().as_str().unwrap(),
8079            "GET"
8080        );
8081    }
8082
8083    #[test]
8084    fn parse_tool_calls_handles_minimax_toolcall_alias_and_cross_close_tag() {
8085        let response = r#"<tool_call>
8086{"name":"shell","arguments":{"command":"date"}}
8087</minimax:toolcall>"#;
8088
8089        let (text, calls) = parse_tool_calls(response);
8090        assert!(text.is_empty());
8091        assert_eq!(calls.len(), 1);
8092        assert_eq!(calls[0].name, "shell");
8093        assert_eq!(
8094            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8095            "date"
8096        );
8097    }
8098
8099    #[test]
8100    fn parse_tool_calls_handles_perl_style_tool_call_blocks() {
8101        let response = r#"TOOL_CALL
8102{tool => "shell", args => { --command "uname -a" }}}
8103/TOOL_CALL"#;
8104
8105        let calls = parse_perl_style_tool_calls(response);
8106        assert_eq!(calls.len(), 1);
8107        assert_eq!(calls[0].name, "shell");
8108        assert_eq!(
8109            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8110            "uname -a"
8111        );
8112    }
8113
8114    #[test]
8115    fn parse_tool_calls_handles_square_bracket_tool_call_blocks() {
8116        let response =
8117            r#"[TOOL_CALL]{tool => "shell", args => {--command "echo hello"}}[/TOOL_CALL]"#;
8118
8119        let calls = parse_perl_style_tool_calls(response);
8120        assert_eq!(calls.len(), 1);
8121        assert_eq!(calls[0].name, "shell");
8122        assert_eq!(
8123            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8124            "echo hello"
8125        );
8126    }
8127
8128    #[test]
8129    fn parse_tool_calls_handles_square_bracket_multiline() {
8130        let response = r#"[TOOL_CALL]
8131{tool => "file_read", args => {
8132  --path "/tmp/test.txt"
8133  --description "Read test file"
8134}}
8135[/TOOL_CALL]"#;
8136
8137        let calls = parse_perl_style_tool_calls(response);
8138        assert_eq!(calls.len(), 1);
8139        assert_eq!(calls[0].name, "file_read");
8140        assert_eq!(
8141            calls[0].arguments.get("path").unwrap().as_str().unwrap(),
8142            "/tmp/test.txt"
8143        );
8144        assert_eq!(
8145            calls[0]
8146                .arguments
8147                .get("description")
8148                .unwrap()
8149                .as_str()
8150                .unwrap(),
8151            "Read test file"
8152        );
8153    }
8154
8155    #[test]
8156    fn parse_tool_calls_recovers_unclosed_tool_call_with_json() {
8157        let response = r#"I will call the tool now.
8158<tool_call>
8159{"name": "shell", "arguments": {"command": "uptime -p"}}"#;
8160
8161        let (text, calls) = parse_tool_calls(response);
8162        assert!(text.contains("I will call the tool now."));
8163        assert_eq!(calls.len(), 1);
8164        assert_eq!(calls[0].name, "shell");
8165        assert_eq!(
8166            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8167            "uptime -p"
8168        );
8169    }
8170
8171    #[test]
8172    fn parse_tool_calls_recovers_mismatched_close_tag() {
8173        let response = r#"<tool_call>
8174{"name": "shell", "arguments": {"command": "uptime"}}
8175</arg_value>"#;
8176
8177        let (text, calls) = parse_tool_calls(response);
8178        assert!(text.is_empty());
8179        assert_eq!(calls.len(), 1);
8180        assert_eq!(calls[0].name, "shell");
8181        assert_eq!(
8182            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8183            "uptime"
8184        );
8185    }
8186
8187    #[test]
8188    fn parse_tool_calls_recovers_cross_alias_closing_tags() {
8189        let response = r#"<toolcall>
8190{"name": "shell", "arguments": {"command": "date"}}
8191</tool_call>"#;
8192
8193        let (text, calls) = parse_tool_calls(response);
8194        assert!(text.is_empty());
8195        assert_eq!(calls.len(), 1);
8196        assert_eq!(calls[0].name, "shell");
8197    }
8198
8199    #[test]
8200    fn parse_tool_calls_rejects_raw_tool_json_without_tags() {
8201        // SECURITY: Raw JSON without explicit wrappers should NOT be parsed
8202        // This prevents prompt injection attacks where malicious content
8203        // could include JSON that mimics a tool call.
8204        let response = r#"Sure, creating the file now.
8205{"name": "file_write", "arguments": {"path": "hello.py", "content": "print('hello')"}}"#;
8206
8207        let (text, calls) = parse_tool_calls(response);
8208        assert!(text.contains("Sure, creating the file now."));
8209        assert_eq!(
8210            calls.len(),
8211            0,
8212            "Raw JSON without wrappers should not be parsed"
8213        );
8214    }
8215
8216    #[test]
8217    fn build_tool_instructions_includes_all_tools() {
8218        use crate::security::SecurityPolicy;
8219        let security = Arc::new(SecurityPolicy::from_config(
8220            &crate::config::AutonomyConfig::default(),
8221            std::path::Path::new("/tmp"),
8222        ));
8223        let tools = tools::default_tools(security);
8224        let instructions = build_tool_instructions(&tools, None);
8225
8226        assert!(instructions.contains("## Tool Use Protocol"));
8227        assert!(instructions.contains("<tool_call>"));
8228        assert!(instructions.contains("shell"));
8229        assert!(instructions.contains("file_read"));
8230        assert!(instructions.contains("file_write"));
8231    }
8232
8233    #[test]
8234    fn tools_to_openai_format_produces_valid_schema() {
8235        use crate::security::SecurityPolicy;
8236        let security = Arc::new(SecurityPolicy::from_config(
8237            &crate::config::AutonomyConfig::default(),
8238            std::path::Path::new("/tmp"),
8239        ));
8240        let tools = tools::default_tools(security);
8241        let formatted = tools_to_openai_format(&tools);
8242
8243        assert!(!formatted.is_empty());
8244        for tool_json in &formatted {
8245            assert_eq!(tool_json["type"], "function");
8246            assert!(tool_json["function"]["name"].is_string());
8247            assert!(tool_json["function"]["description"].is_string());
8248            assert!(!tool_json["function"]["name"].as_str().unwrap().is_empty());
8249        }
8250        // Verify known tools are present
8251        let names: Vec<&str> = formatted
8252            .iter()
8253            .filter_map(|t| t["function"]["name"].as_str())
8254            .collect();
8255        assert!(names.contains(&"shell"));
8256        assert!(names.contains(&"file_read"));
8257    }
8258
8259    #[test]
8260    fn trim_history_preserves_system_prompt() {
8261        let mut history = vec![ChatMessage::system("system prompt")];
8262        for i in 0..DEFAULT_MAX_HISTORY_MESSAGES + 20 {
8263            history.push(ChatMessage::user(format!("msg {i}")));
8264        }
8265        let original_len = history.len();
8266        assert!(original_len > DEFAULT_MAX_HISTORY_MESSAGES + 1);
8267
8268        trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
8269
8270        // System prompt preserved
8271        assert_eq!(history[0].role, "system");
8272        assert_eq!(history[0].content, "system prompt");
8273        // Trimmed to limit
8274        assert_eq!(history.len(), DEFAULT_MAX_HISTORY_MESSAGES + 1); // +1 for system
8275        // Most recent messages preserved
8276        let last = &history[history.len() - 1];
8277        assert_eq!(
8278            last.content,
8279            format!("msg {}", DEFAULT_MAX_HISTORY_MESSAGES + 19)
8280        );
8281    }
8282
8283    #[test]
8284    fn trim_history_noop_when_within_limit() {
8285        let mut history = vec![
8286            ChatMessage::system("sys"),
8287            ChatMessage::user("hello"),
8288            ChatMessage::assistant("hi"),
8289        ];
8290        trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
8291        assert_eq!(history.len(), 3);
8292    }
8293
8294    #[test]
8295    fn autosave_memory_key_has_prefix_and_uniqueness() {
8296        let key1 = autosave_memory_key("user_msg");
8297        let key2 = autosave_memory_key("user_msg");
8298
8299        assert!(key1.starts_with("user_msg_"));
8300        assert!(key2.starts_with("user_msg_"));
8301        assert_ne!(key1, key2);
8302    }
8303
8304    // ═══════════════════════════════════════════════════════════════════════
8305    // Recovery Tests - Tool Call Parsing Edge Cases
8306    // ═══════════════════════════════════════════════════════════════════════
8307
8308    #[test]
8309    fn parse_tool_calls_handles_empty_tool_result() {
8310        // Recovery: Empty tool_result tag should be handled gracefully
8311        let response = r#"I'll run that command.
8312<tool_result name="shell">
8313
8314</tool_result>
8315Done."#;
8316        let (text, calls) = parse_tool_calls(response);
8317        assert!(text.contains("Done."));
8318        assert!(calls.is_empty());
8319    }
8320
8321    #[test]
8322    fn strip_tool_result_blocks_removes_single_block() {
8323        let input = r#"<tool_result name="memory_recall" status="ok">
8324{"matches":["hello"]}
8325</tool_result>
8326Here is my answer."#;
8327        assert_eq!(strip_tool_result_blocks(input), "Here is my answer.");
8328    }
8329
8330    #[test]
8331    fn strip_tool_result_blocks_removes_multiple_blocks() {
8332        let input = r#"<tool_result name="memory_recall" status="ok">
8333{"matches":[]}
8334</tool_result>
8335<tool_result name="shell" status="ok">
8336done
8337</tool_result>
8338Final answer."#;
8339        assert_eq!(strip_tool_result_blocks(input), "Final answer.");
8340    }
8341
8342    #[test]
8343    fn strip_tool_result_blocks_removes_prefix() {
8344        let input =
8345            "[Tool results]\n<tool_result name=\"shell\" status=\"ok\">\nok\n</tool_result>\nDone.";
8346        assert_eq!(strip_tool_result_blocks(input), "Done.");
8347    }
8348
8349    #[test]
8350    fn strip_tool_result_blocks_removes_thinking() {
8351        let input = "<thinking>\nLet me think...\n</thinking>\nHere is the answer.";
8352        assert_eq!(strip_tool_result_blocks(input), "Here is the answer.");
8353    }
8354
8355    #[test]
8356    fn strip_tool_result_blocks_removes_think_tags() {
8357        let input = "<think>\nLet me reason...\n</think>\nHere is the answer.";
8358        assert_eq!(strip_tool_result_blocks(input), "Here is the answer.");
8359    }
8360
8361    #[test]
8362    fn strip_think_tags_removes_single_block() {
8363        assert_eq!(strip_think_tags("<think>reasoning</think>Hello"), "Hello");
8364    }
8365
8366    #[test]
8367    fn strip_think_tags_removes_multiple_blocks() {
8368        assert_eq!(strip_think_tags("<think>a</think>X<think>b</think>Y"), "XY");
8369    }
8370
8371    #[test]
8372    fn strip_think_tags_handles_unclosed_block() {
8373        assert_eq!(strip_think_tags("visible<think>hidden"), "visible");
8374    }
8375
8376    #[test]
8377    fn strip_think_tags_preserves_text_without_tags() {
8378        assert_eq!(strip_think_tags("plain text"), "plain text");
8379    }
8380
8381    #[test]
8382    fn parse_tool_calls_strips_think_before_tool_call() {
8383        // Qwen regression: <think> tags before <tool_call> tags should be
8384        // stripped, allowing the tool call to be parsed correctly.
8385        let response = "<think>I need to list files to understand the project</think>\n<tool_call>\n{\"name\":\"shell\",\"arguments\":{\"command\":\"ls\"}}\n</tool_call>";
8386        let (text, calls) = parse_tool_calls(response);
8387        assert_eq!(
8388            calls.len(),
8389            1,
8390            "should parse tool call after stripping think tags"
8391        );
8392        assert_eq!(calls[0].name, "shell");
8393        assert_eq!(
8394            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8395            "ls"
8396        );
8397        assert!(text.is_empty(), "think content should not appear as text");
8398    }
8399
8400    #[test]
8401    fn parse_tool_calls_strips_think_only_returns_empty() {
8402        // When response is only <think> tags with no tool calls, should
8403        // return empty text and no calls.
8404        let response = "<think>Just thinking, no action needed</think>";
8405        let (text, calls) = parse_tool_calls(response);
8406        assert!(calls.is_empty());
8407        assert!(text.is_empty());
8408    }
8409
8410    #[test]
8411    fn parse_tool_calls_handles_qwen_think_with_multiple_tool_calls() {
8412        let response = "<think>I need to check two things</think>\n<tool_call>\n{\"name\":\"shell\",\"arguments\":{\"command\":\"date\"}}\n</tool_call>\n<tool_call>\n{\"name\":\"shell\",\"arguments\":{\"command\":\"pwd\"}}\n</tool_call>";
8413        let (_, calls) = parse_tool_calls(response);
8414        assert_eq!(calls.len(), 2);
8415        assert_eq!(
8416            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8417            "date"
8418        );
8419        assert_eq!(
8420            calls[1].arguments.get("command").unwrap().as_str().unwrap(),
8421            "pwd"
8422        );
8423    }
8424
8425    #[test]
8426    fn strip_tool_result_blocks_preserves_clean_text() {
8427        let input = "Hello, this is a normal response.";
8428        assert_eq!(strip_tool_result_blocks(input), input);
8429    }
8430
8431    #[test]
8432    fn strip_tool_result_blocks_returns_empty_for_only_tags() {
8433        let input = "<tool_result name=\"memory_recall\" status=\"ok\">\n{}\n</tool_result>";
8434        assert_eq!(strip_tool_result_blocks(input), "");
8435    }
8436
8437    #[test]
8438    fn parse_arguments_value_handles_null() {
8439        // Recovery: null arguments are returned as-is (Value::Null)
8440        let value = serde_json::json!(null);
8441        let result = parse_arguments_value(Some(&value));
8442        assert!(result.is_null());
8443    }
8444
8445    #[test]
8446    fn parse_tool_calls_handles_empty_tool_calls_array() {
8447        // Recovery: Empty tool_calls array returns original response (no tool parsing)
8448        let response = r#"{"content": "Hello", "tool_calls": []}"#;
8449        let (text, calls) = parse_tool_calls(response);
8450        // When tool_calls is empty, the entire JSON is returned as text
8451        assert!(text.contains("Hello"));
8452        assert!(calls.is_empty());
8453    }
8454
8455    #[test]
8456    fn detect_tool_call_parse_issue_flags_malformed_payloads() {
8457        let response =
8458            "<tool_call>{\"name\":\"shell\",\"arguments\":{\"command\":\"pwd\"}</tool_call>";
8459        let issue = detect_tool_call_parse_issue(response, &[]);
8460        assert!(
8461            issue.is_some(),
8462            "malformed tool payload should be flagged for diagnostics"
8463        );
8464    }
8465
8466    #[test]
8467    fn detect_tool_call_parse_issue_ignores_normal_text() {
8468        let issue = detect_tool_call_parse_issue("Thanks, done.", &[]);
8469        assert!(issue.is_none());
8470    }
8471
8472    #[test]
8473    fn parse_tool_calls_handles_whitespace_only_name() {
8474        // Recovery: Whitespace-only tool name should return None
8475        let value = serde_json::json!({"function": {"name": "   ", "arguments": {}}});
8476        let result = parse_tool_call_value(&value);
8477        assert!(result.is_none());
8478    }
8479
8480    #[test]
8481    fn parse_tool_calls_handles_empty_string_arguments() {
8482        // Recovery: Empty string arguments should be handled
8483        let value = serde_json::json!({"name": "test", "arguments": ""});
8484        let result = parse_tool_call_value(&value);
8485        assert!(result.is_some());
8486        assert_eq!(result.unwrap().name, "test");
8487    }
8488
8489    // ═══════════════════════════════════════════════════════════════════════
8490    // Recovery Tests - History Management
8491    // ═══════════════════════════════════════════════════════════════════════
8492
8493    #[test]
8494    fn trim_history_with_no_system_prompt() {
8495        // Recovery: History without system prompt should trim correctly
8496        let mut history = vec![];
8497        for i in 0..DEFAULT_MAX_HISTORY_MESSAGES + 20 {
8498            history.push(ChatMessage::user(format!("msg {i}")));
8499        }
8500        trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
8501        assert_eq!(history.len(), DEFAULT_MAX_HISTORY_MESSAGES);
8502    }
8503
8504    #[test]
8505    fn trim_history_preserves_role_ordering() {
8506        // Recovery: After trimming, role ordering should remain consistent
8507        let mut history = vec![ChatMessage::system("system")];
8508        for i in 0..DEFAULT_MAX_HISTORY_MESSAGES + 10 {
8509            history.push(ChatMessage::user(format!("user {i}")));
8510            history.push(ChatMessage::assistant(format!("assistant {i}")));
8511        }
8512        trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
8513        assert_eq!(history[0].role, "system");
8514        assert_eq!(history[history.len() - 1].role, "assistant");
8515    }
8516
8517    #[test]
8518    fn trim_history_with_only_system_prompt() {
8519        // Recovery: Only system prompt should not be trimmed
8520        let mut history = vec![ChatMessage::system("system prompt")];
8521        trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
8522        assert_eq!(history.len(), 1);
8523    }
8524
8525    // ═══════════════════════════════════════════════════════════════════════
8526    // Recovery Tests - Arguments Parsing
8527    // ═══════════════════════════════════════════════════════════════════════
8528
8529    #[test]
8530    fn parse_arguments_value_handles_invalid_json_string() {
8531        // Recovery: Invalid JSON string should return empty object
8532        let value = serde_json::Value::String("not valid json".to_string());
8533        let result = parse_arguments_value(Some(&value));
8534        assert!(result.is_object());
8535        assert!(result.as_object().unwrap().is_empty());
8536    }
8537
8538    #[test]
8539    fn parse_arguments_value_handles_none() {
8540        // Recovery: None arguments should return empty object
8541        let result = parse_arguments_value(None);
8542        assert!(result.is_object());
8543        assert!(result.as_object().unwrap().is_empty());
8544    }
8545
8546    // ═══════════════════════════════════════════════════════════════════════
8547    // Recovery Tests - JSON Extraction
8548    // ═══════════════════════════════════════════════════════════════════════
8549
8550    #[test]
8551    fn extract_json_values_handles_empty_string() {
8552        // Recovery: Empty input should return empty vec
8553        let result = extract_json_values("");
8554        assert!(result.is_empty());
8555    }
8556
8557    #[test]
8558    fn extract_json_values_handles_whitespace_only() {
8559        // Recovery: Whitespace only should return empty vec
8560        let result = extract_json_values("   \n\t  ");
8561        assert!(result.is_empty());
8562    }
8563
8564    #[test]
8565    fn extract_json_values_handles_multiple_objects() {
8566        // Recovery: Multiple JSON objects should all be extracted
8567        let input = r#"{"a": 1}{"b": 2}{"c": 3}"#;
8568        let result = extract_json_values(input);
8569        assert_eq!(result.len(), 3);
8570    }
8571
8572    #[test]
8573    fn extract_json_values_handles_arrays() {
8574        // Recovery: JSON arrays should be extracted
8575        let input = r#"[1, 2, 3]{"key": "value"}"#;
8576        let result = extract_json_values(input);
8577        assert_eq!(result.len(), 2);
8578    }
8579
8580    // ═══════════════════════════════════════════════════════════════════════
8581    // Recovery Tests - Constants Validation
8582    // ═══════════════════════════════════════════════════════════════════════
8583
8584    const _: () = {
8585        assert!(DEFAULT_MAX_TOOL_ITERATIONS > 0);
8586        assert!(DEFAULT_MAX_TOOL_ITERATIONS <= 100);
8587        assert!(DEFAULT_MAX_HISTORY_MESSAGES > 0);
8588        assert!(DEFAULT_MAX_HISTORY_MESSAGES <= 1000);
8589    };
8590
8591    #[test]
8592    fn constants_bounds_are_compile_time_checked() {
8593        // Bounds are enforced by the const assertions above.
8594    }
8595
8596    // ═══════════════════════════════════════════════════════════════════════
8597    // Recovery Tests - Tool Call Value Parsing
8598    // ═══════════════════════════════════════════════════════════════════════
8599
8600    #[test]
8601    fn parse_tool_call_value_handles_missing_name_field() {
8602        // Recovery: Missing name field should return None
8603        let value = serde_json::json!({"function": {"arguments": {}}});
8604        let result = parse_tool_call_value(&value);
8605        assert!(result.is_none());
8606    }
8607
8608    #[test]
8609    fn parse_tool_call_value_handles_top_level_name() {
8610        // Recovery: Tool call with name at top level (non-OpenAI format)
8611        let value = serde_json::json!({"name": "test_tool", "arguments": {}});
8612        let result = parse_tool_call_value(&value);
8613        assert!(result.is_some());
8614        assert_eq!(result.unwrap().name, "test_tool");
8615    }
8616
8617    #[test]
8618    fn parse_tool_call_value_accepts_top_level_parameters_alias() {
8619        let value = serde_json::json!({
8620            "name": "schedule",
8621            "parameters": {"action": "create", "message": "test"}
8622        });
8623        let result = parse_tool_call_value(&value).expect("tool call should parse");
8624        assert_eq!(result.name, "schedule");
8625        assert_eq!(
8626            result.arguments.get("action").and_then(|v| v.as_str()),
8627            Some("create")
8628        );
8629    }
8630
8631    #[test]
8632    fn parse_tool_call_value_accepts_function_parameters_alias() {
8633        let value = serde_json::json!({
8634            "function": {
8635                "name": "shell",
8636                "parameters": {"command": "date"}
8637            }
8638        });
8639        let result = parse_tool_call_value(&value).expect("tool call should parse");
8640        assert_eq!(result.name, "shell");
8641        assert_eq!(
8642            result.arguments.get("command").and_then(|v| v.as_str()),
8643            Some("date")
8644        );
8645    }
8646
8647    #[test]
8648    fn parse_tool_call_value_preserves_tool_call_id_aliases() {
8649        let value = serde_json::json!({
8650            "call_id": "legacy_1",
8651            "function": {
8652                "name": "shell",
8653                "arguments": {"command": "date"}
8654            }
8655        });
8656        let result = parse_tool_call_value(&value).expect("tool call should parse");
8657        assert_eq!(result.tool_call_id.as_deref(), Some("legacy_1"));
8658    }
8659
8660    #[test]
8661    fn parse_tool_calls_from_json_value_handles_empty_array() {
8662        // Recovery: Empty tool_calls array should return empty vec
8663        let value = serde_json::json!({"tool_calls": []});
8664        let result = parse_tool_calls_from_json_value(&value);
8665        assert!(result.is_empty());
8666    }
8667
8668    #[test]
8669    fn parse_tool_calls_from_json_value_handles_missing_tool_calls() {
8670        // Recovery: Missing tool_calls field should fall through
8671        let value = serde_json::json!({"name": "test", "arguments": {}});
8672        let result = parse_tool_calls_from_json_value(&value);
8673        assert_eq!(result.len(), 1);
8674    }
8675
8676    #[test]
8677    fn parse_tool_calls_from_json_value_handles_top_level_array() {
8678        // Recovery: Top-level array of tool calls
8679        let value = serde_json::json!([
8680            {"name": "tool_a", "arguments": {}},
8681            {"name": "tool_b", "arguments": {}}
8682        ]);
8683        let result = parse_tool_calls_from_json_value(&value);
8684        assert_eq!(result.len(), 2);
8685    }
8686
8687    // ═══════════════════════════════════════════════════════════════════════
8688    // GLM-Style Tool Call Parsing
8689    // ═══════════════════════════════════════════════════════════════════════
8690
8691    #[test]
8692    fn parse_glm_style_browser_open_url() {
8693        let response = "browser_open/url>https://example.com";
8694        let calls = parse_glm_style_tool_calls(response);
8695        assert_eq!(calls.len(), 1);
8696        assert_eq!(calls[0].0, "shell");
8697        assert!(calls[0].1["command"].as_str().unwrap().contains("curl"));
8698        assert!(
8699            calls[0].1["command"]
8700                .as_str()
8701                .unwrap()
8702                .contains("example.com")
8703        );
8704    }
8705
8706    #[test]
8707    fn parse_glm_style_shell_command() {
8708        let response = "shell/command>ls -la";
8709        let calls = parse_glm_style_tool_calls(response);
8710        assert_eq!(calls.len(), 1);
8711        assert_eq!(calls[0].0, "shell");
8712        assert_eq!(calls[0].1["command"], "ls -la");
8713    }
8714
8715    #[test]
8716    fn parse_glm_style_http_request() {
8717        let response = "http_request/url>https://api.example.com/data";
8718        let calls = parse_glm_style_tool_calls(response);
8719        assert_eq!(calls.len(), 1);
8720        assert_eq!(calls[0].0, "http_request");
8721        assert_eq!(calls[0].1["url"], "https://api.example.com/data");
8722        assert_eq!(calls[0].1["method"], "GET");
8723    }
8724
8725    #[test]
8726    fn parse_glm_style_ignores_plain_url() {
8727        // A bare URL should NOT be interpreted as a tool call — this was
8728        // causing false positives when LLMs included URLs in normal text.
8729        let response = "https://example.com/api";
8730        let calls = parse_glm_style_tool_calls(response);
8731        assert!(
8732            calls.is_empty(),
8733            "plain URL must not be parsed as tool call"
8734        );
8735    }
8736
8737    #[test]
8738    fn parse_glm_style_json_args() {
8739        let response = r#"shell/{"command": "echo hello"}"#;
8740        let calls = parse_glm_style_tool_calls(response);
8741        assert_eq!(calls.len(), 1);
8742        assert_eq!(calls[0].0, "shell");
8743        assert_eq!(calls[0].1["command"], "echo hello");
8744    }
8745
8746    #[test]
8747    fn parse_glm_style_multiple_calls() {
8748        let response = r#"shell/command>ls
8749browser_open/url>https://example.com"#;
8750        let calls = parse_glm_style_tool_calls(response);
8751        assert_eq!(calls.len(), 2);
8752    }
8753
8754    #[test]
8755    fn parse_glm_style_tool_call_integration() {
8756        // Integration test: GLM format should be parsed in parse_tool_calls
8757        let response = "Checking...\nbrowser_open/url>https://example.com\nDone";
8758        let (text, calls) = parse_tool_calls(response);
8759        assert_eq!(calls.len(), 1);
8760        assert_eq!(calls[0].name, "shell");
8761        assert!(text.contains("Checking"));
8762        assert!(text.contains("Done"));
8763    }
8764
8765    #[test]
8766    fn parse_glm_style_rejects_non_http_url_param() {
8767        let response = "browser_open/url>javascript:alert(1)";
8768        let calls = parse_glm_style_tool_calls(response);
8769        assert!(calls.is_empty());
8770    }
8771
8772    #[test]
8773    fn parse_tool_calls_handles_unclosed_tool_call_tag() {
8774        let response = "<tool_call>{\"name\":\"shell\",\"arguments\":{\"command\":\"pwd\"}}\nDone";
8775        let (text, calls) = parse_tool_calls(response);
8776        assert_eq!(calls.len(), 1);
8777        assert_eq!(calls[0].name, "shell");
8778        assert_eq!(calls[0].arguments["command"], "pwd");
8779        assert_eq!(text, "Done");
8780    }
8781
8782    // ─────────────────────────────────────────────────────────────────────
8783    // TG4 (inline): parse_tool_calls robustness — malformed/edge-case inputs
8784    // Prevents: Pattern 4 issues #746, #418, #777, #848
8785    // ─────────────────────────────────────────────────────────────────────
8786
8787    #[test]
8788    fn parse_tool_calls_empty_input_returns_empty() {
8789        let (text, calls) = parse_tool_calls("");
8790        assert!(calls.is_empty(), "empty input should produce no tool calls");
8791        assert!(text.is_empty(), "empty input should produce no text");
8792    }
8793
8794    #[test]
8795    fn parse_tool_calls_whitespace_only_returns_empty_calls() {
8796        let (text, calls) = parse_tool_calls("   \n\t  ");
8797        assert!(calls.is_empty());
8798        assert!(text.is_empty() || text.trim().is_empty());
8799    }
8800
8801    #[test]
8802    fn parse_tool_calls_nested_xml_tags_handled() {
8803        // Double-wrapped tool call should still parse the inner call
8804        let response = r#"<tool_call><tool_call>{"name":"echo","arguments":{"msg":"hi"}}</tool_call></tool_call>"#;
8805        let (_text, calls) = parse_tool_calls(response);
8806        // Should find at least one tool call
8807        assert!(
8808            !calls.is_empty(),
8809            "nested XML tags should still yield at least one tool call"
8810        );
8811    }
8812
8813    #[test]
8814    fn parse_tool_calls_truncated_json_no_panic() {
8815        // Incomplete JSON inside tool_call tags
8816        let response = r#"<tool_call>{"name":"shell","arguments":{"command":"ls"</tool_call>"#;
8817        let (_text, _calls) = parse_tool_calls(response);
8818        // Should not panic — graceful handling of truncated JSON
8819    }
8820
8821    #[test]
8822    fn parse_tool_calls_empty_json_object_in_tag() {
8823        let response = "<tool_call>{}</tool_call>";
8824        let (_text, calls) = parse_tool_calls(response);
8825        // Empty JSON object has no name field — should not produce valid tool call
8826        assert!(
8827            calls.is_empty(),
8828            "empty JSON object should not produce a tool call"
8829        );
8830    }
8831
8832    #[test]
8833    fn parse_tool_calls_closing_tag_only_returns_text() {
8834        let response = "Some text </tool_call> more text";
8835        let (text, calls) = parse_tool_calls(response);
8836        assert!(
8837            calls.is_empty(),
8838            "closing tag only should not produce calls"
8839        );
8840        assert!(
8841            !text.is_empty(),
8842            "text around orphaned closing tag should be preserved"
8843        );
8844    }
8845
8846    #[test]
8847    fn parse_tool_calls_very_large_arguments_no_panic() {
8848        let large_arg = "x".repeat(100_000);
8849        let response = format!(
8850            r#"<tool_call>{{"name":"echo","arguments":{{"message":"{}"}}}}</tool_call>"#,
8851            large_arg
8852        );
8853        let (_text, calls) = parse_tool_calls(&response);
8854        assert_eq!(calls.len(), 1, "large arguments should still parse");
8855        assert_eq!(calls[0].name, "echo");
8856    }
8857
8858    #[test]
8859    fn parse_tool_calls_special_characters_in_arguments() {
8860        let response = r#"<tool_call>{"name":"echo","arguments":{"message":"hello \"world\" <>&'\n\t"}}</tool_call>"#;
8861        let (_text, calls) = parse_tool_calls(response);
8862        assert_eq!(calls.len(), 1);
8863        assert_eq!(calls[0].name, "echo");
8864    }
8865
8866    #[test]
8867    fn parse_tool_calls_text_with_embedded_json_not_extracted() {
8868        // Raw JSON without any tags should NOT be extracted as a tool call
8869        let response = r#"Here is some data: {"name":"echo","arguments":{"message":"hi"}} end."#;
8870        let (_text, calls) = parse_tool_calls(response);
8871        assert!(
8872            calls.is_empty(),
8873            "raw JSON in text without tags should not be extracted"
8874        );
8875    }
8876
8877    #[test]
8878    fn parse_tool_calls_multiple_formats_mixed() {
8879        // Mix of text and properly tagged tool call
8880        let response = r#"I'll help you with that.
8881
8882<tool_call>
8883{"name":"shell","arguments":{"command":"echo hello"}}
8884</tool_call>
8885
8886Let me check the result."#;
8887        let (text, calls) = parse_tool_calls(response);
8888        assert_eq!(
8889            calls.len(),
8890            1,
8891            "should extract one tool call from mixed content"
8892        );
8893        assert_eq!(calls[0].name, "shell");
8894        assert!(
8895            text.contains("help you"),
8896            "text before tool call should be preserved"
8897        );
8898    }
8899
8900    // ─────────────────────────────────────────────────────────────────────
8901    // TG4 (inline): scrub_credentials edge cases
8902    // ─────────────────────────────────────────────────────────────────────
8903
8904    #[test]
8905    fn scrub_credentials_empty_input() {
8906        let result = scrub_credentials("");
8907        assert_eq!(result, "");
8908    }
8909
8910    #[test]
8911    fn scrub_credentials_no_sensitive_data() {
8912        let input = "normal text without any secrets";
8913        let result = scrub_credentials(input);
8914        assert_eq!(
8915            result, input,
8916            "non-sensitive text should pass through unchanged"
8917        );
8918    }
8919
8920    #[test]
8921    fn scrub_credentials_multibyte_chars_no_panic() {
8922        // Regression test for #3024: byte index 4 is not a char boundary
8923        // when the captured value contains multi-byte UTF-8 characters.
8924        // The regex only matches quoted values for non-ASCII content, since
8925        // capture group 4 is restricted to [a-zA-Z0-9_\-\.].
8926        let input = "password=\"\u{4f60}\u{7684}WiFi\u{5bc6}\u{7801}ab\"";
8927        let result = scrub_credentials(input);
8928        assert!(
8929            result.contains("[REDACTED]"),
8930            "multi-byte quoted value should be redacted without panic, got: {result}"
8931        );
8932    }
8933
8934    #[test]
8935    fn scrub_credentials_short_values_not_redacted() {
8936        // Values shorter than 8 chars should not be redacted
8937        let input = r#"api_key="short""#;
8938        let result = scrub_credentials(input);
8939        assert_eq!(result, input, "short values should not be redacted");
8940    }
8941
8942    // ─────────────────────────────────────────────────────────────────────
8943    // TG4 (inline): trim_history edge cases
8944    // ─────────────────────────────────────────────────────────────────────
8945
8946    #[test]
8947    fn trim_history_empty_history() {
8948        let mut history: Vec<crate::providers::ChatMessage> = vec![];
8949        trim_history(&mut history, 10);
8950        assert!(history.is_empty());
8951    }
8952
8953    #[test]
8954    fn trim_history_system_only() {
8955        let mut history = vec![crate::providers::ChatMessage::system("system prompt")];
8956        trim_history(&mut history, 10);
8957        assert_eq!(history.len(), 1);
8958        assert_eq!(history[0].role, "system");
8959    }
8960
8961    #[test]
8962    fn trim_history_exactly_at_limit() {
8963        let mut history = vec![
8964            crate::providers::ChatMessage::system("system"),
8965            crate::providers::ChatMessage::user("msg 1"),
8966            crate::providers::ChatMessage::assistant("reply 1"),
8967        ];
8968        trim_history(&mut history, 2); // 2 non-system messages = exactly at limit
8969        assert_eq!(history.len(), 3, "should not trim when exactly at limit");
8970    }
8971
8972    #[test]
8973    fn trim_history_removes_oldest_non_system() {
8974        let mut history = vec![
8975            crate::providers::ChatMessage::system("system"),
8976            crate::providers::ChatMessage::user("old msg"),
8977            crate::providers::ChatMessage::assistant("old reply"),
8978            crate::providers::ChatMessage::user("new msg"),
8979            crate::providers::ChatMessage::assistant("new reply"),
8980        ];
8981        trim_history(&mut history, 2);
8982        assert_eq!(history.len(), 3); // system + 2 kept
8983        assert_eq!(history[0].role, "system");
8984        assert_eq!(history[1].content, "new msg");
8985    }
8986
8987    /// When `build_system_prompt_with_mode` is called with `native_tools = true`,
8988    /// the output must contain ZERO XML protocol artifacts. In the native path
8989    /// `build_tool_instructions` is never called, so the system prompt alone
8990    /// must be clean of XML tool-call protocol.
8991    #[test]
8992    fn native_tools_system_prompt_contains_zero_xml() {
8993        use crate::channels::build_system_prompt_with_mode;
8994
8995        let tool_summaries: Vec<(&str, &str)> = vec![
8996            ("shell", "Execute shell commands"),
8997            ("file_read", "Read files"),
8998        ];
8999
9000        let system_prompt = build_system_prompt_with_mode(
9001            std::path::Path::new("/tmp"),
9002            "test-model",
9003            &tool_summaries,
9004            &[],  // no skills
9005            None, // no identity config
9006            None, // no bootstrap_max_chars
9007            true, // native_tools
9008            crate::config::SkillsPromptInjectionMode::Full,
9009            crate::security::AutonomyLevel::default(),
9010        );
9011
9012        // Must contain zero XML protocol artifacts
9013        assert!(
9014            !system_prompt.contains("<tool_call>"),
9015            "Native prompt must not contain <tool_call>"
9016        );
9017        assert!(
9018            !system_prompt.contains("</tool_call>"),
9019            "Native prompt must not contain </tool_call>"
9020        );
9021        assert!(
9022            !system_prompt.contains("<tool_result>"),
9023            "Native prompt must not contain <tool_result>"
9024        );
9025        assert!(
9026            !system_prompt.contains("</tool_result>"),
9027            "Native prompt must not contain </tool_result>"
9028        );
9029        assert!(
9030            !system_prompt.contains("## Tool Use Protocol"),
9031            "Native prompt must not contain XML protocol header"
9032        );
9033
9034        // Positive: native prompt should still list tools and contain task instructions
9035        assert!(
9036            system_prompt.contains("shell"),
9037            "Native prompt must list tool names"
9038        );
9039        assert!(
9040            system_prompt.contains("## Your Task"),
9041            "Native prompt should contain task instructions"
9042        );
9043    }
9044
9045    // ── Cross-Alias & GLM Shortened Body Tests ──────────────────────────
9046
9047    #[test]
9048    fn parse_tool_calls_cross_alias_close_tag_with_json() {
9049        // <tool_call> opened but closed with </invoke> — JSON body
9050        let input = r#"<tool_call>{"name": "shell", "arguments": {"command": "ls"}}</invoke>"#;
9051        let (text, calls) = parse_tool_calls(input);
9052        assert_eq!(calls.len(), 1);
9053        assert_eq!(calls[0].name, "shell");
9054        assert_eq!(calls[0].arguments["command"], "ls");
9055        assert!(text.is_empty());
9056    }
9057
9058    #[test]
9059    fn parse_tool_calls_cross_alias_close_tag_with_glm_shortened() {
9060        // <tool_call>shell>uname -a</invoke> — GLM shortened inside cross-alias tags
9061        let input = "<tool_call>shell>uname -a</invoke>";
9062        let (text, calls) = parse_tool_calls(input);
9063        assert_eq!(calls.len(), 1);
9064        assert_eq!(calls[0].name, "shell");
9065        assert_eq!(calls[0].arguments["command"], "uname -a");
9066        assert!(text.is_empty());
9067    }
9068
9069    #[test]
9070    fn parse_tool_calls_glm_shortened_body_in_matched_tags() {
9071        // <tool_call>shell>pwd</tool_call> — GLM shortened in matched tags
9072        let input = "<tool_call>shell>pwd</tool_call>";
9073        let (text, calls) = parse_tool_calls(input);
9074        assert_eq!(calls.len(), 1);
9075        assert_eq!(calls[0].name, "shell");
9076        assert_eq!(calls[0].arguments["command"], "pwd");
9077        assert!(text.is_empty());
9078    }
9079
9080    #[test]
9081    fn parse_tool_calls_glm_yaml_style_in_tags() {
9082        // <tool_call>shell>\ncommand: date\napproved: true</invoke>
9083        let input = "<tool_call>shell>\ncommand: date\napproved: true</invoke>";
9084        let (text, calls) = parse_tool_calls(input);
9085        assert_eq!(calls.len(), 1);
9086        assert_eq!(calls[0].name, "shell");
9087        assert_eq!(calls[0].arguments["command"], "date");
9088        assert_eq!(calls[0].arguments["approved"], true);
9089        assert!(text.is_empty());
9090    }
9091
9092    #[test]
9093    fn parse_tool_calls_attribute_style_in_tags() {
9094        // <tool_call>shell command="date" /></tool_call>
9095        let input = r#"<tool_call>shell command="date" /></tool_call>"#;
9096        let (text, calls) = parse_tool_calls(input);
9097        assert_eq!(calls.len(), 1);
9098        assert_eq!(calls[0].name, "shell");
9099        assert_eq!(calls[0].arguments["command"], "date");
9100        assert!(text.is_empty());
9101    }
9102
9103    #[test]
9104    fn parse_tool_calls_file_read_shortened_in_cross_alias() {
9105        // <tool_call>file_read path=".env" /></invoke>
9106        let input = r#"<tool_call>file_read path=".env" /></invoke>"#;
9107        let (text, calls) = parse_tool_calls(input);
9108        assert_eq!(calls.len(), 1);
9109        assert_eq!(calls[0].name, "file_read");
9110        assert_eq!(calls[0].arguments["path"], ".env");
9111        assert!(text.is_empty());
9112    }
9113
9114    #[test]
9115    fn parse_tool_calls_unclosed_glm_shortened_no_close_tag() {
9116        // <tool_call>shell>ls -la (no close tag at all)
9117        let input = "<tool_call>shell>ls -la";
9118        let (text, calls) = parse_tool_calls(input);
9119        assert_eq!(calls.len(), 1);
9120        assert_eq!(calls[0].name, "shell");
9121        assert_eq!(calls[0].arguments["command"], "ls -la");
9122        assert!(text.is_empty());
9123    }
9124
9125    #[test]
9126    fn parse_tool_calls_text_before_cross_alias() {
9127        // Text before and after cross-alias tool call
9128        let input = "Let me check that.\n<tool_call>shell>uname -a</invoke>\nDone.";
9129        let (text, calls) = parse_tool_calls(input);
9130        assert_eq!(calls.len(), 1);
9131        assert_eq!(calls[0].name, "shell");
9132        assert_eq!(calls[0].arguments["command"], "uname -a");
9133        assert!(text.contains("Let me check that."));
9134        assert!(text.contains("Done."));
9135    }
9136
9137    #[test]
9138    fn parse_glm_shortened_body_url_to_curl() {
9139        // URL values for shell should be wrapped in curl
9140        let call = parse_glm_shortened_body("shell>https://example.com/api").unwrap();
9141        assert_eq!(call.name, "shell");
9142        let cmd = call.arguments["command"].as_str().unwrap();
9143        assert!(cmd.contains("curl"));
9144        assert!(cmd.contains("example.com"));
9145    }
9146
9147    #[test]
9148    fn parse_glm_shortened_body_browser_open_maps_to_shell_command() {
9149        // browser_open aliases to shell, and shortened calls must still emit
9150        // shell's canonical "command" argument.
9151        let call = parse_glm_shortened_body("browser_open>https://example.com").unwrap();
9152        assert_eq!(call.name, "shell");
9153        let cmd = call.arguments["command"].as_str().unwrap();
9154        assert!(cmd.contains("curl"));
9155        assert!(cmd.contains("example.com"));
9156    }
9157
9158    #[test]
9159    fn parse_glm_shortened_body_memory_recall() {
9160        // memory_recall>some query — default param is "query"
9161        let call = parse_glm_shortened_body("memory_recall>recent meetings").unwrap();
9162        assert_eq!(call.name, "memory_recall");
9163        assert_eq!(call.arguments["query"], "recent meetings");
9164    }
9165
9166    #[test]
9167    fn parse_glm_shortened_body_function_style_alias_maps_to_message_send() {
9168        let call =
9169            parse_glm_shortened_body(r#"sendmessage(channel="alerts", message="hi")"#).unwrap();
9170        assert_eq!(call.name, "message_send");
9171        assert_eq!(call.arguments["channel"], "alerts");
9172        assert_eq!(call.arguments["message"], "hi");
9173    }
9174
9175    #[test]
9176    fn map_tool_name_alias_direct_coverage() {
9177        assert_eq!(map_tool_name_alias("bash"), "shell");
9178        assert_eq!(map_tool_name_alias("filelist"), "file_list");
9179        assert_eq!(map_tool_name_alias("memorystore"), "memory_store");
9180        assert_eq!(map_tool_name_alias("memoryforget"), "memory_forget");
9181        assert_eq!(map_tool_name_alias("http"), "http_request");
9182        assert_eq!(
9183            map_tool_name_alias("totally_unknown_tool"),
9184            "totally_unknown_tool"
9185        );
9186    }
9187
9188    #[test]
9189    fn default_param_for_tool_coverage() {
9190        assert_eq!(default_param_for_tool("shell"), "command");
9191        assert_eq!(default_param_for_tool("bash"), "command");
9192        assert_eq!(default_param_for_tool("file_read"), "path");
9193        assert_eq!(default_param_for_tool("memory_recall"), "query");
9194        assert_eq!(default_param_for_tool("memory_store"), "content");
9195        assert_eq!(default_param_for_tool("web_search_tool"), "query");
9196        assert_eq!(default_param_for_tool("web_search"), "query");
9197        assert_eq!(default_param_for_tool("search"), "query");
9198        assert_eq!(default_param_for_tool("http_request"), "url");
9199        assert_eq!(default_param_for_tool("browser_open"), "url");
9200        assert_eq!(default_param_for_tool("unknown_tool"), "input");
9201    }
9202
9203    #[test]
9204    fn parse_glm_shortened_body_rejects_empty() {
9205        assert!(parse_glm_shortened_body("").is_none());
9206        assert!(parse_glm_shortened_body("   ").is_none());
9207    }
9208
9209    #[test]
9210    fn parse_glm_shortened_body_rejects_invalid_tool_name() {
9211        // Tool names with special characters should be rejected
9212        assert!(parse_glm_shortened_body("not-a-tool>value").is_none());
9213        assert!(parse_glm_shortened_body("tool name>value").is_none());
9214    }
9215
9216    // ═══════════════════════════════════════════════════════════════════════
9217    // reasoning_content pass-through tests for history builders
9218    // ═══════════════════════════════════════════════════════════════════════
9219
9220    #[test]
9221    fn build_native_assistant_history_includes_reasoning_content() {
9222        let calls = vec![ToolCall {
9223            id: "call_1".into(),
9224            name: "shell".into(),
9225            arguments: "{}".into(),
9226        }];
9227        let result = build_native_assistant_history("answer", &calls, Some("thinking step"));
9228        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
9229        assert_eq!(parsed["content"].as_str(), Some("answer"));
9230        assert_eq!(parsed["reasoning_content"].as_str(), Some("thinking step"));
9231        assert!(parsed["tool_calls"].is_array());
9232    }
9233
9234    #[test]
9235    fn build_native_assistant_history_omits_reasoning_content_when_none() {
9236        let calls = vec![ToolCall {
9237            id: "call_1".into(),
9238            name: "shell".into(),
9239            arguments: "{}".into(),
9240        }];
9241        let result = build_native_assistant_history("answer", &calls, None);
9242        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
9243        assert_eq!(parsed["content"].as_str(), Some("answer"));
9244        assert!(parsed.get("reasoning_content").is_none());
9245    }
9246
9247    #[test]
9248    fn build_native_assistant_history_from_parsed_calls_includes_reasoning_content() {
9249        let calls = vec![ParsedToolCall {
9250            name: "shell".into(),
9251            arguments: serde_json::json!({"command": "pwd"}),
9252            tool_call_id: Some("call_2".into()),
9253        }];
9254        let result = build_native_assistant_history_from_parsed_calls(
9255            "answer",
9256            &calls,
9257            Some("deep thought"),
9258        );
9259        assert!(result.is_some());
9260        let parsed: serde_json::Value = serde_json::from_str(result.as_deref().unwrap()).unwrap();
9261        assert_eq!(parsed["content"].as_str(), Some("answer"));
9262        assert_eq!(parsed["reasoning_content"].as_str(), Some("deep thought"));
9263        assert!(parsed["tool_calls"].is_array());
9264    }
9265
9266    #[test]
9267    fn build_native_assistant_history_from_parsed_calls_omits_reasoning_content_when_none() {
9268        let calls = vec![ParsedToolCall {
9269            name: "shell".into(),
9270            arguments: serde_json::json!({"command": "pwd"}),
9271            tool_call_id: Some("call_2".into()),
9272        }];
9273        let result = build_native_assistant_history_from_parsed_calls("answer", &calls, None);
9274        assert!(result.is_some());
9275        let parsed: serde_json::Value = serde_json::from_str(result.as_deref().unwrap()).unwrap();
9276        assert_eq!(parsed["content"].as_str(), Some("answer"));
9277        assert!(parsed.get("reasoning_content").is_none());
9278    }
9279
9280    // ── glob_match tests ──────────────────────────────────────────────────────
9281
9282    #[test]
9283    fn glob_match_exact_no_wildcard() {
9284        assert!(glob_match("mcp_browser_navigate", "mcp_browser_navigate"));
9285        assert!(!glob_match("mcp_browser_navigate", "mcp_browser_click"));
9286    }
9287
9288    #[test]
9289    fn glob_match_prefix_wildcard() {
9290        // Suffix pattern: mcp_browser_*
9291        assert!(glob_match("mcp_browser_*", "mcp_browser_navigate"));
9292        assert!(glob_match("mcp_browser_*", "mcp_browser_click"));
9293        assert!(!glob_match("mcp_browser_*", "mcp_filesystem_read"));
9294
9295        // Prefix pattern: *_read
9296        assert!(glob_match("*_read", "mcp_filesystem_read"));
9297        assert!(!glob_match("*_read", "mcp_filesystem_write"));
9298
9299        // Infix: mcp_*_navigate
9300        assert!(glob_match("mcp_*_navigate", "mcp_browser_navigate"));
9301        assert!(!glob_match("mcp_*_navigate", "mcp_browser_click"));
9302    }
9303
9304    #[test]
9305    fn glob_match_star_matches_everything() {
9306        assert!(glob_match("*", "anything_at_all"));
9307        assert!(glob_match("*", ""));
9308    }
9309
9310    // ── filter_tool_specs_for_turn tests ──────────────────────────────────────
9311
9312    fn make_spec(name: &str) -> crate::tools::ToolSpec {
9313        crate::tools::ToolSpec {
9314            name: name.to_string(),
9315            description: String::new(),
9316            parameters: serde_json::json!({}),
9317        }
9318    }
9319
9320    #[test]
9321    fn filter_tool_specs_no_groups_returns_all() {
9322        let specs = vec![
9323            make_spec("shell_exec"),
9324            make_spec("mcp_browser_navigate"),
9325            make_spec("mcp_filesystem_read"),
9326        ];
9327        let result = filter_tool_specs_for_turn(specs, &[], "hello");
9328        assert_eq!(result.len(), 3);
9329    }
9330
9331    #[test]
9332    fn filter_tool_specs_always_group_includes_matching_mcp_tool() {
9333        use crate::config::schema::{ToolFilterGroup, ToolFilterGroupMode};
9334
9335        let specs = vec![
9336            make_spec("shell_exec"),
9337            make_spec("mcp_browser_navigate"),
9338            make_spec("mcp_filesystem_read"),
9339        ];
9340        let groups = vec![ToolFilterGroup {
9341            mode: ToolFilterGroupMode::Always,
9342            tools: vec!["mcp_filesystem_*".into()],
9343            keywords: vec![],
9344            filter_builtins: false,
9345        }];
9346        let result = filter_tool_specs_for_turn(specs, &groups, "anything");
9347        let names: Vec<&str> = result.iter().map(|s| s.name.as_str()).collect();
9348        // Built-in passes through, matched MCP passes, unmatched MCP excluded.
9349        assert!(names.contains(&"shell_exec"));
9350        assert!(names.contains(&"mcp_filesystem_read"));
9351        assert!(!names.contains(&"mcp_browser_navigate"));
9352    }
9353
9354    #[test]
9355    fn filter_tool_specs_dynamic_group_included_on_keyword_match() {
9356        use crate::config::schema::{ToolFilterGroup, ToolFilterGroupMode};
9357
9358        let specs = vec![make_spec("shell_exec"), make_spec("mcp_browser_navigate")];
9359        let groups = vec![ToolFilterGroup {
9360            mode: ToolFilterGroupMode::Dynamic,
9361            tools: vec!["mcp_browser_*".into()],
9362            keywords: vec!["browse".into(), "website".into()],
9363            filter_builtins: false,
9364        }];
9365        let result = filter_tool_specs_for_turn(specs, &groups, "please browse this page");
9366        let names: Vec<&str> = result.iter().map(|s| s.name.as_str()).collect();
9367        assert!(names.contains(&"shell_exec"));
9368        assert!(names.contains(&"mcp_browser_navigate"));
9369    }
9370
9371    #[test]
9372    fn filter_tool_specs_dynamic_group_excluded_on_no_keyword_match() {
9373        use crate::config::schema::{ToolFilterGroup, ToolFilterGroupMode};
9374
9375        let specs = vec![make_spec("shell_exec"), make_spec("mcp_browser_navigate")];
9376        let groups = vec![ToolFilterGroup {
9377            mode: ToolFilterGroupMode::Dynamic,
9378            tools: vec!["mcp_browser_*".into()],
9379            keywords: vec!["browse".into(), "website".into()],
9380            filter_builtins: false,
9381        }];
9382        let result = filter_tool_specs_for_turn(specs, &groups, "read the file /etc/hosts");
9383        let names: Vec<&str> = result.iter().map(|s| s.name.as_str()).collect();
9384        assert!(names.contains(&"shell_exec"));
9385        assert!(!names.contains(&"mcp_browser_navigate"));
9386    }
9387
9388    #[test]
9389    fn filter_tool_specs_dynamic_keyword_match_is_case_insensitive() {
9390        use crate::config::schema::{ToolFilterGroup, ToolFilterGroupMode};
9391
9392        let specs = vec![make_spec("mcp_browser_navigate")];
9393        let groups = vec![ToolFilterGroup {
9394            mode: ToolFilterGroupMode::Dynamic,
9395            tools: vec!["mcp_browser_*".into()],
9396            keywords: vec!["Browse".into()],
9397            filter_builtins: false,
9398        }];
9399        let result = filter_tool_specs_for_turn(specs, &groups, "BROWSE the site");
9400        assert_eq!(result.len(), 1);
9401    }
9402
9403    // ── Token-based compaction tests ──────────────────────────
9404
9405    #[test]
9406    fn estimate_history_tokens_empty() {
9407        assert_eq!(super::estimate_history_tokens(&[]), 0);
9408    }
9409
9410    #[test]
9411    fn estimate_history_tokens_single_message() {
9412        let history = vec![ChatMessage::user("hello world")]; // 11 chars
9413        let tokens = super::estimate_history_tokens(&history);
9414        // 11.div_ceil(4) + 4 = 3 + 4 = 7
9415        assert_eq!(tokens, 7);
9416    }
9417
9418    #[test]
9419    fn estimate_history_tokens_multiple_messages() {
9420        let history = vec![
9421            ChatMessage::system("You are helpful."), // 16 chars → 4 + 4 = 8
9422            ChatMessage::user("What is Rust?"),      // 13 chars → 4 + 4 = 8
9423            ChatMessage::assistant("A language."),   // 11 chars → 3 + 4 = 7
9424        ];
9425        let tokens = super::estimate_history_tokens(&history);
9426        assert_eq!(tokens, 23);
9427    }
9428
9429    #[tokio::test]
9430    async fn run_tool_call_loop_surfaces_tool_failure_reason_in_on_delta() {
9431        let provider = ScriptedProvider::from_text_responses(vec![
9432            r#"<tool_call>
9433{"name":"failing_shell","arguments":{"command":"rm -rf /"}}
9434</tool_call>"#,
9435            "I could not execute that command.",
9436        ]);
9437
9438        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(FailingTool::new(
9439            "failing_shell",
9440            "Command not allowed by security policy: rm -rf /",
9441        ))];
9442
9443        let mut history = vec![
9444            ChatMessage::system("test-system"),
9445            ChatMessage::user("delete everything"),
9446        ];
9447        let observer = NoopObserver;
9448
9449        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(64);
9450
9451        let result = run_tool_call_loop(
9452            &provider,
9453            &mut history,
9454            &tools_registry,
9455            &observer,
9456            "mock-provider",
9457            "mock-model",
9458            0.0,
9459            true,
9460            None,
9461            "telegram",
9462            None,
9463            &crate::config::MultimodalConfig::default(),
9464            4,
9465            None,
9466            Some(tx),
9467            None,
9468            &[],
9469            &[],
9470            None,
9471            None,
9472            &crate::config::PacingConfig::default(),
9473            0,
9474            0,
9475            None,
9476        )
9477        .await
9478        .expect("tool loop should complete");
9479
9480        // Collect all messages sent to the on_delta channel.
9481        let mut deltas = Vec::new();
9482        while let Ok(msg) = rx.try_recv() {
9483            deltas.push(msg);
9484        }
9485
9486        let all_deltas: String = deltas
9487            .iter()
9488            .filter_map(|d| match d {
9489                DraftEvent::Progress(t) | DraftEvent::Content(t) => Some(t.as_str()),
9490                DraftEvent::Clear => None,
9491            })
9492            .collect();
9493
9494        // The failure reason should appear in the progress messages.
9495        assert!(
9496            all_deltas.contains("Command not allowed by security policy"),
9497            "on_delta messages should include the tool failure reason, got: {all_deltas}"
9498        );
9499
9500        // Should also contain the cross mark (❌) icon to indicate failure.
9501        assert!(
9502            all_deltas.contains('\u{274c}'),
9503            "on_delta messages should include ❌ for failed tool calls, got: {all_deltas}"
9504        );
9505
9506        assert_eq!(result, "I could not execute that command.");
9507    }
9508
9509    // ── filter_by_allowed_tools tests ─────────────────────────────────────
9510
9511    #[test]
9512    fn filter_by_allowed_tools_none_passes_all() {
9513        let specs = vec![
9514            make_spec("shell"),
9515            make_spec("memory_store"),
9516            make_spec("file_read"),
9517        ];
9518        let result = filter_by_allowed_tools(specs, None);
9519        assert_eq!(result.len(), 3);
9520    }
9521
9522    #[test]
9523    fn filter_by_allowed_tools_some_restricts_to_listed() {
9524        let specs = vec![
9525            make_spec("shell"),
9526            make_spec("memory_store"),
9527            make_spec("file_read"),
9528        ];
9529        let allowed = vec!["shell".to_string(), "memory_store".to_string()];
9530        let result = filter_by_allowed_tools(specs, Some(&allowed));
9531        let names: Vec<&str> = result.iter().map(|s| s.name.as_str()).collect();
9532        assert_eq!(names.len(), 2);
9533        assert!(names.contains(&"shell"));
9534        assert!(names.contains(&"memory_store"));
9535        assert!(!names.contains(&"file_read"));
9536    }
9537
9538    #[test]
9539    fn filter_by_allowed_tools_unknown_names_silently_ignored() {
9540        let specs = vec![make_spec("shell"), make_spec("file_read")];
9541        let allowed = vec![
9542            "shell".to_string(),
9543            "nonexistent_tool".to_string(),
9544            "another_missing".to_string(),
9545        ];
9546        let result = filter_by_allowed_tools(specs, Some(&allowed));
9547        let names: Vec<&str> = result.iter().map(|s| s.name.as_str()).collect();
9548        assert_eq!(names.len(), 1);
9549        assert!(names.contains(&"shell"));
9550    }
9551
9552    #[test]
9553    fn filter_by_allowed_tools_empty_list_excludes_all() {
9554        let specs = vec![make_spec("shell"), make_spec("file_read")];
9555        let allowed: Vec<String> = vec![];
9556        let result = filter_by_allowed_tools(specs, Some(&allowed));
9557        assert!(result.is_empty());
9558    }
9559
9560    // ── Cost tracking tests ──
9561
9562    #[tokio::test]
9563    async fn cost_tracking_records_usage_when_scoped() {
9564        use super::{
9565            TOOL_LOOP_COST_TRACKING_CONTEXT, ToolLoopCostTrackingContext, run_tool_call_loop,
9566        };
9567        use crate::config::schema::ModelPricing;
9568        use crate::cost::CostTracker;
9569        use crate::observability::noop::NoopObserver;
9570        use std::collections::HashMap;
9571
9572        let provider = ScriptedProvider {
9573            responses: Arc::new(Mutex::new(VecDeque::from([ChatResponse {
9574                text: Some("done".to_string()),
9575                tool_calls: Vec::new(),
9576                usage: Some(crate::providers::traits::TokenUsage {
9577                    input_tokens: Some(1_000),
9578                    output_tokens: Some(200),
9579                    cached_input_tokens: None,
9580                }),
9581                reasoning_content: None,
9582            }]))),
9583            capabilities: ProviderCapabilities::default(),
9584        };
9585        let observer = NoopObserver;
9586        let workspace = tempfile::TempDir::new().unwrap();
9587        let mut cost_config = crate::config::CostConfig {
9588            enabled: true,
9589            ..crate::config::CostConfig::default()
9590        };
9591        cost_config.prices = HashMap::from([(
9592            "mock-model".to_string(),
9593            ModelPricing {
9594                input: 3.0,
9595                output: 15.0,
9596            },
9597        )]);
9598        let tracker = Arc::new(CostTracker::new(cost_config.clone(), workspace.path()).unwrap());
9599        let ctx = ToolLoopCostTrackingContext::new(
9600            Arc::clone(&tracker),
9601            Arc::new(cost_config.prices.clone()),
9602        );
9603        let mut history = vec![ChatMessage::system("test"), ChatMessage::user("hello")];
9604
9605        let result = TOOL_LOOP_COST_TRACKING_CONTEXT
9606            .scope(
9607                Some(ctx),
9608                run_tool_call_loop(
9609                    &provider,
9610                    &mut history,
9611                    &[],
9612                    &observer,
9613                    "mock-provider",
9614                    "mock-model",
9615                    0.0,
9616                    true,
9617                    None,
9618                    "test",
9619                    None,
9620                    &crate::config::MultimodalConfig::default(),
9621                    2,
9622                    None,
9623                    None,
9624                    None,
9625                    &[],
9626                    &[],
9627                    None,
9628                    None,
9629                    &crate::config::PacingConfig::default(),
9630                    0,
9631                    0,
9632                    None,
9633                ),
9634            )
9635            .await
9636            .expect("tool loop should succeed");
9637
9638        assert_eq!(result, "done");
9639        let summary = tracker.get_summary().unwrap();
9640        assert_eq!(summary.request_count, 1);
9641        assert_eq!(summary.total_tokens, 1_200);
9642        assert!(summary.session_cost_usd > 0.0);
9643    }
9644
9645    #[tokio::test]
9646    async fn cost_tracking_enforces_budget() {
9647        use super::{
9648            TOOL_LOOP_COST_TRACKING_CONTEXT, ToolLoopCostTrackingContext, run_tool_call_loop,
9649        };
9650        use crate::config::schema::ModelPricing;
9651        use crate::cost::CostTracker;
9652        use crate::observability::noop::NoopObserver;
9653        use std::collections::HashMap;
9654
9655        let provider = ScriptedProvider::from_text_responses(vec!["should not reach this"]);
9656        let observer = NoopObserver;
9657        let workspace = tempfile::TempDir::new().unwrap();
9658        let cost_config = crate::config::CostConfig {
9659            enabled: true,
9660            daily_limit_usd: 0.001, // very low limit
9661            ..crate::config::CostConfig::default()
9662        };
9663        let tracker = Arc::new(CostTracker::new(cost_config.clone(), workspace.path()).unwrap());
9664        // Record a usage that already exceeds the limit
9665        tracker
9666            .record_usage(crate::cost::types::TokenUsage::new(
9667                "mock-model",
9668                100_000,
9669                50_000,
9670                1.0,
9671                1.0,
9672            ))
9673            .unwrap();
9674
9675        let ctx = ToolLoopCostTrackingContext::new(
9676            Arc::clone(&tracker),
9677            Arc::new(HashMap::from([(
9678                "mock-model".to_string(),
9679                ModelPricing {
9680                    input: 1.0,
9681                    output: 1.0,
9682                },
9683            )])),
9684        );
9685        let mut history = vec![ChatMessage::system("test"), ChatMessage::user("hello")];
9686
9687        let err = TOOL_LOOP_COST_TRACKING_CONTEXT
9688            .scope(
9689                Some(ctx),
9690                run_tool_call_loop(
9691                    &provider,
9692                    &mut history,
9693                    &[],
9694                    &observer,
9695                    "mock-provider",
9696                    "mock-model",
9697                    0.0,
9698                    true,
9699                    None,
9700                    "test",
9701                    None,
9702                    &crate::config::MultimodalConfig::default(),
9703                    2,
9704                    None,
9705                    None,
9706                    None,
9707                    &[],
9708                    &[],
9709                    None,
9710                    None,
9711                    &crate::config::PacingConfig::default(),
9712                    0,
9713                    0,
9714                    None,
9715                ),
9716            )
9717            .await
9718            .expect_err("should fail with budget exceeded");
9719
9720        assert!(
9721            err.to_string().contains("Budget exceeded"),
9722            "error should mention budget: {err}"
9723        );
9724    }
9725
9726    #[tokio::test]
9727    async fn cost_tracking_is_noop_without_scope() {
9728        use super::run_tool_call_loop;
9729        use crate::observability::noop::NoopObserver;
9730
9731        // No TOOL_LOOP_COST_TRACKING_CONTEXT scoped — should run fine
9732        let provider = ScriptedProvider {
9733            responses: Arc::new(Mutex::new(VecDeque::from([ChatResponse {
9734                text: Some("ok".to_string()),
9735                tool_calls: Vec::new(),
9736                usage: Some(crate::providers::traits::TokenUsage {
9737                    input_tokens: Some(500),
9738                    output_tokens: Some(100),
9739                    cached_input_tokens: None,
9740                }),
9741                reasoning_content: None,
9742            }]))),
9743            capabilities: ProviderCapabilities::default(),
9744        };
9745        let observer = NoopObserver;
9746        let mut history = vec![ChatMessage::system("test"), ChatMessage::user("hello")];
9747
9748        let result = run_tool_call_loop(
9749            &provider,
9750            &mut history,
9751            &[],
9752            &observer,
9753            "mock-provider",
9754            "mock-model",
9755            0.0,
9756            true,
9757            None,
9758            "test",
9759            None,
9760            &crate::config::MultimodalConfig::default(),
9761            2,
9762            None,
9763            None,
9764            None,
9765            &[],
9766            &[],
9767            None,
9768            None,
9769            &crate::config::PacingConfig::default(),
9770            0,
9771            0,
9772            None,
9773        )
9774        .await
9775        .expect("should succeed without cost scope");
9776
9777        assert_eq!(result, "ok");
9778    }
9779}