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        // Stream when tools are requested if the provider can emit native tool
2550        // events OR doesn't use native tool calling at all — in the latter case
2551        // tool calls arrive as text and are parsed from `response_text` after
2552        // the stream completes.
2553        let should_consume_provider_stream = on_delta.is_some()
2554            && provider.supports_streaming()
2555            && (request_tools.is_none()
2556                || !provider.supports_native_tools()
2557                || provider.supports_streaming_tool_events());
2558        tracing::debug!(
2559            has_on_delta = on_delta.is_some(),
2560            supports_streaming = provider.supports_streaming(),
2561            should_consume_provider_stream,
2562            "Streaming decision for iteration {}",
2563            iteration + 1,
2564        );
2565        let mut streamed_live_deltas = false;
2566
2567        let chat_result = if should_consume_provider_stream {
2568            match consume_provider_streaming_response(
2569                active_provider,
2570                &prepared_messages.messages,
2571                request_tools,
2572                active_model,
2573                temperature,
2574                cancellation_token.as_ref(),
2575                on_delta.as_ref(),
2576            )
2577            .await
2578            {
2579                Ok(streamed) => {
2580                    streamed_live_deltas = streamed.forwarded_live_deltas;
2581                    Ok(crate::providers::ChatResponse {
2582                        text: Some(streamed.response_text),
2583                        tool_calls: streamed.tool_calls,
2584                        usage: None,
2585                        reasoning_content: None,
2586                    })
2587                }
2588                Err(stream_err) => {
2589                    tracing::warn!(
2590                        provider = active_provider_name,
2591                        model = active_model,
2592                        iteration = iteration + 1,
2593                        "provider streaming failed, falling back to non-streaming chat: {stream_err}"
2594                    );
2595                    runtime_trace::record_event(
2596                        "llm_stream_fallback",
2597                        Some(channel_name),
2598                        Some(active_provider_name),
2599                        Some(active_model),
2600                        Some(&turn_id),
2601                        Some(false),
2602                        Some("provider stream failed; fallback to non-streaming chat"),
2603                        serde_json::json!({
2604                            "iteration": iteration + 1,
2605                            "error": scrub_credentials(&stream_err.to_string()),
2606                        }),
2607                    );
2608                    if let Some(ref tx) = on_delta {
2609                        let _ = tx.send(DraftEvent::Clear).await;
2610                    }
2611                    {
2612                        let chat_future = active_provider.chat(
2613                            ChatRequest {
2614                                messages: &prepared_messages.messages,
2615                                tools: request_tools,
2616                            },
2617                            active_model,
2618                            temperature,
2619                        );
2620                        if let Some(token) = cancellation_token.as_ref() {
2621                            tokio::select! {
2622                                () = token.cancelled() => Err(ToolLoopCancelled.into()),
2623                                result = chat_future => result,
2624                            }
2625                        } else {
2626                            chat_future.await
2627                        }
2628                    }
2629                }
2630            }
2631        } else {
2632            // Non-streaming path: wrap with optional per-step timeout from
2633            // pacing config to catch hung model responses.
2634            let chat_future = active_provider.chat(
2635                ChatRequest {
2636                    messages: &prepared_messages.messages,
2637                    tools: request_tools,
2638                },
2639                active_model,
2640                temperature,
2641            );
2642
2643            match pacing.step_timeout_secs {
2644                Some(step_secs) if step_secs > 0 => {
2645                    let step_timeout = Duration::from_secs(step_secs);
2646                    if let Some(token) = cancellation_token.as_ref() {
2647                        tokio::select! {
2648                            () = token.cancelled() => return Err(ToolLoopCancelled.into()),
2649                            result = tokio::time::timeout(step_timeout, chat_future) => {
2650                                match result {
2651                                    Ok(inner) => inner,
2652                                    Err(_) => anyhow::bail!(
2653                                        "LLM inference step timed out after {step_secs}s (step_timeout_secs)"
2654                                    ),
2655                                }
2656                            },
2657                        }
2658                    } else {
2659                        match tokio::time::timeout(step_timeout, chat_future).await {
2660                            Ok(inner) => inner,
2661                            Err(_) => anyhow::bail!(
2662                                "LLM inference step timed out after {step_secs}s (step_timeout_secs)"
2663                            ),
2664                        }
2665                    }
2666                }
2667                _ => {
2668                    if let Some(token) = cancellation_token.as_ref() {
2669                        tokio::select! {
2670                            () = token.cancelled() => return Err(ToolLoopCancelled.into()),
2671                            result = chat_future => result,
2672                        }
2673                    } else {
2674                        chat_future.await
2675                    }
2676                }
2677            }
2678        };
2679
2680        let (
2681            response_text,
2682            parsed_text,
2683            tool_calls,
2684            assistant_history_content,
2685            native_tool_calls,
2686            _parse_issue_detected,
2687            response_streamed_live,
2688        ) = match chat_result {
2689            Ok(resp) => {
2690                let (resp_input_tokens, resp_output_tokens) = resp
2691                    .usage
2692                    .as_ref()
2693                    .map(|u| (u.input_tokens, u.output_tokens))
2694                    .unwrap_or((None, None));
2695
2696                observer.record_event(&ObserverEvent::LlmResponse {
2697                    provider: provider_name.to_string(),
2698                    model: model.to_string(),
2699                    duration: llm_started_at.elapsed(),
2700                    success: true,
2701                    error_message: None,
2702                    input_tokens: resp_input_tokens,
2703                    output_tokens: resp_output_tokens,
2704                });
2705
2706                // Record cost via task-local tracker (no-op when not scoped).
2707                // Always record — even when the provider omits usage — so request_count
2708                // on the cost page reflects every turn. Zero-token requests emit a warn
2709                // inside record_tool_loop_cost_usage so the gap is visible in logs.
2710                let usage_for_cost = resp
2711                    .usage
2712                    .clone()
2713                    .unwrap_or_else(crate::providers::traits::TokenUsage::default);
2714                let _ = record_tool_loop_cost_usage(provider_name, model, &usage_for_cost);
2715
2716                let response_text = resp.text_or_empty().to_string();
2717                // First try native structured tool calls (OpenAI-format).
2718                // Fall back to text-based parsing (XML tags, markdown blocks,
2719                // GLM format) only if the provider returned no native calls —
2720                // this ensures we support both native and prompt-guided models.
2721                let mut calls: Vec<ParsedToolCall> = resp
2722                    .tool_calls
2723                    .iter()
2724                    .map(|call| ParsedToolCall {
2725                        name: call.name.clone(),
2726                        arguments: serde_json::from_str::<serde_json::Value>(&call.arguments)
2727                            .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new())),
2728                        tool_call_id: Some(call.id.clone()),
2729                    })
2730                    .collect();
2731                let mut parsed_text = String::new();
2732
2733                if calls.is_empty() {
2734                    let (fallback_text, fallback_calls) = parse_tool_calls(&response_text);
2735                    if !fallback_text.is_empty() {
2736                        parsed_text = fallback_text;
2737                    }
2738                    calls = fallback_calls;
2739                }
2740
2741                let parse_issue = detect_tool_call_parse_issue(&response_text, &calls);
2742                if let Some(ref issue) = parse_issue {
2743                    runtime_trace::record_event(
2744                        "tool_call_parse_issue",
2745                        Some(channel_name),
2746                        Some(provider_name),
2747                        Some(model),
2748                        Some(&turn_id),
2749                        Some(false),
2750                        Some(issue.as_str()),
2751                        serde_json::json!({
2752                            "iteration": iteration + 1,
2753                            "response_excerpt": truncate_with_ellipsis(
2754                                &scrub_credentials(&response_text),
2755                                600
2756                            ),
2757                        }),
2758                    );
2759                }
2760
2761                runtime_trace::record_event(
2762                    "llm_response",
2763                    Some(channel_name),
2764                    Some(provider_name),
2765                    Some(model),
2766                    Some(&turn_id),
2767                    Some(true),
2768                    None,
2769                    serde_json::json!({
2770                        "iteration": iteration + 1,
2771                        "duration_ms": llm_started_at.elapsed().as_millis(),
2772                        "input_tokens": resp_input_tokens,
2773                        "output_tokens": resp_output_tokens,
2774                        "raw_response": scrub_credentials(&response_text),
2775                        "native_tool_calls": resp.tool_calls.len(),
2776                        "parsed_tool_calls": calls.len(),
2777                    }),
2778                );
2779
2780                // Preserve native tool call IDs in assistant history so role=tool
2781                // follow-up messages can reference the exact call id.
2782                let reasoning_content = resp.reasoning_content.clone();
2783                let assistant_history_content = if resp.tool_calls.is_empty() {
2784                    if use_native_tools {
2785                        build_native_assistant_history_from_parsed_calls(
2786                            &response_text,
2787                            &calls,
2788                            reasoning_content.as_deref(),
2789                        )
2790                        .unwrap_or_else(|| response_text.clone())
2791                    } else {
2792                        response_text.clone()
2793                    }
2794                } else {
2795                    build_native_assistant_history(
2796                        &response_text,
2797                        &resp.tool_calls,
2798                        reasoning_content.as_deref(),
2799                    )
2800                };
2801
2802                let native_calls = resp.tool_calls;
2803                (
2804                    response_text,
2805                    parsed_text,
2806                    calls,
2807                    assistant_history_content,
2808                    native_calls,
2809                    parse_issue.is_some(),
2810                    streamed_live_deltas,
2811                )
2812            }
2813            Err(e) => {
2814                let safe_error = crate::providers::sanitize_api_error(&e.to_string());
2815                observer.record_event(&ObserverEvent::LlmResponse {
2816                    provider: provider_name.to_string(),
2817                    model: model.to_string(),
2818                    duration: llm_started_at.elapsed(),
2819                    success: false,
2820                    error_message: Some(safe_error.clone()),
2821                    input_tokens: None,
2822                    output_tokens: None,
2823                });
2824                runtime_trace::record_event(
2825                    "llm_response",
2826                    Some(channel_name),
2827                    Some(provider_name),
2828                    Some(model),
2829                    Some(&turn_id),
2830                    Some(false),
2831                    Some(&safe_error),
2832                    serde_json::json!({
2833                        "iteration": iteration + 1,
2834                        "duration_ms": llm_started_at.elapsed().as_millis(),
2835                    }),
2836                );
2837
2838                // Context overflow recovery: trim history and retry
2839                if crate::providers::reliable::is_context_window_exceeded(&e) {
2840                    tracing::warn!(
2841                        iteration = iteration + 1,
2842                        "Context window exceeded, attempting in-loop recovery"
2843                    );
2844
2845                    // Step 1: fast-trim old tool results (cheap)
2846                    let chars_saved = fast_trim_tool_results(history, 4);
2847                    if chars_saved > 0 {
2848                        tracing::info!(
2849                            chars_saved,
2850                            "Context recovery: trimmed old tool results, retrying"
2851                        );
2852                        continue;
2853                    }
2854
2855                    // Step 2: emergency drop oldest non-system messages
2856                    let dropped = emergency_history_trim(history, 4);
2857                    if dropped > 0 {
2858                        tracing::info!(dropped, "Context recovery: dropped old messages, retrying");
2859                        continue;
2860                    }
2861
2862                    // Nothing left to trim — truly unrecoverable
2863                    tracing::error!("Context overflow unrecoverable: no trimmable messages");
2864                }
2865
2866                return Err(e);
2867            }
2868        };
2869
2870        let display_text = if parsed_text.is_empty() {
2871            response_text.clone()
2872        } else {
2873            parsed_text
2874        };
2875
2876        // ── Progress: LLM responded ─────────────────────────────
2877        if let Some(ref tx) = on_delta {
2878            let llm_secs = llm_started_at.elapsed().as_secs();
2879            if !tool_calls.is_empty() {
2880                let _ = tx
2881                    .send(DraftEvent::Progress(format!(
2882                        "\u{1f4ac} Got {} tool call(s) ({llm_secs}s)\n",
2883                        tool_calls.len()
2884                    )))
2885                    .await;
2886            }
2887        }
2888
2889        if tool_calls.is_empty() {
2890            runtime_trace::record_event(
2891                "turn_final_response",
2892                Some(channel_name),
2893                Some(provider_name),
2894                Some(model),
2895                Some(&turn_id),
2896                Some(true),
2897                None,
2898                serde_json::json!({
2899                    "iteration": iteration + 1,
2900                    "text": scrub_credentials(&display_text),
2901                }),
2902            );
2903            // No tool calls — this is the final response.
2904            // If a streaming sender is provided, relay the text in small chunks
2905            // so the channel can progressively update the draft message.
2906            if let Some(ref tx) = on_delta {
2907                let should_emit_post_hoc_chunks =
2908                    !response_streamed_live || display_text != response_text;
2909                if !should_emit_post_hoc_chunks {
2910                    history.push(ChatMessage::assistant(response_text.clone()));
2911                    return Ok(display_text);
2912                }
2913                // Clear accumulated progress lines before streaming the final answer.
2914                let _ = tx.send(DraftEvent::Clear).await;
2915                // Split on whitespace boundaries, accumulating chunks of at least
2916                // STREAM_CHUNK_MIN_CHARS characters for progressive draft updates.
2917                let mut chunk = String::new();
2918                for word in display_text.split_inclusive(char::is_whitespace) {
2919                    if cancellation_token
2920                        .as_ref()
2921                        .is_some_and(CancellationToken::is_cancelled)
2922                    {
2923                        return Err(ToolLoopCancelled.into());
2924                    }
2925                    chunk.push_str(word);
2926                    if chunk.len() >= STREAM_CHUNK_MIN_CHARS
2927                        && tx
2928                            .send(DraftEvent::Content(std::mem::take(&mut chunk)))
2929                            .await
2930                            .is_err()
2931                    {
2932                        break; // receiver dropped
2933                    }
2934                }
2935                if !chunk.is_empty() {
2936                    let _ = tx.send(DraftEvent::Content(chunk)).await;
2937                }
2938            }
2939            history.push(ChatMessage::assistant(response_text.clone()));
2940            return Ok(display_text);
2941        }
2942
2943        // Native tool-call providers can return assistant text separately from
2944        // the structured call payload; relay it to draft-capable channels.
2945        if !display_text.is_empty() {
2946            if !native_tool_calls.is_empty() {
2947                if let Some(ref tx) = on_delta {
2948                    let mut narration = display_text.clone();
2949                    if !narration.ends_with('\n') {
2950                        narration.push('\n');
2951                    }
2952                    let _ = tx.send(DraftEvent::Content(narration)).await;
2953                }
2954            }
2955            if !silent {
2956                print!("{display_text}");
2957                let _ = std::io::stdout().flush();
2958            }
2959        }
2960
2961        // Execute tool calls and build results. `individual_results` tracks per-call output so
2962        // native-mode history can emit one role=tool message per tool call with the correct ID.
2963        //
2964        // When multiple tool calls are present and interactive CLI approval is not needed, run
2965        // tool executions concurrently for lower wall-clock latency.
2966        let mut tool_results = String::new();
2967        let mut individual_results: Vec<(Option<String>, String)> = Vec::new();
2968        let mut ordered_results: Vec<Option<(String, Option<String>, ToolExecutionOutcome)>> =
2969            (0..tool_calls.len()).map(|_| None).collect();
2970        let allow_parallel_execution = should_execute_tools_in_parallel(&tool_calls, approval);
2971        let mut executable_indices: Vec<usize> = Vec::new();
2972        let mut executable_calls: Vec<ParsedToolCall> = Vec::new();
2973
2974        for (idx, call) in tool_calls.iter().enumerate() {
2975            // ── Hook: before_tool_call (modifying) ──────────
2976            let mut tool_name = call.name.clone();
2977            let mut tool_args = call.arguments.clone();
2978            if let Some(hooks) = hooks {
2979                match hooks
2980                    .run_before_tool_call(tool_name.clone(), tool_args.clone())
2981                    .await
2982                {
2983                    crate::hooks::HookResult::Cancel(reason) => {
2984                        tracing::info!(tool = %call.name, %reason, "tool call cancelled by hook");
2985                        let cancelled = format!("Cancelled by hook: {reason}");
2986                        runtime_trace::record_event(
2987                            "tool_call_result",
2988                            Some(channel_name),
2989                            Some(provider_name),
2990                            Some(model),
2991                            Some(&turn_id),
2992                            Some(false),
2993                            Some(&cancelled),
2994                            serde_json::json!({
2995                                "iteration": iteration + 1,
2996                                "tool": call.name,
2997                                "arguments": scrub_credentials(&tool_args.to_string()),
2998                            }),
2999                        );
3000                        if let Some(ref tx) = on_delta {
3001                            let _ = tx
3002                                .send(DraftEvent::Progress(format!(
3003                                    "\u{274c} {}: {}\n",
3004                                    call.name,
3005                                    truncate_with_ellipsis(&scrub_credentials(&cancelled), 200)
3006                                )))
3007                                .await;
3008                        }
3009                        ordered_results[idx] = Some((
3010                            call.name.clone(),
3011                            call.tool_call_id.clone(),
3012                            ToolExecutionOutcome {
3013                                output: cancelled,
3014                                success: false,
3015                                error_reason: Some(scrub_credentials(&reason)),
3016                                duration: Duration::ZERO,
3017                            },
3018                        ));
3019                        continue;
3020                    }
3021                    crate::hooks::HookResult::Continue((name, args)) => {
3022                        tool_name = name;
3023                        tool_args = args;
3024                    }
3025                }
3026            }
3027
3028            maybe_inject_channel_delivery_defaults(
3029                &tool_name,
3030                &mut tool_args,
3031                channel_name,
3032                channel_reply_target,
3033            );
3034
3035            // ── Approval hook ────────────────────────────────
3036            if let Some(mgr) = approval {
3037                if mgr.needs_approval(&tool_name) {
3038                    let request = ApprovalRequest {
3039                        tool_name: tool_name.clone(),
3040                        arguments: tool_args.clone(),
3041                    };
3042
3043                    // Interactive CLI: prompt the operator.
3044                    // Non-interactive (channels): auto-deny since no operator
3045                    // is present to approve.
3046                    let decision = if mgr.is_non_interactive() {
3047                        ApprovalResponse::No
3048                    } else {
3049                        mgr.prompt_cli(&request)
3050                    };
3051
3052                    mgr.record_decision(&tool_name, &tool_args, decision, channel_name);
3053
3054                    if decision == ApprovalResponse::No {
3055                        let denied = "Denied by user.".to_string();
3056                        runtime_trace::record_event(
3057                            "tool_call_result",
3058                            Some(channel_name),
3059                            Some(provider_name),
3060                            Some(model),
3061                            Some(&turn_id),
3062                            Some(false),
3063                            Some(&denied),
3064                            serde_json::json!({
3065                                "iteration": iteration + 1,
3066                                "tool": tool_name.clone(),
3067                                "arguments": scrub_credentials(&tool_args.to_string()),
3068                            }),
3069                        );
3070                        if let Some(ref tx) = on_delta {
3071                            let _ = tx
3072                                .send(DraftEvent::Progress(format!(
3073                                    "\u{274c} {}: {}\n",
3074                                    tool_name, denied
3075                                )))
3076                                .await;
3077                        }
3078                        ordered_results[idx] = Some((
3079                            tool_name.clone(),
3080                            call.tool_call_id.clone(),
3081                            ToolExecutionOutcome {
3082                                output: denied.clone(),
3083                                success: false,
3084                                error_reason: Some(denied),
3085                                duration: Duration::ZERO,
3086                            },
3087                        ));
3088                        continue;
3089                    }
3090                }
3091            }
3092
3093            let signature = {
3094                let canonical_args = canonicalize_json_for_tool_signature(&tool_args);
3095                let args_json =
3096                    serde_json::to_string(&canonical_args).unwrap_or_else(|_| "{}".to_string());
3097                (tool_name.trim().to_ascii_lowercase(), args_json)
3098            };
3099            let dedup_exempt = dedup_exempt_tools.iter().any(|e| e == &tool_name);
3100            if !dedup_exempt && !seen_tool_signatures.insert(signature) {
3101                let duplicate = format!(
3102                    "Skipped duplicate tool call '{tool_name}' with identical arguments in this turn."
3103                );
3104                runtime_trace::record_event(
3105                    "tool_call_result",
3106                    Some(channel_name),
3107                    Some(provider_name),
3108                    Some(model),
3109                    Some(&turn_id),
3110                    Some(false),
3111                    Some(&duplicate),
3112                    serde_json::json!({
3113                        "iteration": iteration + 1,
3114                        "tool": tool_name.clone(),
3115                        "arguments": scrub_credentials(&tool_args.to_string()),
3116                        "deduplicated": true,
3117                    }),
3118                );
3119                if let Some(ref tx) = on_delta {
3120                    let _ = tx
3121                        .send(DraftEvent::Progress(format!(
3122                            "\u{274c} {}: {}\n",
3123                            tool_name, duplicate
3124                        )))
3125                        .await;
3126                }
3127                ordered_results[idx] = Some((
3128                    tool_name.clone(),
3129                    call.tool_call_id.clone(),
3130                    ToolExecutionOutcome {
3131                        output: duplicate.clone(),
3132                        success: false,
3133                        error_reason: Some(duplicate),
3134                        duration: Duration::ZERO,
3135                    },
3136                ));
3137                continue;
3138            }
3139
3140            runtime_trace::record_event(
3141                "tool_call_start",
3142                Some(channel_name),
3143                Some(provider_name),
3144                Some(model),
3145                Some(&turn_id),
3146                None,
3147                None,
3148                serde_json::json!({
3149                    "iteration": iteration + 1,
3150                    "tool": tool_name.clone(),
3151                    "arguments": scrub_credentials(&tool_args.to_string()),
3152                }),
3153            );
3154
3155            // ── Progress: tool start ────────────────────────────
3156            if let Some(ref tx) = on_delta {
3157                let progress = if let Some(suffix) = tool_name.strip_prefix("construct-operator__")
3158                {
3159                    // Operator tools get user-friendly progress messages
3160                    match suffix {
3161                        "create_agent" => {
3162                            let title = tool_args
3163                                .get("title")
3164                                .and_then(|v| v.as_str())
3165                                .unwrap_or("agent");
3166                            format!("\u{1f916} Spawning agent: {title}\n")
3167                        }
3168                        "wait_for_agent" => "\u{23f3} Waiting for agent to finish…\n".to_string(),
3169                        "send_agent_prompt" => {
3170                            "\u{1f4e8} Sending follow-up to agent…\n".to_string()
3171                        }
3172                        "get_agent_activity" => "\u{1f4cb} Collecting agent results…\n".to_string(),
3173                        "get_agent_status" => "\u{1f50d} Checking agent status…\n".to_string(),
3174                        "list_agents" => "\u{1f4cb} Listing active agents…\n".to_string(),
3175                        "search_agent_pool" | "list_agent_templates" => {
3176                            "\u{1f50d} Searching agent pool…\n".to_string()
3177                        }
3178                        "save_agent_template" => {
3179                            let name = tool_args
3180                                .get("name")
3181                                .and_then(|v| v.as_str())
3182                                .unwrap_or("template");
3183                            format!("\u{1f4be} Saving agent template: {name}\n")
3184                        }
3185                        "list_teams" | "search_teams" => "\u{1f50d} Searching teams…\n".to_string(),
3186                        "get_team" => "\u{1f4cb} Loading team details…\n".to_string(),
3187                        "spawn_team" => "\u{1f680} Deploying team…\n".to_string(),
3188                        "create_team" => {
3189                            let name = tool_args
3190                                .get("name")
3191                                .and_then(|v| v.as_str())
3192                                .unwrap_or("team");
3193                            format!("\u{1f4be} Creating team: {name}\n")
3194                        }
3195                        "get_budget_status" => "\u{1f4b0} Checking budget…\n".to_string(),
3196                        "save_plan" => "\u{1f4be} Saving execution plan…\n".to_string(),
3197                        "recall_plans" => "\u{1f50d} Searching past plans…\n".to_string(),
3198                        "create_goal" => {
3199                            let name = tool_args
3200                                .get("name")
3201                                .and_then(|v| v.as_str())
3202                                .unwrap_or("goal");
3203                            format!("\u{1f3af} Creating goal: {name}\n")
3204                        }
3205                        "get_goals" => "\u{1f3af} Loading goals…\n".to_string(),
3206                        "update_goal" => "\u{1f3af} Updating goal…\n".to_string(),
3207                        "record_agent_outcome" => {
3208                            "\u{1f4ca} Recording agent outcome…\n".to_string()
3209                        }
3210                        "get_agent_trust" => "\u{1f4ca} Checking trust scores…\n".to_string(),
3211                        "publish_to_clawhub" => "\u{1f4e4} Publishing to ClawHub…\n".to_string(),
3212                        "search_clawhub" => {
3213                            "\u{1f50d} Searching ClawHub marketplace…\n".to_string()
3214                        }
3215                        "install_from_clawhub" => {
3216                            "\u{1f4e5} Installing from ClawHub…\n".to_string()
3217                        }
3218                        "list_nodes" => "\u{1f310} Discovering connected nodes…\n".to_string(),
3219                        "invoke_node" => "\u{1f4e1} Invoking node capability…\n".to_string(),
3220                        "get_session_history" => "\u{1f4c3} Loading session history…\n".to_string(),
3221                        "archive_session" => "\u{1f4e6} Archiving session…\n".to_string(),
3222                        "capture_skill" => {
3223                            let name = tool_args
3224                                .get("name")
3225                                .and_then(|v| v.as_str())
3226                                .unwrap_or("skill");
3227                            format!("\u{1f4da} Capturing skill: {name}\n")
3228                        }
3229                        _ => format!("\u{2699}\u{fe0f} Operator: {suffix}\n"),
3230                    }
3231                } else {
3232                    let hint = {
3233                        let raw = match tool_name.as_str() {
3234                            "shell" => tool_args.get("command").and_then(|v| v.as_str()),
3235                            "file_read" | "file_write" => {
3236                                tool_args.get("path").and_then(|v| v.as_str())
3237                            }
3238                            _ => tool_args
3239                                .get("action")
3240                                .and_then(|v| v.as_str())
3241                                .or_else(|| tool_args.get("query").and_then(|v| v.as_str())),
3242                        };
3243                        match raw {
3244                            Some(s) => truncate_with_ellipsis(s, 60),
3245                            None => String::new(),
3246                        }
3247                    };
3248                    if hint.is_empty() {
3249                        format!("\u{23f3} {}\n", tool_name)
3250                    } else {
3251                        format!("\u{23f3} {}: {hint}\n", tool_name)
3252                    }
3253                };
3254                tracing::debug!(tool = %tool_name, "Sending progress start to draft");
3255                let _ = tx.send(DraftEvent::Progress(progress)).await;
3256            }
3257
3258            executable_indices.push(idx);
3259            executable_calls.push(ParsedToolCall {
3260                name: tool_name,
3261                arguments: tool_args,
3262                tool_call_id: call.tool_call_id.clone(),
3263            });
3264        }
3265
3266        let executed_outcomes = if allow_parallel_execution && executable_calls.len() > 1 {
3267            execute_tools_parallel(
3268                &executable_calls,
3269                tools_registry,
3270                activated_tools,
3271                observer,
3272                cancellation_token.as_ref(),
3273            )
3274            .await?
3275        } else {
3276            execute_tools_sequential(
3277                &executable_calls,
3278                tools_registry,
3279                activated_tools,
3280                observer,
3281                cancellation_token.as_ref(),
3282            )
3283            .await?
3284        };
3285
3286        for ((idx, call), outcome) in executable_indices
3287            .iter()
3288            .zip(executable_calls.iter())
3289            .zip(executed_outcomes.into_iter())
3290        {
3291            runtime_trace::record_event(
3292                "tool_call_result",
3293                Some(channel_name),
3294                Some(provider_name),
3295                Some(model),
3296                Some(&turn_id),
3297                Some(outcome.success),
3298                outcome.error_reason.as_deref(),
3299                serde_json::json!({
3300                    "iteration": iteration + 1,
3301                    "tool": call.name.clone(),
3302                    "duration_ms": outcome.duration.as_millis(),
3303                    "output": scrub_credentials(&outcome.output),
3304                }),
3305            );
3306
3307            // ── Hook: after_tool_call (void) ─────────────────
3308            if let Some(hooks) = hooks {
3309                let tool_result_obj = crate::tools::ToolResult {
3310                    success: outcome.success,
3311                    output: outcome.output.clone(),
3312                    error: None,
3313                };
3314                hooks
3315                    .fire_after_tool_call(&call.name, &tool_result_obj, outcome.duration)
3316                    .await;
3317            }
3318
3319            // ── Progress: tool completion ───────────────────────
3320            if let Some(ref tx) = on_delta {
3321                let secs = outcome.duration.as_secs();
3322                let progress_msg = if let Some(suffix) =
3323                    call.name.strip_prefix("construct-operator__")
3324                {
3325                    // Operator tools get user-friendly completion messages
3326                    if outcome.success {
3327                        match suffix {
3328                            "create_agent" => format!("\u{2705} Agent spawned ({secs}s)\n"),
3329                            "wait_for_agent" => format!("\u{2705} Agent finished ({secs}s)\n"),
3330                            "get_agent_activity" => {
3331                                format!("\u{2705} Results collected ({secs}s)\n")
3332                            }
3333                            "save_agent_template" => format!("\u{2705} Template saved ({secs}s)\n"),
3334                            "send_agent_prompt" => format!("\u{2705} Follow-up sent ({secs}s)\n"),
3335                            "search_agent_pool" | "list_agent_templates" => {
3336                                format!("\u{2705} Pool search done ({secs}s)\n")
3337                            }
3338                            "list_teams" | "search_teams" => {
3339                                format!("\u{2705} Team search done ({secs}s)\n")
3340                            }
3341                            "get_team" => format!("\u{2705} Team loaded ({secs}s)\n"),
3342                            "spawn_team" => format!("\u{2705} Team deployed ({secs}s)\n"),
3343                            "create_team" => format!("\u{2705} Team created ({secs}s)\n"),
3344                            "get_budget_status" => format!("\u{2705} Budget checked ({secs}s)\n"),
3345                            "save_plan" => format!("\u{2705} Plan saved ({secs}s)\n"),
3346                            "recall_plans" => format!("\u{2705} Plans retrieved ({secs}s)\n"),
3347                            "create_goal" => format!("\u{2705} Goal created ({secs}s)\n"),
3348                            "get_goals" => format!("\u{2705} Goals loaded ({secs}s)\n"),
3349                            "update_goal" => format!("\u{2705} Goal updated ({secs}s)\n"),
3350                            "record_agent_outcome" => {
3351                                format!("\u{2705} Outcome recorded ({secs}s)\n")
3352                            }
3353                            "get_agent_trust" => {
3354                                format!("\u{2705} Trust scores loaded ({secs}s)\n")
3355                            }
3356                            "capture_skill" => format!("\u{2705} Skill captured ({secs}s)\n"),
3357                            "publish_to_clawhub" => {
3358                                format!("\u{2705} Published to ClawHub ({secs}s)\n")
3359                            }
3360                            "search_clawhub" => {
3361                                format!("\u{2705} ClawHub search complete ({secs}s)\n")
3362                            }
3363                            "install_from_clawhub" => {
3364                                format!("\u{2705} Installed from ClawHub ({secs}s)\n")
3365                            }
3366                            "list_nodes" => format!("\u{2705} Nodes discovered ({secs}s)\n"),
3367                            "invoke_node" => {
3368                                format!("\u{2705} Node invocation complete ({secs}s)\n")
3369                            }
3370                            "get_session_history" => {
3371                                format!("\u{2705} Session history loaded ({secs}s)\n")
3372                            }
3373                            "archive_session" => format!("\u{2705} Session archived ({secs}s)\n"),
3374                            _ => format!("\u{2705} {suffix} ({secs}s)\n"),
3375                        }
3376                    } else {
3377                        let reason_hint = outcome.error_reason.as_deref().unwrap_or("failed");
3378                        format!(
3379                            "\u{274c} {suffix} ({secs}s): {}\n",
3380                            truncate_with_ellipsis(reason_hint, 200)
3381                        )
3382                    }
3383                } else if outcome.success {
3384                    format!("\u{2705} {} ({secs}s)\n", call.name)
3385                } else if let Some(ref reason) = outcome.error_reason {
3386                    format!(
3387                        "\u{274c} {} ({secs}s): {}\n",
3388                        call.name,
3389                        truncate_with_ellipsis(reason, 200)
3390                    )
3391                } else {
3392                    format!("\u{274c} {} ({secs}s)\n", call.name)
3393                };
3394                tracing::debug!(tool = %call.name, secs, "Sending progress complete to draft");
3395                let _ = tx.send(DraftEvent::Progress(progress_msg)).await;
3396            }
3397
3398            ordered_results[*idx] = Some((call.name.clone(), call.tool_call_id.clone(), outcome));
3399        }
3400
3401        // Collect tool results and build per-tool output for loop detection.
3402        // Only non-ignored tool outputs contribute to the identical-output hash.
3403        let mut detection_relevant_output = String::new();
3404        // Use enumerate *before* filter_map so result_index stays aligned with
3405        // tool_calls even when some ordered_results entries are None.
3406        for (result_index, (tool_name, tool_call_id, outcome)) in ordered_results
3407            .into_iter()
3408            .enumerate()
3409            .filter_map(|(i, opt)| opt.map(|v| (i, v)))
3410        {
3411            if !loop_ignore_tools.contains(tool_name.as_str()) {
3412                detection_relevant_output.push_str(&outcome.output);
3413
3414                // Feed the pattern-based loop detector with name + args + result.
3415                let args = tool_calls
3416                    .get(result_index)
3417                    .map(|c| &c.arguments)
3418                    .unwrap_or(&serde_json::Value::Null);
3419                let det_result = loop_detector.record(&tool_name, args, &outcome.output);
3420                match det_result {
3421                    crate::agent::loop_detector::LoopDetectionResult::Ok => {}
3422                    crate::agent::loop_detector::LoopDetectionResult::Warning(ref msg) => {
3423                        tracing::warn!(tool = %tool_name, %msg, "loop detector warning");
3424                        // Inject a system nudge so the LLM adjusts strategy.
3425                        history.push(ChatMessage::system(format!("[Loop Detection] {msg}")));
3426                    }
3427                    crate::agent::loop_detector::LoopDetectionResult::Block(ref msg) => {
3428                        tracing::warn!(tool = %tool_name, %msg, "loop detector blocked tool call");
3429                        // Replace the tool output with the block message.
3430                        // We still continue the loop so the LLM sees the block feedback.
3431                        history.push(ChatMessage::system(format!(
3432                            "[Loop Detection — BLOCKED] {msg}"
3433                        )));
3434                    }
3435                    crate::agent::loop_detector::LoopDetectionResult::Break(msg) => {
3436                        runtime_trace::record_event(
3437                            "loop_detector_circuit_breaker",
3438                            Some(channel_name),
3439                            Some(provider_name),
3440                            Some(model),
3441                            Some(&turn_id),
3442                            Some(false),
3443                            Some(&msg),
3444                            serde_json::json!({
3445                                "iteration": iteration + 1,
3446                                "tool": tool_name,
3447                            }),
3448                        );
3449                        anyhow::bail!("Agent loop aborted by loop detector: {msg}");
3450                    }
3451                }
3452            }
3453            let result_output = truncate_tool_result(&outcome.output, max_tool_result_chars);
3454            individual_results.push((tool_call_id, result_output.clone()));
3455            let _ = writeln!(
3456                tool_results,
3457                "<tool_result name=\"{}\">\n{}\n</tool_result>",
3458                tool_name, result_output
3459            );
3460        }
3461
3462        // ── Time-gated loop detection ──────────────────────────
3463        // When pacing.loop_detection_min_elapsed_secs is set, identical-output
3464        // loop detection activates after the task has been running that long.
3465        // This avoids false-positive aborts on long-running browser/research
3466        // workflows while keeping aggressive protection for quick tasks.
3467        // When not configured, identical-output detection is disabled (preserving
3468        // existing behavior where only max_iterations prevents runaway loops).
3469        let loop_detection_active = match pacing.loop_detection_min_elapsed_secs {
3470            Some(min_secs) => loop_started_at.elapsed() >= Duration::from_secs(min_secs),
3471            None => false, // disabled when not configured (backwards compatible)
3472        };
3473
3474        if loop_detection_active && !detection_relevant_output.is_empty() {
3475            use std::hash::{Hash, Hasher};
3476            let mut hasher = std::collections::hash_map::DefaultHasher::new();
3477            detection_relevant_output.hash(&mut hasher);
3478            let current_hash = hasher.finish();
3479
3480            if last_tool_output_hash == Some(current_hash) {
3481                consecutive_identical_outputs += 1;
3482            } else {
3483                consecutive_identical_outputs = 0;
3484                last_tool_output_hash = Some(current_hash);
3485            }
3486
3487            // Bail if we see 3+ consecutive identical tool outputs (clear runaway).
3488            if consecutive_identical_outputs >= 3 {
3489                runtime_trace::record_event(
3490                    "tool_loop_identical_output_abort",
3491                    Some(channel_name),
3492                    Some(provider_name),
3493                    Some(model),
3494                    Some(&turn_id),
3495                    Some(false),
3496                    Some("identical tool output detected 3 consecutive times"),
3497                    serde_json::json!({
3498                        "iteration": iteration + 1,
3499                        "consecutive_identical": consecutive_identical_outputs,
3500                    }),
3501                );
3502                anyhow::bail!(
3503                    "Agent loop aborted: identical tool output detected {} consecutive times",
3504                    consecutive_identical_outputs
3505                );
3506            }
3507        }
3508
3509        // Add assistant message with tool calls + tool results to history.
3510        // Native mode: use JSON-structured messages so convert_messages() can
3511        // reconstruct proper OpenAI-format tool_calls and tool result messages.
3512        // Prompt mode: use XML-based text format as before.
3513        history.push(ChatMessage::assistant(assistant_history_content));
3514        if native_tool_calls.is_empty() {
3515            let all_results_have_ids = use_native_tools
3516                && !individual_results.is_empty()
3517                && individual_results
3518                    .iter()
3519                    .all(|(tool_call_id, _)| tool_call_id.is_some());
3520            if all_results_have_ids {
3521                for (tool_call_id, result) in &individual_results {
3522                    let tool_msg = serde_json::json!({
3523                        "tool_call_id": tool_call_id,
3524                        "content": result,
3525                    });
3526                    history.push(ChatMessage::tool(tool_msg.to_string()));
3527                }
3528            } else {
3529                history.push(ChatMessage::user(format!("[Tool results]\n{tool_results}")));
3530            }
3531        } else {
3532            for (native_call, (_, result)) in
3533                native_tool_calls.iter().zip(individual_results.iter())
3534            {
3535                let tool_msg = serde_json::json!({
3536                    "tool_call_id": native_call.id,
3537                    "content": result,
3538                });
3539                history.push(ChatMessage::tool(tool_msg.to_string()));
3540            }
3541        }
3542    }
3543
3544    runtime_trace::record_event(
3545        "tool_loop_exhausted",
3546        Some(channel_name),
3547        Some(provider_name),
3548        Some(model),
3549        Some(&turn_id),
3550        Some(false),
3551        Some("agent exceeded maximum tool iterations"),
3552        serde_json::json!({
3553            "max_iterations": max_iterations,
3554        }),
3555    );
3556
3557    // Graceful shutdown: ask the LLM for a final summary without tools
3558    tracing::warn!(
3559        max_iterations,
3560        "Max iterations reached, requesting final summary"
3561    );
3562    history.push(ChatMessage::user(
3563        "You have reached the maximum number of tool iterations. \
3564         Please provide your best answer based on the work completed so far. \
3565         Summarize what you accomplished and what remains to be done."
3566            .to_string(),
3567    ));
3568
3569    let summary_request = crate::providers::ChatRequest {
3570        messages: history,
3571        tools: None, // No tools — force a text response
3572    };
3573    match provider.chat(summary_request, model, temperature).await {
3574        Ok(resp) => {
3575            let text = resp.text.unwrap_or_default();
3576            if text.is_empty() {
3577                anyhow::bail!("Agent exceeded maximum tool iterations ({max_iterations})")
3578            }
3579            Ok(text)
3580        }
3581        Err(e) => {
3582            tracing::warn!(error = %e, "Final summary LLM call failed, bailing");
3583            anyhow::bail!("Agent exceeded maximum tool iterations ({max_iterations})")
3584        }
3585    }
3586}
3587
3588/// Build the tool instruction block for the system prompt so the LLM knows
3589/// how to invoke tools.
3590pub(crate) fn build_tool_instructions(
3591    tools_registry: &[Box<dyn Tool>],
3592    tool_descriptions: Option<&ToolDescriptions>,
3593) -> String {
3594    let mut instructions = String::new();
3595    instructions.push_str("\n## Tool Use Protocol\n\n");
3596    instructions.push_str("To use a tool, wrap a JSON object in <tool_call></tool_call> tags:\n\n");
3597    instructions.push_str("```\n<tool_call>\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n```\n\n");
3598    instructions.push_str(
3599        "CRITICAL: Output actual <tool_call> tags—never describe steps or give examples.\n\n",
3600    );
3601    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");
3602    instructions.push_str("You may use multiple tool calls in a single response. ");
3603    instructions.push_str("After tool execution, results appear in <tool_result> tags. ");
3604    instructions
3605        .push_str("Continue reasoning with the results until you can give a final answer.\n\n");
3606    instructions.push_str("### Available Tools\n\n");
3607
3608    for tool in tools_registry {
3609        let desc = tool_descriptions
3610            .and_then(|td| td.get(tool.name()))
3611            .unwrap_or_else(|| tool.description());
3612        let _ = writeln!(
3613            instructions,
3614            "**{}**: {}\nParameters: `{}`\n",
3615            tool.name(),
3616            desc,
3617            tool.parameters_schema()
3618        );
3619    }
3620
3621    instructions
3622}
3623
3624// ── CLI Entrypoint ───────────────────────────────────────────────────────
3625// Wires up all subsystems (observer, runtime, security, memory, tools,
3626// provider, hardware RAG, peripherals) and enters either single-shot or
3627// interactive REPL mode. The interactive loop manages history compaction
3628// and hard trimming to keep the context window bounded.
3629
3630#[allow(clippy::too_many_lines)]
3631pub async fn run(
3632    config: Config,
3633    message: Option<String>,
3634    provider_override: Option<String>,
3635    model_override: Option<String>,
3636    temperature: f64,
3637    peripheral_overrides: Vec<String>,
3638    interactive: bool,
3639    session_state_file: Option<PathBuf>,
3640    allowed_tools: Option<Vec<String>>,
3641) -> Result<String> {
3642    // ── Wire up agnostic subsystems ──────────────────────────────
3643    let base_observer = observability::create_observer(&config.observability);
3644    let observer: Arc<dyn Observer> = Arc::from(base_observer);
3645    let runtime: Arc<dyn runtime::RuntimeAdapter> =
3646        Arc::from(runtime::create_runtime(&config.runtime)?);
3647    let security = Arc::new(SecurityPolicy::from_config(
3648        &config.autonomy,
3649        &config.workspace_dir,
3650    ));
3651
3652    // ── Memory (the brain) ────────────────────────────────────────
3653    let mem: Arc<dyn Memory> = Arc::from(memory::create_memory_with_storage_and_routes(
3654        &config.memory,
3655        &config.embedding_routes,
3656        Some(&config.storage.provider.config),
3657        &config.workspace_dir,
3658        config.api_key.as_deref(),
3659    )?);
3660    tracing::info!(backend = mem.name(), "Memory initialized");
3661
3662    // ── Peripherals (merge peripheral tools into registry) ─
3663    if !peripheral_overrides.is_empty() {
3664        tracing::info!(
3665            peripherals = ?peripheral_overrides,
3666            "Peripheral overrides from CLI (config boards take precedence)"
3667        );
3668    }
3669
3670    // ── Tools (including memory tools and peripherals) ────────────
3671    let (composio_key, composio_entity_id) = if config.composio.enabled {
3672        (
3673            config.composio.api_key.as_deref(),
3674            Some(config.composio.entity_id.as_str()),
3675        )
3676    } else {
3677        (None, None)
3678    };
3679    let (
3680        mut tools_registry,
3681        delegate_handle,
3682        _reaction_handle,
3683        _channel_map_handle,
3684        _ask_user_handle,
3685        _escalate_handle,
3686    ) = tools::all_tools_with_runtime(
3687        Arc::new(config.clone()),
3688        &security,
3689        runtime,
3690        mem.clone(),
3691        composio_key,
3692        composio_entity_id,
3693        &config.browser,
3694        &config.http_request,
3695        &config.web_fetch,
3696        &config.workspace_dir,
3697        &config.agents,
3698        config.api_key.as_deref(),
3699        &config,
3700        None,
3701    );
3702
3703    let peripheral_tools: Vec<Box<dyn Tool>> =
3704        crate::peripherals::create_peripheral_tools(&config.peripherals).await?;
3705    if !peripheral_tools.is_empty() {
3706        tracing::info!(count = peripheral_tools.len(), "Peripheral tools added");
3707        tools_registry.extend(peripheral_tools);
3708    }
3709
3710    // ── Capability-based tool access control ─────────────────────
3711    // When `allowed_tools` is `Some(list)`, restrict the tool registry to only
3712    // those tools whose name appears in the list. Unknown names are silently
3713    // ignored. When `None`, all tools remain available (backward compatible).
3714    if let Some(ref allow_list) = allowed_tools {
3715        tools_registry.retain(|t| allow_list.iter().any(|name| name == t.name()));
3716        tracing::info!(
3717            allowed = allow_list.len(),
3718            retained = tools_registry.len(),
3719            "Applied capability-based tool access filter"
3720        );
3721    }
3722
3723    // ── Inject Kumiho memory MCP server (first-class, non-fatal) ──
3724    // Kumiho is Construct's only persistent memory store and is wired into every
3725    // non-internal agent unconditionally.  inject_kumiho() is idempotent.
3726    let config = crate::agent::kumiho::inject_kumiho(config, false);
3727
3728    // ── Inject Operator orchestration MCP server (first-class, non-fatal) ──
3729    let config = crate::agent::operator::inject_operator(config, false);
3730
3731    // ── Wire MCP tools (non-fatal) — CLI path ────────────────────
3732    // NOTE: MCP tools are injected after built-in tool filtering
3733    // (filter_primary_agent_tools_or_fail / agent.allowed_tools / agent.denied_tools).
3734    // MCP servers are user-declared external integrations; the built-in allow/deny
3735    // filter is not appropriate for them and would silently drop all MCP tools when
3736    // a restrictive allowlist is configured. Keep this block after any such filter call.
3737    //
3738    // When `deferred_loading` is enabled, MCP tools are NOT added to the registry
3739    // eagerly. Instead, a `tool_search` built-in is registered so the LLM can
3740    // fetch schemas on demand. This reduces context window waste.
3741    let mut deferred_section = String::new();
3742    let mut activated_handle: Option<
3743        std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>,
3744    > = None;
3745    if config.mcp.enabled && !config.mcp.servers.is_empty() {
3746        tracing::info!(
3747            "Initializing MCP client — {} server(s) configured",
3748            config.mcp.servers.len()
3749        );
3750        match crate::tools::McpRegistry::connect_all(&config.mcp.servers).await {
3751            Ok(registry) => {
3752                let registry = std::sync::Arc::new(registry);
3753                if config.mcp.deferred_loading {
3754                    // Hybrid path: eagerly load essential tools, defer the rest.
3755                    //
3756                    // Local models (Ollama) get the minimal local eager set
3757                    // because large tool sets cause hallucinated tool names.
3758                    // Cloud providers get the curated operator-seat eager set
3759                    // (operator essentials + Kumiho memory reflexes); the rest
3760                    // is discoverable via tool_search to keep per-turn input
3761                    // tokens bounded.
3762                    let early_provider = provider_override
3763                        .as_deref()
3764                        .or(config.default_provider.as_deref())
3765                        .unwrap_or("openrouter");
3766                    let is_local_provider = early_provider == "ollama";
3767                    let is_eager_tool = |name: &str| -> bool {
3768                        if is_local_provider {
3769                            crate::tools::mcp_deferred::is_local_model_eager_tool(name)
3770                        } else {
3771                            crate::tools::mcp_deferred::is_operator_seat_eager_tool(name)
3772                        }
3773                    };
3774
3775                    let all_names = registry.tool_names();
3776                    let mut eager_count = 0usize;
3777
3778                    for name in &all_names {
3779                        if is_eager_tool(name) {
3780                            if let Some(def) = registry.get_tool_def(name).await {
3781                                let wrapper: std::sync::Arc<dyn Tool> =
3782                                    std::sync::Arc::new(crate::tools::McpToolWrapper::new(
3783                                        name.clone(),
3784                                        def,
3785                                        std::sync::Arc::clone(&registry),
3786                                    ));
3787                                if let Some(ref handle) = delegate_handle {
3788                                    handle.write().push(std::sync::Arc::clone(&wrapper));
3789                                }
3790                                tools_registry.push(Box::new(crate::tools::ArcToolRef(wrapper)));
3791                                eager_count += 1;
3792                            }
3793                        }
3794                    }
3795
3796                    // Defer everything that wasn't eagerly loaded.
3797                    let deferred_set = crate::tools::DeferredMcpToolSet::from_registry_filtered(
3798                        std::sync::Arc::clone(&registry),
3799                        move |name: &str| {
3800                            if is_local_provider {
3801                                !crate::tools::mcp_deferred::is_local_model_eager_tool(name)
3802                            } else {
3803                                !crate::tools::mcp_deferred::is_operator_seat_eager_tool(name)
3804                            }
3805                        },
3806                    )
3807                    .await;
3808                    tracing::info!(
3809                        "MCP hybrid: {} eager tool(s), {} deferred stub(s) from {} server(s) (local_provider={})",
3810                        eager_count,
3811                        deferred_set.len(),
3812                        registry.server_count(),
3813                        is_local_provider,
3814                    );
3815                    deferred_section =
3816                        crate::tools::mcp_deferred::build_deferred_tools_section(&deferred_set);
3817                    let activated = std::sync::Arc::new(std::sync::Mutex::new(
3818                        crate::tools::ActivatedToolSet::new(),
3819                    ));
3820                    activated_handle = Some(std::sync::Arc::clone(&activated));
3821                    tools_registry.push(Box::new(crate::tools::ToolSearchTool::new(
3822                        deferred_set,
3823                        activated,
3824                    )));
3825                } else {
3826                    // Eager path: register all MCP tools directly
3827                    let names = registry.tool_names();
3828                    let mut registered = 0usize;
3829                    for name in names {
3830                        if let Some(def) = registry.get_tool_def(&name).await {
3831                            let wrapper: std::sync::Arc<dyn Tool> =
3832                                std::sync::Arc::new(crate::tools::McpToolWrapper::new(
3833                                    name,
3834                                    def,
3835                                    std::sync::Arc::clone(&registry),
3836                                ));
3837                            if let Some(ref handle) = delegate_handle {
3838                                handle.write().push(std::sync::Arc::clone(&wrapper));
3839                            }
3840                            tools_registry.push(Box::new(crate::tools::ArcToolRef(wrapper)));
3841                            registered += 1;
3842                        }
3843                    }
3844                    tracing::info!(
3845                        "MCP: {} tool(s) registered from {} server(s)",
3846                        registered,
3847                        registry.server_count()
3848                    );
3849                }
3850            }
3851            Err(e) => {
3852                tracing::error!("MCP registry failed to initialize: {e:#}");
3853            }
3854        }
3855    }
3856
3857    // ── Resolve provider ─────────────────────────────────────────
3858    let mut provider_name = provider_override
3859        .as_deref()
3860        .or(config.default_provider.as_deref())
3861        .unwrap_or("openrouter")
3862        .to_string();
3863
3864    let mut model_name = model_override
3865        .as_deref()
3866        .or(config.default_model.as_deref())
3867        .unwrap_or("anthropic/claude-sonnet-4")
3868        .to_string();
3869
3870    let provider_runtime_options = providers::provider_runtime_options_from_config(&config);
3871
3872    let mut provider: Box<dyn Provider> = providers::create_routed_provider_with_options(
3873        &provider_name,
3874        config.api_key.as_deref(),
3875        config.api_url.as_deref(),
3876        &config.reliability,
3877        &config.model_routes,
3878        &model_name,
3879        &provider_runtime_options,
3880    )?;
3881
3882    let model_switch_callback = get_model_switch_state();
3883
3884    observer.record_event(&ObserverEvent::AgentStart {
3885        provider: provider_name.to_string(),
3886        model: model_name.to_string(),
3887    });
3888
3889    // ── Hardware RAG (datasheet retrieval when peripherals + datasheet_dir) ──
3890    let hardware_rag: Option<crate::rag::HardwareRag> = config
3891        .peripherals
3892        .datasheet_dir
3893        .as_ref()
3894        .filter(|d| !d.trim().is_empty())
3895        .map(|dir| crate::rag::HardwareRag::load(&config.workspace_dir, dir.trim()))
3896        .and_then(Result::ok)
3897        .filter(|r: &crate::rag::HardwareRag| !r.is_empty());
3898    if let Some(ref rag) = hardware_rag {
3899        tracing::info!(chunks = rag.len(), "Hardware RAG loaded");
3900    }
3901
3902    let board_names: Vec<String> = config
3903        .peripherals
3904        .boards
3905        .iter()
3906        .map(|b| b.board.clone())
3907        .collect();
3908
3909    // ── Load locale-aware tool descriptions ────────────────────────
3910    let i18n_locale = config
3911        .locale
3912        .as_deref()
3913        .filter(|s| !s.is_empty())
3914        .map(ToString::to_string)
3915        .unwrap_or_else(crate::i18n::detect_locale);
3916    let i18n_search_dirs = crate::i18n::default_search_dirs(&config.workspace_dir);
3917    let i18n_descs = crate::i18n::ToolDescriptions::load(&i18n_locale, &i18n_search_dirs);
3918
3919    // ── Build system prompt from workspace MD files (OpenClaw framework) ──
3920    let skills = crate::skills::load_skills_with_config(&config.workspace_dir, &config);
3921
3922    // Register skill-defined tools as callable tool specs in the tool registry
3923    // so the LLM can invoke them via native function calling, not just XML prompts.
3924    tools::register_skill_tools(&mut tools_registry, &skills, security.clone());
3925
3926    let mut tool_descs: Vec<(&str, &str)> = vec![
3927        (
3928            "shell",
3929            "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.",
3930        ),
3931        (
3932            "file_read",
3933            "Read file contents. Use when: inspecting project files, configs, logs. Don't use when: a targeted search is enough.",
3934        ),
3935        (
3936            "file_write",
3937            "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.",
3938        ),
3939        (
3940            "memory_store",
3941            "Save to memory. Use when: preserving durable preferences, decisions, key context. Don't use when: information is transient/noisy/sensitive without need.",
3942        ),
3943        (
3944            "memory_recall",
3945            "Search memory. Use when: retrieving prior decisions, user preferences, historical context. Don't use when: answer is already in current context.",
3946        ),
3947        (
3948            "memory_forget",
3949            "Delete a memory entry. Use when: memory is incorrect/stale or explicitly requested for removal. Don't use when: impact is uncertain.",
3950        ),
3951    ];
3952    if matches!(
3953        config.skills.prompt_injection_mode,
3954        crate::config::SkillsPromptInjectionMode::Compact
3955    ) {
3956        tool_descs.push((
3957            "read_skill",
3958            "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.",
3959        ));
3960    }
3961    tool_descs.push((
3962        "cron_add",
3963        "Create a cron job. Supports schedule kinds: cron, at, every; and job types: shell or agent.",
3964    ));
3965    tool_descs.push((
3966        "cron_list",
3967        "List all cron jobs with schedule, status, and metadata.",
3968    ));
3969    tool_descs.push(("cron_remove", "Remove a cron job by job_id."));
3970    tool_descs.push((
3971        "cron_update",
3972        "Patch a cron job (schedule, enabled, command/prompt, model, delivery, session_target).",
3973    ));
3974    tool_descs.push((
3975        "cron_run",
3976        "Force-run a cron job immediately and record a run history entry.",
3977    ));
3978    tool_descs.push(("cron_runs", "Show recent run history for a cron job."));
3979    tool_descs.push((
3980        "screenshot",
3981        "Capture a screenshot of the current screen. Returns file path and base64-encoded PNG. Use when: visual verification, UI inspection, debugging displays.",
3982    ));
3983    tool_descs.push((
3984        "image_info",
3985        "Read image file metadata (format, dimensions, size) and optionally base64-encode it. Use when: inspecting images, preparing visual data for analysis.",
3986    ));
3987    if config.browser.enabled {
3988        tool_descs.push((
3989            "browser_open",
3990            "Open approved HTTPS URLs in system browser (allowlist-only, no scraping)",
3991        ));
3992    }
3993    if config.composio.enabled {
3994        tool_descs.push((
3995            "composio",
3996            "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.",
3997        ));
3998    }
3999    tool_descs.push((
4000        "schedule",
4001        "Manage scheduled tasks (create/list/get/cancel/pause/resume). Supports recurring cron and one-shot delays.",
4002    ));
4003    tool_descs.push((
4004        "model_routing_config",
4005        "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'.",
4006    ));
4007    if !config.agents.is_empty() {
4008        tool_descs.push((
4009            "delegate",
4010            "Delegate a sub-task to a specialized agent. Use when: task needs different model/capability, or to parallelize work.",
4011        ));
4012    }
4013    if config.peripherals.enabled && !config.peripherals.boards.is_empty() {
4014        tool_descs.push((
4015            "gpio_read",
4016            "Read GPIO pin value (0 or 1) on connected hardware (STM32, Arduino). Use when: checking sensor/button state, LED status.",
4017        ));
4018        tool_descs.push((
4019            "gpio_write",
4020            "Set GPIO pin high (1) or low (0) on connected hardware. Use when: turning LED on/off, controlling actuators.",
4021        ));
4022        tool_descs.push((
4023            "arduino_upload",
4024            "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.",
4025        ));
4026        tool_descs.push((
4027            "hardware_memory_map",
4028            "Return flash and RAM address ranges for connected hardware. Use when: user asks for 'upper and lower memory addresses', 'memory map', or 'readable addresses'.",
4029        ));
4030        tool_descs.push((
4031            "hardware_board_info",
4032            "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'.",
4033        ));
4034        tool_descs.push((
4035            "hardware_memory_read",
4036            "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).",
4037        ));
4038        tool_descs.push((
4039            "hardware_capabilities",
4040            "Query connected hardware for reported GPIO pins and LED pin. Use when: user asks what pins are available.",
4041        ));
4042    }
4043    let bootstrap_max_chars = if config.agent.compact_context {
4044        Some(6000)
4045    } else {
4046        None
4047    };
4048    let native_tools = provider.supports_native_tools();
4049    let mut system_prompt = crate::channels::build_system_prompt_with_mode_and_autonomy(
4050        &config.workspace_dir,
4051        &model_name,
4052        &tool_descs,
4053        &skills,
4054        Some(&config.identity),
4055        bootstrap_max_chars,
4056        Some(&config.autonomy),
4057        native_tools,
4058        config.skills.prompt_injection_mode,
4059        config.agent.compact_context,
4060        config.agent.max_system_prompt_chars,
4061    );
4062
4063    // Append structured tool-use instructions with schemas (only for non-native providers)
4064    if !native_tools {
4065        system_prompt.push_str(&build_tool_instructions(&tools_registry, Some(&i18n_descs)));
4066    }
4067
4068    // Append deferred MCP tool names so the LLM knows what is available
4069    if !deferred_section.is_empty() {
4070        system_prompt.push('\n');
4071        system_prompt.push_str(&deferred_section);
4072    }
4073
4074    // Append Kumiho memory session-bootstrap instructions
4075    crate::agent::kumiho::append_kumiho_bootstrap(&mut system_prompt, &config, false);
4076
4077    // Append Operator orchestration instructions
4078    crate::agent::operator::append_operator_prompt(&mut system_prompt, &config, false, &model_name);
4079
4080    // ── Approval manager (supervised mode) ───────────────────────
4081    let approval_manager = if interactive {
4082        let trust_tracker = std::sync::Arc::new(parking_lot::Mutex::new(
4083            crate::trust::TrustTracker::new(config.trust.clone()),
4084        ));
4085        Some(ApprovalManager::from_config(&config.autonomy).with_trust_tracker(trust_tracker))
4086    } else {
4087        None
4088    };
4089    let channel_name = if interactive { "cli" } else { "daemon" };
4090    let memory_session_id = session_state_file.as_deref().and_then(|path| {
4091        let raw = path.to_string_lossy().trim().to_string();
4092        if raw.is_empty() {
4093            None
4094        } else {
4095            Some(format!("cli:{raw}"))
4096        }
4097    });
4098
4099    // ── Execute ──────────────────────────────────────────────────
4100    let start = Instant::now();
4101
4102    let mut final_output = String::new();
4103
4104    // Save the base system prompt before any thinking modifications so
4105    // the interactive loop can restore it between turns.
4106    let base_system_prompt = system_prompt.clone();
4107
4108    if let Some(msg) = message {
4109        // ── Parse thinking directive from user message ─────────
4110        let (thinking_directive, effective_msg) =
4111            match crate::agent::thinking::parse_thinking_directive(&msg) {
4112                Some((level, remaining)) => {
4113                    tracing::info!(thinking_level = ?level, "Thinking directive parsed from message");
4114                    (Some(level), remaining)
4115                }
4116                None => (None, msg.clone()),
4117            };
4118        let thinking_level = crate::agent::thinking::resolve_thinking_level(
4119            thinking_directive,
4120            None,
4121            &config.agent.thinking,
4122        );
4123        let thinking_params = crate::agent::thinking::apply_thinking_level(thinking_level);
4124        let effective_temperature = crate::agent::thinking::clamp_temperature(
4125            temperature + thinking_params.temperature_adjustment,
4126        );
4127
4128        // Prepend thinking system prompt prefix when present.
4129        if let Some(ref prefix) = thinking_params.system_prompt_prefix {
4130            system_prompt = format!("{prefix}\n\n{system_prompt}");
4131        }
4132
4133        // Auto-save user message to memory (skip short/trivial messages)
4134        if config.memory.auto_save
4135            && effective_msg.chars().count() >= AUTOSAVE_MIN_MESSAGE_CHARS
4136            && !memory::should_skip_autosave_content(&effective_msg)
4137        {
4138            let user_key = autosave_memory_key("user_msg");
4139            let _ = mem
4140                .store(
4141                    &user_key,
4142                    &effective_msg,
4143                    MemoryCategory::Conversation,
4144                    memory_session_id.as_deref(),
4145                )
4146                .await;
4147        }
4148
4149        // Inject memory + hardware RAG context into user message
4150        let mem_context = build_context(
4151            mem.as_ref(),
4152            &effective_msg,
4153            config.memory.min_relevance_score,
4154            memory_session_id.as_deref(),
4155        )
4156        .await;
4157        let rag_limit = if config.agent.compact_context { 2 } else { 5 };
4158        let hw_context = hardware_rag
4159            .as_ref()
4160            .map(|r| build_hardware_context(r, &effective_msg, &board_names, rag_limit))
4161            .unwrap_or_default();
4162        let context = format!("{mem_context}{hw_context}");
4163        let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z");
4164        let enriched = if context.is_empty() {
4165            format!("[{now}] {effective_msg}")
4166        } else {
4167            format!("{context}[{now}] {effective_msg}")
4168        };
4169
4170        let mut history = vec![
4171            ChatMessage::system(&system_prompt),
4172            ChatMessage::user(&enriched),
4173        ];
4174
4175        // Prune history for token efficiency (when enabled).
4176        if config.agent.history_pruning.enabled {
4177            let _stats = crate::agent::history_pruner::prune_history(
4178                &mut history,
4179                &config.agent.history_pruning,
4180            );
4181        }
4182
4183        // Compute per-turn excluded MCP tools from tool_filter_groups.
4184        let excluded_tools = compute_excluded_mcp_tools(
4185            &tools_registry,
4186            &config.agent.tool_filter_groups,
4187            &effective_msg,
4188        );
4189
4190        #[allow(unused_assignments)]
4191        let mut response = String::new();
4192        loop {
4193            match run_tool_call_loop(
4194                provider.as_ref(),
4195                &mut history,
4196                &tools_registry,
4197                observer.as_ref(),
4198                &provider_name,
4199                &model_name,
4200                effective_temperature,
4201                false,
4202                approval_manager.as_ref(),
4203                channel_name,
4204                None,
4205                &config.multimodal,
4206                effective_max_tool_iterations(&config),
4207                None,
4208                None,
4209                None,
4210                &excluded_tools,
4211                &config.agent.tool_call_dedup_exempt,
4212                activated_handle.as_ref(),
4213                Some(model_switch_callback.clone()),
4214                &config.pacing,
4215                config.agent.max_tool_result_chars,
4216                config.agent.max_context_tokens,
4217                None, // shared_budget
4218            )
4219            .await
4220            {
4221                Ok(resp) => {
4222                    response = resp;
4223                    break;
4224                }
4225                Err(e) => {
4226                    if let Some((new_provider, new_model)) = is_model_switch_requested(&e) {
4227                        tracing::info!(
4228                            "Model switch requested, switching from {} {} to {} {}",
4229                            provider_name,
4230                            model_name,
4231                            new_provider,
4232                            new_model
4233                        );
4234
4235                        provider = providers::create_routed_provider_with_options(
4236                            &new_provider,
4237                            config.api_key.as_deref(),
4238                            config.api_url.as_deref(),
4239                            &config.reliability,
4240                            &config.model_routes,
4241                            &new_model,
4242                            &provider_runtime_options,
4243                        )?;
4244
4245                        provider_name = new_provider;
4246                        model_name = new_model;
4247
4248                        clear_model_switch_request();
4249
4250                        observer.record_event(&ObserverEvent::AgentStart {
4251                            provider: provider_name.to_string(),
4252                            model: model_name.to_string(),
4253                        });
4254
4255                        continue;
4256                    }
4257                    return Err(e);
4258                }
4259            }
4260        }
4261
4262        // After successful multi-step execution, attempt autonomous skill creation.
4263        #[cfg(feature = "skill-creation")]
4264        if config.skills.skill_creation.enabled {
4265            let tool_calls = crate::skills::creator::extract_tool_calls_from_history(&history);
4266            if tool_calls.len() >= 2 {
4267                let creator = crate::skills::creator::SkillCreator::new(
4268                    config.workspace_dir.clone(),
4269                    config.skills.skill_creation.clone(),
4270                );
4271                match creator.create_from_execution(&msg, &tool_calls, None).await {
4272                    Ok(Some(slug)) => {
4273                        tracing::info!(slug, "Auto-created skill from execution");
4274                    }
4275                    Ok(None) => {
4276                        tracing::debug!("Skill creation skipped (duplicate or disabled)");
4277                    }
4278                    Err(e) => tracing::warn!("Skill creation failed: {e}"),
4279                }
4280            }
4281        }
4282        final_output = response.clone();
4283        println!("{response}");
4284        observer.record_event(&ObserverEvent::TurnComplete);
4285    } else {
4286        println!("🦀 Construct Interactive Mode");
4287        println!("Type /help for commands.\n");
4288        let cli = crate::channels::CliChannel::new();
4289
4290        // Persistent conversation history across turns
4291        let mut history = if let Some(path) = session_state_file.as_deref() {
4292            load_interactive_session_history(path, &system_prompt)?
4293        } else {
4294            vec![ChatMessage::system(&system_prompt)]
4295        };
4296
4297        loop {
4298            print!("> ");
4299            let _ = std::io::stdout().flush();
4300
4301            // Read raw bytes to avoid UTF-8 validation errors when PTY
4302            // transport splits multi-byte characters at frame boundaries
4303            // (e.g. CJK input with spaces over kubectl exec / SSH).
4304            let mut raw = Vec::new();
4305            match std::io::BufRead::read_until(&mut std::io::stdin().lock(), b'\n', &mut raw) {
4306                Ok(0) => break,
4307                Ok(_) => {}
4308                Err(e) => {
4309                    eprintln!("\nError reading input: {e}\n");
4310                    break;
4311                }
4312            }
4313            let input = String::from_utf8_lossy(&raw).into_owned();
4314
4315            let user_input = input.trim().to_string();
4316            if user_input.is_empty() {
4317                continue;
4318            }
4319            match user_input.as_str() {
4320                "/quit" | "/exit" => break,
4321                "/help" => {
4322                    println!("Available commands:");
4323                    println!("  /help             Show this help message");
4324                    println!("  /clear /new       Clear conversation history");
4325                    println!("  /quit /exit       Exit interactive mode");
4326                    println!(
4327                        "  /think:<level>    Set reasoning depth (off|minimal|low|medium|high|max)\n"
4328                    );
4329                    continue;
4330                }
4331                "/clear" | "/new" => {
4332                    println!(
4333                        "This will clear the current conversation and delete all session memory."
4334                    );
4335                    println!("Core memories (long-term facts/preferences) will be preserved.");
4336                    print!("Continue? [y/N] ");
4337                    let _ = std::io::stdout().flush();
4338
4339                    let mut confirm_raw = Vec::new();
4340                    if std::io::BufRead::read_until(
4341                        &mut std::io::stdin().lock(),
4342                        b'\n',
4343                        &mut confirm_raw,
4344                    )
4345                    .is_err()
4346                    {
4347                        continue;
4348                    }
4349                    let confirm = String::from_utf8_lossy(&confirm_raw);
4350                    if !matches!(confirm.trim().to_lowercase().as_str(), "y" | "yes") {
4351                        println!("Cancelled.\n");
4352                        continue;
4353                    }
4354
4355                    history.clear();
4356                    history.push(ChatMessage::system(&system_prompt));
4357                    // Clear conversation and daily memory
4358                    let mut cleared = 0;
4359                    for category in [MemoryCategory::Conversation, MemoryCategory::Daily] {
4360                        let entries = mem.list(Some(&category), None).await.unwrap_or_default();
4361                        for entry in entries {
4362                            if mem.forget(&entry.key).await.unwrap_or(false) {
4363                                cleared += 1;
4364                            }
4365                        }
4366                    }
4367                    if cleared > 0 {
4368                        println!("Conversation cleared ({cleared} memory entries removed).\n");
4369                    } else {
4370                        println!("Conversation cleared.\n");
4371                    }
4372                    if let Some(path) = session_state_file.as_deref() {
4373                        save_interactive_session_history(path, &history)?;
4374                    }
4375                    continue;
4376                }
4377                _ => {}
4378            }
4379
4380            // ── Parse thinking directive from interactive input ───
4381            let (thinking_directive, effective_input) =
4382                match crate::agent::thinking::parse_thinking_directive(&user_input) {
4383                    Some((level, remaining)) => {
4384                        tracing::info!(thinking_level = ?level, "Thinking directive parsed");
4385                        (Some(level), remaining)
4386                    }
4387                    None => (None, user_input.clone()),
4388                };
4389            let thinking_level = crate::agent::thinking::resolve_thinking_level(
4390                thinking_directive,
4391                None,
4392                &config.agent.thinking,
4393            );
4394            let thinking_params = crate::agent::thinking::apply_thinking_level(thinking_level);
4395            let turn_temperature = crate::agent::thinking::clamp_temperature(
4396                temperature + thinking_params.temperature_adjustment,
4397            );
4398
4399            // For non-Medium levels, temporarily patch the system prompt with prefix.
4400            let turn_system_prompt;
4401            if let Some(ref prefix) = thinking_params.system_prompt_prefix {
4402                turn_system_prompt = format!("{prefix}\n\n{system_prompt}");
4403                // Update the system message in history for this turn.
4404                if let Some(sys_msg) = history.first_mut() {
4405                    if sys_msg.role == "system" {
4406                        sys_msg.content = turn_system_prompt.clone();
4407                    }
4408                }
4409            }
4410
4411            // Auto-save conversation turns (skip short/trivial messages)
4412            if config.memory.auto_save
4413                && effective_input.chars().count() >= AUTOSAVE_MIN_MESSAGE_CHARS
4414                && !memory::should_skip_autosave_content(&effective_input)
4415            {
4416                let user_key = autosave_memory_key("user_msg");
4417                let _ = mem
4418                    .store(
4419                        &user_key,
4420                        &effective_input,
4421                        MemoryCategory::Conversation,
4422                        memory_session_id.as_deref(),
4423                    )
4424                    .await;
4425            }
4426
4427            // Inject memory + hardware RAG context into user message
4428            let mem_context = build_context(
4429                mem.as_ref(),
4430                &effective_input,
4431                config.memory.min_relevance_score,
4432                memory_session_id.as_deref(),
4433            )
4434            .await;
4435            let rag_limit = if config.agent.compact_context { 2 } else { 5 };
4436            let hw_context = hardware_rag
4437                .as_ref()
4438                .map(|r| build_hardware_context(r, &effective_input, &board_names, rag_limit))
4439                .unwrap_or_default();
4440            let context = format!("{mem_context}{hw_context}");
4441            let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z");
4442            let enriched = if context.is_empty() {
4443                format!("[{now}] {effective_input}")
4444            } else {
4445                format!("{context}[{now}] {effective_input}")
4446            };
4447
4448            history.push(ChatMessage::user(&enriched));
4449
4450            // Compute per-turn excluded MCP tools from tool_filter_groups.
4451            let excluded_tools = compute_excluded_mcp_tools(
4452                &tools_registry,
4453                &config.agent.tool_filter_groups,
4454                &effective_input,
4455            );
4456
4457            // Set up streaming channel so tool progress and response
4458            // content are printed progressively instead of buffered.
4459            let (delta_tx, mut delta_rx) = tokio::sync::mpsc::channel::<DraftEvent>(64);
4460            let content_was_streamed =
4461                std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
4462            let content_streamed_flag = content_was_streamed.clone();
4463            let is_tty = std::io::IsTerminal::is_terminal(&std::io::stderr());
4464
4465            let consumer_handle = tokio::spawn(async move {
4466                use std::io::Write;
4467                while let Some(event) = delta_rx.recv().await {
4468                    match event {
4469                        DraftEvent::Clear => {
4470                            let _ = writeln!(std::io::stderr());
4471                        }
4472                        DraftEvent::Progress(text) => {
4473                            if is_tty {
4474                                let _ = write!(std::io::stderr(), "\x1b[2m{text}\x1b[0m");
4475                            } else {
4476                                let _ = write!(std::io::stderr(), "{text}");
4477                            }
4478                            let _ = std::io::stderr().flush();
4479                        }
4480                        DraftEvent::Content(text) => {
4481                            content_streamed_flag.store(true, std::sync::atomic::Ordering::Relaxed);
4482                            print!("{text}");
4483                            let _ = std::io::stdout().flush();
4484                        }
4485                    }
4486                }
4487            });
4488
4489            // Ctrl+C cancels the in-flight turn instead of killing the process.
4490            let cancel_token = CancellationToken::new();
4491            let cancel_token_clone = cancel_token.clone();
4492            let ctrlc_handle = tokio::spawn(async move {
4493                if tokio::signal::ctrl_c().await.is_ok() {
4494                    cancel_token_clone.cancel();
4495                }
4496            });
4497
4498            let response = loop {
4499                match run_tool_call_loop(
4500                    provider.as_ref(),
4501                    &mut history,
4502                    &tools_registry,
4503                    observer.as_ref(),
4504                    &provider_name,
4505                    &model_name,
4506                    turn_temperature,
4507                    true,
4508                    approval_manager.as_ref(),
4509                    channel_name,
4510                    None,
4511                    &config.multimodal,
4512                    effective_max_tool_iterations(&config),
4513                    Some(cancel_token.clone()),
4514                    Some(delta_tx.clone()),
4515                    None,
4516                    &excluded_tools,
4517                    &config.agent.tool_call_dedup_exempt,
4518                    activated_handle.as_ref(),
4519                    Some(model_switch_callback.clone()),
4520                    &config.pacing,
4521                    config.agent.max_tool_result_chars,
4522                    config.agent.max_context_tokens,
4523                    None, // shared_budget
4524                )
4525                .await
4526                {
4527                    Ok(resp) => break resp,
4528                    Err(e) => {
4529                        if is_tool_loop_cancelled(&e) {
4530                            eprintln!("\n\x1b[2m(cancelled)\x1b[0m");
4531                            break String::new();
4532                        }
4533                        if let Some((new_provider, new_model)) = is_model_switch_requested(&e) {
4534                            tracing::info!(
4535                                "Model switch requested, switching from {} {} to {} {}",
4536                                provider_name,
4537                                model_name,
4538                                new_provider,
4539                                new_model
4540                            );
4541
4542                            provider = providers::create_routed_provider_with_options(
4543                                &new_provider,
4544                                config.api_key.as_deref(),
4545                                config.api_url.as_deref(),
4546                                &config.reliability,
4547                                &config.model_routes,
4548                                &new_model,
4549                                &provider_runtime_options,
4550                            )?;
4551
4552                            provider_name = new_provider;
4553                            model_name = new_model;
4554
4555                            clear_model_switch_request();
4556
4557                            observer.record_event(&ObserverEvent::AgentStart {
4558                                provider: provider_name.to_string(),
4559                                model: model_name.to_string(),
4560                            });
4561
4562                            continue;
4563                        }
4564                        // Context overflow recovery: compress and retry
4565                        if crate::providers::reliable::is_context_window_exceeded(&e) {
4566                            tracing::warn!(
4567                                "Context overflow in interactive loop, attempting recovery"
4568                            );
4569                            let mut compressor =
4570                                crate::agent::context_compressor::ContextCompressor::new(
4571                                    config.agent.context_compression.clone(),
4572                                    config.agent.max_context_tokens,
4573                                )
4574                                .with_memory(mem.clone());
4575                            let error_msg = format!("{e}");
4576                            match compressor
4577                                .compress_on_error(
4578                                    &mut history,
4579                                    provider.as_ref(),
4580                                    &model_name,
4581                                    &error_msg,
4582                                )
4583                                .await
4584                            {
4585                                Ok(true) => {
4586                                    tracing::info!(
4587                                        "Context recovered via compression, retrying turn"
4588                                    );
4589                                    continue;
4590                                }
4591                                Ok(false) => {
4592                                    tracing::warn!("Compression ran but couldn't reduce enough");
4593                                }
4594                                Err(compress_err) => {
4595                                    tracing::warn!(
4596                                        error = %compress_err,
4597                                        "Compression failed during recovery"
4598                                    );
4599                                }
4600                            }
4601                        }
4602
4603                        eprintln!("\nError: {e}\n");
4604                        break String::new();
4605                    }
4606                }
4607            };
4608
4609            // Clean up: stop the Ctrl+C listener and flush streaming events.
4610            ctrlc_handle.abort();
4611            drop(delta_tx);
4612            let _ = consumer_handle.await;
4613
4614            final_output = response.clone();
4615            if content_was_streamed.load(std::sync::atomic::Ordering::Relaxed) {
4616                println!();
4617            } else if let Err(e) = crate::channels::Channel::send(
4618                &cli,
4619                &crate::channels::traits::SendMessage::new(format!("\n{response}\n"), "user"),
4620            )
4621            .await
4622            {
4623                eprintln!("\nError sending CLI response: {e}\n");
4624            }
4625            observer.record_event(&ObserverEvent::TurnComplete);
4626
4627            // Context compression before hard trimming to preserve long-context signal.
4628            {
4629                let compressor = crate::agent::context_compressor::ContextCompressor::new(
4630                    config.agent.context_compression.clone(),
4631                    config.agent.max_context_tokens,
4632                )
4633                .with_memory(mem.clone());
4634                match compressor
4635                    .compress_if_needed(&mut history, provider.as_ref(), &model_name)
4636                    .await
4637                {
4638                    Ok(result) if result.compressed => {
4639                        tracing::info!(
4640                            passes = result.passes_used,
4641                            before = result.tokens_before,
4642                            after = result.tokens_after,
4643                            "Context compression complete"
4644                        );
4645                    }
4646                    Ok(_) => {} // No compression needed
4647                    Err(e) => {
4648                        tracing::warn!(
4649                            error = %e,
4650                            "Context compression failed, falling back to history trim"
4651                        );
4652                        trim_history(&mut history, config.agent.max_history_messages / 2);
4653                    }
4654                }
4655            }
4656
4657            // Hard cap as a safety net.
4658            trim_history(&mut history, config.agent.max_history_messages);
4659
4660            // Restore base system prompt (remove per-turn thinking prefix).
4661            if thinking_params.system_prompt_prefix.is_some() {
4662                if let Some(sys_msg) = history.first_mut() {
4663                    if sys_msg.role == "system" {
4664                        sys_msg.content.clone_from(&base_system_prompt);
4665                    }
4666                }
4667            }
4668
4669            if let Some(path) = session_state_file.as_deref() {
4670                save_interactive_session_history(path, &history)?;
4671            }
4672        }
4673    }
4674
4675    let duration = start.elapsed();
4676    observer.record_event(&ObserverEvent::AgentEnd {
4677        provider: provider_name.to_string(),
4678        model: model_name.to_string(),
4679        duration,
4680        tokens_used: None,
4681        cost_usd: None,
4682    });
4683
4684    Ok(final_output)
4685}
4686
4687/// Process a single message through the full agent (with tools, peripherals, memory).
4688/// Used by channels (Telegram, Discord, etc.) to enable hardware and tool use.
4689pub async fn process_message(
4690    config: Config,
4691    message: &str,
4692    session_id: Option<&str>,
4693) -> Result<String> {
4694    let observer: Arc<dyn Observer> =
4695        Arc::from(observability::create_observer(&config.observability));
4696    let runtime: Arc<dyn runtime::RuntimeAdapter> =
4697        Arc::from(runtime::create_runtime(&config.runtime)?);
4698    let security = Arc::new(SecurityPolicy::from_config(
4699        &config.autonomy,
4700        &config.workspace_dir,
4701    ));
4702    let approval_manager = ApprovalManager::for_non_interactive(&config.autonomy);
4703    let mem: Arc<dyn Memory> = Arc::from(memory::create_memory_with_storage_and_routes(
4704        &config.memory,
4705        &config.embedding_routes,
4706        Some(&config.storage.provider.config),
4707        &config.workspace_dir,
4708        config.api_key.as_deref(),
4709    )?);
4710
4711    let (composio_key, composio_entity_id) = if config.composio.enabled {
4712        (
4713            config.composio.api_key.as_deref(),
4714            Some(config.composio.entity_id.as_str()),
4715        )
4716    } else {
4717        (None, None)
4718    };
4719    let (
4720        mut tools_registry,
4721        delegate_handle_pm,
4722        _reaction_handle_pm,
4723        _channel_map_handle_pm,
4724        _ask_user_handle_pm,
4725        _escalate_handle_pm,
4726    ) = tools::all_tools_with_runtime(
4727        Arc::new(config.clone()),
4728        &security,
4729        runtime,
4730        mem.clone(),
4731        composio_key,
4732        composio_entity_id,
4733        &config.browser,
4734        &config.http_request,
4735        &config.web_fetch,
4736        &config.workspace_dir,
4737        &config.agents,
4738        config.api_key.as_deref(),
4739        &config,
4740        None,
4741    );
4742    let peripheral_tools: Vec<Box<dyn Tool>> =
4743        crate::peripherals::create_peripheral_tools(&config.peripherals).await?;
4744    tools_registry.extend(peripheral_tools);
4745
4746    // ── Inject Kumiho memory MCP server (first-class, non-fatal) ──
4747    let config = crate::agent::kumiho::inject_kumiho(config, false);
4748
4749    // ── Inject Operator orchestration MCP server (first-class, non-fatal) ──
4750    let config = crate::agent::operator::inject_operator(config, false);
4751
4752    // ── Wire MCP tools (non-fatal) — process_message path ────────
4753    // NOTE: Same ordering contract as the CLI path above — MCP tools must be
4754    // injected after filter_primary_agent_tools_or_fail (or equivalent built-in
4755    // tool allow/deny filtering) to avoid MCP tools being silently dropped.
4756    let mut deferred_section = String::new();
4757    let mut activated_handle_pm: Option<
4758        std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>,
4759    > = None;
4760    if config.mcp.enabled && !config.mcp.servers.is_empty() {
4761        tracing::info!(
4762            "Initializing MCP client — {} server(s) configured",
4763            config.mcp.servers.len()
4764        );
4765        match crate::tools::McpRegistry::connect_all(&config.mcp.servers).await {
4766            Ok(registry) => {
4767                let registry = std::sync::Arc::new(registry);
4768                if config.mcp.deferred_loading {
4769                    // Hybrid: eagerly load operator tools, defer the rest.
4770                    let operator_prefix =
4771                        format!("{}__", crate::agent::operator::OPERATOR_SERVER_NAME);
4772                    let all_names = registry.tool_names();
4773                    let mut eager_count = 0usize;
4774
4775                    let is_eager = |name: &str| -> bool {
4776                        name.starts_with(&operator_prefix)
4777                            || name == "kumiho-memory__kumiho_memory_engage"
4778                            || name == "kumiho-memory__kumiho_memory_reflect"
4779                    };
4780
4781                    for name in &all_names {
4782                        if is_eager(name) {
4783                            if let Some(def) = registry.get_tool_def(name).await {
4784                                let wrapper: std::sync::Arc<dyn Tool> =
4785                                    std::sync::Arc::new(crate::tools::McpToolWrapper::new(
4786                                        name.clone(),
4787                                        def,
4788                                        std::sync::Arc::clone(&registry),
4789                                    ));
4790                                if let Some(ref handle) = delegate_handle_pm {
4791                                    handle.write().push(std::sync::Arc::clone(&wrapper));
4792                                }
4793                                tools_registry.push(Box::new(crate::tools::ArcToolRef(wrapper)));
4794                                eager_count += 1;
4795                            }
4796                        }
4797                    }
4798
4799                    let operator_pfx = operator_prefix.clone();
4800                    let deferred_set = crate::tools::DeferredMcpToolSet::from_registry_filtered(
4801                        std::sync::Arc::clone(&registry),
4802                        move |name: &str| {
4803                            !(name.starts_with(&operator_pfx)
4804                                || name == "kumiho-memory__kumiho_memory_engage"
4805                                || name == "kumiho-memory__kumiho_memory_reflect")
4806                        },
4807                    )
4808                    .await;
4809                    tracing::info!(
4810                        "MCP hybrid: {} eager tool(s) (operator + kumiho reflexes), {} deferred stub(s) from {} server(s)",
4811                        eager_count,
4812                        deferred_set.len(),
4813                        registry.server_count()
4814                    );
4815                    deferred_section =
4816                        crate::tools::mcp_deferred::build_deferred_tools_section(&deferred_set);
4817                    let activated = std::sync::Arc::new(std::sync::Mutex::new(
4818                        crate::tools::ActivatedToolSet::new(),
4819                    ));
4820                    activated_handle_pm = Some(std::sync::Arc::clone(&activated));
4821                    tools_registry.push(Box::new(crate::tools::ToolSearchTool::new(
4822                        deferred_set,
4823                        activated,
4824                    )));
4825                } else {
4826                    let names = registry.tool_names();
4827                    let mut registered = 0usize;
4828                    for name in names {
4829                        if let Some(def) = registry.get_tool_def(&name).await {
4830                            let wrapper: std::sync::Arc<dyn Tool> =
4831                                std::sync::Arc::new(crate::tools::McpToolWrapper::new(
4832                                    name,
4833                                    def,
4834                                    std::sync::Arc::clone(&registry),
4835                                ));
4836                            if let Some(ref handle) = delegate_handle_pm {
4837                                handle.write().push(std::sync::Arc::clone(&wrapper));
4838                            }
4839                            tools_registry.push(Box::new(crate::tools::ArcToolRef(wrapper)));
4840                            registered += 1;
4841                        }
4842                    }
4843                    tracing::info!(
4844                        "MCP: {} tool(s) registered from {} server(s)",
4845                        registered,
4846                        registry.server_count()
4847                    );
4848                }
4849            }
4850            Err(e) => {
4851                tracing::error!("MCP registry failed to initialize: {e:#}");
4852            }
4853        }
4854    }
4855
4856    let provider_name = config.default_provider.as_deref().unwrap_or("openrouter");
4857    let model_name = config
4858        .default_model
4859        .clone()
4860        .unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into());
4861    let provider_runtime_options = providers::provider_runtime_options_from_config(&config);
4862    let provider: Box<dyn Provider> = providers::create_routed_provider_with_options(
4863        provider_name,
4864        config.api_key.as_deref(),
4865        config.api_url.as_deref(),
4866        &config.reliability,
4867        &config.model_routes,
4868        &model_name,
4869        &provider_runtime_options,
4870    )?;
4871
4872    let hardware_rag: Option<crate::rag::HardwareRag> = config
4873        .peripherals
4874        .datasheet_dir
4875        .as_ref()
4876        .filter(|d| !d.trim().is_empty())
4877        .map(|dir| crate::rag::HardwareRag::load(&config.workspace_dir, dir.trim()))
4878        .and_then(Result::ok)
4879        .filter(|r: &crate::rag::HardwareRag| !r.is_empty());
4880    let board_names: Vec<String> = config
4881        .peripherals
4882        .boards
4883        .iter()
4884        .map(|b| b.board.clone())
4885        .collect();
4886
4887    // ── Load locale-aware tool descriptions ────────────────────────
4888    let i18n_locale = config
4889        .locale
4890        .as_deref()
4891        .filter(|s| !s.is_empty())
4892        .map(ToString::to_string)
4893        .unwrap_or_else(crate::i18n::detect_locale);
4894    let i18n_search_dirs = crate::i18n::default_search_dirs(&config.workspace_dir);
4895    let i18n_descs = crate::i18n::ToolDescriptions::load(&i18n_locale, &i18n_search_dirs);
4896
4897    let skills = crate::skills::load_skills_with_config(&config.workspace_dir, &config);
4898
4899    // Register skill-defined tools as callable tool specs (process_message path).
4900    tools::register_skill_tools(&mut tools_registry, &skills, security.clone());
4901
4902    let mut tool_descs: Vec<(&str, &str)> = vec![
4903        ("shell", "Execute terminal commands."),
4904        ("file_read", "Read file contents."),
4905        ("file_write", "Write file contents."),
4906        ("memory_store", "Save to memory."),
4907        ("memory_recall", "Search memory."),
4908        ("memory_forget", "Delete a memory entry."),
4909        (
4910            "model_routing_config",
4911            "Configure default model, scenario routing, and delegate agents.",
4912        ),
4913        ("screenshot", "Capture a screenshot."),
4914        ("image_info", "Read image metadata."),
4915    ];
4916    if matches!(
4917        config.skills.prompt_injection_mode,
4918        crate::config::SkillsPromptInjectionMode::Compact
4919    ) {
4920        tool_descs.push((
4921            "read_skill",
4922            "Load the full source for an available skill by name.",
4923        ));
4924    }
4925    if config.browser.enabled {
4926        tool_descs.push(("browser_open", "Open approved URLs in browser."));
4927    }
4928    if config.composio.enabled {
4929        tool_descs.push(("composio", "Execute actions on 1000+ apps via Composio."));
4930    }
4931    if config.peripherals.enabled && !config.peripherals.boards.is_empty() {
4932        tool_descs.push(("gpio_read", "Read GPIO pin value on connected hardware."));
4933        tool_descs.push((
4934            "gpio_write",
4935            "Set GPIO pin high or low on connected hardware.",
4936        ));
4937        tool_descs.push((
4938            "arduino_upload",
4939            "Upload Arduino sketch. Use for 'make a heart', custom patterns. You write full .ino code; Construct uploads it.",
4940        ));
4941        tool_descs.push((
4942            "hardware_memory_map",
4943            "Return flash and RAM address ranges. Use when user asks for memory addresses or memory map.",
4944        ));
4945        tool_descs.push((
4946            "hardware_board_info",
4947            "Return full board info (chip, architecture, memory map). Use when user asks for board info, what board, connected hardware, or chip info.",
4948        ));
4949        tool_descs.push((
4950            "hardware_memory_read",
4951            "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.",
4952        ));
4953        tool_descs.push((
4954            "hardware_capabilities",
4955            "Query connected hardware for reported GPIO pins and LED pin. Use when user asks what pins are available.",
4956        ));
4957    }
4958
4959    // Filter out tools excluded for non-CLI channels (gateway counts as non-CLI).
4960    // Skip when autonomy is `Full` — full-autonomy agents keep all tools.
4961    if config.autonomy.level != AutonomyLevel::Full {
4962        let excluded = &config.autonomy.non_cli_excluded_tools;
4963        if !excluded.is_empty() {
4964            tool_descs.retain(|(name, _)| !excluded.iter().any(|ex| ex == name));
4965        }
4966    }
4967
4968    let bootstrap_max_chars = if config.agent.compact_context {
4969        Some(6000)
4970    } else {
4971        None
4972    };
4973    let native_tools = provider.supports_native_tools();
4974    let mut system_prompt = crate::channels::build_system_prompt_with_mode_and_autonomy(
4975        &config.workspace_dir,
4976        &model_name,
4977        &tool_descs,
4978        &skills,
4979        Some(&config.identity),
4980        bootstrap_max_chars,
4981        Some(&config.autonomy),
4982        native_tools,
4983        config.skills.prompt_injection_mode,
4984        config.agent.compact_context,
4985        config.agent.max_system_prompt_chars,
4986    );
4987    if !native_tools {
4988        system_prompt.push_str(&build_tool_instructions(&tools_registry, Some(&i18n_descs)));
4989    }
4990    if !deferred_section.is_empty() {
4991        system_prompt.push('\n');
4992        system_prompt.push_str(&deferred_section);
4993    }
4994
4995    // Append Kumiho memory session-bootstrap instructions
4996    crate::agent::kumiho::append_kumiho_bootstrap(&mut system_prompt, &config, false);
4997
4998    // Append Operator orchestration instructions
4999    crate::agent::operator::append_operator_prompt(&mut system_prompt, &config, false, &model_name);
5000
5001    // ── Parse thinking directive from user message ─────────────
5002    let (thinking_directive, effective_message) =
5003        match crate::agent::thinking::parse_thinking_directive(message) {
5004            Some((level, remaining)) => {
5005                tracing::info!(thinking_level = ?level, "Thinking directive parsed from message");
5006                (Some(level), remaining)
5007            }
5008            None => (None, message.to_string()),
5009        };
5010    let thinking_level = crate::agent::thinking::resolve_thinking_level(
5011        thinking_directive,
5012        None,
5013        &config.agent.thinking,
5014    );
5015    let thinking_params = crate::agent::thinking::apply_thinking_level(thinking_level);
5016    let effective_temperature = crate::agent::thinking::clamp_temperature(
5017        config.default_temperature + thinking_params.temperature_adjustment,
5018    );
5019
5020    // Prepend thinking system prompt prefix when present.
5021    if let Some(ref prefix) = thinking_params.system_prompt_prefix {
5022        system_prompt = format!("{prefix}\n\n{system_prompt}");
5023    }
5024
5025    let effective_msg_ref = effective_message.as_str();
5026    let mem_context = build_context(
5027        mem.as_ref(),
5028        effective_msg_ref,
5029        config.memory.min_relevance_score,
5030        session_id,
5031    )
5032    .await;
5033    let rag_limit = if config.agent.compact_context { 2 } else { 5 };
5034    let hw_context = hardware_rag
5035        .as_ref()
5036        .map(|r| build_hardware_context(r, effective_msg_ref, &board_names, rag_limit))
5037        .unwrap_or_default();
5038    let context = format!("{mem_context}{hw_context}");
5039    let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z");
5040    let enriched = if context.is_empty() {
5041        format!("[{now}] {effective_message}")
5042    } else {
5043        format!("{context}[{now}] {effective_message}")
5044    };
5045
5046    let mut history = vec![
5047        ChatMessage::system(&system_prompt),
5048        ChatMessage::user(&enriched),
5049    ];
5050    let mut excluded_tools = compute_excluded_mcp_tools(
5051        &tools_registry,
5052        &config.agent.tool_filter_groups,
5053        effective_msg_ref,
5054    );
5055    if config.autonomy.level != AutonomyLevel::Full {
5056        excluded_tools.extend(config.autonomy.non_cli_excluded_tools.iter().cloned());
5057    }
5058
5059    agent_turn(
5060        provider.as_ref(),
5061        &mut history,
5062        &tools_registry,
5063        observer.as_ref(),
5064        provider_name,
5065        &model_name,
5066        effective_temperature,
5067        true,
5068        "daemon",
5069        None,
5070        &config.multimodal,
5071        config.agent.max_tool_iterations,
5072        Some(&approval_manager),
5073        &excluded_tools,
5074        &config.agent.tool_call_dedup_exempt,
5075        activated_handle_pm.as_ref(),
5076        None,
5077    )
5078    .await
5079}
5080
5081#[cfg(test)]
5082mod tests {
5083    use super::{
5084        emergency_history_trim, estimate_history_tokens, fast_trim_tool_results,
5085        load_interactive_session_history, save_interactive_session_history, truncate_tool_result,
5086    };
5087    use crate::agent::history::{DEFAULT_MAX_HISTORY_MESSAGES, InteractiveSessionState};
5088    use crate::agent::tool_execution::execute_one_tool;
5089    use crate::providers::ChatMessage;
5090    use tempfile::tempdir;
5091
5092    // ── truncate_tool_result tests ────────────────────────────────
5093
5094    #[test]
5095    fn truncate_tool_result_short_passthrough() {
5096        let output = "short output";
5097        assert_eq!(truncate_tool_result(output, 100), output);
5098    }
5099
5100    #[test]
5101    fn truncate_tool_result_exact_boundary() {
5102        let output = "a".repeat(100);
5103        assert_eq!(truncate_tool_result(&output, 100), output);
5104    }
5105
5106    #[test]
5107    fn truncate_tool_result_zero_disables() {
5108        let output = "a".repeat(200_000);
5109        assert_eq!(truncate_tool_result(&output, 0), output);
5110    }
5111
5112    #[test]
5113    fn truncate_tool_result_truncates_with_marker() {
5114        let output = "a".repeat(200);
5115        let result = truncate_tool_result(&output, 100);
5116        assert!(result.contains("[... "));
5117        assert!(result.contains("characters truncated ...]\n\n"));
5118        // Head should be ~2/3 of 100 = 66, tail ~1/3 = 34
5119        assert!(result.starts_with("aaa"));
5120        assert!(result.ends_with("aaa"));
5121        // Result should be shorter than original
5122        assert!(result.len() < output.len());
5123    }
5124
5125    #[test]
5126    fn truncate_tool_result_preserves_head_tail_ratio() {
5127        let output: String = (0u32..1000)
5128            .map(|i| char::from(b'a' + (i % 26) as u8))
5129            .collect();
5130        let result = truncate_tool_result(&output, 300);
5131        // Head = 2/3 of 300 = 200 chars, tail = 100 chars
5132        // Find the marker
5133        let marker_start = result.find("[... ").unwrap();
5134        let marker_end = result.find("characters truncated ...]\n\n").unwrap()
5135            + "characters truncated ...]\n\n".len();
5136        let head = &result[..marker_start - 2]; // subtract \n\n
5137        let tail = &result[marker_end..];
5138        assert!(
5139            head.len() >= 190 && head.len() <= 210,
5140            "head len={}",
5141            head.len()
5142        );
5143        assert!(
5144            tail.len() >= 90 && tail.len() <= 110,
5145            "tail len={}",
5146            tail.len()
5147        );
5148    }
5149
5150    #[test]
5151    fn truncate_tool_result_utf8_boundary_safety() {
5152        // Create string with multi-byte chars: each emoji is 4 bytes
5153        let output = "🦀".repeat(100); // 400 bytes
5154        // This should not panic even with a limit that falls mid-char
5155        let result = truncate_tool_result(&output, 50);
5156        assert!(result.contains("[... "));
5157        // Verify the result is valid UTF-8 (would panic otherwise)
5158        let _ = result.len();
5159    }
5160
5161    #[test]
5162    fn truncate_tool_result_very_small_max() {
5163        let output = "abcdefghijklmnopqrstuvwxyz";
5164        // With max=5, head=3 tail=2 — result includes marker overhead
5165        // but should not panic and should contain truncation marker
5166        let result = truncate_tool_result(output, 5);
5167        assert!(result.contains("[... "));
5168        // Head (3 chars) + tail (2 chars) from original should be preserved
5169        assert!(result.starts_with("abc"));
5170        assert!(result.ends_with("yz"));
5171    }
5172
5173    // ── fast_trim_tool_results tests ────────────────────────────
5174
5175    #[test]
5176    fn fast_trim_protects_recent_messages() {
5177        let mut history = vec![
5178            ChatMessage::system("sys"),
5179            ChatMessage::tool("a".repeat(5000)),
5180            ChatMessage::tool("b".repeat(5000)),
5181            ChatMessage::user("recent user msg"),
5182            ChatMessage::tool("c".repeat(5000)), // recent, should be protected
5183        ];
5184        // protect_last_n = 2 → last 2 messages protected
5185        let saved = fast_trim_tool_results(&mut history, 2);
5186        assert!(saved > 0);
5187        // First two tool messages should be trimmed
5188        assert!(history[1].content.len() <= 2100);
5189        assert!(history[2].content.len() <= 2100);
5190        // Last tool message (protected) should be unchanged
5191        assert_eq!(history[4].content.len(), 5000);
5192    }
5193
5194    #[test]
5195    fn fast_trim_skips_non_tool_messages() {
5196        let mut history = vec![
5197            ChatMessage::system("sys"),
5198            ChatMessage::user("a".repeat(5000)),
5199            ChatMessage::assistant("b".repeat(5000)),
5200        ];
5201        let saved = fast_trim_tool_results(&mut history, 0);
5202        assert_eq!(saved, 0);
5203        assert_eq!(history[1].content.len(), 5000);
5204        assert_eq!(history[2].content.len(), 5000);
5205    }
5206
5207    #[test]
5208    fn fast_trim_small_tool_results_unchanged() {
5209        let mut history = vec![
5210            ChatMessage::system("sys"),
5211            ChatMessage::tool("short result"),
5212        ];
5213        let saved = fast_trim_tool_results(&mut history, 0);
5214        assert_eq!(saved, 0);
5215        assert_eq!(history[1].content, "short result");
5216    }
5217
5218    // ── emergency_history_trim tests ──────────────────────────────
5219
5220    #[test]
5221    fn emergency_trim_preserves_system() {
5222        let mut history = vec![
5223            ChatMessage::system("sys"),
5224            ChatMessage::user("msg1"),
5225            ChatMessage::assistant("resp1"),
5226            ChatMessage::user("msg2"),
5227            ChatMessage::assistant("resp2"),
5228            ChatMessage::user("msg3"),
5229        ];
5230        let dropped = emergency_history_trim(&mut history, 2);
5231        assert!(dropped > 0);
5232        // System message should always be preserved
5233        assert_eq!(history[0].role, "system");
5234        assert_eq!(history[0].content, "sys");
5235        // Last 2 messages should be preserved
5236        let len = history.len();
5237        assert_eq!(history[len - 1].content, "msg3");
5238    }
5239
5240    #[test]
5241    fn emergency_trim_preserves_recent() {
5242        let mut history = vec![
5243            ChatMessage::system("sys"),
5244            ChatMessage::user("old1"),
5245            ChatMessage::user("old2"),
5246            ChatMessage::user("recent1"),
5247            ChatMessage::user("recent2"),
5248        ];
5249        let dropped = emergency_history_trim(&mut history, 2);
5250        assert!(dropped > 0);
5251        // Last 2 should be preserved
5252        let len = history.len();
5253        assert_eq!(history[len - 1].content, "recent2");
5254        assert_eq!(history[len - 2].content, "recent1");
5255    }
5256
5257    #[test]
5258    fn emergency_trim_nothing_to_drop() {
5259        let mut history = vec![
5260            ChatMessage::system("sys"),
5261            ChatMessage::user("only user msg"),
5262        ];
5263        // protect_last = 1, system is protected → only 1 droppable
5264        // target_drop = 2/3 = 0 → nothing dropped
5265        let dropped = emergency_history_trim(&mut history, 1);
5266        assert_eq!(dropped, 0);
5267    }
5268
5269    // ── estimate_history_tokens tests ─────────────────────────────
5270
5271    #[test]
5272    fn estimate_tokens_empty_history() {
5273        let history: Vec<ChatMessage> = vec![];
5274        assert_eq!(estimate_history_tokens(&history), 0);
5275    }
5276
5277    #[test]
5278    fn estimate_tokens_single_message() {
5279        // 40 chars → 40.div_ceil(4) + 4 = 10 + 4 = 14 tokens
5280        let msg = "a".repeat(40);
5281        let history = vec![ChatMessage::user(&msg)];
5282        let est = estimate_history_tokens(&history);
5283        assert_eq!(est, 14);
5284    }
5285
5286    #[test]
5287    fn estimate_tokens_multiple_messages() {
5288        let history = vec![
5289            ChatMessage::system("system prompt here"), // 18 chars → 18/4=4 +4=8 (div_ceil: 5+4=9)
5290            ChatMessage::user("hello"),                // 5 chars → 5/4=1 +4=5 (div_ceil: 2+4=6)
5291            ChatMessage::assistant("world"),           // 5 chars → 5/4=1 +4=5 (div_ceil: 2+4=6)
5292        ];
5293        let est = estimate_history_tokens(&history);
5294        // Each message: content_len.div_ceil(4) + 4
5295        // 18.div_ceil(4)=5, 5.div_ceil(4)=2, 5.div_ceil(4)=2 → 5+4 + 2+4 + 2+4 = 21
5296        assert_eq!(est, 21);
5297    }
5298
5299    #[test]
5300    fn estimate_tokens_large_tool_result() {
5301        let big = "x".repeat(40_000);
5302        let history = vec![ChatMessage::tool(&big)];
5303        let est = estimate_history_tokens(&history);
5304        // 40000.div_ceil(4) + 4 = 10000 + 4 = 10004
5305        assert_eq!(est, 10_004);
5306    }
5307
5308    // ── shared_budget tests ───────────────────────────────────────
5309
5310    #[test]
5311    fn shared_budget_decrement_logic() {
5312        use std::sync::Arc;
5313        use std::sync::atomic::{AtomicUsize, Ordering};
5314
5315        let budget = Arc::new(AtomicUsize::new(3));
5316
5317        // Simulate 3 iterations decrementing
5318        for i in 0..3 {
5319            let remaining = budget.load(Ordering::Relaxed);
5320            assert!(remaining > 0, "Budget should be >0 at iteration {i}");
5321            budget.fetch_sub(1, Ordering::Relaxed);
5322        }
5323
5324        // Budget should now be 0
5325        assert_eq!(budget.load(Ordering::Relaxed), 0);
5326    }
5327
5328    #[test]
5329    fn shared_budget_none_has_no_effect() {
5330        // When shared_budget is None, the check is simply skipped
5331        let budget: Option<Arc<std::sync::atomic::AtomicUsize>> = None;
5332        assert!(budget.is_none());
5333    }
5334
5335    // ── existing tests ────────────────────────────────────────────
5336
5337    #[test]
5338    fn interactive_session_state_round_trips_history() {
5339        let dir = tempdir().unwrap();
5340        let path = dir.path().join("session.json");
5341        let history = vec![
5342            ChatMessage::system("system"),
5343            ChatMessage::user("hello"),
5344            ChatMessage::assistant("hi"),
5345        ];
5346
5347        save_interactive_session_history(&path, &history).unwrap();
5348        let restored = load_interactive_session_history(&path, "fallback").unwrap();
5349
5350        assert_eq!(restored.len(), 3);
5351        assert_eq!(restored[0].role, "system");
5352        assert_eq!(restored[1].content, "hello");
5353        assert_eq!(restored[2].content, "hi");
5354    }
5355
5356    #[test]
5357    fn interactive_session_state_adds_missing_system_prompt() {
5358        let dir = tempdir().unwrap();
5359        let path = dir.path().join("session.json");
5360        let payload = serde_json::to_string_pretty(&InteractiveSessionState {
5361            version: 1,
5362            history: vec![ChatMessage::user("orphan")],
5363        })
5364        .unwrap();
5365        std::fs::write(&path, payload).unwrap();
5366
5367        let restored = load_interactive_session_history(&path, "fallback system").unwrap();
5368
5369        assert_eq!(restored[0].role, "system");
5370        assert_eq!(restored[0].content, "fallback system");
5371        assert_eq!(restored[1].content, "orphan");
5372    }
5373
5374    use super::*;
5375    use async_trait::async_trait;
5376    use base64::{Engine as _, engine::general_purpose::STANDARD};
5377    use std::collections::VecDeque;
5378    use std::sync::atomic::{AtomicUsize, Ordering};
5379    use std::sync::{Arc, Mutex};
5380    use std::time::Duration;
5381
5382    #[test]
5383    fn scrub_credentials_redacts_bearer_token() {
5384        let input = "API_KEY=sk-1234567890abcdef; token: 1234567890; password=\"secret123456\"";
5385        let scrubbed = scrub_credentials(input);
5386        assert!(scrubbed.contains("API_KEY=sk-1*[REDACTED]"));
5387        assert!(scrubbed.contains("token: 1234*[REDACTED]"));
5388        assert!(scrubbed.contains("password=\"secr*[REDACTED]\""));
5389        assert!(!scrubbed.contains("abcdef"));
5390        assert!(!scrubbed.contains("secret123456"));
5391    }
5392
5393    #[test]
5394    fn scrub_credentials_redacts_json_api_key() {
5395        let input = r#"{"api_key": "sk-1234567890", "other": "public"}"#;
5396        let scrubbed = scrub_credentials(input);
5397        assert!(scrubbed.contains("\"api_key\": \"sk-1*[REDACTED]\""));
5398        assert!(scrubbed.contains("public"));
5399    }
5400
5401    #[tokio::test]
5402    async fn execute_one_tool_does_not_panic_on_utf8_boundary() {
5403        let call_arguments = (0..600)
5404            .map(|n| serde_json::json!({ "content": format!("{}:tail", "a".repeat(n)) }))
5405            .find(|args| {
5406                let raw = args.to_string();
5407                raw.len() > 300 && !raw.is_char_boundary(300)
5408            })
5409            .expect("should produce a sample whose byte index 300 is not a char boundary");
5410
5411        let observer = NoopObserver;
5412        let result =
5413            execute_one_tool("unknown_tool", call_arguments, &[], None, &observer, None).await;
5414        assert!(result.is_ok(), "execute_one_tool should not panic or error");
5415
5416        let outcome = result.unwrap();
5417        assert!(!outcome.success);
5418        assert!(outcome.output.contains("Unknown tool: unknown_tool"));
5419    }
5420
5421    #[tokio::test]
5422    async fn execute_one_tool_resolves_unique_activated_tool_suffix() {
5423        let observer = NoopObserver;
5424        let invocations = Arc::new(AtomicUsize::new(0));
5425        let activated = Arc::new(std::sync::Mutex::new(crate::tools::ActivatedToolSet::new()));
5426        let activated_tool: Arc<dyn Tool> = Arc::new(CountingTool::new(
5427            "docker-mcp__extract_text",
5428            Arc::clone(&invocations),
5429        ));
5430        activated
5431            .lock()
5432            .unwrap()
5433            .activate("docker-mcp__extract_text".into(), activated_tool);
5434
5435        let outcome = execute_one_tool(
5436            "extract_text",
5437            serde_json::json!({ "value": "ok" }),
5438            &[],
5439            Some(&activated),
5440            &observer,
5441            None,
5442        )
5443        .await
5444        .expect("suffix alias should execute the unique activated tool");
5445
5446        assert!(outcome.success);
5447        assert_eq!(outcome.output, "counted:ok");
5448        assert_eq!(invocations.load(Ordering::SeqCst), 1);
5449    }
5450
5451    use crate::observability::NoopObserver;
5452    use crate::providers::ChatResponse;
5453    use crate::providers::router::{Route, RouterProvider};
5454    use crate::providers::traits::{ProviderCapabilities, StreamChunk, StreamEvent, StreamOptions};
5455    use tempfile::TempDir;
5456
5457    struct NonVisionProvider {
5458        calls: Arc<AtomicUsize>,
5459    }
5460
5461    #[async_trait]
5462    impl Provider for NonVisionProvider {
5463        async fn chat_with_system(
5464            &self,
5465            _system_prompt: Option<&str>,
5466            _message: &str,
5467            _model: &str,
5468            _temperature: f64,
5469        ) -> anyhow::Result<String> {
5470            self.calls.fetch_add(1, Ordering::SeqCst);
5471            Ok("ok".to_string())
5472        }
5473    }
5474
5475    struct VisionProvider {
5476        calls: Arc<AtomicUsize>,
5477    }
5478
5479    #[async_trait]
5480    impl Provider for VisionProvider {
5481        fn capabilities(&self) -> ProviderCapabilities {
5482            ProviderCapabilities {
5483                native_tool_calling: false,
5484                vision: true,
5485                prompt_caching: false,
5486            }
5487        }
5488
5489        async fn chat_with_system(
5490            &self,
5491            _system_prompt: Option<&str>,
5492            _message: &str,
5493            _model: &str,
5494            _temperature: f64,
5495        ) -> anyhow::Result<String> {
5496            self.calls.fetch_add(1, Ordering::SeqCst);
5497            Ok("ok".to_string())
5498        }
5499
5500        async fn chat(
5501            &self,
5502            request: ChatRequest<'_>,
5503            _model: &str,
5504            _temperature: f64,
5505        ) -> anyhow::Result<ChatResponse> {
5506            self.calls.fetch_add(1, Ordering::SeqCst);
5507            let marker_count = crate::multimodal::count_image_markers(request.messages);
5508            if marker_count == 0 {
5509                anyhow::bail!("expected image markers in request messages");
5510            }
5511
5512            if request.tools.is_some() {
5513                anyhow::bail!("no tools should be attached for this test");
5514            }
5515
5516            Ok(ChatResponse {
5517                text: Some("vision-ok".to_string()),
5518                tool_calls: Vec::new(),
5519                usage: None,
5520                reasoning_content: None,
5521            })
5522        }
5523    }
5524
5525    struct ScriptedProvider {
5526        responses: Arc<Mutex<VecDeque<ChatResponse>>>,
5527        capabilities: ProviderCapabilities,
5528    }
5529
5530    impl ScriptedProvider {
5531        fn from_text_responses(responses: Vec<&str>) -> Self {
5532            let scripted = responses
5533                .into_iter()
5534                .map(|text| ChatResponse {
5535                    text: Some(text.to_string()),
5536                    tool_calls: Vec::new(),
5537                    usage: None,
5538                    reasoning_content: None,
5539                })
5540                .collect();
5541            Self {
5542                responses: Arc::new(Mutex::new(scripted)),
5543                capabilities: ProviderCapabilities::default(),
5544            }
5545        }
5546
5547        fn with_native_tool_support(mut self) -> Self {
5548            self.capabilities.native_tool_calling = true;
5549            self
5550        }
5551    }
5552
5553    #[async_trait]
5554    impl Provider for ScriptedProvider {
5555        fn capabilities(&self) -> ProviderCapabilities {
5556            self.capabilities.clone()
5557        }
5558
5559        async fn chat_with_system(
5560            &self,
5561            _system_prompt: Option<&str>,
5562            _message: &str,
5563            _model: &str,
5564            _temperature: f64,
5565        ) -> anyhow::Result<String> {
5566            anyhow::bail!("chat_with_system should not be used in scripted provider tests");
5567        }
5568
5569        async fn chat(
5570            &self,
5571            _request: ChatRequest<'_>,
5572            _model: &str,
5573            _temperature: f64,
5574        ) -> anyhow::Result<ChatResponse> {
5575            let mut responses = self
5576                .responses
5577                .lock()
5578                .expect("responses lock should be valid");
5579            responses
5580                .pop_front()
5581                .ok_or_else(|| anyhow::anyhow!("scripted provider exhausted responses"))
5582        }
5583    }
5584
5585    struct StreamingScriptedProvider {
5586        responses: Arc<Mutex<VecDeque<String>>>,
5587        stream_calls: Arc<AtomicUsize>,
5588        chat_calls: Arc<AtomicUsize>,
5589    }
5590
5591    impl StreamingScriptedProvider {
5592        fn from_text_responses(responses: Vec<&str>) -> Self {
5593            Self {
5594                responses: Arc::new(Mutex::new(
5595                    responses.into_iter().map(ToString::to_string).collect(),
5596                )),
5597                stream_calls: Arc::new(AtomicUsize::new(0)),
5598                chat_calls: Arc::new(AtomicUsize::new(0)),
5599            }
5600        }
5601    }
5602
5603    #[async_trait]
5604    impl Provider for StreamingScriptedProvider {
5605        async fn chat_with_system(
5606            &self,
5607            _system_prompt: Option<&str>,
5608            _message: &str,
5609            _model: &str,
5610            _temperature: f64,
5611        ) -> anyhow::Result<String> {
5612            anyhow::bail!(
5613                "chat_with_system should not be used in streaming scripted provider tests"
5614            );
5615        }
5616
5617        async fn chat(
5618            &self,
5619            _request: ChatRequest<'_>,
5620            _model: &str,
5621            _temperature: f64,
5622        ) -> anyhow::Result<ChatResponse> {
5623            self.chat_calls.fetch_add(1, Ordering::SeqCst);
5624            anyhow::bail!("chat should not be called when streaming succeeds")
5625        }
5626
5627        fn supports_streaming(&self) -> bool {
5628            true
5629        }
5630
5631        fn stream_chat_with_history(
5632            &self,
5633            _messages: &[ChatMessage],
5634            _model: &str,
5635            _temperature: f64,
5636            options: StreamOptions,
5637        ) -> futures_util::stream::BoxStream<
5638            'static,
5639            crate::providers::traits::StreamResult<StreamChunk>,
5640        > {
5641            self.stream_calls.fetch_add(1, Ordering::SeqCst);
5642            if !options.enabled {
5643                return Box::pin(futures_util::stream::empty());
5644            }
5645
5646            let response = self
5647                .responses
5648                .lock()
5649                .expect("responses lock should be valid")
5650                .pop_front()
5651                .unwrap_or_default();
5652
5653            Box::pin(futures_util::stream::iter(vec![
5654                Ok(StreamChunk::delta(response)),
5655                Ok(StreamChunk::final_chunk()),
5656            ]))
5657        }
5658    }
5659
5660    enum NativeStreamTurn {
5661        ToolCall(ToolCall),
5662        Text(String),
5663    }
5664
5665    struct StreamingNativeToolEventProvider {
5666        turns: Arc<Mutex<VecDeque<NativeStreamTurn>>>,
5667        stream_calls: Arc<AtomicUsize>,
5668        stream_tool_requests: Arc<AtomicUsize>,
5669        chat_calls: Arc<AtomicUsize>,
5670    }
5671
5672    impl StreamingNativeToolEventProvider {
5673        fn with_turns(turns: Vec<NativeStreamTurn>) -> Self {
5674            Self {
5675                turns: Arc::new(Mutex::new(turns.into())),
5676                stream_calls: Arc::new(AtomicUsize::new(0)),
5677                stream_tool_requests: Arc::new(AtomicUsize::new(0)),
5678                chat_calls: Arc::new(AtomicUsize::new(0)),
5679            }
5680        }
5681    }
5682
5683    #[async_trait]
5684    impl Provider for StreamingNativeToolEventProvider {
5685        fn capabilities(&self) -> ProviderCapabilities {
5686            ProviderCapabilities {
5687                native_tool_calling: true,
5688                vision: false,
5689                prompt_caching: false,
5690            }
5691        }
5692
5693        async fn chat_with_system(
5694            &self,
5695            _system_prompt: Option<&str>,
5696            _message: &str,
5697            _model: &str,
5698            _temperature: f64,
5699        ) -> anyhow::Result<String> {
5700            anyhow::bail!(
5701                "chat_with_system should not be used in streaming native tool event provider tests"
5702            );
5703        }
5704
5705        async fn chat(
5706            &self,
5707            _request: ChatRequest<'_>,
5708            _model: &str,
5709            _temperature: f64,
5710        ) -> anyhow::Result<ChatResponse> {
5711            self.chat_calls.fetch_add(1, Ordering::SeqCst);
5712            anyhow::bail!("chat should not be called when native streaming events succeed")
5713        }
5714
5715        fn supports_streaming(&self) -> bool {
5716            true
5717        }
5718
5719        fn supports_streaming_tool_events(&self) -> bool {
5720            true
5721        }
5722
5723        fn stream_chat(
5724            &self,
5725            request: ChatRequest<'_>,
5726            _model: &str,
5727            _temperature: f64,
5728            options: StreamOptions,
5729        ) -> futures_util::stream::BoxStream<
5730            'static,
5731            crate::providers::traits::StreamResult<StreamEvent>,
5732        > {
5733            self.stream_calls.fetch_add(1, Ordering::SeqCst);
5734            if request.tools.is_some_and(|tools| !tools.is_empty()) {
5735                self.stream_tool_requests.fetch_add(1, Ordering::SeqCst);
5736            }
5737            if !options.enabled {
5738                return Box::pin(futures_util::stream::empty());
5739            }
5740
5741            let turn = self
5742                .turns
5743                .lock()
5744                .expect("turns lock should be valid")
5745                .pop_front()
5746                .expect("streaming turns should have scripted output");
5747            match turn {
5748                NativeStreamTurn::ToolCall(tool_call) => {
5749                    Box::pin(futures_util::stream::iter(vec![
5750                        Ok(StreamEvent::ToolCall(tool_call)),
5751                        Ok(StreamEvent::Final),
5752                    ]))
5753                }
5754                NativeStreamTurn::Text(text) => Box::pin(futures_util::stream::iter(vec![
5755                    Ok(StreamEvent::TextDelta(StreamChunk::delta(text))),
5756                    Ok(StreamEvent::Final),
5757                ])),
5758            }
5759        }
5760    }
5761
5762    struct RouteAwareStreamingProvider {
5763        response: String,
5764        stream_calls: Arc<AtomicUsize>,
5765        chat_calls: Arc<AtomicUsize>,
5766        last_model: Arc<Mutex<String>>,
5767    }
5768
5769    impl RouteAwareStreamingProvider {
5770        fn new(response: &str) -> Self {
5771            Self {
5772                response: response.to_string(),
5773                stream_calls: Arc::new(AtomicUsize::new(0)),
5774                chat_calls: Arc::new(AtomicUsize::new(0)),
5775                last_model: Arc::new(Mutex::new(String::new())),
5776            }
5777        }
5778    }
5779
5780    #[async_trait]
5781    impl Provider for RouteAwareStreamingProvider {
5782        async fn chat_with_system(
5783            &self,
5784            _system_prompt: Option<&str>,
5785            _message: &str,
5786            _model: &str,
5787            _temperature: f64,
5788        ) -> anyhow::Result<String> {
5789            anyhow::bail!("chat_with_system should not be used in route-aware stream tests");
5790        }
5791
5792        async fn chat(
5793            &self,
5794            _request: ChatRequest<'_>,
5795            _model: &str,
5796            _temperature: f64,
5797        ) -> anyhow::Result<ChatResponse> {
5798            self.chat_calls.fetch_add(1, Ordering::SeqCst);
5799            anyhow::bail!("chat should not be called when routed streaming succeeds")
5800        }
5801
5802        fn supports_streaming(&self) -> bool {
5803            true
5804        }
5805
5806        fn stream_chat_with_history(
5807            &self,
5808            _messages: &[ChatMessage],
5809            model: &str,
5810            _temperature: f64,
5811            options: StreamOptions,
5812        ) -> futures_util::stream::BoxStream<
5813            'static,
5814            crate::providers::traits::StreamResult<StreamChunk>,
5815        > {
5816            self.stream_calls.fetch_add(1, Ordering::SeqCst);
5817            *self
5818                .last_model
5819                .lock()
5820                .expect("last_model lock should be valid") = model.to_string();
5821            if !options.enabled {
5822                return Box::pin(futures_util::stream::empty());
5823            }
5824
5825            Box::pin(futures_util::stream::iter(vec![
5826                Ok(StreamChunk::delta(self.response.clone())),
5827                Ok(StreamChunk::final_chunk()),
5828            ]))
5829        }
5830    }
5831
5832    struct CountingTool {
5833        name: String,
5834        invocations: Arc<AtomicUsize>,
5835    }
5836
5837    impl CountingTool {
5838        fn new(name: &str, invocations: Arc<AtomicUsize>) -> Self {
5839            Self {
5840                name: name.to_string(),
5841                invocations,
5842            }
5843        }
5844    }
5845
5846    #[async_trait]
5847    impl Tool for CountingTool {
5848        fn name(&self) -> &str {
5849            &self.name
5850        }
5851
5852        fn description(&self) -> &str {
5853            "Counts executions for loop-stability tests"
5854        }
5855
5856        fn parameters_schema(&self) -> serde_json::Value {
5857            serde_json::json!({
5858                "type": "object",
5859                "properties": {
5860                    "value": { "type": "string" }
5861                }
5862            })
5863        }
5864
5865        async fn execute(
5866            &self,
5867            args: serde_json::Value,
5868        ) -> anyhow::Result<crate::tools::ToolResult> {
5869            self.invocations.fetch_add(1, Ordering::SeqCst);
5870            let value = args
5871                .get("value")
5872                .and_then(serde_json::Value::as_str)
5873                .unwrap_or_default();
5874            Ok(crate::tools::ToolResult {
5875                success: true,
5876                output: format!("counted:{value}"),
5877                error: None,
5878            })
5879        }
5880    }
5881
5882    struct RecordingArgsTool {
5883        name: String,
5884        recorded_args: Arc<Mutex<Vec<serde_json::Value>>>,
5885    }
5886
5887    impl RecordingArgsTool {
5888        fn new(name: &str, recorded_args: Arc<Mutex<Vec<serde_json::Value>>>) -> Self {
5889            Self {
5890                name: name.to_string(),
5891                recorded_args,
5892            }
5893        }
5894    }
5895
5896    #[async_trait]
5897    impl Tool for RecordingArgsTool {
5898        fn name(&self) -> &str {
5899            &self.name
5900        }
5901
5902        fn description(&self) -> &str {
5903            "Records tool arguments for regression tests"
5904        }
5905
5906        fn parameters_schema(&self) -> serde_json::Value {
5907            serde_json::json!({
5908                "type": "object",
5909                "properties": {
5910                    "prompt": { "type": "string" },
5911                    "schedule": { "type": "object" },
5912                    "delivery": { "type": "object" }
5913                }
5914            })
5915        }
5916
5917        async fn execute(
5918            &self,
5919            args: serde_json::Value,
5920        ) -> anyhow::Result<crate::tools::ToolResult> {
5921            self.recorded_args
5922                .lock()
5923                .expect("recorded args lock should be valid")
5924                .push(args.clone());
5925            Ok(crate::tools::ToolResult {
5926                success: true,
5927                output: args.to_string(),
5928                error: None,
5929            })
5930        }
5931    }
5932
5933    struct DelayTool {
5934        name: String,
5935        delay_ms: u64,
5936        active: Arc<AtomicUsize>,
5937        max_active: Arc<AtomicUsize>,
5938    }
5939
5940    impl DelayTool {
5941        fn new(
5942            name: &str,
5943            delay_ms: u64,
5944            active: Arc<AtomicUsize>,
5945            max_active: Arc<AtomicUsize>,
5946        ) -> Self {
5947            Self {
5948                name: name.to_string(),
5949                delay_ms,
5950                active,
5951                max_active,
5952            }
5953        }
5954    }
5955
5956    #[async_trait]
5957    impl Tool for DelayTool {
5958        fn name(&self) -> &str {
5959            &self.name
5960        }
5961
5962        fn description(&self) -> &str {
5963            "Delay tool for testing parallel tool execution"
5964        }
5965
5966        fn parameters_schema(&self) -> serde_json::Value {
5967            serde_json::json!({
5968                "type": "object",
5969                "properties": {
5970                    "value": { "type": "string" }
5971                },
5972                "required": ["value"]
5973            })
5974        }
5975
5976        async fn execute(
5977            &self,
5978            args: serde_json::Value,
5979        ) -> anyhow::Result<crate::tools::ToolResult> {
5980            let now_active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
5981            self.max_active.fetch_max(now_active, Ordering::SeqCst);
5982
5983            tokio::time::sleep(Duration::from_millis(self.delay_ms)).await;
5984
5985            self.active.fetch_sub(1, Ordering::SeqCst);
5986
5987            let value = args
5988                .get("value")
5989                .and_then(serde_json::Value::as_str)
5990                .unwrap_or_default()
5991                .to_string();
5992
5993            Ok(crate::tools::ToolResult {
5994                success: true,
5995                output: format!("ok:{value}"),
5996                error: None,
5997            })
5998        }
5999    }
6000
6001    /// A tool that always returns a failure with a given error reason.
6002    struct FailingTool {
6003        tool_name: String,
6004        error_reason: String,
6005    }
6006
6007    impl FailingTool {
6008        fn new(name: &str, error_reason: &str) -> Self {
6009            Self {
6010                tool_name: name.to_string(),
6011                error_reason: error_reason.to_string(),
6012            }
6013        }
6014    }
6015
6016    #[async_trait]
6017    impl Tool for FailingTool {
6018        fn name(&self) -> &str {
6019            &self.tool_name
6020        }
6021
6022        fn description(&self) -> &str {
6023            "A tool that always fails for testing failure surfacing"
6024        }
6025
6026        fn parameters_schema(&self) -> serde_json::Value {
6027            serde_json::json!({
6028                "type": "object",
6029                "properties": {
6030                    "command": { "type": "string" }
6031                }
6032            })
6033        }
6034
6035        async fn execute(
6036            &self,
6037            _args: serde_json::Value,
6038        ) -> anyhow::Result<crate::tools::ToolResult> {
6039            Ok(crate::tools::ToolResult {
6040                success: false,
6041                output: String::new(),
6042                error: Some(self.error_reason.clone()),
6043            })
6044        }
6045    }
6046
6047    #[tokio::test]
6048    async fn run_tool_call_loop_returns_structured_error_for_non_vision_provider() {
6049        let calls = Arc::new(AtomicUsize::new(0));
6050        let provider = NonVisionProvider {
6051            calls: Arc::clone(&calls),
6052        };
6053
6054        let mut history = vec![ChatMessage::user(
6055            "please inspect [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
6056        )];
6057        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6058        let observer = NoopObserver;
6059
6060        let err = run_tool_call_loop(
6061            &provider,
6062            &mut history,
6063            &tools_registry,
6064            &observer,
6065            "mock-provider",
6066            "mock-model",
6067            0.0,
6068            true,
6069            None,
6070            "cli",
6071            None,
6072            &crate::config::MultimodalConfig::default(),
6073            3,
6074            None,
6075            None,
6076            None,
6077            &[],
6078            &[],
6079            None,
6080            None,
6081            &crate::config::PacingConfig::default(),
6082            0,
6083            0,
6084            None,
6085        )
6086        .await
6087        .expect_err("provider without vision support should fail");
6088
6089        assert!(err.to_string().contains("provider_capability_error"));
6090        assert!(err.to_string().contains("capability=vision"));
6091        assert_eq!(calls.load(Ordering::SeqCst), 0);
6092    }
6093
6094    #[tokio::test]
6095    async fn run_tool_call_loop_rejects_oversized_image_payload() {
6096        let calls = Arc::new(AtomicUsize::new(0));
6097        let provider = VisionProvider {
6098            calls: Arc::clone(&calls),
6099        };
6100
6101        let oversized_payload = STANDARD.encode(vec![0_u8; (1024 * 1024) + 1]);
6102        let mut history = vec![ChatMessage::user(format!(
6103            "[IMAGE:data:image/png;base64,{oversized_payload}]"
6104        ))];
6105
6106        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6107        let observer = NoopObserver;
6108        let multimodal = crate::config::MultimodalConfig {
6109            max_images: 4,
6110            max_image_size_mb: 1,
6111            allow_remote_fetch: false,
6112            ..Default::default()
6113        };
6114
6115        let err = run_tool_call_loop(
6116            &provider,
6117            &mut history,
6118            &tools_registry,
6119            &observer,
6120            "mock-provider",
6121            "mock-model",
6122            0.0,
6123            true,
6124            None,
6125            "cli",
6126            None,
6127            &multimodal,
6128            3,
6129            None,
6130            None,
6131            None,
6132            &[],
6133            &[],
6134            None,
6135            None,
6136            &crate::config::PacingConfig::default(),
6137            0,
6138            0,
6139            None,
6140        )
6141        .await
6142        .expect_err("oversized payload must fail");
6143
6144        assert!(
6145            err.to_string()
6146                .contains("multimodal image size limit exceeded")
6147        );
6148        assert_eq!(calls.load(Ordering::SeqCst), 0);
6149    }
6150
6151    #[tokio::test]
6152    async fn run_tool_call_loop_accepts_valid_multimodal_request_flow() {
6153        let calls = Arc::new(AtomicUsize::new(0));
6154        let provider = VisionProvider {
6155            calls: Arc::clone(&calls),
6156        };
6157
6158        let mut history = vec![ChatMessage::user(
6159            "Analyze this [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
6160        )];
6161        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6162        let observer = NoopObserver;
6163
6164        let result = run_tool_call_loop(
6165            &provider,
6166            &mut history,
6167            &tools_registry,
6168            &observer,
6169            "mock-provider",
6170            "mock-model",
6171            0.0,
6172            true,
6173            None,
6174            "cli",
6175            None,
6176            &crate::config::MultimodalConfig::default(),
6177            3,
6178            None,
6179            None,
6180            None,
6181            &[],
6182            &[],
6183            None,
6184            None,
6185            &crate::config::PacingConfig::default(),
6186            0,
6187            0,
6188            None,
6189        )
6190        .await
6191        .expect("valid multimodal payload should pass");
6192
6193        assert_eq!(result, "vision-ok");
6194        assert_eq!(calls.load(Ordering::SeqCst), 1);
6195    }
6196
6197    /// When `vision_provider` is not set and the default provider lacks vision
6198    /// support, the original `ProviderCapabilityError` should be returned.
6199    #[tokio::test]
6200    async fn run_tool_call_loop_no_vision_provider_config_preserves_error() {
6201        let calls = Arc::new(AtomicUsize::new(0));
6202        let provider = NonVisionProvider {
6203            calls: Arc::clone(&calls),
6204        };
6205
6206        let mut history = vec![ChatMessage::user(
6207            "check [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
6208        )];
6209        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6210        let observer = NoopObserver;
6211
6212        let err = run_tool_call_loop(
6213            &provider,
6214            &mut history,
6215            &tools_registry,
6216            &observer,
6217            "mock-provider",
6218            "mock-model",
6219            0.0,
6220            true,
6221            None,
6222            "cli",
6223            None,
6224            &crate::config::MultimodalConfig::default(),
6225            3,
6226            None,
6227            None,
6228            None,
6229            &[],
6230            &[],
6231            None,
6232            None,
6233            &crate::config::PacingConfig::default(),
6234            0,
6235            0,
6236            None,
6237        )
6238        .await
6239        .expect_err("should fail without vision_provider config");
6240
6241        assert!(err.to_string().contains("capability=vision"));
6242        assert_eq!(calls.load(Ordering::SeqCst), 0);
6243    }
6244
6245    /// When `vision_provider` is set but the provider factory cannot resolve
6246    /// the name, a descriptive error should be returned (not the generic
6247    /// capability error).
6248    #[tokio::test]
6249    async fn run_tool_call_loop_vision_provider_creation_failure() {
6250        let calls = Arc::new(AtomicUsize::new(0));
6251        let provider = NonVisionProvider {
6252            calls: Arc::clone(&calls),
6253        };
6254
6255        let mut history = vec![ChatMessage::user(
6256            "inspect [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
6257        )];
6258        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6259        let observer = NoopObserver;
6260
6261        let multimodal = crate::config::MultimodalConfig {
6262            vision_provider: Some("nonexistent-provider-xyz".to_string()),
6263            vision_model: Some("some-model".to_string()),
6264            ..Default::default()
6265        };
6266
6267        let err = run_tool_call_loop(
6268            &provider,
6269            &mut history,
6270            &tools_registry,
6271            &observer,
6272            "mock-provider",
6273            "mock-model",
6274            0.0,
6275            true,
6276            None,
6277            "cli",
6278            None,
6279            &multimodal,
6280            3,
6281            None,
6282            None,
6283            None,
6284            &[],
6285            &[],
6286            None,
6287            None,
6288            &crate::config::PacingConfig::default(),
6289            0,
6290            0,
6291            None,
6292        )
6293        .await
6294        .expect_err("should fail when vision provider cannot be created");
6295
6296        assert!(
6297            err.to_string().contains("failed to create vision provider"),
6298            "expected creation failure error, got: {}",
6299            err
6300        );
6301        assert_eq!(calls.load(Ordering::SeqCst), 0);
6302    }
6303
6304    /// Messages without image markers should use the default provider even
6305    /// when `vision_provider` is configured.
6306    #[tokio::test]
6307    async fn run_tool_call_loop_no_images_uses_default_provider() {
6308        let provider = ScriptedProvider::from_text_responses(vec!["hello world"]);
6309
6310        let mut history = vec![ChatMessage::user("just text, no images".to_string())];
6311        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6312        let observer = NoopObserver;
6313
6314        let multimodal = crate::config::MultimodalConfig {
6315            vision_provider: Some("nonexistent-provider-xyz".to_string()),
6316            vision_model: Some("some-model".to_string()),
6317            ..Default::default()
6318        };
6319
6320        // Even though vision_provider points to a nonexistent provider, this
6321        // should succeed because there are no image markers to trigger routing.
6322        let result = run_tool_call_loop(
6323            &provider,
6324            &mut history,
6325            &tools_registry,
6326            &observer,
6327            "scripted",
6328            "scripted-model",
6329            0.0,
6330            true,
6331            None,
6332            "cli",
6333            None,
6334            &multimodal,
6335            3,
6336            None,
6337            None,
6338            None,
6339            &[],
6340            &[],
6341            None,
6342            None,
6343            &crate::config::PacingConfig::default(),
6344            0,
6345            0,
6346            None,
6347        )
6348        .await
6349        .expect("text-only messages should succeed with default provider");
6350
6351        assert_eq!(result, "hello world");
6352    }
6353
6354    /// When `vision_provider` is set but `vision_model` is not, the default
6355    /// model should be used as fallback for the vision provider.
6356    #[tokio::test]
6357    async fn run_tool_call_loop_vision_provider_without_model_falls_back() {
6358        let calls = Arc::new(AtomicUsize::new(0));
6359        let provider = NonVisionProvider {
6360            calls: Arc::clone(&calls),
6361        };
6362
6363        let mut history = vec![ChatMessage::user(
6364            "look [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
6365        )];
6366        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6367        let observer = NoopObserver;
6368
6369        // vision_provider set but vision_model is None — the code should
6370        // fall back to the default model. Since the provider name is invalid,
6371        // we just verify the error path references the correct provider.
6372        let multimodal = crate::config::MultimodalConfig {
6373            vision_provider: Some("nonexistent-provider-xyz".to_string()),
6374            vision_model: None,
6375            ..Default::default()
6376        };
6377
6378        let err = run_tool_call_loop(
6379            &provider,
6380            &mut history,
6381            &tools_registry,
6382            &observer,
6383            "mock-provider",
6384            "mock-model",
6385            0.0,
6386            true,
6387            None,
6388            "cli",
6389            None,
6390            &multimodal,
6391            3,
6392            None,
6393            None,
6394            None,
6395            &[],
6396            &[],
6397            None,
6398            None,
6399            &crate::config::PacingConfig::default(),
6400            0,
6401            0,
6402            None,
6403        )
6404        .await
6405        .expect_err("should fail due to nonexistent vision provider");
6406
6407        // Verify the routing was attempted (not the generic capability error).
6408        assert!(
6409            err.to_string().contains("failed to create vision provider"),
6410            "expected creation failure, got: {}",
6411            err
6412        );
6413    }
6414
6415    /// Empty `[IMAGE:]` markers (which are preserved as literal text by the
6416    /// parser) should not trigger vision provider routing.
6417    #[tokio::test]
6418    async fn run_tool_call_loop_empty_image_markers_use_default_provider() {
6419        let provider = ScriptedProvider::from_text_responses(vec!["handled"]);
6420
6421        let mut history = vec![ChatMessage::user(
6422            "empty marker [IMAGE:] should be ignored".to_string(),
6423        )];
6424        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6425        let observer = NoopObserver;
6426
6427        let multimodal = crate::config::MultimodalConfig {
6428            vision_provider: Some("nonexistent-provider-xyz".to_string()),
6429            ..Default::default()
6430        };
6431
6432        let result = run_tool_call_loop(
6433            &provider,
6434            &mut history,
6435            &tools_registry,
6436            &observer,
6437            "scripted",
6438            "scripted-model",
6439            0.0,
6440            true,
6441            None,
6442            "cli",
6443            None,
6444            &multimodal,
6445            3,
6446            None,
6447            None,
6448            None,
6449            &[],
6450            &[],
6451            None,
6452            None,
6453            &crate::config::PacingConfig::default(),
6454            0,
6455            0,
6456            None,
6457        )
6458        .await
6459        .expect("empty image markers should not trigger vision routing");
6460
6461        assert_eq!(result, "handled");
6462    }
6463
6464    /// Multiple image markers should still trigger vision routing when
6465    /// vision_provider is configured.
6466    #[tokio::test]
6467    async fn run_tool_call_loop_multiple_images_trigger_vision_routing() {
6468        let calls = Arc::new(AtomicUsize::new(0));
6469        let provider = NonVisionProvider {
6470            calls: Arc::clone(&calls),
6471        };
6472
6473        let mut history = vec![ChatMessage::user(
6474            "two images [IMAGE:data:image/png;base64,aQ==] and [IMAGE:data:image/png;base64,bQ==]"
6475                .to_string(),
6476        )];
6477        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6478        let observer = NoopObserver;
6479
6480        let multimodal = crate::config::MultimodalConfig {
6481            vision_provider: Some("nonexistent-provider-xyz".to_string()),
6482            vision_model: Some("llava:7b".to_string()),
6483            ..Default::default()
6484        };
6485
6486        let err = run_tool_call_loop(
6487            &provider,
6488            &mut history,
6489            &tools_registry,
6490            &observer,
6491            "mock-provider",
6492            "mock-model",
6493            0.0,
6494            true,
6495            None,
6496            "cli",
6497            None,
6498            &multimodal,
6499            3,
6500            None,
6501            None,
6502            None,
6503            &[],
6504            &[],
6505            None,
6506            None,
6507            &crate::config::PacingConfig::default(),
6508            0,
6509            0,
6510            None,
6511        )
6512        .await
6513        .expect_err("should attempt vision provider creation for multiple images");
6514
6515        assert!(
6516            err.to_string().contains("failed to create vision provider"),
6517            "expected creation failure for multiple images, got: {}",
6518            err
6519        );
6520    }
6521
6522    #[test]
6523    fn should_execute_tools_in_parallel_returns_false_for_single_call() {
6524        let calls = vec![ParsedToolCall {
6525            name: "file_read".to_string(),
6526            arguments: serde_json::json!({"path": "a.txt"}),
6527            tool_call_id: None,
6528        }];
6529
6530        assert!(!should_execute_tools_in_parallel(&calls, None));
6531    }
6532
6533    #[test]
6534    fn should_execute_tools_in_parallel_returns_false_when_approval_is_required() {
6535        let calls = vec![
6536            ParsedToolCall {
6537                name: "shell".to_string(),
6538                arguments: serde_json::json!({"command": "pwd"}),
6539                tool_call_id: None,
6540            },
6541            ParsedToolCall {
6542                name: "http_request".to_string(),
6543                arguments: serde_json::json!({"url": "https://example.com"}),
6544                tool_call_id: None,
6545            },
6546        ];
6547        let approval_cfg = crate::config::AutonomyConfig::default();
6548        let approval_mgr = ApprovalManager::from_config(&approval_cfg);
6549
6550        assert!(!should_execute_tools_in_parallel(
6551            &calls,
6552            Some(&approval_mgr)
6553        ));
6554    }
6555
6556    #[test]
6557    fn should_execute_tools_in_parallel_returns_true_when_cli_has_no_interactive_approvals() {
6558        let calls = vec![
6559            ParsedToolCall {
6560                name: "shell".to_string(),
6561                arguments: serde_json::json!({"command": "pwd"}),
6562                tool_call_id: None,
6563            },
6564            ParsedToolCall {
6565                name: "http_request".to_string(),
6566                arguments: serde_json::json!({"url": "https://example.com"}),
6567                tool_call_id: None,
6568            },
6569        ];
6570        let approval_cfg = crate::config::AutonomyConfig {
6571            level: crate::security::AutonomyLevel::Full,
6572            ..crate::config::AutonomyConfig::default()
6573        };
6574        let approval_mgr = ApprovalManager::from_config(&approval_cfg);
6575
6576        assert!(should_execute_tools_in_parallel(
6577            &calls,
6578            Some(&approval_mgr)
6579        ));
6580    }
6581
6582    #[tokio::test]
6583    async fn run_tool_call_loop_executes_multiple_tools_with_ordered_results() {
6584        let provider = ScriptedProvider::from_text_responses(vec![
6585            r#"<tool_call>
6586{"name":"delay_a","arguments":{"value":"A"}}
6587</tool_call>
6588<tool_call>
6589{"name":"delay_b","arguments":{"value":"B"}}
6590</tool_call>"#,
6591            "done",
6592        ]);
6593
6594        let active = Arc::new(AtomicUsize::new(0));
6595        let max_active = Arc::new(AtomicUsize::new(0));
6596        let tools_registry: Vec<Box<dyn Tool>> = vec![
6597            Box::new(DelayTool::new(
6598                "delay_a",
6599                200,
6600                Arc::clone(&active),
6601                Arc::clone(&max_active),
6602            )),
6603            Box::new(DelayTool::new(
6604                "delay_b",
6605                200,
6606                Arc::clone(&active),
6607                Arc::clone(&max_active),
6608            )),
6609        ];
6610
6611        let approval_cfg = crate::config::AutonomyConfig {
6612            level: crate::security::AutonomyLevel::Full,
6613            ..crate::config::AutonomyConfig::default()
6614        };
6615        let approval_mgr = ApprovalManager::from_config(&approval_cfg);
6616
6617        let mut history = vec![
6618            ChatMessage::system("test-system"),
6619            ChatMessage::user("run tool calls"),
6620        ];
6621        let observer = NoopObserver;
6622
6623        let result = run_tool_call_loop(
6624            &provider,
6625            &mut history,
6626            &tools_registry,
6627            &observer,
6628            "mock-provider",
6629            "mock-model",
6630            0.0,
6631            true,
6632            Some(&approval_mgr),
6633            "telegram",
6634            None,
6635            &crate::config::MultimodalConfig::default(),
6636            4,
6637            None,
6638            None,
6639            None,
6640            &[],
6641            &[],
6642            None,
6643            None,
6644            &crate::config::PacingConfig::default(),
6645            0,
6646            0,
6647            None,
6648        )
6649        .await
6650        .expect("parallel execution should complete");
6651
6652        assert_eq!(result, "done");
6653        assert!(
6654            max_active.load(Ordering::SeqCst) >= 1,
6655            "tools should execute successfully"
6656        );
6657
6658        let tool_results_message = history
6659            .iter()
6660            .find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]"))
6661            .expect("tool results message should be present");
6662        let idx_a = tool_results_message
6663            .content
6664            .find("name=\"delay_a\"")
6665            .expect("delay_a result should be present");
6666        let idx_b = tool_results_message
6667            .content
6668            .find("name=\"delay_b\"")
6669            .expect("delay_b result should be present");
6670        assert!(
6671            idx_a < idx_b,
6672            "tool results should preserve input order for tool call mapping"
6673        );
6674    }
6675
6676    #[tokio::test]
6677    async fn run_tool_call_loop_injects_channel_delivery_defaults_for_cron_add() {
6678        let provider = ScriptedProvider::from_text_responses(vec![
6679            r#"<tool_call>
6680{"name":"cron_add","arguments":{"job_type":"agent","prompt":"remind me later","schedule":{"kind":"every","every_ms":60000}}}
6681</tool_call>"#,
6682            "done",
6683        ]);
6684
6685        let recorded_args = Arc::new(Mutex::new(Vec::new()));
6686        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(RecordingArgsTool::new(
6687            "cron_add",
6688            Arc::clone(&recorded_args),
6689        ))];
6690
6691        let mut history = vec![
6692            ChatMessage::system("test-system"),
6693            ChatMessage::user("schedule a reminder"),
6694        ];
6695        let observer = NoopObserver;
6696
6697        let result = run_tool_call_loop(
6698            &provider,
6699            &mut history,
6700            &tools_registry,
6701            &observer,
6702            "mock-provider",
6703            "mock-model",
6704            0.0,
6705            true,
6706            None,
6707            "telegram",
6708            Some("chat-42"),
6709            &crate::config::MultimodalConfig::default(),
6710            4,
6711            None,
6712            None,
6713            None,
6714            &[],
6715            &[],
6716            None,
6717            None,
6718            &crate::config::PacingConfig::default(),
6719            0,
6720            0,
6721            None,
6722        )
6723        .await
6724        .expect("cron_add delivery defaults should be injected");
6725
6726        assert_eq!(result, "done");
6727
6728        let recorded = recorded_args
6729            .lock()
6730            .expect("recorded args lock should be valid");
6731        let delivery = recorded[0]["delivery"].clone();
6732        assert_eq!(
6733            delivery,
6734            serde_json::json!({
6735                "mode": "announce",
6736                "channel": "telegram",
6737                "to": "chat-42",
6738            })
6739        );
6740    }
6741
6742    #[tokio::test]
6743    async fn run_tool_call_loop_preserves_explicit_cron_delivery_none() {
6744        let provider = ScriptedProvider::from_text_responses(vec![
6745            r#"<tool_call>
6746{"name":"cron_add","arguments":{"job_type":"agent","prompt":"run silently","schedule":{"kind":"every","every_ms":60000},"delivery":{"mode":"none"}}}
6747</tool_call>"#,
6748            "done",
6749        ]);
6750
6751        let recorded_args = Arc::new(Mutex::new(Vec::new()));
6752        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(RecordingArgsTool::new(
6753            "cron_add",
6754            Arc::clone(&recorded_args),
6755        ))];
6756
6757        let mut history = vec![
6758            ChatMessage::system("test-system"),
6759            ChatMessage::user("schedule a quiet cron job"),
6760        ];
6761        let observer = NoopObserver;
6762
6763        let result = run_tool_call_loop(
6764            &provider,
6765            &mut history,
6766            &tools_registry,
6767            &observer,
6768            "mock-provider",
6769            "mock-model",
6770            0.0,
6771            true,
6772            None,
6773            "telegram",
6774            Some("chat-42"),
6775            &crate::config::MultimodalConfig::default(),
6776            4,
6777            None,
6778            None,
6779            None,
6780            &[],
6781            &[],
6782            None,
6783            None,
6784            &crate::config::PacingConfig::default(),
6785            0,
6786            0,
6787            None,
6788        )
6789        .await
6790        .expect("explicit delivery mode should be preserved");
6791
6792        assert_eq!(result, "done");
6793
6794        let recorded = recorded_args
6795            .lock()
6796            .expect("recorded args lock should be valid");
6797        assert_eq!(recorded[0]["delivery"], serde_json::json!({"mode": "none"}));
6798    }
6799
6800    #[tokio::test]
6801    async fn run_tool_call_loop_deduplicates_repeated_tool_calls() {
6802        let provider = ScriptedProvider::from_text_responses(vec![
6803            r#"<tool_call>
6804{"name":"count_tool","arguments":{"value":"A"}}
6805</tool_call>
6806<tool_call>
6807{"name":"count_tool","arguments":{"value":"A"}}
6808</tool_call>"#,
6809            "done",
6810        ]);
6811
6812        let invocations = Arc::new(AtomicUsize::new(0));
6813        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
6814            "count_tool",
6815            Arc::clone(&invocations),
6816        ))];
6817
6818        let mut history = vec![
6819            ChatMessage::system("test-system"),
6820            ChatMessage::user("run tool calls"),
6821        ];
6822        let observer = NoopObserver;
6823
6824        let result = run_tool_call_loop(
6825            &provider,
6826            &mut history,
6827            &tools_registry,
6828            &observer,
6829            "mock-provider",
6830            "mock-model",
6831            0.0,
6832            true,
6833            None,
6834            "cli",
6835            None,
6836            &crate::config::MultimodalConfig::default(),
6837            4,
6838            None,
6839            None,
6840            None,
6841            &[],
6842            &[],
6843            None,
6844            None,
6845            &crate::config::PacingConfig::default(),
6846            0,
6847            0,
6848            None,
6849        )
6850        .await
6851        .expect("loop should finish after deduplicating repeated calls");
6852
6853        assert_eq!(result, "done");
6854        assert_eq!(
6855            invocations.load(Ordering::SeqCst),
6856            1,
6857            "duplicate tool call with same args should not execute twice"
6858        );
6859
6860        let tool_results = history
6861            .iter()
6862            .find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]"))
6863            .expect("prompt-mode tool result payload should be present");
6864        assert!(tool_results.content.contains("counted:A"));
6865        assert!(tool_results.content.contains("Skipped duplicate tool call"));
6866    }
6867
6868    #[tokio::test]
6869    async fn run_tool_call_loop_allows_low_risk_shell_in_non_interactive_mode() {
6870        let provider = ScriptedProvider::from_text_responses(vec![
6871            r#"<tool_call>
6872{"name":"shell","arguments":{"command":"echo hello"}}
6873</tool_call>"#,
6874            "done",
6875        ]);
6876
6877        let tmp = TempDir::new().expect("temp dir");
6878        let security = Arc::new(crate::security::SecurityPolicy {
6879            autonomy: crate::security::AutonomyLevel::Supervised,
6880            workspace_dir: tmp.path().to_path_buf(),
6881            ..crate::security::SecurityPolicy::default()
6882        });
6883        let runtime: Arc<dyn crate::runtime::RuntimeAdapter> =
6884            Arc::new(crate::runtime::NativeRuntime::new());
6885        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(
6886            crate::tools::shell::ShellTool::new(security, runtime),
6887        )];
6888
6889        let mut history = vec![
6890            ChatMessage::system("test-system"),
6891            ChatMessage::user("run shell"),
6892        ];
6893        let observer = NoopObserver;
6894        let approval_mgr =
6895            ApprovalManager::for_non_interactive(&crate::config::AutonomyConfig::default());
6896
6897        let result = run_tool_call_loop(
6898            &provider,
6899            &mut history,
6900            &tools_registry,
6901            &observer,
6902            "mock-provider",
6903            "mock-model",
6904            0.0,
6905            true,
6906            Some(&approval_mgr),
6907            "telegram",
6908            None,
6909            &crate::config::MultimodalConfig::default(),
6910            4,
6911            None,
6912            None,
6913            None,
6914            &[],
6915            &[],
6916            None,
6917            None,
6918            &crate::config::PacingConfig::default(),
6919            0,
6920            0,
6921            None,
6922        )
6923        .await
6924        .expect("non-interactive shell should succeed for low-risk command");
6925
6926        assert_eq!(result, "done");
6927
6928        let tool_results = history
6929            .iter()
6930            .find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]"))
6931            .expect("tool results message should be present");
6932        assert!(tool_results.content.contains("hello"));
6933        assert!(!tool_results.content.contains("Denied by user."));
6934    }
6935
6936    #[tokio::test]
6937    async fn run_tool_call_loop_dedup_exempt_allows_repeated_calls() {
6938        let provider = ScriptedProvider::from_text_responses(vec![
6939            r#"<tool_call>
6940{"name":"count_tool","arguments":{"value":"A"}}
6941</tool_call>
6942<tool_call>
6943{"name":"count_tool","arguments":{"value":"A"}}
6944</tool_call>"#,
6945            "done",
6946        ]);
6947
6948        let invocations = Arc::new(AtomicUsize::new(0));
6949        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
6950            "count_tool",
6951            Arc::clone(&invocations),
6952        ))];
6953
6954        let mut history = vec![
6955            ChatMessage::system("test-system"),
6956            ChatMessage::user("run tool calls"),
6957        ];
6958        let observer = NoopObserver;
6959        let exempt = vec!["count_tool".to_string()];
6960
6961        let result = run_tool_call_loop(
6962            &provider,
6963            &mut history,
6964            &tools_registry,
6965            &observer,
6966            "mock-provider",
6967            "mock-model",
6968            0.0,
6969            true,
6970            None,
6971            "cli",
6972            None,
6973            &crate::config::MultimodalConfig::default(),
6974            4,
6975            None,
6976            None,
6977            None,
6978            &[],
6979            &exempt,
6980            None,
6981            None,
6982            &crate::config::PacingConfig::default(),
6983            0,
6984            0,
6985            None,
6986        )
6987        .await
6988        .expect("loop should finish with exempt tool executing twice");
6989
6990        assert_eq!(result, "done");
6991        assert_eq!(
6992            invocations.load(Ordering::SeqCst),
6993            2,
6994            "exempt tool should execute both duplicate calls"
6995        );
6996
6997        let tool_results = history
6998            .iter()
6999            .find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]"))
7000            .expect("prompt-mode tool result payload should be present");
7001        assert!(
7002            !tool_results.content.contains("Skipped duplicate tool call"),
7003            "exempt tool calls should not be suppressed"
7004        );
7005    }
7006
7007    #[tokio::test]
7008    async fn run_tool_call_loop_dedup_exempt_only_affects_listed_tools() {
7009        let provider = ScriptedProvider::from_text_responses(vec![
7010            r#"<tool_call>
7011{"name":"count_tool","arguments":{"value":"A"}}
7012</tool_call>
7013<tool_call>
7014{"name":"count_tool","arguments":{"value":"A"}}
7015</tool_call>
7016<tool_call>
7017{"name":"other_tool","arguments":{"value":"B"}}
7018</tool_call>
7019<tool_call>
7020{"name":"other_tool","arguments":{"value":"B"}}
7021</tool_call>"#,
7022            "done",
7023        ]);
7024
7025        let count_invocations = Arc::new(AtomicUsize::new(0));
7026        let other_invocations = Arc::new(AtomicUsize::new(0));
7027        let tools_registry: Vec<Box<dyn Tool>> = vec![
7028            Box::new(CountingTool::new(
7029                "count_tool",
7030                Arc::clone(&count_invocations),
7031            )),
7032            Box::new(CountingTool::new(
7033                "other_tool",
7034                Arc::clone(&other_invocations),
7035            )),
7036        ];
7037
7038        let mut history = vec![
7039            ChatMessage::system("test-system"),
7040            ChatMessage::user("run tool calls"),
7041        ];
7042        let observer = NoopObserver;
7043        let exempt = vec!["count_tool".to_string()];
7044
7045        let _result = run_tool_call_loop(
7046            &provider,
7047            &mut history,
7048            &tools_registry,
7049            &observer,
7050            "mock-provider",
7051            "mock-model",
7052            0.0,
7053            true,
7054            None,
7055            "cli",
7056            None,
7057            &crate::config::MultimodalConfig::default(),
7058            4,
7059            None,
7060            None,
7061            None,
7062            &[],
7063            &exempt,
7064            None,
7065            None,
7066            &crate::config::PacingConfig::default(),
7067            0,
7068            0,
7069            None,
7070        )
7071        .await
7072        .expect("loop should complete");
7073
7074        assert_eq!(
7075            count_invocations.load(Ordering::SeqCst),
7076            2,
7077            "exempt tool should execute both calls"
7078        );
7079        assert_eq!(
7080            other_invocations.load(Ordering::SeqCst),
7081            1,
7082            "non-exempt tool should still be deduped"
7083        );
7084    }
7085
7086    #[tokio::test]
7087    async fn run_tool_call_loop_native_mode_preserves_fallback_tool_call_ids() {
7088        let provider = ScriptedProvider::from_text_responses(vec![
7089            r#"{"content":"Need to call tool","tool_calls":[{"id":"call_abc","name":"count_tool","arguments":"{\"value\":\"X\"}"}]}"#,
7090            "done",
7091        ])
7092        .with_native_tool_support();
7093
7094        let invocations = Arc::new(AtomicUsize::new(0));
7095        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
7096            "count_tool",
7097            Arc::clone(&invocations),
7098        ))];
7099
7100        let mut history = vec![
7101            ChatMessage::system("test-system"),
7102            ChatMessage::user("run tool calls"),
7103        ];
7104        let observer = NoopObserver;
7105
7106        let result = run_tool_call_loop(
7107            &provider,
7108            &mut history,
7109            &tools_registry,
7110            &observer,
7111            "mock-provider",
7112            "mock-model",
7113            0.0,
7114            true,
7115            None,
7116            "cli",
7117            None,
7118            &crate::config::MultimodalConfig::default(),
7119            4,
7120            None,
7121            None,
7122            None,
7123            &[],
7124            &[],
7125            None,
7126            None,
7127            &crate::config::PacingConfig::default(),
7128            0,
7129            0,
7130            None,
7131        )
7132        .await
7133        .expect("native fallback id flow should complete");
7134
7135        assert_eq!(result, "done");
7136        assert_eq!(invocations.load(Ordering::SeqCst), 1);
7137        assert!(
7138            history.iter().any(|msg| {
7139                msg.role == "tool" && msg.content.contains("\"tool_call_id\":\"call_abc\"")
7140            }),
7141            "tool result should preserve parsed fallback tool_call_id in native mode"
7142        );
7143        assert!(
7144            history
7145                .iter()
7146                .all(|msg| !(msg.role == "user" && msg.content.starts_with("[Tool results]"))),
7147            "native mode should use role=tool history instead of prompt fallback wrapper"
7148        );
7149    }
7150
7151    #[tokio::test]
7152    async fn run_tool_call_loop_relays_native_tool_call_text_via_on_delta() {
7153        let provider = ScriptedProvider {
7154            responses: Arc::new(Mutex::new(VecDeque::from(vec![
7155                ChatResponse {
7156                    text: Some("Task started. Waiting 30 seconds before checking status.".into()),
7157                    tool_calls: vec![ToolCall {
7158                        id: "call_wait".into(),
7159                        name: "count_tool".into(),
7160                        arguments: r#"{"value":"A"}"#.into(),
7161                    }],
7162                    usage: None,
7163                    reasoning_content: None,
7164                },
7165                ChatResponse {
7166                    text: Some("Final answer".into()),
7167                    tool_calls: Vec::new(),
7168                    usage: None,
7169                    reasoning_content: None,
7170                },
7171            ]))),
7172            capabilities: ProviderCapabilities {
7173                native_tool_calling: true,
7174                ..ProviderCapabilities::default()
7175            },
7176        };
7177
7178        let invocations = Arc::new(AtomicUsize::new(0));
7179        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
7180            "count_tool",
7181            Arc::clone(&invocations),
7182        ))];
7183
7184        let mut history = vec![
7185            ChatMessage::system("test-system"),
7186            ChatMessage::user("run tool calls"),
7187        ];
7188        let observer = NoopObserver;
7189        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
7190
7191        let result = run_tool_call_loop(
7192            &provider,
7193            &mut history,
7194            &tools_registry,
7195            &observer,
7196            "mock-provider",
7197            "mock-model",
7198            0.0,
7199            true,
7200            None,
7201            "telegram",
7202            None,
7203            &crate::config::MultimodalConfig::default(),
7204            4,
7205            None,
7206            Some(tx),
7207            None,
7208            &[],
7209            &[],
7210            None,
7211            None,
7212            &crate::config::PacingConfig::default(),
7213            0,
7214            0,
7215            None,
7216        )
7217        .await
7218        .expect("native tool-call text should be relayed through on_delta");
7219
7220        let mut deltas: Vec<DraftEvent> = Vec::new();
7221        while let Some(delta) = rx.recv().await {
7222            deltas.push(delta);
7223        }
7224
7225        let explanation_idx = deltas
7226            .iter()
7227            .position(|delta| matches!(delta, DraftEvent::Content(t) if t == "Task started. Waiting 30 seconds before checking status.\n"))
7228            .expect("native assistant text should be relayed to on_delta");
7229        let clear_idx = deltas
7230            .iter()
7231            .position(|delta| matches!(delta, DraftEvent::Clear))
7232            .expect("final answer streaming should clear prior draft state");
7233
7234        assert!(
7235            deltas
7236                .iter()
7237                .any(|delta| matches!(delta, DraftEvent::Progress(t) if t.starts_with("\u{1f4ac} Got 1 tool call(s)"))),
7238            "tool-call progress line should still be relayed"
7239        );
7240        assert!(
7241            explanation_idx < clear_idx,
7242            "native assistant text should arrive before final-answer draft clearing"
7243        );
7244        assert_eq!(result, "Final answer");
7245        assert_eq!(invocations.load(Ordering::SeqCst), 1);
7246    }
7247
7248    #[tokio::test]
7249    async fn run_tool_call_loop_consumes_provider_stream_for_final_response() {
7250        let provider =
7251            StreamingScriptedProvider::from_text_responses(vec!["streamed final answer"]);
7252        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
7253        let mut history = vec![
7254            ChatMessage::system("test-system"),
7255            ChatMessage::user("say hi"),
7256        ];
7257        let observer = NoopObserver;
7258        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(32);
7259
7260        let result = run_tool_call_loop(
7261            &provider,
7262            &mut history,
7263            &tools_registry,
7264            &observer,
7265            "mock-provider",
7266            "mock-model",
7267            0.0,
7268            true,
7269            None,
7270            "telegram",
7271            None,
7272            &crate::config::MultimodalConfig::default(),
7273            4,
7274            None,
7275            Some(tx),
7276            None,
7277            &[],
7278            &[],
7279            None,
7280            None,
7281            &crate::config::PacingConfig::default(),
7282            0,
7283            0,
7284            None,
7285        )
7286        .await
7287        .expect("streaming provider should complete");
7288
7289        let mut visible_deltas = String::new();
7290        while let Some(delta) = rx.recv().await {
7291            match delta {
7292                DraftEvent::Clear => {
7293                    visible_deltas.clear();
7294                }
7295                DraftEvent::Progress(_) => {}
7296                DraftEvent::Content(text) => {
7297                    visible_deltas.push_str(&text);
7298                }
7299            }
7300        }
7301
7302        assert_eq!(result, "streamed final answer");
7303        assert_eq!(
7304            visible_deltas, "streamed final answer",
7305            "draft should receive upstream deltas once without post-hoc duplication"
7306        );
7307        assert_eq!(provider.stream_calls.load(Ordering::SeqCst), 1);
7308        assert_eq!(provider.chat_calls.load(Ordering::SeqCst), 0);
7309    }
7310
7311    #[tokio::test]
7312    async fn run_tool_call_loop_streaming_path_preserves_tool_loop_semantics() {
7313        let provider = StreamingScriptedProvider::from_text_responses(vec![
7314            r#"<tool_call>
7315{"name":"count_tool","arguments":{"value":"A"}}
7316</tool_call>"#,
7317            "done",
7318        ]);
7319        let invocations = Arc::new(AtomicUsize::new(0));
7320        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
7321            "count_tool",
7322            Arc::clone(&invocations),
7323        ))];
7324        let mut history = vec![
7325            ChatMessage::system("test-system"),
7326            ChatMessage::user("run tool calls"),
7327        ];
7328        let observer = NoopObserver;
7329        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(64);
7330
7331        let result = run_tool_call_loop(
7332            &provider,
7333            &mut history,
7334            &tools_registry,
7335            &observer,
7336            "mock-provider",
7337            "mock-model",
7338            0.0,
7339            true,
7340            None,
7341            "telegram",
7342            None,
7343            &crate::config::MultimodalConfig::default(),
7344            5,
7345            None,
7346            Some(tx),
7347            None,
7348            &[],
7349            &[],
7350            None,
7351            None,
7352            &crate::config::PacingConfig::default(),
7353            0,
7354            0,
7355            None,
7356        )
7357        .await
7358        .expect("streaming tool loop should execute tool and finish");
7359
7360        let mut visible_deltas = String::new();
7361        while let Some(delta) = rx.recv().await {
7362            match delta {
7363                DraftEvent::Clear => {
7364                    visible_deltas.clear();
7365                }
7366                DraftEvent::Progress(_) => {}
7367                DraftEvent::Content(text) => {
7368                    visible_deltas.push_str(&text);
7369                }
7370            }
7371        }
7372
7373        assert_eq!(result, "done");
7374        assert_eq!(invocations.load(Ordering::SeqCst), 1);
7375        assert_eq!(provider.stream_calls.load(Ordering::SeqCst), 2);
7376        assert_eq!(provider.chat_calls.load(Ordering::SeqCst), 0);
7377        assert_eq!(visible_deltas, "done");
7378        assert!(
7379            !visible_deltas.contains("<tool_call"),
7380            "draft text should not leak streamed tool payload markers"
7381        );
7382    }
7383
7384    #[tokio::test]
7385    async fn run_tool_call_loop_streams_native_tool_events_without_chat_fallback() {
7386        let provider = StreamingNativeToolEventProvider::with_turns(vec![
7387            NativeStreamTurn::ToolCall(ToolCall {
7388                id: "call_native_1".to_string(),
7389                name: "count_tool".to_string(),
7390                arguments: r#"{"value":"A"}"#.to_string(),
7391            }),
7392            NativeStreamTurn::Text("done".to_string()),
7393        ]);
7394        let invocations = Arc::new(AtomicUsize::new(0));
7395        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
7396            "count_tool",
7397            Arc::clone(&invocations),
7398        ))];
7399        let mut history = vec![
7400            ChatMessage::system("test-system"),
7401            ChatMessage::user("run native tools"),
7402        ];
7403        let observer = NoopObserver;
7404        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(64);
7405
7406        let result = run_tool_call_loop(
7407            &provider,
7408            &mut history,
7409            &tools_registry,
7410            &observer,
7411            "mock-provider",
7412            "mock-model",
7413            0.0,
7414            true,
7415            None,
7416            "telegram",
7417            None,
7418            &crate::config::MultimodalConfig::default(),
7419            5,
7420            None,
7421            Some(tx),
7422            None,
7423            &[],
7424            &[],
7425            None,
7426            None,
7427            &crate::config::PacingConfig::default(),
7428            0,
7429            0,
7430            None,
7431        )
7432        .await
7433        .expect("native streaming events should preserve tool loop semantics");
7434
7435        let mut visible_deltas = String::new();
7436        while let Some(delta) = rx.recv().await {
7437            match delta {
7438                DraftEvent::Clear => {
7439                    visible_deltas.clear();
7440                }
7441                DraftEvent::Progress(_) => {}
7442                DraftEvent::Content(text) => {
7443                    visible_deltas.push_str(&text);
7444                }
7445            }
7446        }
7447
7448        assert_eq!(result, "done");
7449        assert_eq!(invocations.load(Ordering::SeqCst), 1);
7450        assert_eq!(provider.stream_calls.load(Ordering::SeqCst), 2);
7451        assert_eq!(provider.stream_tool_requests.load(Ordering::SeqCst), 2);
7452        assert_eq!(provider.chat_calls.load(Ordering::SeqCst), 0);
7453        assert_eq!(visible_deltas, "done");
7454    }
7455
7456    #[tokio::test]
7457    async fn run_tool_call_loop_routed_streaming_uses_live_provider_deltas_once() {
7458        let default_provider = RouteAwareStreamingProvider::new("default answer");
7459        let default_stream_calls = Arc::clone(&default_provider.stream_calls);
7460        let default_chat_calls = Arc::clone(&default_provider.chat_calls);
7461
7462        let routed_provider = RouteAwareStreamingProvider::new("routed streamed answer");
7463        let routed_stream_calls = Arc::clone(&routed_provider.stream_calls);
7464        let routed_chat_calls = Arc::clone(&routed_provider.chat_calls);
7465        let routed_last_model = Arc::clone(&routed_provider.last_model);
7466
7467        let router = RouterProvider::new(
7468            vec![
7469                ("default".to_string(), Box::new(default_provider)),
7470                ("fast".to_string(), Box::new(routed_provider)),
7471            ],
7472            vec![(
7473                "fast".to_string(),
7474                Route {
7475                    provider_name: "fast".to_string(),
7476                    model: "routed-model".to_string(),
7477                },
7478            )],
7479            "default-model".to_string(),
7480        );
7481
7482        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
7483        let mut history = vec![
7484            ChatMessage::system("test-system"),
7485            ChatMessage::user("say hi"),
7486        ];
7487        let observer = NoopObserver;
7488        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(32);
7489
7490        let result = run_tool_call_loop(
7491            &router,
7492            &mut history,
7493            &tools_registry,
7494            &observer,
7495            "router",
7496            "hint:fast",
7497            0.0,
7498            true,
7499            None,
7500            "telegram",
7501            None,
7502            &crate::config::MultimodalConfig::default(),
7503            4,
7504            None,
7505            Some(tx),
7506            None,
7507            &[],
7508            &[],
7509            None,
7510            None,
7511            &crate::config::PacingConfig::default(),
7512            0,
7513            0,
7514            None,
7515        )
7516        .await
7517        .expect("routed streaming provider should complete");
7518
7519        let mut visible_deltas = String::new();
7520        while let Some(delta) = rx.recv().await {
7521            match delta {
7522                DraftEvent::Clear => {
7523                    visible_deltas.clear();
7524                }
7525                DraftEvent::Progress(_) => {}
7526                DraftEvent::Content(text) => {
7527                    visible_deltas.push_str(&text);
7528                }
7529            }
7530        }
7531
7532        assert_eq!(result, "routed streamed answer");
7533        assert_eq!(
7534            visible_deltas, "routed streamed answer",
7535            "routed draft should receive upstream deltas once without post-hoc duplication"
7536        );
7537        assert_eq!(default_stream_calls.load(Ordering::SeqCst), 0);
7538        assert_eq!(routed_stream_calls.load(Ordering::SeqCst), 1);
7539        assert_eq!(default_chat_calls.load(Ordering::SeqCst), 0);
7540        assert_eq!(routed_chat_calls.load(Ordering::SeqCst), 0);
7541        assert_eq!(
7542            routed_last_model
7543                .lock()
7544                .expect("routed_last_model lock should be valid")
7545                .as_str(),
7546            "routed-model"
7547        );
7548    }
7549
7550    #[test]
7551    fn agent_turn_executes_activated_tool_from_wrapper() {
7552        let runtime = tokio::runtime::Builder::new_current_thread()
7553            .enable_all()
7554            .build()
7555            .expect("test runtime should initialize");
7556
7557        runtime.block_on(async {
7558            let provider = ScriptedProvider::from_text_responses(vec![
7559                r#"<tool_call>
7560{"name":"pixel__get_api_health","arguments":{"value":"ok"}}
7561</tool_call>"#,
7562                "done",
7563            ]);
7564
7565            let invocations = Arc::new(AtomicUsize::new(0));
7566            let activated = Arc::new(std::sync::Mutex::new(crate::tools::ActivatedToolSet::new()));
7567            let activated_tool: Arc<dyn Tool> = Arc::new(CountingTool::new(
7568                "pixel__get_api_health",
7569                Arc::clone(&invocations),
7570            ));
7571            activated
7572                .lock()
7573                .unwrap()
7574                .activate("pixel__get_api_health".into(), activated_tool);
7575
7576            let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
7577            let mut history = vec![
7578                ChatMessage::system("test-system"),
7579                ChatMessage::user("use the activated MCP tool"),
7580            ];
7581            let observer = NoopObserver;
7582
7583            let result = agent_turn(
7584                &provider,
7585                &mut history,
7586                &tools_registry,
7587                &observer,
7588                "mock-provider",
7589                "mock-model",
7590                0.0,
7591                true,
7592                "daemon",
7593                None,
7594                &crate::config::MultimodalConfig::default(),
7595                4,
7596                None,
7597                &[],
7598                &[],
7599                Some(&activated),
7600                None,
7601            )
7602            .await
7603            .expect("wrapper path should execute activated tools");
7604
7605            assert_eq!(result, "done");
7606            assert_eq!(invocations.load(Ordering::SeqCst), 1);
7607        });
7608    }
7609
7610    #[test]
7611    fn resolve_display_text_hides_raw_payload_for_tool_only_turns() {
7612        let display = resolve_display_text(
7613            "<tool_call>{\"name\":\"memory_store\"}</tool_call>",
7614            "",
7615            true,
7616            false,
7617        );
7618        assert!(display.is_empty());
7619    }
7620
7621    #[test]
7622    fn resolve_display_text_keeps_plain_text_for_tool_turns() {
7623        let display = resolve_display_text(
7624            "<tool_call>{\"name\":\"shell\"}</tool_call>",
7625            "Let me check that.",
7626            true,
7627            false,
7628        );
7629        assert_eq!(display, "Let me check that.");
7630    }
7631
7632    #[test]
7633    fn resolve_display_text_uses_response_text_for_native_tool_turns() {
7634        let display = resolve_display_text("Task started.", "", true, true);
7635        assert_eq!(display, "Task started.");
7636    }
7637
7638    #[test]
7639    fn resolve_display_text_uses_response_text_for_final_turns() {
7640        let display = resolve_display_text("Final answer", "", false, false);
7641        assert_eq!(display, "Final answer");
7642    }
7643
7644    #[test]
7645    fn parse_tool_calls_extracts_single_call() {
7646        let response = r#"Let me check that.
7647<tool_call>
7648{"name": "shell", "arguments": {"command": "ls -la"}}
7649</tool_call>"#;
7650
7651        let (text, calls) = parse_tool_calls(response);
7652        assert_eq!(text, "Let me check that.");
7653        assert_eq!(calls.len(), 1);
7654        assert_eq!(calls[0].name, "shell");
7655        assert_eq!(
7656            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7657            "ls -la"
7658        );
7659    }
7660
7661    #[test]
7662    fn parse_tool_calls_extracts_multiple_calls() {
7663        let response = r#"<tool_call>
7664{"name": "file_read", "arguments": {"path": "a.txt"}}
7665</tool_call>
7666<tool_call>
7667{"name": "file_read", "arguments": {"path": "b.txt"}}
7668</tool_call>"#;
7669
7670        let (_, calls) = parse_tool_calls(response);
7671        assert_eq!(calls.len(), 2);
7672        assert_eq!(calls[0].name, "file_read");
7673        assert_eq!(calls[1].name, "file_read");
7674    }
7675
7676    #[test]
7677    fn parse_tool_calls_returns_text_only_when_no_calls() {
7678        let response = "Just a normal response with no tools.";
7679        let (text, calls) = parse_tool_calls(response);
7680        assert_eq!(text, "Just a normal response with no tools.");
7681        assert!(calls.is_empty());
7682    }
7683
7684    #[test]
7685    fn parse_tool_calls_handles_malformed_json() {
7686        let response = r#"<tool_call>
7687not valid json
7688</tool_call>
7689Some text after."#;
7690
7691        let (text, calls) = parse_tool_calls(response);
7692        assert!(calls.is_empty());
7693        assert!(text.contains("Some text after."));
7694    }
7695
7696    #[test]
7697    fn parse_tool_calls_text_before_and_after() {
7698        let response = r#"Before text.
7699<tool_call>
7700{"name": "shell", "arguments": {"command": "echo hi"}}
7701</tool_call>
7702After text."#;
7703
7704        let (text, calls) = parse_tool_calls(response);
7705        assert!(text.contains("Before text."));
7706        assert!(text.contains("After text."));
7707        assert_eq!(calls.len(), 1);
7708    }
7709
7710    #[test]
7711    fn parse_tool_calls_handles_openai_format() {
7712        // OpenAI-style response with tool_calls array
7713        let response = r#"{"content": "Let me check that for you.", "tool_calls": [{"type": "function", "function": {"name": "shell", "arguments": "{\"command\": \"ls -la\"}"}}]}"#;
7714
7715        let (text, calls) = parse_tool_calls(response);
7716        assert_eq!(text, "Let me check that for you.");
7717        assert_eq!(calls.len(), 1);
7718        assert_eq!(calls[0].name, "shell");
7719        assert_eq!(
7720            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7721            "ls -la"
7722        );
7723    }
7724
7725    #[test]
7726    fn parse_tool_calls_handles_openai_format_multiple_calls() {
7727        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\"}"}}]}"#;
7728
7729        let (_, calls) = parse_tool_calls(response);
7730        assert_eq!(calls.len(), 2);
7731        assert_eq!(calls[0].name, "file_read");
7732        assert_eq!(calls[1].name, "file_read");
7733    }
7734
7735    #[test]
7736    fn parse_tool_calls_openai_format_without_content() {
7737        // Some providers don't include content field with tool_calls
7738        let response = r#"{"tool_calls": [{"type": "function", "function": {"name": "memory_recall", "arguments": "{}"}}]}"#;
7739
7740        let (text, calls) = parse_tool_calls(response);
7741        assert!(text.is_empty()); // No content field
7742        assert_eq!(calls.len(), 1);
7743        assert_eq!(calls[0].name, "memory_recall");
7744    }
7745
7746    #[test]
7747    fn parse_tool_calls_preserves_openai_tool_call_ids() {
7748        let response = r#"{"tool_calls":[{"id":"call_42","function":{"name":"shell","arguments":"{\"command\":\"pwd\"}"}}]}"#;
7749        let (_, calls) = parse_tool_calls(response);
7750        assert_eq!(calls.len(), 1);
7751        assert_eq!(calls[0].tool_call_id.as_deref(), Some("call_42"));
7752    }
7753
7754    #[test]
7755    fn parse_tool_calls_handles_markdown_json_inside_tool_call_tag() {
7756        let response = r#"<tool_call>
7757```json
7758{"name": "file_write", "arguments": {"path": "test.py", "content": "print('ok')"}}
7759```
7760</tool_call>"#;
7761
7762        let (text, calls) = parse_tool_calls(response);
7763        assert!(text.is_empty());
7764        assert_eq!(calls.len(), 1);
7765        assert_eq!(calls[0].name, "file_write");
7766        assert_eq!(
7767            calls[0].arguments.get("path").unwrap().as_str().unwrap(),
7768            "test.py"
7769        );
7770    }
7771
7772    #[test]
7773    fn parse_tool_calls_handles_noisy_tool_call_tag_body() {
7774        let response = r#"<tool_call>
7775I will now call the tool with this payload:
7776{"name": "shell", "arguments": {"command": "pwd"}}
7777</tool_call>"#;
7778
7779        let (text, calls) = parse_tool_calls(response);
7780        assert!(text.is_empty());
7781        assert_eq!(calls.len(), 1);
7782        assert_eq!(calls[0].name, "shell");
7783        assert_eq!(
7784            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7785            "pwd"
7786        );
7787    }
7788
7789    #[test]
7790    fn parse_tool_calls_handles_tool_call_inline_attributes_with_send_message_alias() {
7791        let response = r#"<tool_call>send_message channel="user_channel" message="Hello! How can I assist you today?"</tool_call>"#;
7792
7793        let (text, calls) = parse_tool_calls(response);
7794        assert!(text.is_empty());
7795        assert_eq!(calls.len(), 1);
7796        assert_eq!(calls[0].name, "message_send");
7797        assert_eq!(
7798            calls[0].arguments.get("channel").unwrap().as_str().unwrap(),
7799            "user_channel"
7800        );
7801        assert_eq!(
7802            calls[0].arguments.get("message").unwrap().as_str().unwrap(),
7803            "Hello! How can I assist you today?"
7804        );
7805    }
7806
7807    #[test]
7808    fn parse_tool_calls_handles_tool_call_function_style_arguments() {
7809        let response = r#"<tool_call>message_send(channel="general", message="test")</tool_call>"#;
7810
7811        let (text, calls) = parse_tool_calls(response);
7812        assert!(text.is_empty());
7813        assert_eq!(calls.len(), 1);
7814        assert_eq!(calls[0].name, "message_send");
7815        assert_eq!(
7816            calls[0].arguments.get("channel").unwrap().as_str().unwrap(),
7817            "general"
7818        );
7819        assert_eq!(
7820            calls[0].arguments.get("message").unwrap().as_str().unwrap(),
7821            "test"
7822        );
7823    }
7824
7825    #[test]
7826    fn parse_tool_calls_handles_xml_nested_tool_payload() {
7827        let response = r#"<tool_call>
7828<memory_recall>
7829<query>project roadmap</query>
7830</memory_recall>
7831</tool_call>"#;
7832
7833        let (text, calls) = parse_tool_calls(response);
7834        assert!(text.is_empty());
7835        assert_eq!(calls.len(), 1);
7836        assert_eq!(calls[0].name, "memory_recall");
7837        assert_eq!(
7838            calls[0].arguments.get("query").unwrap().as_str().unwrap(),
7839            "project roadmap"
7840        );
7841    }
7842
7843    #[test]
7844    fn parse_tool_calls_ignores_xml_thinking_wrapper() {
7845        let response = r#"<tool_call>
7846<thinking>Need to inspect memory first</thinking>
7847<memory_recall>
7848<query>recent deploy notes</query>
7849</memory_recall>
7850</tool_call>"#;
7851
7852        let (text, calls) = parse_tool_calls(response);
7853        assert!(text.is_empty());
7854        assert_eq!(calls.len(), 1);
7855        assert_eq!(calls[0].name, "memory_recall");
7856        assert_eq!(
7857            calls[0].arguments.get("query").unwrap().as_str().unwrap(),
7858            "recent deploy notes"
7859        );
7860    }
7861
7862    #[test]
7863    fn parse_tool_calls_handles_xml_with_json_arguments() {
7864        let response = r#"<tool_call>
7865<shell>{"command":"pwd"}</shell>
7866</tool_call>"#;
7867
7868        let (text, calls) = parse_tool_calls(response);
7869        assert!(text.is_empty());
7870        assert_eq!(calls.len(), 1);
7871        assert_eq!(calls[0].name, "shell");
7872        assert_eq!(
7873            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7874            "pwd"
7875        );
7876    }
7877
7878    #[test]
7879    fn parse_tool_calls_handles_markdown_tool_call_fence() {
7880        let response = r#"I'll check that.
7881```tool_call
7882{"name": "shell", "arguments": {"command": "pwd"}}
7883```
7884Done."#;
7885
7886        let (text, calls) = parse_tool_calls(response);
7887        assert_eq!(calls.len(), 1);
7888        assert_eq!(calls[0].name, "shell");
7889        assert_eq!(
7890            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7891            "pwd"
7892        );
7893        assert!(text.contains("I'll check that."));
7894        assert!(text.contains("Done."));
7895        assert!(!text.contains("```tool_call"));
7896    }
7897
7898    #[test]
7899    fn parse_tool_calls_handles_markdown_tool_call_hybrid_close_tag() {
7900        let response = r#"Preface
7901```tool-call
7902{"name": "shell", "arguments": {"command": "date"}}
7903</tool_call>
7904Tail"#;
7905
7906        let (text, calls) = parse_tool_calls(response);
7907        assert_eq!(calls.len(), 1);
7908        assert_eq!(calls[0].name, "shell");
7909        assert_eq!(
7910            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7911            "date"
7912        );
7913        assert!(text.contains("Preface"));
7914        assert!(text.contains("Tail"));
7915        assert!(!text.contains("```tool-call"));
7916    }
7917
7918    #[test]
7919    fn parse_tool_calls_handles_markdown_invoke_fence() {
7920        let response = r#"Checking.
7921```invoke
7922{"name": "shell", "arguments": {"command": "date"}}
7923```
7924Done."#;
7925
7926        let (text, calls) = parse_tool_calls(response);
7927        assert_eq!(calls.len(), 1);
7928        assert_eq!(calls[0].name, "shell");
7929        assert_eq!(
7930            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7931            "date"
7932        );
7933        assert!(text.contains("Checking."));
7934        assert!(text.contains("Done."));
7935    }
7936
7937    #[test]
7938    fn parse_tool_calls_handles_tool_name_fence_format() {
7939        // Issue #1420: xAI grok models use ```tool <name> format
7940        let response = r#"I'll write a test file.
7941```tool file_write
7942{"path": "/home/user/test.txt", "content": "Hello world"}
7943```
7944Done."#;
7945
7946        let (text, calls) = parse_tool_calls(response);
7947        assert_eq!(calls.len(), 1);
7948        assert_eq!(calls[0].name, "file_write");
7949        assert_eq!(
7950            calls[0].arguments.get("path").unwrap().as_str().unwrap(),
7951            "/home/user/test.txt"
7952        );
7953        assert!(text.contains("I'll write a test file."));
7954        assert!(text.contains("Done."));
7955    }
7956
7957    #[test]
7958    fn parse_tool_calls_handles_tool_name_fence_shell() {
7959        // Issue #1420: Test shell command in ```tool shell format
7960        let response = r#"```tool shell
7961{"command": "ls -la"}
7962```"#;
7963
7964        let (_text, calls) = parse_tool_calls(response);
7965        assert_eq!(calls.len(), 1);
7966        assert_eq!(calls[0].name, "shell");
7967        assert_eq!(
7968            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7969            "ls -la"
7970        );
7971    }
7972
7973    #[test]
7974    fn parse_tool_calls_handles_multiple_tool_name_fences() {
7975        // Multiple tool calls in ```tool <name> format
7976        let response = r#"First, I'll write a file.
7977```tool file_write
7978{"path": "/tmp/a.txt", "content": "A"}
7979```
7980Then read it.
7981```tool file_read
7982{"path": "/tmp/a.txt"}
7983```
7984Done."#;
7985
7986        let (text, calls) = parse_tool_calls(response);
7987        assert_eq!(calls.len(), 2);
7988        assert_eq!(calls[0].name, "file_write");
7989        assert_eq!(calls[1].name, "file_read");
7990        assert!(text.contains("First, I'll write a file."));
7991        assert!(text.contains("Then read it."));
7992        assert!(text.contains("Done."));
7993    }
7994
7995    #[test]
7996    fn parse_tool_calls_handles_toolcall_tag_alias() {
7997        let response = r#"<toolcall>
7998{"name": "shell", "arguments": {"command": "date"}}
7999</toolcall>"#;
8000
8001        let (text, calls) = parse_tool_calls(response);
8002        assert!(text.is_empty());
8003        assert_eq!(calls.len(), 1);
8004        assert_eq!(calls[0].name, "shell");
8005        assert_eq!(
8006            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8007            "date"
8008        );
8009    }
8010
8011    #[test]
8012    fn parse_tool_calls_handles_tool_dash_call_tag_alias() {
8013        let response = r#"<tool-call>
8014{"name": "shell", "arguments": {"command": "whoami"}}
8015</tool-call>"#;
8016
8017        let (text, calls) = parse_tool_calls(response);
8018        assert!(text.is_empty());
8019        assert_eq!(calls.len(), 1);
8020        assert_eq!(calls[0].name, "shell");
8021        assert_eq!(
8022            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8023            "whoami"
8024        );
8025    }
8026
8027    #[test]
8028    fn parse_tool_calls_handles_invoke_tag_alias() {
8029        let response = r#"<invoke>
8030{"name": "shell", "arguments": {"command": "uptime"}}
8031</invoke>"#;
8032
8033        let (text, calls) = parse_tool_calls(response);
8034        assert!(text.is_empty());
8035        assert_eq!(calls.len(), 1);
8036        assert_eq!(calls[0].name, "shell");
8037        assert_eq!(
8038            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8039            "uptime"
8040        );
8041    }
8042
8043    #[test]
8044    fn parse_tool_calls_handles_minimax_invoke_parameter_format() {
8045        let response = r#"<minimax:tool_call>
8046<invoke name="shell">
8047<parameter name="command">sqlite3 /tmp/test.db ".tables"</parameter>
8048</invoke>
8049</minimax:tool_call>"#;
8050
8051        let (text, calls) = parse_tool_calls(response);
8052        assert!(text.is_empty());
8053        assert_eq!(calls.len(), 1);
8054        assert_eq!(calls[0].name, "shell");
8055        assert_eq!(
8056            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8057            r#"sqlite3 /tmp/test.db ".tables""#
8058        );
8059    }
8060
8061    #[test]
8062    fn parse_tool_calls_handles_minimax_invoke_with_surrounding_text() {
8063        let response = r#"Preface
8064<minimax:tool_call>
8065<invoke name='http_request'>
8066<parameter name='url'>https://example.com</parameter>
8067<parameter name='method'>GET</parameter>
8068</invoke>
8069</minimax:tool_call>
8070Tail"#;
8071
8072        let (text, calls) = parse_tool_calls(response);
8073        assert!(text.contains("Preface"));
8074        assert!(text.contains("Tail"));
8075        assert_eq!(calls.len(), 1);
8076        assert_eq!(calls[0].name, "http_request");
8077        assert_eq!(
8078            calls[0].arguments.get("url").unwrap().as_str().unwrap(),
8079            "https://example.com"
8080        );
8081        assert_eq!(
8082            calls[0].arguments.get("method").unwrap().as_str().unwrap(),
8083            "GET"
8084        );
8085    }
8086
8087    #[test]
8088    fn parse_tool_calls_handles_minimax_toolcall_alias_and_cross_close_tag() {
8089        let response = r#"<tool_call>
8090{"name":"shell","arguments":{"command":"date"}}
8091</minimax:toolcall>"#;
8092
8093        let (text, calls) = parse_tool_calls(response);
8094        assert!(text.is_empty());
8095        assert_eq!(calls.len(), 1);
8096        assert_eq!(calls[0].name, "shell");
8097        assert_eq!(
8098            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8099            "date"
8100        );
8101    }
8102
8103    #[test]
8104    fn parse_tool_calls_handles_perl_style_tool_call_blocks() {
8105        let response = r#"TOOL_CALL
8106{tool => "shell", args => { --command "uname -a" }}}
8107/TOOL_CALL"#;
8108
8109        let calls = parse_perl_style_tool_calls(response);
8110        assert_eq!(calls.len(), 1);
8111        assert_eq!(calls[0].name, "shell");
8112        assert_eq!(
8113            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8114            "uname -a"
8115        );
8116    }
8117
8118    #[test]
8119    fn parse_tool_calls_handles_square_bracket_tool_call_blocks() {
8120        let response =
8121            r#"[TOOL_CALL]{tool => "shell", args => {--command "echo hello"}}[/TOOL_CALL]"#;
8122
8123        let calls = parse_perl_style_tool_calls(response);
8124        assert_eq!(calls.len(), 1);
8125        assert_eq!(calls[0].name, "shell");
8126        assert_eq!(
8127            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8128            "echo hello"
8129        );
8130    }
8131
8132    #[test]
8133    fn parse_tool_calls_handles_square_bracket_multiline() {
8134        let response = r#"[TOOL_CALL]
8135{tool => "file_read", args => {
8136  --path "/tmp/test.txt"
8137  --description "Read test file"
8138}}
8139[/TOOL_CALL]"#;
8140
8141        let calls = parse_perl_style_tool_calls(response);
8142        assert_eq!(calls.len(), 1);
8143        assert_eq!(calls[0].name, "file_read");
8144        assert_eq!(
8145            calls[0].arguments.get("path").unwrap().as_str().unwrap(),
8146            "/tmp/test.txt"
8147        );
8148        assert_eq!(
8149            calls[0]
8150                .arguments
8151                .get("description")
8152                .unwrap()
8153                .as_str()
8154                .unwrap(),
8155            "Read test file"
8156        );
8157    }
8158
8159    #[test]
8160    fn parse_tool_calls_recovers_unclosed_tool_call_with_json() {
8161        let response = r#"I will call the tool now.
8162<tool_call>
8163{"name": "shell", "arguments": {"command": "uptime -p"}}"#;
8164
8165        let (text, calls) = parse_tool_calls(response);
8166        assert!(text.contains("I will call the tool now."));
8167        assert_eq!(calls.len(), 1);
8168        assert_eq!(calls[0].name, "shell");
8169        assert_eq!(
8170            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8171            "uptime -p"
8172        );
8173    }
8174
8175    #[test]
8176    fn parse_tool_calls_recovers_mismatched_close_tag() {
8177        let response = r#"<tool_call>
8178{"name": "shell", "arguments": {"command": "uptime"}}
8179</arg_value>"#;
8180
8181        let (text, calls) = parse_tool_calls(response);
8182        assert!(text.is_empty());
8183        assert_eq!(calls.len(), 1);
8184        assert_eq!(calls[0].name, "shell");
8185        assert_eq!(
8186            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8187            "uptime"
8188        );
8189    }
8190
8191    #[test]
8192    fn parse_tool_calls_recovers_cross_alias_closing_tags() {
8193        let response = r#"<toolcall>
8194{"name": "shell", "arguments": {"command": "date"}}
8195</tool_call>"#;
8196
8197        let (text, calls) = parse_tool_calls(response);
8198        assert!(text.is_empty());
8199        assert_eq!(calls.len(), 1);
8200        assert_eq!(calls[0].name, "shell");
8201    }
8202
8203    #[test]
8204    fn parse_tool_calls_rejects_raw_tool_json_without_tags() {
8205        // SECURITY: Raw JSON without explicit wrappers should NOT be parsed
8206        // This prevents prompt injection attacks where malicious content
8207        // could include JSON that mimics a tool call.
8208        let response = r#"Sure, creating the file now.
8209{"name": "file_write", "arguments": {"path": "hello.py", "content": "print('hello')"}}"#;
8210
8211        let (text, calls) = parse_tool_calls(response);
8212        assert!(text.contains("Sure, creating the file now."));
8213        assert_eq!(
8214            calls.len(),
8215            0,
8216            "Raw JSON without wrappers should not be parsed"
8217        );
8218    }
8219
8220    #[test]
8221    fn build_tool_instructions_includes_all_tools() {
8222        use crate::security::SecurityPolicy;
8223        let security = Arc::new(SecurityPolicy::from_config(
8224            &crate::config::AutonomyConfig::default(),
8225            std::path::Path::new("/tmp"),
8226        ));
8227        let tools = tools::default_tools(security);
8228        let instructions = build_tool_instructions(&tools, None);
8229
8230        assert!(instructions.contains("## Tool Use Protocol"));
8231        assert!(instructions.contains("<tool_call>"));
8232        assert!(instructions.contains("shell"));
8233        assert!(instructions.contains("file_read"));
8234        assert!(instructions.contains("file_write"));
8235    }
8236
8237    #[test]
8238    fn tools_to_openai_format_produces_valid_schema() {
8239        use crate::security::SecurityPolicy;
8240        let security = Arc::new(SecurityPolicy::from_config(
8241            &crate::config::AutonomyConfig::default(),
8242            std::path::Path::new("/tmp"),
8243        ));
8244        let tools = tools::default_tools(security);
8245        let formatted = tools_to_openai_format(&tools);
8246
8247        assert!(!formatted.is_empty());
8248        for tool_json in &formatted {
8249            assert_eq!(tool_json["type"], "function");
8250            assert!(tool_json["function"]["name"].is_string());
8251            assert!(tool_json["function"]["description"].is_string());
8252            assert!(!tool_json["function"]["name"].as_str().unwrap().is_empty());
8253        }
8254        // Verify known tools are present
8255        let names: Vec<&str> = formatted
8256            .iter()
8257            .filter_map(|t| t["function"]["name"].as_str())
8258            .collect();
8259        assert!(names.contains(&"shell"));
8260        assert!(names.contains(&"file_read"));
8261    }
8262
8263    #[test]
8264    fn trim_history_preserves_system_prompt() {
8265        let mut history = vec![ChatMessage::system("system prompt")];
8266        for i in 0..DEFAULT_MAX_HISTORY_MESSAGES + 20 {
8267            history.push(ChatMessage::user(format!("msg {i}")));
8268        }
8269        let original_len = history.len();
8270        assert!(original_len > DEFAULT_MAX_HISTORY_MESSAGES + 1);
8271
8272        trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
8273
8274        // System prompt preserved
8275        assert_eq!(history[0].role, "system");
8276        assert_eq!(history[0].content, "system prompt");
8277        // Trimmed to limit
8278        assert_eq!(history.len(), DEFAULT_MAX_HISTORY_MESSAGES + 1); // +1 for system
8279        // Most recent messages preserved
8280        let last = &history[history.len() - 1];
8281        assert_eq!(
8282            last.content,
8283            format!("msg {}", DEFAULT_MAX_HISTORY_MESSAGES + 19)
8284        );
8285    }
8286
8287    #[test]
8288    fn trim_history_noop_when_within_limit() {
8289        let mut history = vec![
8290            ChatMessage::system("sys"),
8291            ChatMessage::user("hello"),
8292            ChatMessage::assistant("hi"),
8293        ];
8294        trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
8295        assert_eq!(history.len(), 3);
8296    }
8297
8298    #[test]
8299    fn autosave_memory_key_has_prefix_and_uniqueness() {
8300        let key1 = autosave_memory_key("user_msg");
8301        let key2 = autosave_memory_key("user_msg");
8302
8303        assert!(key1.starts_with("user_msg_"));
8304        assert!(key2.starts_with("user_msg_"));
8305        assert_ne!(key1, key2);
8306    }
8307
8308    // ═══════════════════════════════════════════════════════════════════════
8309    // Recovery Tests - Tool Call Parsing Edge Cases
8310    // ═══════════════════════════════════════════════════════════════════════
8311
8312    #[test]
8313    fn parse_tool_calls_handles_empty_tool_result() {
8314        // Recovery: Empty tool_result tag should be handled gracefully
8315        let response = r#"I'll run that command.
8316<tool_result name="shell">
8317
8318</tool_result>
8319Done."#;
8320        let (text, calls) = parse_tool_calls(response);
8321        assert!(text.contains("Done."));
8322        assert!(calls.is_empty());
8323    }
8324
8325    #[test]
8326    fn strip_tool_result_blocks_removes_single_block() {
8327        let input = r#"<tool_result name="memory_recall" status="ok">
8328{"matches":["hello"]}
8329</tool_result>
8330Here is my answer."#;
8331        assert_eq!(strip_tool_result_blocks(input), "Here is my answer.");
8332    }
8333
8334    #[test]
8335    fn strip_tool_result_blocks_removes_multiple_blocks() {
8336        let input = r#"<tool_result name="memory_recall" status="ok">
8337{"matches":[]}
8338</tool_result>
8339<tool_result name="shell" status="ok">
8340done
8341</tool_result>
8342Final answer."#;
8343        assert_eq!(strip_tool_result_blocks(input), "Final answer.");
8344    }
8345
8346    #[test]
8347    fn strip_tool_result_blocks_removes_prefix() {
8348        let input =
8349            "[Tool results]\n<tool_result name=\"shell\" status=\"ok\">\nok\n</tool_result>\nDone.";
8350        assert_eq!(strip_tool_result_blocks(input), "Done.");
8351    }
8352
8353    #[test]
8354    fn strip_tool_result_blocks_removes_thinking() {
8355        let input = "<thinking>\nLet me think...\n</thinking>\nHere is the answer.";
8356        assert_eq!(strip_tool_result_blocks(input), "Here is the answer.");
8357    }
8358
8359    #[test]
8360    fn strip_tool_result_blocks_removes_think_tags() {
8361        let input = "<think>\nLet me reason...\n</think>\nHere is the answer.";
8362        assert_eq!(strip_tool_result_blocks(input), "Here is the answer.");
8363    }
8364
8365    #[test]
8366    fn strip_think_tags_removes_single_block() {
8367        assert_eq!(strip_think_tags("<think>reasoning</think>Hello"), "Hello");
8368    }
8369
8370    #[test]
8371    fn strip_think_tags_removes_multiple_blocks() {
8372        assert_eq!(strip_think_tags("<think>a</think>X<think>b</think>Y"), "XY");
8373    }
8374
8375    #[test]
8376    fn strip_think_tags_handles_unclosed_block() {
8377        assert_eq!(strip_think_tags("visible<think>hidden"), "visible");
8378    }
8379
8380    #[test]
8381    fn strip_think_tags_preserves_text_without_tags() {
8382        assert_eq!(strip_think_tags("plain text"), "plain text");
8383    }
8384
8385    #[test]
8386    fn parse_tool_calls_strips_think_before_tool_call() {
8387        // Qwen regression: <think> tags before <tool_call> tags should be
8388        // stripped, allowing the tool call to be parsed correctly.
8389        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>";
8390        let (text, calls) = parse_tool_calls(response);
8391        assert_eq!(
8392            calls.len(),
8393            1,
8394            "should parse tool call after stripping think tags"
8395        );
8396        assert_eq!(calls[0].name, "shell");
8397        assert_eq!(
8398            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8399            "ls"
8400        );
8401        assert!(text.is_empty(), "think content should not appear as text");
8402    }
8403
8404    #[test]
8405    fn parse_tool_calls_strips_think_only_returns_empty() {
8406        // When response is only <think> tags with no tool calls, should
8407        // return empty text and no calls.
8408        let response = "<think>Just thinking, no action needed</think>";
8409        let (text, calls) = parse_tool_calls(response);
8410        assert!(calls.is_empty());
8411        assert!(text.is_empty());
8412    }
8413
8414    #[test]
8415    fn parse_tool_calls_handles_qwen_think_with_multiple_tool_calls() {
8416        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>";
8417        let (_, calls) = parse_tool_calls(response);
8418        assert_eq!(calls.len(), 2);
8419        assert_eq!(
8420            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8421            "date"
8422        );
8423        assert_eq!(
8424            calls[1].arguments.get("command").unwrap().as_str().unwrap(),
8425            "pwd"
8426        );
8427    }
8428
8429    #[test]
8430    fn strip_tool_result_blocks_preserves_clean_text() {
8431        let input = "Hello, this is a normal response.";
8432        assert_eq!(strip_tool_result_blocks(input), input);
8433    }
8434
8435    #[test]
8436    fn strip_tool_result_blocks_returns_empty_for_only_tags() {
8437        let input = "<tool_result name=\"memory_recall\" status=\"ok\">\n{}\n</tool_result>";
8438        assert_eq!(strip_tool_result_blocks(input), "");
8439    }
8440
8441    #[test]
8442    fn parse_arguments_value_handles_null() {
8443        // Recovery: null arguments are returned as-is (Value::Null)
8444        let value = serde_json::json!(null);
8445        let result = parse_arguments_value(Some(&value));
8446        assert!(result.is_null());
8447    }
8448
8449    #[test]
8450    fn parse_tool_calls_handles_empty_tool_calls_array() {
8451        // Recovery: Empty tool_calls array returns original response (no tool parsing)
8452        let response = r#"{"content": "Hello", "tool_calls": []}"#;
8453        let (text, calls) = parse_tool_calls(response);
8454        // When tool_calls is empty, the entire JSON is returned as text
8455        assert!(text.contains("Hello"));
8456        assert!(calls.is_empty());
8457    }
8458
8459    #[test]
8460    fn detect_tool_call_parse_issue_flags_malformed_payloads() {
8461        let response =
8462            "<tool_call>{\"name\":\"shell\",\"arguments\":{\"command\":\"pwd\"}</tool_call>";
8463        let issue = detect_tool_call_parse_issue(response, &[]);
8464        assert!(
8465            issue.is_some(),
8466            "malformed tool payload should be flagged for diagnostics"
8467        );
8468    }
8469
8470    #[test]
8471    fn detect_tool_call_parse_issue_ignores_normal_text() {
8472        let issue = detect_tool_call_parse_issue("Thanks, done.", &[]);
8473        assert!(issue.is_none());
8474    }
8475
8476    #[test]
8477    fn parse_tool_calls_handles_whitespace_only_name() {
8478        // Recovery: Whitespace-only tool name should return None
8479        let value = serde_json::json!({"function": {"name": "   ", "arguments": {}}});
8480        let result = parse_tool_call_value(&value);
8481        assert!(result.is_none());
8482    }
8483
8484    #[test]
8485    fn parse_tool_calls_handles_empty_string_arguments() {
8486        // Recovery: Empty string arguments should be handled
8487        let value = serde_json::json!({"name": "test", "arguments": ""});
8488        let result = parse_tool_call_value(&value);
8489        assert!(result.is_some());
8490        assert_eq!(result.unwrap().name, "test");
8491    }
8492
8493    // ═══════════════════════════════════════════════════════════════════════
8494    // Recovery Tests - History Management
8495    // ═══════════════════════════════════════════════════════════════════════
8496
8497    #[test]
8498    fn trim_history_with_no_system_prompt() {
8499        // Recovery: History without system prompt should trim correctly
8500        let mut history = vec![];
8501        for i in 0..DEFAULT_MAX_HISTORY_MESSAGES + 20 {
8502            history.push(ChatMessage::user(format!("msg {i}")));
8503        }
8504        trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
8505        assert_eq!(history.len(), DEFAULT_MAX_HISTORY_MESSAGES);
8506    }
8507
8508    #[test]
8509    fn trim_history_preserves_role_ordering() {
8510        // Recovery: After trimming, role ordering should remain consistent
8511        let mut history = vec![ChatMessage::system("system")];
8512        for i in 0..DEFAULT_MAX_HISTORY_MESSAGES + 10 {
8513            history.push(ChatMessage::user(format!("user {i}")));
8514            history.push(ChatMessage::assistant(format!("assistant {i}")));
8515        }
8516        trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
8517        assert_eq!(history[0].role, "system");
8518        assert_eq!(history[history.len() - 1].role, "assistant");
8519    }
8520
8521    #[test]
8522    fn trim_history_with_only_system_prompt() {
8523        // Recovery: Only system prompt should not be trimmed
8524        let mut history = vec![ChatMessage::system("system prompt")];
8525        trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
8526        assert_eq!(history.len(), 1);
8527    }
8528
8529    // ═══════════════════════════════════════════════════════════════════════
8530    // Recovery Tests - Arguments Parsing
8531    // ═══════════════════════════════════════════════════════════════════════
8532
8533    #[test]
8534    fn parse_arguments_value_handles_invalid_json_string() {
8535        // Recovery: Invalid JSON string should return empty object
8536        let value = serde_json::Value::String("not valid json".to_string());
8537        let result = parse_arguments_value(Some(&value));
8538        assert!(result.is_object());
8539        assert!(result.as_object().unwrap().is_empty());
8540    }
8541
8542    #[test]
8543    fn parse_arguments_value_handles_none() {
8544        // Recovery: None arguments should return empty object
8545        let result = parse_arguments_value(None);
8546        assert!(result.is_object());
8547        assert!(result.as_object().unwrap().is_empty());
8548    }
8549
8550    // ═══════════════════════════════════════════════════════════════════════
8551    // Recovery Tests - JSON Extraction
8552    // ═══════════════════════════════════════════════════════════════════════
8553
8554    #[test]
8555    fn extract_json_values_handles_empty_string() {
8556        // Recovery: Empty input should return empty vec
8557        let result = extract_json_values("");
8558        assert!(result.is_empty());
8559    }
8560
8561    #[test]
8562    fn extract_json_values_handles_whitespace_only() {
8563        // Recovery: Whitespace only should return empty vec
8564        let result = extract_json_values("   \n\t  ");
8565        assert!(result.is_empty());
8566    }
8567
8568    #[test]
8569    fn extract_json_values_handles_multiple_objects() {
8570        // Recovery: Multiple JSON objects should all be extracted
8571        let input = r#"{"a": 1}{"b": 2}{"c": 3}"#;
8572        let result = extract_json_values(input);
8573        assert_eq!(result.len(), 3);
8574    }
8575
8576    #[test]
8577    fn extract_json_values_handles_arrays() {
8578        // Recovery: JSON arrays should be extracted
8579        let input = r#"[1, 2, 3]{"key": "value"}"#;
8580        let result = extract_json_values(input);
8581        assert_eq!(result.len(), 2);
8582    }
8583
8584    // ═══════════════════════════════════════════════════════════════════════
8585    // Recovery Tests - Constants Validation
8586    // ═══════════════════════════════════════════════════════════════════════
8587
8588    const _: () = {
8589        assert!(DEFAULT_MAX_TOOL_ITERATIONS > 0);
8590        assert!(DEFAULT_MAX_TOOL_ITERATIONS <= 100);
8591        assert!(DEFAULT_MAX_HISTORY_MESSAGES > 0);
8592        assert!(DEFAULT_MAX_HISTORY_MESSAGES <= 1000);
8593    };
8594
8595    #[test]
8596    fn constants_bounds_are_compile_time_checked() {
8597        // Bounds are enforced by the const assertions above.
8598    }
8599
8600    // ═══════════════════════════════════════════════════════════════════════
8601    // Recovery Tests - Tool Call Value Parsing
8602    // ═══════════════════════════════════════════════════════════════════════
8603
8604    #[test]
8605    fn parse_tool_call_value_handles_missing_name_field() {
8606        // Recovery: Missing name field should return None
8607        let value = serde_json::json!({"function": {"arguments": {}}});
8608        let result = parse_tool_call_value(&value);
8609        assert!(result.is_none());
8610    }
8611
8612    #[test]
8613    fn parse_tool_call_value_handles_top_level_name() {
8614        // Recovery: Tool call with name at top level (non-OpenAI format)
8615        let value = serde_json::json!({"name": "test_tool", "arguments": {}});
8616        let result = parse_tool_call_value(&value);
8617        assert!(result.is_some());
8618        assert_eq!(result.unwrap().name, "test_tool");
8619    }
8620
8621    #[test]
8622    fn parse_tool_call_value_accepts_top_level_parameters_alias() {
8623        let value = serde_json::json!({
8624            "name": "schedule",
8625            "parameters": {"action": "create", "message": "test"}
8626        });
8627        let result = parse_tool_call_value(&value).expect("tool call should parse");
8628        assert_eq!(result.name, "schedule");
8629        assert_eq!(
8630            result.arguments.get("action").and_then(|v| v.as_str()),
8631            Some("create")
8632        );
8633    }
8634
8635    #[test]
8636    fn parse_tool_call_value_accepts_function_parameters_alias() {
8637        let value = serde_json::json!({
8638            "function": {
8639                "name": "shell",
8640                "parameters": {"command": "date"}
8641            }
8642        });
8643        let result = parse_tool_call_value(&value).expect("tool call should parse");
8644        assert_eq!(result.name, "shell");
8645        assert_eq!(
8646            result.arguments.get("command").and_then(|v| v.as_str()),
8647            Some("date")
8648        );
8649    }
8650
8651    #[test]
8652    fn parse_tool_call_value_preserves_tool_call_id_aliases() {
8653        let value = serde_json::json!({
8654            "call_id": "legacy_1",
8655            "function": {
8656                "name": "shell",
8657                "arguments": {"command": "date"}
8658            }
8659        });
8660        let result = parse_tool_call_value(&value).expect("tool call should parse");
8661        assert_eq!(result.tool_call_id.as_deref(), Some("legacy_1"));
8662    }
8663
8664    #[test]
8665    fn parse_tool_calls_from_json_value_handles_empty_array() {
8666        // Recovery: Empty tool_calls array should return empty vec
8667        let value = serde_json::json!({"tool_calls": []});
8668        let result = parse_tool_calls_from_json_value(&value);
8669        assert!(result.is_empty());
8670    }
8671
8672    #[test]
8673    fn parse_tool_calls_from_json_value_handles_missing_tool_calls() {
8674        // Recovery: Missing tool_calls field should fall through
8675        let value = serde_json::json!({"name": "test", "arguments": {}});
8676        let result = parse_tool_calls_from_json_value(&value);
8677        assert_eq!(result.len(), 1);
8678    }
8679
8680    #[test]
8681    fn parse_tool_calls_from_json_value_handles_top_level_array() {
8682        // Recovery: Top-level array of tool calls
8683        let value = serde_json::json!([
8684            {"name": "tool_a", "arguments": {}},
8685            {"name": "tool_b", "arguments": {}}
8686        ]);
8687        let result = parse_tool_calls_from_json_value(&value);
8688        assert_eq!(result.len(), 2);
8689    }
8690
8691    // ═══════════════════════════════════════════════════════════════════════
8692    // GLM-Style Tool Call Parsing
8693    // ═══════════════════════════════════════════════════════════════════════
8694
8695    #[test]
8696    fn parse_glm_style_browser_open_url() {
8697        let response = "browser_open/url>https://example.com";
8698        let calls = parse_glm_style_tool_calls(response);
8699        assert_eq!(calls.len(), 1);
8700        assert_eq!(calls[0].0, "shell");
8701        assert!(calls[0].1["command"].as_str().unwrap().contains("curl"));
8702        assert!(
8703            calls[0].1["command"]
8704                .as_str()
8705                .unwrap()
8706                .contains("example.com")
8707        );
8708    }
8709
8710    #[test]
8711    fn parse_glm_style_shell_command() {
8712        let response = "shell/command>ls -la";
8713        let calls = parse_glm_style_tool_calls(response);
8714        assert_eq!(calls.len(), 1);
8715        assert_eq!(calls[0].0, "shell");
8716        assert_eq!(calls[0].1["command"], "ls -la");
8717    }
8718
8719    #[test]
8720    fn parse_glm_style_http_request() {
8721        let response = "http_request/url>https://api.example.com/data";
8722        let calls = parse_glm_style_tool_calls(response);
8723        assert_eq!(calls.len(), 1);
8724        assert_eq!(calls[0].0, "http_request");
8725        assert_eq!(calls[0].1["url"], "https://api.example.com/data");
8726        assert_eq!(calls[0].1["method"], "GET");
8727    }
8728
8729    #[test]
8730    fn parse_glm_style_ignores_plain_url() {
8731        // A bare URL should NOT be interpreted as a tool call — this was
8732        // causing false positives when LLMs included URLs in normal text.
8733        let response = "https://example.com/api";
8734        let calls = parse_glm_style_tool_calls(response);
8735        assert!(
8736            calls.is_empty(),
8737            "plain URL must not be parsed as tool call"
8738        );
8739    }
8740
8741    #[test]
8742    fn parse_glm_style_json_args() {
8743        let response = r#"shell/{"command": "echo hello"}"#;
8744        let calls = parse_glm_style_tool_calls(response);
8745        assert_eq!(calls.len(), 1);
8746        assert_eq!(calls[0].0, "shell");
8747        assert_eq!(calls[0].1["command"], "echo hello");
8748    }
8749
8750    #[test]
8751    fn parse_glm_style_multiple_calls() {
8752        let response = r#"shell/command>ls
8753browser_open/url>https://example.com"#;
8754        let calls = parse_glm_style_tool_calls(response);
8755        assert_eq!(calls.len(), 2);
8756    }
8757
8758    #[test]
8759    fn parse_glm_style_tool_call_integration() {
8760        // Integration test: GLM format should be parsed in parse_tool_calls
8761        let response = "Checking...\nbrowser_open/url>https://example.com\nDone";
8762        let (text, calls) = parse_tool_calls(response);
8763        assert_eq!(calls.len(), 1);
8764        assert_eq!(calls[0].name, "shell");
8765        assert!(text.contains("Checking"));
8766        assert!(text.contains("Done"));
8767    }
8768
8769    #[test]
8770    fn parse_glm_style_rejects_non_http_url_param() {
8771        let response = "browser_open/url>javascript:alert(1)";
8772        let calls = parse_glm_style_tool_calls(response);
8773        assert!(calls.is_empty());
8774    }
8775
8776    #[test]
8777    fn parse_tool_calls_handles_unclosed_tool_call_tag() {
8778        let response = "<tool_call>{\"name\":\"shell\",\"arguments\":{\"command\":\"pwd\"}}\nDone";
8779        let (text, calls) = parse_tool_calls(response);
8780        assert_eq!(calls.len(), 1);
8781        assert_eq!(calls[0].name, "shell");
8782        assert_eq!(calls[0].arguments["command"], "pwd");
8783        assert_eq!(text, "Done");
8784    }
8785
8786    // ─────────────────────────────────────────────────────────────────────
8787    // TG4 (inline): parse_tool_calls robustness — malformed/edge-case inputs
8788    // Prevents: Pattern 4 issues #746, #418, #777, #848
8789    // ─────────────────────────────────────────────────────────────────────
8790
8791    #[test]
8792    fn parse_tool_calls_empty_input_returns_empty() {
8793        let (text, calls) = parse_tool_calls("");
8794        assert!(calls.is_empty(), "empty input should produce no tool calls");
8795        assert!(text.is_empty(), "empty input should produce no text");
8796    }
8797
8798    #[test]
8799    fn parse_tool_calls_whitespace_only_returns_empty_calls() {
8800        let (text, calls) = parse_tool_calls("   \n\t  ");
8801        assert!(calls.is_empty());
8802        assert!(text.is_empty() || text.trim().is_empty());
8803    }
8804
8805    #[test]
8806    fn parse_tool_calls_nested_xml_tags_handled() {
8807        // Double-wrapped tool call should still parse the inner call
8808        let response = r#"<tool_call><tool_call>{"name":"echo","arguments":{"msg":"hi"}}</tool_call></tool_call>"#;
8809        let (_text, calls) = parse_tool_calls(response);
8810        // Should find at least one tool call
8811        assert!(
8812            !calls.is_empty(),
8813            "nested XML tags should still yield at least one tool call"
8814        );
8815    }
8816
8817    #[test]
8818    fn parse_tool_calls_truncated_json_no_panic() {
8819        // Incomplete JSON inside tool_call tags
8820        let response = r#"<tool_call>{"name":"shell","arguments":{"command":"ls"</tool_call>"#;
8821        let (_text, _calls) = parse_tool_calls(response);
8822        // Should not panic — graceful handling of truncated JSON
8823    }
8824
8825    #[test]
8826    fn parse_tool_calls_empty_json_object_in_tag() {
8827        let response = "<tool_call>{}</tool_call>";
8828        let (_text, calls) = parse_tool_calls(response);
8829        // Empty JSON object has no name field — should not produce valid tool call
8830        assert!(
8831            calls.is_empty(),
8832            "empty JSON object should not produce a tool call"
8833        );
8834    }
8835
8836    #[test]
8837    fn parse_tool_calls_closing_tag_only_returns_text() {
8838        let response = "Some text </tool_call> more text";
8839        let (text, calls) = parse_tool_calls(response);
8840        assert!(
8841            calls.is_empty(),
8842            "closing tag only should not produce calls"
8843        );
8844        assert!(
8845            !text.is_empty(),
8846            "text around orphaned closing tag should be preserved"
8847        );
8848    }
8849
8850    #[test]
8851    fn parse_tool_calls_very_large_arguments_no_panic() {
8852        let large_arg = "x".repeat(100_000);
8853        let response = format!(
8854            r#"<tool_call>{{"name":"echo","arguments":{{"message":"{}"}}}}</tool_call>"#,
8855            large_arg
8856        );
8857        let (_text, calls) = parse_tool_calls(&response);
8858        assert_eq!(calls.len(), 1, "large arguments should still parse");
8859        assert_eq!(calls[0].name, "echo");
8860    }
8861
8862    #[test]
8863    fn parse_tool_calls_special_characters_in_arguments() {
8864        let response = r#"<tool_call>{"name":"echo","arguments":{"message":"hello \"world\" <>&'\n\t"}}</tool_call>"#;
8865        let (_text, calls) = parse_tool_calls(response);
8866        assert_eq!(calls.len(), 1);
8867        assert_eq!(calls[0].name, "echo");
8868    }
8869
8870    #[test]
8871    fn parse_tool_calls_text_with_embedded_json_not_extracted() {
8872        // Raw JSON without any tags should NOT be extracted as a tool call
8873        let response = r#"Here is some data: {"name":"echo","arguments":{"message":"hi"}} end."#;
8874        let (_text, calls) = parse_tool_calls(response);
8875        assert!(
8876            calls.is_empty(),
8877            "raw JSON in text without tags should not be extracted"
8878        );
8879    }
8880
8881    #[test]
8882    fn parse_tool_calls_multiple_formats_mixed() {
8883        // Mix of text and properly tagged tool call
8884        let response = r#"I'll help you with that.
8885
8886<tool_call>
8887{"name":"shell","arguments":{"command":"echo hello"}}
8888</tool_call>
8889
8890Let me check the result."#;
8891        let (text, calls) = parse_tool_calls(response);
8892        assert_eq!(
8893            calls.len(),
8894            1,
8895            "should extract one tool call from mixed content"
8896        );
8897        assert_eq!(calls[0].name, "shell");
8898        assert!(
8899            text.contains("help you"),
8900            "text before tool call should be preserved"
8901        );
8902    }
8903
8904    // ─────────────────────────────────────────────────────────────────────
8905    // TG4 (inline): scrub_credentials edge cases
8906    // ─────────────────────────────────────────────────────────────────────
8907
8908    #[test]
8909    fn scrub_credentials_empty_input() {
8910        let result = scrub_credentials("");
8911        assert_eq!(result, "");
8912    }
8913
8914    #[test]
8915    fn scrub_credentials_no_sensitive_data() {
8916        let input = "normal text without any secrets";
8917        let result = scrub_credentials(input);
8918        assert_eq!(
8919            result, input,
8920            "non-sensitive text should pass through unchanged"
8921        );
8922    }
8923
8924    #[test]
8925    fn scrub_credentials_multibyte_chars_no_panic() {
8926        // Regression test for #3024: byte index 4 is not a char boundary
8927        // when the captured value contains multi-byte UTF-8 characters.
8928        // The regex only matches quoted values for non-ASCII content, since
8929        // capture group 4 is restricted to [a-zA-Z0-9_\-\.].
8930        let input = "password=\"\u{4f60}\u{7684}WiFi\u{5bc6}\u{7801}ab\"";
8931        let result = scrub_credentials(input);
8932        assert!(
8933            result.contains("[REDACTED]"),
8934            "multi-byte quoted value should be redacted without panic, got: {result}"
8935        );
8936    }
8937
8938    #[test]
8939    fn scrub_credentials_short_values_not_redacted() {
8940        // Values shorter than 8 chars should not be redacted
8941        let input = r#"api_key="short""#;
8942        let result = scrub_credentials(input);
8943        assert_eq!(result, input, "short values should not be redacted");
8944    }
8945
8946    // ─────────────────────────────────────────────────────────────────────
8947    // TG4 (inline): trim_history edge cases
8948    // ─────────────────────────────────────────────────────────────────────
8949
8950    #[test]
8951    fn trim_history_empty_history() {
8952        let mut history: Vec<crate::providers::ChatMessage> = vec![];
8953        trim_history(&mut history, 10);
8954        assert!(history.is_empty());
8955    }
8956
8957    #[test]
8958    fn trim_history_system_only() {
8959        let mut history = vec![crate::providers::ChatMessage::system("system prompt")];
8960        trim_history(&mut history, 10);
8961        assert_eq!(history.len(), 1);
8962        assert_eq!(history[0].role, "system");
8963    }
8964
8965    #[test]
8966    fn trim_history_exactly_at_limit() {
8967        let mut history = vec![
8968            crate::providers::ChatMessage::system("system"),
8969            crate::providers::ChatMessage::user("msg 1"),
8970            crate::providers::ChatMessage::assistant("reply 1"),
8971        ];
8972        trim_history(&mut history, 2); // 2 non-system messages = exactly at limit
8973        assert_eq!(history.len(), 3, "should not trim when exactly at limit");
8974    }
8975
8976    #[test]
8977    fn trim_history_removes_oldest_non_system() {
8978        let mut history = vec![
8979            crate::providers::ChatMessage::system("system"),
8980            crate::providers::ChatMessage::user("old msg"),
8981            crate::providers::ChatMessage::assistant("old reply"),
8982            crate::providers::ChatMessage::user("new msg"),
8983            crate::providers::ChatMessage::assistant("new reply"),
8984        ];
8985        trim_history(&mut history, 2);
8986        assert_eq!(history.len(), 3); // system + 2 kept
8987        assert_eq!(history[0].role, "system");
8988        assert_eq!(history[1].content, "new msg");
8989    }
8990
8991    /// When `build_system_prompt_with_mode` is called with `native_tools = true`,
8992    /// the output must contain ZERO XML protocol artifacts. In the native path
8993    /// `build_tool_instructions` is never called, so the system prompt alone
8994    /// must be clean of XML tool-call protocol.
8995    #[test]
8996    fn native_tools_system_prompt_contains_zero_xml() {
8997        use crate::channels::build_system_prompt_with_mode;
8998
8999        let tool_summaries: Vec<(&str, &str)> = vec![
9000            ("shell", "Execute shell commands"),
9001            ("file_read", "Read files"),
9002        ];
9003
9004        let system_prompt = build_system_prompt_with_mode(
9005            std::path::Path::new("/tmp"),
9006            "test-model",
9007            &tool_summaries,
9008            &[],  // no skills
9009            None, // no identity config
9010            None, // no bootstrap_max_chars
9011            true, // native_tools
9012            crate::config::SkillsPromptInjectionMode::Full,
9013            crate::security::AutonomyLevel::default(),
9014        );
9015
9016        // Must contain zero XML protocol artifacts
9017        assert!(
9018            !system_prompt.contains("<tool_call>"),
9019            "Native prompt must not contain <tool_call>"
9020        );
9021        assert!(
9022            !system_prompt.contains("</tool_call>"),
9023            "Native prompt must not contain </tool_call>"
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_result>"),
9031            "Native prompt must not contain </tool_result>"
9032        );
9033        assert!(
9034            !system_prompt.contains("## Tool Use Protocol"),
9035            "Native prompt must not contain XML protocol header"
9036        );
9037
9038        // Positive: native prompt should still list tools and contain task instructions
9039        assert!(
9040            system_prompt.contains("shell"),
9041            "Native prompt must list tool names"
9042        );
9043        assert!(
9044            system_prompt.contains("## Your Task"),
9045            "Native prompt should contain task instructions"
9046        );
9047    }
9048
9049    // ── Cross-Alias & GLM Shortened Body Tests ──────────────────────────
9050
9051    #[test]
9052    fn parse_tool_calls_cross_alias_close_tag_with_json() {
9053        // <tool_call> opened but closed with </invoke> — JSON body
9054        let input = r#"<tool_call>{"name": "shell", "arguments": {"command": "ls"}}</invoke>"#;
9055        let (text, calls) = parse_tool_calls(input);
9056        assert_eq!(calls.len(), 1);
9057        assert_eq!(calls[0].name, "shell");
9058        assert_eq!(calls[0].arguments["command"], "ls");
9059        assert!(text.is_empty());
9060    }
9061
9062    #[test]
9063    fn parse_tool_calls_cross_alias_close_tag_with_glm_shortened() {
9064        // <tool_call>shell>uname -a</invoke> — GLM shortened inside cross-alias tags
9065        let input = "<tool_call>shell>uname -a</invoke>";
9066        let (text, calls) = parse_tool_calls(input);
9067        assert_eq!(calls.len(), 1);
9068        assert_eq!(calls[0].name, "shell");
9069        assert_eq!(calls[0].arguments["command"], "uname -a");
9070        assert!(text.is_empty());
9071    }
9072
9073    #[test]
9074    fn parse_tool_calls_glm_shortened_body_in_matched_tags() {
9075        // <tool_call>shell>pwd</tool_call> — GLM shortened in matched tags
9076        let input = "<tool_call>shell>pwd</tool_call>";
9077        let (text, calls) = parse_tool_calls(input);
9078        assert_eq!(calls.len(), 1);
9079        assert_eq!(calls[0].name, "shell");
9080        assert_eq!(calls[0].arguments["command"], "pwd");
9081        assert!(text.is_empty());
9082    }
9083
9084    #[test]
9085    fn parse_tool_calls_glm_yaml_style_in_tags() {
9086        // <tool_call>shell>\ncommand: date\napproved: true</invoke>
9087        let input = "<tool_call>shell>\ncommand: date\napproved: true</invoke>";
9088        let (text, calls) = parse_tool_calls(input);
9089        assert_eq!(calls.len(), 1);
9090        assert_eq!(calls[0].name, "shell");
9091        assert_eq!(calls[0].arguments["command"], "date");
9092        assert_eq!(calls[0].arguments["approved"], true);
9093        assert!(text.is_empty());
9094    }
9095
9096    #[test]
9097    fn parse_tool_calls_attribute_style_in_tags() {
9098        // <tool_call>shell command="date" /></tool_call>
9099        let input = r#"<tool_call>shell command="date" /></tool_call>"#;
9100        let (text, calls) = parse_tool_calls(input);
9101        assert_eq!(calls.len(), 1);
9102        assert_eq!(calls[0].name, "shell");
9103        assert_eq!(calls[0].arguments["command"], "date");
9104        assert!(text.is_empty());
9105    }
9106
9107    #[test]
9108    fn parse_tool_calls_file_read_shortened_in_cross_alias() {
9109        // <tool_call>file_read path=".env" /></invoke>
9110        let input = r#"<tool_call>file_read path=".env" /></invoke>"#;
9111        let (text, calls) = parse_tool_calls(input);
9112        assert_eq!(calls.len(), 1);
9113        assert_eq!(calls[0].name, "file_read");
9114        assert_eq!(calls[0].arguments["path"], ".env");
9115        assert!(text.is_empty());
9116    }
9117
9118    #[test]
9119    fn parse_tool_calls_unclosed_glm_shortened_no_close_tag() {
9120        // <tool_call>shell>ls -la (no close tag at all)
9121        let input = "<tool_call>shell>ls -la";
9122        let (text, calls) = parse_tool_calls(input);
9123        assert_eq!(calls.len(), 1);
9124        assert_eq!(calls[0].name, "shell");
9125        assert_eq!(calls[0].arguments["command"], "ls -la");
9126        assert!(text.is_empty());
9127    }
9128
9129    #[test]
9130    fn parse_tool_calls_text_before_cross_alias() {
9131        // Text before and after cross-alias tool call
9132        let input = "Let me check that.\n<tool_call>shell>uname -a</invoke>\nDone.";
9133        let (text, calls) = parse_tool_calls(input);
9134        assert_eq!(calls.len(), 1);
9135        assert_eq!(calls[0].name, "shell");
9136        assert_eq!(calls[0].arguments["command"], "uname -a");
9137        assert!(text.contains("Let me check that."));
9138        assert!(text.contains("Done."));
9139    }
9140
9141    #[test]
9142    fn parse_glm_shortened_body_url_to_curl() {
9143        // URL values for shell should be wrapped in curl
9144        let call = parse_glm_shortened_body("shell>https://example.com/api").unwrap();
9145        assert_eq!(call.name, "shell");
9146        let cmd = call.arguments["command"].as_str().unwrap();
9147        assert!(cmd.contains("curl"));
9148        assert!(cmd.contains("example.com"));
9149    }
9150
9151    #[test]
9152    fn parse_glm_shortened_body_browser_open_maps_to_shell_command() {
9153        // browser_open aliases to shell, and shortened calls must still emit
9154        // shell's canonical "command" argument.
9155        let call = parse_glm_shortened_body("browser_open>https://example.com").unwrap();
9156        assert_eq!(call.name, "shell");
9157        let cmd = call.arguments["command"].as_str().unwrap();
9158        assert!(cmd.contains("curl"));
9159        assert!(cmd.contains("example.com"));
9160    }
9161
9162    #[test]
9163    fn parse_glm_shortened_body_memory_recall() {
9164        // memory_recall>some query — default param is "query"
9165        let call = parse_glm_shortened_body("memory_recall>recent meetings").unwrap();
9166        assert_eq!(call.name, "memory_recall");
9167        assert_eq!(call.arguments["query"], "recent meetings");
9168    }
9169
9170    #[test]
9171    fn parse_glm_shortened_body_function_style_alias_maps_to_message_send() {
9172        let call =
9173            parse_glm_shortened_body(r#"sendmessage(channel="alerts", message="hi")"#).unwrap();
9174        assert_eq!(call.name, "message_send");
9175        assert_eq!(call.arguments["channel"], "alerts");
9176        assert_eq!(call.arguments["message"], "hi");
9177    }
9178
9179    #[test]
9180    fn map_tool_name_alias_direct_coverage() {
9181        assert_eq!(map_tool_name_alias("bash"), "shell");
9182        assert_eq!(map_tool_name_alias("filelist"), "file_list");
9183        assert_eq!(map_tool_name_alias("memorystore"), "memory_store");
9184        assert_eq!(map_tool_name_alias("memoryforget"), "memory_forget");
9185        assert_eq!(map_tool_name_alias("http"), "http_request");
9186        assert_eq!(
9187            map_tool_name_alias("totally_unknown_tool"),
9188            "totally_unknown_tool"
9189        );
9190    }
9191
9192    #[test]
9193    fn default_param_for_tool_coverage() {
9194        assert_eq!(default_param_for_tool("shell"), "command");
9195        assert_eq!(default_param_for_tool("bash"), "command");
9196        assert_eq!(default_param_for_tool("file_read"), "path");
9197        assert_eq!(default_param_for_tool("memory_recall"), "query");
9198        assert_eq!(default_param_for_tool("memory_store"), "content");
9199        assert_eq!(default_param_for_tool("web_search_tool"), "query");
9200        assert_eq!(default_param_for_tool("web_search"), "query");
9201        assert_eq!(default_param_for_tool("search"), "query");
9202        assert_eq!(default_param_for_tool("http_request"), "url");
9203        assert_eq!(default_param_for_tool("browser_open"), "url");
9204        assert_eq!(default_param_for_tool("unknown_tool"), "input");
9205    }
9206
9207    #[test]
9208    fn parse_glm_shortened_body_rejects_empty() {
9209        assert!(parse_glm_shortened_body("").is_none());
9210        assert!(parse_glm_shortened_body("   ").is_none());
9211    }
9212
9213    #[test]
9214    fn parse_glm_shortened_body_rejects_invalid_tool_name() {
9215        // Tool names with special characters should be rejected
9216        assert!(parse_glm_shortened_body("not-a-tool>value").is_none());
9217        assert!(parse_glm_shortened_body("tool name>value").is_none());
9218    }
9219
9220    // ═══════════════════════════════════════════════════════════════════════
9221    // reasoning_content pass-through tests for history builders
9222    // ═══════════════════════════════════════════════════════════════════════
9223
9224    #[test]
9225    fn build_native_assistant_history_includes_reasoning_content() {
9226        let calls = vec![ToolCall {
9227            id: "call_1".into(),
9228            name: "shell".into(),
9229            arguments: "{}".into(),
9230        }];
9231        let result = build_native_assistant_history("answer", &calls, Some("thinking step"));
9232        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
9233        assert_eq!(parsed["content"].as_str(), Some("answer"));
9234        assert_eq!(parsed["reasoning_content"].as_str(), Some("thinking step"));
9235        assert!(parsed["tool_calls"].is_array());
9236    }
9237
9238    #[test]
9239    fn build_native_assistant_history_omits_reasoning_content_when_none() {
9240        let calls = vec![ToolCall {
9241            id: "call_1".into(),
9242            name: "shell".into(),
9243            arguments: "{}".into(),
9244        }];
9245        let result = build_native_assistant_history("answer", &calls, None);
9246        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
9247        assert_eq!(parsed["content"].as_str(), Some("answer"));
9248        assert!(parsed.get("reasoning_content").is_none());
9249    }
9250
9251    #[test]
9252    fn build_native_assistant_history_from_parsed_calls_includes_reasoning_content() {
9253        let calls = vec![ParsedToolCall {
9254            name: "shell".into(),
9255            arguments: serde_json::json!({"command": "pwd"}),
9256            tool_call_id: Some("call_2".into()),
9257        }];
9258        let result = build_native_assistant_history_from_parsed_calls(
9259            "answer",
9260            &calls,
9261            Some("deep thought"),
9262        );
9263        assert!(result.is_some());
9264        let parsed: serde_json::Value = serde_json::from_str(result.as_deref().unwrap()).unwrap();
9265        assert_eq!(parsed["content"].as_str(), Some("answer"));
9266        assert_eq!(parsed["reasoning_content"].as_str(), Some("deep thought"));
9267        assert!(parsed["tool_calls"].is_array());
9268    }
9269
9270    #[test]
9271    fn build_native_assistant_history_from_parsed_calls_omits_reasoning_content_when_none() {
9272        let calls = vec![ParsedToolCall {
9273            name: "shell".into(),
9274            arguments: serde_json::json!({"command": "pwd"}),
9275            tool_call_id: Some("call_2".into()),
9276        }];
9277        let result = build_native_assistant_history_from_parsed_calls("answer", &calls, None);
9278        assert!(result.is_some());
9279        let parsed: serde_json::Value = serde_json::from_str(result.as_deref().unwrap()).unwrap();
9280        assert_eq!(parsed["content"].as_str(), Some("answer"));
9281        assert!(parsed.get("reasoning_content").is_none());
9282    }
9283
9284    // ── glob_match tests ──────────────────────────────────────────────────────
9285
9286    #[test]
9287    fn glob_match_exact_no_wildcard() {
9288        assert!(glob_match("mcp_browser_navigate", "mcp_browser_navigate"));
9289        assert!(!glob_match("mcp_browser_navigate", "mcp_browser_click"));
9290    }
9291
9292    #[test]
9293    fn glob_match_prefix_wildcard() {
9294        // Suffix pattern: mcp_browser_*
9295        assert!(glob_match("mcp_browser_*", "mcp_browser_navigate"));
9296        assert!(glob_match("mcp_browser_*", "mcp_browser_click"));
9297        assert!(!glob_match("mcp_browser_*", "mcp_filesystem_read"));
9298
9299        // Prefix pattern: *_read
9300        assert!(glob_match("*_read", "mcp_filesystem_read"));
9301        assert!(!glob_match("*_read", "mcp_filesystem_write"));
9302
9303        // Infix: mcp_*_navigate
9304        assert!(glob_match("mcp_*_navigate", "mcp_browser_navigate"));
9305        assert!(!glob_match("mcp_*_navigate", "mcp_browser_click"));
9306    }
9307
9308    #[test]
9309    fn glob_match_star_matches_everything() {
9310        assert!(glob_match("*", "anything_at_all"));
9311        assert!(glob_match("*", ""));
9312    }
9313
9314    // ── filter_tool_specs_for_turn tests ──────────────────────────────────────
9315
9316    fn make_spec(name: &str) -> crate::tools::ToolSpec {
9317        crate::tools::ToolSpec {
9318            name: name.to_string(),
9319            description: String::new(),
9320            parameters: serde_json::json!({}),
9321        }
9322    }
9323
9324    #[test]
9325    fn filter_tool_specs_no_groups_returns_all() {
9326        let specs = vec![
9327            make_spec("shell_exec"),
9328            make_spec("mcp_browser_navigate"),
9329            make_spec("mcp_filesystem_read"),
9330        ];
9331        let result = filter_tool_specs_for_turn(specs, &[], "hello");
9332        assert_eq!(result.len(), 3);
9333    }
9334
9335    #[test]
9336    fn filter_tool_specs_always_group_includes_matching_mcp_tool() {
9337        use crate::config::schema::{ToolFilterGroup, ToolFilterGroupMode};
9338
9339        let specs = vec![
9340            make_spec("shell_exec"),
9341            make_spec("mcp_browser_navigate"),
9342            make_spec("mcp_filesystem_read"),
9343        ];
9344        let groups = vec![ToolFilterGroup {
9345            mode: ToolFilterGroupMode::Always,
9346            tools: vec!["mcp_filesystem_*".into()],
9347            keywords: vec![],
9348            filter_builtins: false,
9349        }];
9350        let result = filter_tool_specs_for_turn(specs, &groups, "anything");
9351        let names: Vec<&str> = result.iter().map(|s| s.name.as_str()).collect();
9352        // Built-in passes through, matched MCP passes, unmatched MCP excluded.
9353        assert!(names.contains(&"shell_exec"));
9354        assert!(names.contains(&"mcp_filesystem_read"));
9355        assert!(!names.contains(&"mcp_browser_navigate"));
9356    }
9357
9358    #[test]
9359    fn filter_tool_specs_dynamic_group_included_on_keyword_match() {
9360        use crate::config::schema::{ToolFilterGroup, ToolFilterGroupMode};
9361
9362        let specs = vec![make_spec("shell_exec"), make_spec("mcp_browser_navigate")];
9363        let groups = vec![ToolFilterGroup {
9364            mode: ToolFilterGroupMode::Dynamic,
9365            tools: vec!["mcp_browser_*".into()],
9366            keywords: vec!["browse".into(), "website".into()],
9367            filter_builtins: false,
9368        }];
9369        let result = filter_tool_specs_for_turn(specs, &groups, "please browse this page");
9370        let names: Vec<&str> = result.iter().map(|s| s.name.as_str()).collect();
9371        assert!(names.contains(&"shell_exec"));
9372        assert!(names.contains(&"mcp_browser_navigate"));
9373    }
9374
9375    #[test]
9376    fn filter_tool_specs_dynamic_group_excluded_on_no_keyword_match() {
9377        use crate::config::schema::{ToolFilterGroup, ToolFilterGroupMode};
9378
9379        let specs = vec![make_spec("shell_exec"), make_spec("mcp_browser_navigate")];
9380        let groups = vec![ToolFilterGroup {
9381            mode: ToolFilterGroupMode::Dynamic,
9382            tools: vec!["mcp_browser_*".into()],
9383            keywords: vec!["browse".into(), "website".into()],
9384            filter_builtins: false,
9385        }];
9386        let result = filter_tool_specs_for_turn(specs, &groups, "read the file /etc/hosts");
9387        let names: Vec<&str> = result.iter().map(|s| s.name.as_str()).collect();
9388        assert!(names.contains(&"shell_exec"));
9389        assert!(!names.contains(&"mcp_browser_navigate"));
9390    }
9391
9392    #[test]
9393    fn filter_tool_specs_dynamic_keyword_match_is_case_insensitive() {
9394        use crate::config::schema::{ToolFilterGroup, ToolFilterGroupMode};
9395
9396        let specs = vec![make_spec("mcp_browser_navigate")];
9397        let groups = vec![ToolFilterGroup {
9398            mode: ToolFilterGroupMode::Dynamic,
9399            tools: vec!["mcp_browser_*".into()],
9400            keywords: vec!["Browse".into()],
9401            filter_builtins: false,
9402        }];
9403        let result = filter_tool_specs_for_turn(specs, &groups, "BROWSE the site");
9404        assert_eq!(result.len(), 1);
9405    }
9406
9407    // ── Token-based compaction tests ──────────────────────────
9408
9409    #[test]
9410    fn estimate_history_tokens_empty() {
9411        assert_eq!(super::estimate_history_tokens(&[]), 0);
9412    }
9413
9414    #[test]
9415    fn estimate_history_tokens_single_message() {
9416        let history = vec![ChatMessage::user("hello world")]; // 11 chars
9417        let tokens = super::estimate_history_tokens(&history);
9418        // 11.div_ceil(4) + 4 = 3 + 4 = 7
9419        assert_eq!(tokens, 7);
9420    }
9421
9422    #[test]
9423    fn estimate_history_tokens_multiple_messages() {
9424        let history = vec![
9425            ChatMessage::system("You are helpful."), // 16 chars → 4 + 4 = 8
9426            ChatMessage::user("What is Rust?"),      // 13 chars → 4 + 4 = 8
9427            ChatMessage::assistant("A language."),   // 11 chars → 3 + 4 = 7
9428        ];
9429        let tokens = super::estimate_history_tokens(&history);
9430        assert_eq!(tokens, 23);
9431    }
9432
9433    #[tokio::test]
9434    async fn run_tool_call_loop_surfaces_tool_failure_reason_in_on_delta() {
9435        let provider = ScriptedProvider::from_text_responses(vec![
9436            r#"<tool_call>
9437{"name":"failing_shell","arguments":{"command":"rm -rf /"}}
9438</tool_call>"#,
9439            "I could not execute that command.",
9440        ]);
9441
9442        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(FailingTool::new(
9443            "failing_shell",
9444            "Command not allowed by security policy: rm -rf /",
9445        ))];
9446
9447        let mut history = vec![
9448            ChatMessage::system("test-system"),
9449            ChatMessage::user("delete everything"),
9450        ];
9451        let observer = NoopObserver;
9452
9453        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(64);
9454
9455        let result = run_tool_call_loop(
9456            &provider,
9457            &mut history,
9458            &tools_registry,
9459            &observer,
9460            "mock-provider",
9461            "mock-model",
9462            0.0,
9463            true,
9464            None,
9465            "telegram",
9466            None,
9467            &crate::config::MultimodalConfig::default(),
9468            4,
9469            None,
9470            Some(tx),
9471            None,
9472            &[],
9473            &[],
9474            None,
9475            None,
9476            &crate::config::PacingConfig::default(),
9477            0,
9478            0,
9479            None,
9480        )
9481        .await
9482        .expect("tool loop should complete");
9483
9484        // Collect all messages sent to the on_delta channel.
9485        let mut deltas = Vec::new();
9486        while let Ok(msg) = rx.try_recv() {
9487            deltas.push(msg);
9488        }
9489
9490        let all_deltas: String = deltas
9491            .iter()
9492            .filter_map(|d| match d {
9493                DraftEvent::Progress(t) | DraftEvent::Content(t) => Some(t.as_str()),
9494                DraftEvent::Clear => None,
9495            })
9496            .collect();
9497
9498        // The failure reason should appear in the progress messages.
9499        assert!(
9500            all_deltas.contains("Command not allowed by security policy"),
9501            "on_delta messages should include the tool failure reason, got: {all_deltas}"
9502        );
9503
9504        // Should also contain the cross mark (❌) icon to indicate failure.
9505        assert!(
9506            all_deltas.contains('\u{274c}'),
9507            "on_delta messages should include ❌ for failed tool calls, got: {all_deltas}"
9508        );
9509
9510        assert_eq!(result, "I could not execute that command.");
9511    }
9512
9513    // ── filter_by_allowed_tools tests ─────────────────────────────────────
9514
9515    #[test]
9516    fn filter_by_allowed_tools_none_passes_all() {
9517        let specs = vec![
9518            make_spec("shell"),
9519            make_spec("memory_store"),
9520            make_spec("file_read"),
9521        ];
9522        let result = filter_by_allowed_tools(specs, None);
9523        assert_eq!(result.len(), 3);
9524    }
9525
9526    #[test]
9527    fn filter_by_allowed_tools_some_restricts_to_listed() {
9528        let specs = vec![
9529            make_spec("shell"),
9530            make_spec("memory_store"),
9531            make_spec("file_read"),
9532        ];
9533        let allowed = vec!["shell".to_string(), "memory_store".to_string()];
9534        let result = filter_by_allowed_tools(specs, Some(&allowed));
9535        let names: Vec<&str> = result.iter().map(|s| s.name.as_str()).collect();
9536        assert_eq!(names.len(), 2);
9537        assert!(names.contains(&"shell"));
9538        assert!(names.contains(&"memory_store"));
9539        assert!(!names.contains(&"file_read"));
9540    }
9541
9542    #[test]
9543    fn filter_by_allowed_tools_unknown_names_silently_ignored() {
9544        let specs = vec![make_spec("shell"), make_spec("file_read")];
9545        let allowed = vec![
9546            "shell".to_string(),
9547            "nonexistent_tool".to_string(),
9548            "another_missing".to_string(),
9549        ];
9550        let result = filter_by_allowed_tools(specs, Some(&allowed));
9551        let names: Vec<&str> = result.iter().map(|s| s.name.as_str()).collect();
9552        assert_eq!(names.len(), 1);
9553        assert!(names.contains(&"shell"));
9554    }
9555
9556    #[test]
9557    fn filter_by_allowed_tools_empty_list_excludes_all() {
9558        let specs = vec![make_spec("shell"), make_spec("file_read")];
9559        let allowed: Vec<String> = vec![];
9560        let result = filter_by_allowed_tools(specs, Some(&allowed));
9561        assert!(result.is_empty());
9562    }
9563
9564    // ── Cost tracking tests ──
9565
9566    #[tokio::test]
9567    async fn cost_tracking_records_usage_when_scoped() {
9568        use super::{
9569            TOOL_LOOP_COST_TRACKING_CONTEXT, ToolLoopCostTrackingContext, run_tool_call_loop,
9570        };
9571        use crate::config::schema::ModelPricing;
9572        use crate::cost::CostTracker;
9573        use crate::observability::noop::NoopObserver;
9574        use std::collections::HashMap;
9575
9576        let provider = ScriptedProvider {
9577            responses: Arc::new(Mutex::new(VecDeque::from([ChatResponse {
9578                text: Some("done".to_string()),
9579                tool_calls: Vec::new(),
9580                usage: Some(crate::providers::traits::TokenUsage {
9581                    input_tokens: Some(1_000),
9582                    output_tokens: Some(200),
9583                    cached_input_tokens: None,
9584                }),
9585                reasoning_content: None,
9586            }]))),
9587            capabilities: ProviderCapabilities::default(),
9588        };
9589        let observer = NoopObserver;
9590        let workspace = tempfile::TempDir::new().unwrap();
9591        let mut cost_config = crate::config::CostConfig {
9592            enabled: true,
9593            ..crate::config::CostConfig::default()
9594        };
9595        cost_config.prices = HashMap::from([(
9596            "mock-model".to_string(),
9597            ModelPricing {
9598                input: 3.0,
9599                output: 15.0,
9600            },
9601        )]);
9602        let tracker = Arc::new(CostTracker::new(cost_config.clone(), workspace.path()).unwrap());
9603        let ctx = ToolLoopCostTrackingContext::new(
9604            Arc::clone(&tracker),
9605            Arc::new(cost_config.prices.clone()),
9606        );
9607        let mut history = vec![ChatMessage::system("test"), ChatMessage::user("hello")];
9608
9609        let result = TOOL_LOOP_COST_TRACKING_CONTEXT
9610            .scope(
9611                Some(ctx),
9612                run_tool_call_loop(
9613                    &provider,
9614                    &mut history,
9615                    &[],
9616                    &observer,
9617                    "mock-provider",
9618                    "mock-model",
9619                    0.0,
9620                    true,
9621                    None,
9622                    "test",
9623                    None,
9624                    &crate::config::MultimodalConfig::default(),
9625                    2,
9626                    None,
9627                    None,
9628                    None,
9629                    &[],
9630                    &[],
9631                    None,
9632                    None,
9633                    &crate::config::PacingConfig::default(),
9634                    0,
9635                    0,
9636                    None,
9637                ),
9638            )
9639            .await
9640            .expect("tool loop should succeed");
9641
9642        assert_eq!(result, "done");
9643        let summary = tracker.get_summary().unwrap();
9644        assert_eq!(summary.request_count, 1);
9645        assert_eq!(summary.total_tokens, 1_200);
9646        assert!(summary.session_cost_usd > 0.0);
9647    }
9648
9649    #[tokio::test]
9650    async fn cost_tracking_enforces_budget() {
9651        use super::{
9652            TOOL_LOOP_COST_TRACKING_CONTEXT, ToolLoopCostTrackingContext, run_tool_call_loop,
9653        };
9654        use crate::config::schema::ModelPricing;
9655        use crate::cost::CostTracker;
9656        use crate::observability::noop::NoopObserver;
9657        use std::collections::HashMap;
9658
9659        let provider = ScriptedProvider::from_text_responses(vec!["should not reach this"]);
9660        let observer = NoopObserver;
9661        let workspace = tempfile::TempDir::new().unwrap();
9662        let cost_config = crate::config::CostConfig {
9663            enabled: true,
9664            daily_limit_usd: 0.001, // very low limit
9665            ..crate::config::CostConfig::default()
9666        };
9667        let tracker = Arc::new(CostTracker::new(cost_config.clone(), workspace.path()).unwrap());
9668        // Record a usage that already exceeds the limit
9669        tracker
9670            .record_usage(crate::cost::types::TokenUsage::new(
9671                "mock-model",
9672                100_000,
9673                50_000,
9674                1.0,
9675                1.0,
9676            ))
9677            .unwrap();
9678
9679        let ctx = ToolLoopCostTrackingContext::new(
9680            Arc::clone(&tracker),
9681            Arc::new(HashMap::from([(
9682                "mock-model".to_string(),
9683                ModelPricing {
9684                    input: 1.0,
9685                    output: 1.0,
9686                },
9687            )])),
9688        );
9689        let mut history = vec![ChatMessage::system("test"), ChatMessage::user("hello")];
9690
9691        let err = TOOL_LOOP_COST_TRACKING_CONTEXT
9692            .scope(
9693                Some(ctx),
9694                run_tool_call_loop(
9695                    &provider,
9696                    &mut history,
9697                    &[],
9698                    &observer,
9699                    "mock-provider",
9700                    "mock-model",
9701                    0.0,
9702                    true,
9703                    None,
9704                    "test",
9705                    None,
9706                    &crate::config::MultimodalConfig::default(),
9707                    2,
9708                    None,
9709                    None,
9710                    None,
9711                    &[],
9712                    &[],
9713                    None,
9714                    None,
9715                    &crate::config::PacingConfig::default(),
9716                    0,
9717                    0,
9718                    None,
9719                ),
9720            )
9721            .await
9722            .expect_err("should fail with budget exceeded");
9723
9724        assert!(
9725            err.to_string().contains("Budget exceeded"),
9726            "error should mention budget: {err}"
9727        );
9728    }
9729
9730    #[tokio::test]
9731    async fn cost_tracking_is_noop_without_scope() {
9732        use super::run_tool_call_loop;
9733        use crate::observability::noop::NoopObserver;
9734
9735        // No TOOL_LOOP_COST_TRACKING_CONTEXT scoped — should run fine
9736        let provider = ScriptedProvider {
9737            responses: Arc::new(Mutex::new(VecDeque::from([ChatResponse {
9738                text: Some("ok".to_string()),
9739                tool_calls: Vec::new(),
9740                usage: Some(crate::providers::traits::TokenUsage {
9741                    input_tokens: Some(500),
9742                    output_tokens: Some(100),
9743                    cached_input_tokens: None,
9744                }),
9745                reasoning_content: None,
9746            }]))),
9747            capabilities: ProviderCapabilities::default(),
9748        };
9749        let observer = NoopObserver;
9750        let mut history = vec![ChatMessage::system("test"), ChatMessage::user("hello")];
9751
9752        let result = run_tool_call_loop(
9753            &provider,
9754            &mut history,
9755            &[],
9756            &observer,
9757            "mock-provider",
9758            "mock-model",
9759            0.0,
9760            true,
9761            None,
9762            "test",
9763            None,
9764            &crate::config::MultimodalConfig::default(),
9765            2,
9766            None,
9767            None,
9768            None,
9769            &[],
9770            &[],
9771            None,
9772            None,
9773            &crate::config::PacingConfig::default(),
9774            0,
9775            0,
9776            None,
9777        )
9778        .await
9779        .expect("should succeed without cost scope");
9780
9781        assert_eq!(result, "ok");
9782    }
9783}