Skip to main content

zeroclaw_runtime/agent/
loop_.rs

1use crate::approval::{ApprovalManager, ApprovalRequest, ApprovalRequirement, ApprovalResponse};
2
3/// CLI channel factory, injected by the binary. Returns a `Box<dyn Channel>` for interactive mode.
4pub static CLI_CHANNEL_FN: std::sync::OnceLock<
5    Box<dyn Fn() -> Box<dyn zeroclaw_api::channel::Channel> + Send + Sync>,
6> = std::sync::OnceLock::new();
7
8/// Register the CLI channel factory. Called once at startup by the binary.
9pub fn register_cli_channel_fn(
10    f: Box<dyn Fn() -> Box<dyn zeroclaw_api::channel::Channel> + Send + Sync>,
11) {
12    let _ = CLI_CHANNEL_FN.set(f);
13}
14
15/// Peripheral tools factory type — takes owned config so the returned future is 'static.
16pub type PeripheralToolsFn = Box<
17    dyn Fn(
18            zeroclaw_config::schema::PeripheralsConfig,
19        ) -> std::pin::Pin<
20            Box<dyn std::future::Future<Output = anyhow::Result<Vec<Box<dyn Tool>>>> + Send>,
21        > + Send
22        + Sync,
23>;
24
25/// Peripheral tools factory, injected by the binary when hardware feature is on.
26static PERIPHERAL_TOOLS_FN: std::sync::OnceLock<PeripheralToolsFn> = std::sync::OnceLock::new();
27
28/// Register the peripheral tools factory. Called once at startup by the binary.
29pub fn register_peripheral_tools_fn(f: PeripheralToolsFn) {
30    let _ = PERIPHERAL_TOOLS_FN.set(f);
31}
32
33/// Public helper for other crates (e.g. channels orchestrator) to load
34/// peripheral tools through the registered factory. Returns empty vec
35/// when nothing is registered (hardware feature off or not yet wired).
36pub async fn load_peripheral_tools(
37    config: zeroclaw_config::schema::PeripheralsConfig,
38) -> Vec<Box<dyn Tool>> {
39    if let Some(f) = PERIPHERAL_TOOLS_FN.get() {
40        f(config).await.unwrap_or_default()
41    } else {
42        Vec::new()
43    }
44}
45
46/// Channel map factory type — builds `channel_key → Arc<dyn Channel>` map.
47/// Injected by the binary so `zeroclaw-runtime` doesn't depend on
48/// `zeroclaw-channels`.
49type ChannelMapFn = Box<
50    dyn Fn()
51            -> std::collections::HashMap<String, std::sync::Arc<dyn zeroclaw_api::channel::Channel>>
52        + Send
53        + Sync,
54>;
55
56/// Channel map factory, injected by the binary.
57static CHANNEL_MAP_FN: std::sync::OnceLock<ChannelMapFn> = std::sync::OnceLock::new();
58
59const AUTO_DELIVERY_DEFAULT_CHANNELS: &[&str] = &[
60    "telegram",
61    "discord",
62    "slack",
63    "mattermost",
64    "matrix",
65    "dingtalk",
66];
67
68/// Register the channel map factory. Called once at startup by the binary.
69pub fn register_channel_map_fn(f: ChannelMapFn) {
70    let _ = CHANNEL_MAP_FN.set(f);
71}
72
73/// Populate all channel-driven tool handles from the registered factory.
74/// Returns the number of channels seeded.
75///
76/// Parameter order matches the return tuple of `all_tools_with_runtime`:
77/// Seed all channel-driven tool handles from the registered channel map factory.
78/// Returns the number of channels seeded. Parameters match the return order of
79/// `all_tools_with_runtime`:
80///   ask_user_handle = `Option<PerToolChannelHandle>`
81///   reaction_handle = `PerToolChannelHandle` (NOT Option)
82///   poll_handle = `Option<PerToolChannelHandle>`
83///   escalate_handle = `Option<PerToolChannelHandle>`
84pub(crate) fn seed_channel_handles(
85    ask_user_handle: &Option<tools::PerToolChannelHandle>,
86    reaction_handle: &tools::PerToolChannelHandle,
87    poll_handle: &Option<tools::PerToolChannelHandle>,
88    escalate_handle: &Option<tools::PerToolChannelHandle>,
89) -> usize {
90    let Some(factory) = CHANNEL_MAP_FN.get() else {
91        return 0;
92    };
93    let map = factory();
94    if map.is_empty() {
95        return 0;
96    }
97
98    let handles = [
99        ask_user_handle.as_ref(),
100        Some(reaction_handle),
101        poll_handle.as_ref(),
102        escalate_handle.as_ref(),
103    ];
104
105    let mut count = 0;
106    for (name, ch) in &map {
107        for handle in handles.iter().flatten() {
108            handle
109                .write()
110                .insert(name.clone(), std::sync::Arc::clone(ch));
111        }
112        count += 1;
113    }
114    count
115}
116use crate::cost::types::BudgetCheck;
117use crate::observability::{self, Observer, ObserverEvent};
118use crate::platform;
119use crate::security::{AutonomyLevel, SecurityPolicy};
120use crate::tools::{self, Tool};
121use crate::util::truncate_with_ellipsis;
122use anyhow::{Context, Result};
123use futures_util::StreamExt;
124use regex::Regex;
125use std::collections::HashSet;
126use std::fmt::Write;
127use std::io::Write as _;
128use std::path::PathBuf;
129use std::sync::{Arc, LazyLock, Mutex};
130use std::time::{Duration, Instant};
131use tokio_util::sync::CancellationToken;
132use uuid::Uuid;
133use zeroclaw_api::channel::Channel;
134use zeroclaw_api::model_provider::StreamEvent;
135use zeroclaw_config::schema::Config;
136use zeroclaw_memory::{
137    self, MEMORY_CONTEXT_CLOSE, MEMORY_CONTEXT_OPEN, Memory, MemoryCategory, decay,
138};
139use zeroclaw_providers::multimodal;
140use zeroclaw_providers::{
141    self, ChatMessage, ChatRequest, ModelProvider, ProviderCapabilityError, ToolCall,
142};
143
144// Cost tracking moved to `super::cost`.
145pub use super::cost::{
146    TOOL_LOOP_COST_TRACKING_CONTEXT, ToolLoopCostTrackingContext, TurnUsage,
147    check_tool_loop_budget, record_tool_loop_cost_usage,
148};
149
150/// Minimum characters per chunk when relaying LLM text to a streaming draft.
151const STREAM_CHUNK_MIN_CHARS: usize = 80;
152/// Maximum malformed internal tool-protocol retries before returning a safe fallback.
153const MAX_MALFORMED_TOOL_PROTOCOL_RETRIES: usize = 2;
154
155/// Default maximum agentic tool-use iterations per user message to prevent runaway loops.
156/// Used as a safe fallback when `max_tool_iterations` is unset or configured as zero.
157const DEFAULT_MAX_TOOL_ITERATIONS: usize = 10;
158
159// History management moved to `super::history`.
160pub use super::history::{
161    append_or_merge_system_message, canonicalize_tool_result_media_markers, emergency_history_trim,
162    estimate_history_tokens, fast_trim_tool_results, load_interactive_session_history,
163    normalize_system_messages, save_interactive_session_history, trim_history,
164    truncate_tool_result,
165};
166
167/// Minimum user-message length (in chars) for auto-save to memory.
168/// Matches the channel-side constant in `channels/mod.rs`.
169const AUTOSAVE_MIN_MESSAGE_CHARS: usize = 20;
170
171/// Callback type for checking if model has been switched during tool execution.
172/// Returns Some((model_provider, model)) if a switch was requested, None otherwise.
173pub type ModelSwitchCallback = Arc<Mutex<Option<(String, String)>>>;
174
175/// Global model switch request state - used for runtime model switching via model_switch tool.
176/// This is set by the model_switch tool and checked by the agent loop.
177#[allow(clippy::type_complexity)]
178static MODEL_SWITCH_REQUEST: LazyLock<Arc<Mutex<Option<(String, String)>>>> =
179    LazyLock::new(|| Arc::new(Mutex::new(None)));
180
181/// Get the global model switch request state
182pub fn get_model_switch_state() -> ModelSwitchCallback {
183    Arc::clone(&MODEL_SWITCH_REQUEST)
184}
185
186/// Clear any pending model switch request
187pub fn clear_model_switch_request() {
188    if let Ok(guard) = MODEL_SWITCH_REQUEST.lock() {
189        let mut guard = guard;
190        *guard = None;
191    }
192}
193
194fn glob_match(pattern: &str, name: &str) -> bool {
195    match pattern.find('*') {
196        None => pattern == name,
197        Some(star) => {
198            let prefix = &pattern[..star];
199            let suffix = &pattern[star + 1..];
200            name.starts_with(prefix)
201                && name.ends_with(suffix)
202                && name.len() >= prefix.len() + suffix.len()
203        }
204    }
205}
206
207/// Drop tools from `tools` that fail either gate.
208///
209/// 1. The parent agent's `SecurityPolicy.allowed_tools` allowlist plus
210///    `SecurityPolicy.excluded_tools` denylist, evaluated via
211///    `SecurityPolicy::is_tool_allowed`.
212/// 2. The caller-supplied `caller_allowed` filter (the existing
213///    `agent::run`-level `allowed_tools` parameter).
214///
215/// A tool survives only when BOTH gates admit its name. `None` on
216/// either gate is unrestricted for that gate alone. Built-in tools,
217/// MCP tools, and skill tools all flow through the same filter; the
218/// helper does not know or care about category.
219pub fn apply_policy_tool_filter(
220    tools: &mut Vec<Box<dyn Tool>>,
221    policy: Option<&zeroclaw_config::policy::SecurityPolicy>,
222    caller_allowed: Option<&[String]>,
223) {
224    tools.retain(|t| {
225        let name = t.name();
226        let policy_ok = policy.is_none_or(|p| p.is_tool_allowed(name));
227        let caller_ok = caller_allowed.is_none_or(|list| list.iter().any(|n| n == name));
228        policy_ok && caller_ok
229    });
230}
231
232pub(crate) fn mcp_tool_access_policy(
233    security: &zeroclaw_config::policy::SecurityPolicy,
234    caller_allowed: Option<&[String]>,
235) -> Option<zeroclaw_tools::tool_search::ToolAccessPolicy> {
236    zeroclaw_tools::tool_search::ToolAccessPolicy::from_security(
237        security.allowed_tools.as_deref(),
238        security.excluded_tools.as_deref(),
239        caller_allowed,
240    )
241}
242
243pub(crate) fn eager_mcp_tool_allowed(
244    name: &str,
245    policy: Option<&zeroclaw_tools::tool_search::ToolAccessPolicy>,
246) -> bool {
247    policy.is_none_or(|policy| policy.is_tool_allowed(name))
248}
249
250pub(crate) fn mcp_allowed_tool_count<'a>(
251    names: impl IntoIterator<Item = &'a str>,
252    policy: Option<&zeroclaw_tools::tool_search::ToolAccessPolicy>,
253) -> usize {
254    names
255        .into_iter()
256        .filter(|name| eager_mcp_tool_allowed(name, policy))
257        .count()
258}
259
260pub(crate) fn register_eager_mcp_tool_if_allowed(
261    wrapper: std::sync::Arc<dyn Tool>,
262    tools: &mut Vec<Box<dyn Tool>>,
263    delegate_handle: Option<&tools::DelegateParentToolsHandle>,
264    policy: Option<&zeroclaw_tools::tool_search::ToolAccessPolicy>,
265) -> bool {
266    if !eager_mcp_tool_allowed(wrapper.name(), policy) {
267        return false;
268    }
269    if let Some(handle) = delegate_handle {
270        handle.write().push(std::sync::Arc::clone(&wrapper));
271    }
272    tools.push(Box::new(tools::ArcToolRef(wrapper)));
273    true
274}
275
276/// Apply the SecurityPolicy built-in tool filter on the channel/daemon
277/// (`process_message`) path.
278///
279/// Extracted as a named seam so the production filtering of the eager
280/// built-in registry is regression-testable without driving the full agent
281/// loop (see `process_message_policy_filters_eager_builtins`). The channel
282/// path has no caller-supplied allowlist, so only the agent's own
283/// `SecurityPolicy` (`allowed_tools` + `excluded_tools`) gates here; the
284/// `run()` path additionally composes a caller-supplied `allowed_tools` gate.
285pub(crate) fn filter_channel_builtin_tools(
286    tools_registry: &mut Vec<Box<dyn Tool>>,
287    security: &zeroclaw_config::policy::SecurityPolicy,
288) {
289    let before_filter = tools_registry.len();
290    apply_policy_tool_filter(tools_registry, Some(security), None);
291    if tools_registry.len() != before_filter {
292        ::zeroclaw_log::record!(
293            INFO,
294            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
295                ::serde_json::json!({
296                    "before": before_filter,
297                    "retained": tools_registry.len(),
298                    "policy_allowed": security.allowed_tools.as_ref().map(|v| v.len()),
299                    "policy_excluded": security.excluded_tools.as_ref().map(|v| v.len()),
300                })
301            ),
302            "Applied capability-based tool access filter (process_message)"
303        );
304    }
305}
306
307/// Returns the subset of `tool_specs` that should be sent to the LLM for this turn.
308///
309/// Rules (mirrors NullClaw `filterToolSpecsForTurn`):
310/// - Built-in tools (names that do not start with `"mcp_"`) always pass through.
311/// - When `groups` is empty, all tools pass through (backward compatible default).
312/// - An MCP tool is included if at least one group matches it:
313///   - `always` group: included unconditionally if any pattern matches the tool name.
314///   - `dynamic` group: included if any pattern matches AND the user message contains
315///     at least one keyword (case-insensitive substring).
316pub fn filter_tool_specs_for_turn(
317    tool_specs: Vec<crate::tools::ToolSpec>,
318    groups: &[zeroclaw_config::schema::ToolFilterGroup],
319    user_message: &str,
320) -> Vec<crate::tools::ToolSpec> {
321    use zeroclaw_config::schema::ToolFilterGroupMode;
322
323    if groups.is_empty() {
324        return tool_specs;
325    }
326
327    let msg_lower = user_message.to_ascii_lowercase();
328
329    tool_specs
330        .into_iter()
331        .filter(|spec| {
332            // Built-in tools always pass through.
333            if !spec.name.starts_with("mcp_") {
334                return true;
335            }
336            // MCP tool: include if any active group matches.
337            groups.iter().any(|group| {
338                let pattern_matches = group.tools.iter().any(|pat| glob_match(pat, &spec.name));
339                if !pattern_matches {
340                    return false;
341                }
342                match group.mode {
343                    ToolFilterGroupMode::Always => true,
344                    ToolFilterGroupMode::Dynamic => group
345                        .keywords
346                        .iter()
347                        .any(|kw| msg_lower.contains(&kw.to_ascii_lowercase())),
348                }
349            })
350        })
351        .collect()
352}
353
354/// Filters a tool spec list by an optional capability allowlist.
355///
356/// When `allowed` is `None`, all specs pass through unchanged.
357/// When `allowed` is `Some(list)`, only specs whose name appears in the list
358/// are retained. Unknown names in the allowlist are silently ignored.
359pub fn filter_by_allowed_tools(
360    specs: Vec<crate::tools::ToolSpec>,
361    allowed: Option<&[String]>,
362) -> Vec<crate::tools::ToolSpec> {
363    match allowed {
364        None => specs,
365        Some(list) => specs
366            .into_iter()
367            .filter(|spec| list.iter().any(|name| name == &spec.name))
368            .collect(),
369    }
370}
371
372// Re-export from zeroclaw-types for backwards compatibility.
373pub use zeroclaw_api::TOOL_LOOP_SESSION_KEY;
374pub use zeroclaw_api::TOOL_LOOP_THREAD_ID;
375
376// Re-export tool call parsing from the standalone parser crate.
377pub use zeroclaw_tool_call_parser::{
378    ParsedToolCall, ToolProtocolEnvelopeKind, build_native_assistant_history_from_parsed_calls,
379    canonicalize_json_for_tool_signature, classify_tool_protocol_envelope,
380    contains_tool_protocol_tag_call, detect_tool_call_parse_issue,
381    looks_like_malformed_tool_protocol_envelope,
382    looks_like_malformed_tool_protocol_envelope_for_known_tools, looks_like_tool_protocol_envelope,
383    looks_like_tool_protocol_example, parse_tool_calls, strip_think_tags, strip_tool_result_blocks,
384    tool_protocol_envelope_mentions_known_tool,
385};
386
387/// Run a future with the thread ID set in task-local storage.
388/// Rate-limiting reads this to assign per-sender buckets.
389pub async fn scope_thread_id<F>(thread_id: Option<String>, future: F) -> F::Output
390where
391    F: std::future::Future,
392{
393    TOOL_LOOP_THREAD_ID.scope(thread_id, future).await
394}
395
396/// Run a future with the session key set in task-local storage.
397/// The scope wraps the entire agent turn, so all tools invoked during
398/// the turn (including nested calls) see the same session key.
399/// SessionsCurrentTool reads this to identify the active session.
400pub async fn scope_session_key<F>(session_key: Option<String>, future: F) -> F::Output
401where
402    F: std::future::Future,
403{
404    TOOL_LOOP_SESSION_KEY.scope(session_key, future).await
405}
406
407/// Computes the list of MCP tool names that should be excluded for a given turn
408/// based on `tool_filter_groups` and the user message.
409///
410/// Returns an empty `Vec` when `groups` is empty (no filtering).
411fn compute_excluded_mcp_tools(
412    tools_registry: &[Box<dyn Tool>],
413    groups: &[zeroclaw_config::schema::ToolFilterGroup],
414    user_message: &str,
415) -> Vec<String> {
416    if groups.is_empty() {
417        return Vec::new();
418    }
419    let filtered_specs = filter_tool_specs_for_turn(
420        tools_registry.iter().map(|t| t.spec()).collect(),
421        groups,
422        user_message,
423    );
424    let included: HashSet<&str> = filtered_specs.iter().map(|s| s.name.as_str()).collect();
425    tools_registry
426        .iter()
427        .filter(|t| t.name().starts_with("mcp_") && !included.contains(t.name()))
428        .map(|t| t.name().to_string())
429        .collect()
430}
431
432static SENSITIVE_KV_REGEX: LazyLock<Regex> = LazyLock::new(|| {
433    Regex::new(r#"(?i)(token|api[_-]?key|password|secret|user[_-]?key|bearer|credential)["']?\s*[:=]\s*(?:"([^"]{8,})"|'([^']{8,})'|([a-zA-Z0-9_\-\.]{8,}))"#).unwrap()
434});
435
436/// Scrub credentials from tool output to prevent accidental exfiltration.
437/// Replaces known credential patterns with a redacted placeholder while preserving
438/// a small prefix for context.
439pub fn scrub_credentials(input: &str) -> String {
440    SENSITIVE_KV_REGEX
441        .replace_all(input, |caps: &regex::Captures| {
442            let full_match = &caps[0];
443            let key = &caps[1];
444            let val = caps
445                .get(2)
446                .or(caps.get(3))
447                .or(caps.get(4))
448                .map(|m| m.as_str())
449                .unwrap_or("");
450
451            // Preserve first 4 chars for context, then redact.
452            // Use char_indices to find the byte offset of the 4th character
453            // so we never slice in the middle of a multi-byte UTF-8 sequence.
454            let prefix = if val.len() > 4 {
455                val.char_indices()
456                    .nth(4)
457                    .map(|(byte_idx, _)| &val[..byte_idx])
458                    .unwrap_or(val)
459            } else {
460                ""
461            };
462
463            if full_match.contains(':') {
464                if full_match.contains('"') {
465                    format!("\"{}\": \"{}*[REDACTED]\"", key, prefix)
466                } else {
467                    format!("{}: {}*[REDACTED]", key, prefix)
468                }
469            } else if full_match.contains('=') {
470                if full_match.contains('"') {
471                    format!("{}=\"{}*[REDACTED]\"", key, prefix)
472                } else {
473                    format!("{}={}*[REDACTED]", key, prefix)
474                }
475            } else {
476                format!("{}: {}*[REDACTED]", key, prefix)
477            }
478        })
479        .to_string()
480}
481
482/// Default trigger for auto-compaction when non-system message count exceeds this threshold.
483/// Prefer passing the config-driven value via `run_tool_call_loop`; this constant is only
484/// used when callers omit the parameter.
485/// Minimum interval between progress sends to avoid flooding the draft channel.
486pub const PROGRESS_MIN_INTERVAL_MS: u64 = 500;
487
488/// Delta sent from the agent loop to the channel's draft updater.
489/// Append-only — no clear/reset variant exists by design.
490#[derive(Debug, Clone)]
491pub enum StreamDelta {
492    /// Response text to append to the message buffer.
493    Text(String),
494    /// Ephemeral tool progress (not part of the response body).
495    Status(String),
496}
497
498/// Backwards-compatible alias while callers are migrated.
499pub type DraftEvent = StreamDelta;
500
501pub use zeroclaw_api::TOOL_CHOICE_OVERRIDE;
502
503/// Convert a tool registry to OpenAI function-calling format for native tool support.
504#[cfg(test)]
505fn tools_to_openai_format(tools_registry: &[Box<dyn Tool>]) -> Vec<serde_json::Value> {
506    tools_registry
507        .iter()
508        .map(|tool| {
509            serde_json::json!({
510                "type": "function",
511                "function": {
512                    "name": tool.name(),
513                    "description": tool.description(),
514                    "parameters": tool.parameters_schema()
515                }
516            })
517        })
518        .collect()
519}
520
521fn autosave_memory_key(prefix: &str) -> String {
522    format!("{prefix}_{}", Uuid::new_v4())
523}
524
525/// Build context preamble by searching memory for relevant entries.
526/// Entries with a hybrid score below `min_relevance_score` are dropped to
527/// prevent unrelated memories from bleeding into the conversation.
528/// Core memories are exempt from time decay (evergreen).
529///
530/// `exclude_conversation` skips `MemoryCategory::Conversation` entries
531/// regardless of their key shape. Set to `true` for autonomous/scheduled
532/// runs (cron, daemon heartbeat) so chat memory cannot leak into prompts
533/// the user did not initiate. / #5456.
534async fn build_context(
535    mem: &dyn Memory,
536    user_msg: &str,
537    min_relevance_score: f64,
538    session_id: Option<&str>,
539    exclude_conversation: bool,
540) -> String {
541    let mut context = String::new();
542
543    // Pull relevant memories for this message
544    if let Ok(mut entries) = mem.recall(user_msg, 5, session_id, None, None).await {
545        // Apply time decay: older non-Core memories score lower
546        decay::apply_time_decay(&mut entries, decay::DEFAULT_HALF_LIFE_DAYS);
547
548        let relevant: Vec<_> = entries
549            .iter()
550            .filter(|e| match e.score {
551                Some(score) => score >= min_relevance_score,
552                None => true,
553            })
554            .collect();
555
556        if !relevant.is_empty() {
557            let mut included = false;
558            for entry in &relevant {
559                // Scheduled (cron / heartbeat) runs must not see chat-origin
560                // memories. The autosave-key checks below catch the agent's
561                // own autosaves but miss Conversation entries written by
562                // channel handlers (Discord, gateway, WhatsApp, …) under
563                // their own keys. / #5456.
564                if exclude_conversation && matches!(entry.category, MemoryCategory::Conversation) {
565                    continue;
566                }
567                if zeroclaw_memory::is_assistant_autosave_key(&entry.key) {
568                    continue;
569                }
570                // Skip raw per-turn user messages: re-injecting them causes each
571                // recalled entry to embed all prior generations, growing exponentially.
572                // Consolidated knowledge is already promoted to Core/Daily entries.
573                if zeroclaw_memory::is_user_autosave_key(&entry.key) {
574                    continue;
575                }
576                if zeroclaw_memory::should_skip_autosave_content(&entry.content) {
577                    continue;
578                }
579                // Skip entries containing tool_result blocks — they can leak
580                // stale tool output from previous heartbeat ticks into new
581                // sessions, presenting the LLM with orphan tool_result data.
582                if entry.content.contains("<tool_result") {
583                    continue;
584                }
585                if !included {
586                    context.push_str(MEMORY_CONTEXT_OPEN);
587                    context.push('\n');
588                    included = true;
589                }
590                let _ = writeln!(context, "- {}: {}", entry.key, entry.content);
591            }
592            if included {
593                context.push_str(MEMORY_CONTEXT_CLOSE);
594                context.push_str("\n\n");
595            }
596        }
597    }
598
599    context
600}
601
602/// Build hardware datasheet context from RAG when peripherals are enabled.
603/// Includes pin-alias lookup (e.g. "red_led" → 13) when query matches, plus retrieved chunks.
604fn build_hardware_context(
605    rag: &crate::rag::HardwareRag,
606    user_msg: &str,
607    boards: &[String],
608    chunk_limit: usize,
609) -> String {
610    if rag.is_empty() || boards.is_empty() {
611        return String::new();
612    }
613
614    let mut context = String::new();
615
616    // Pin aliases: when user says "red led", inject "red_led: 13" for matching boards
617    let pin_ctx = rag.pin_alias_context(user_msg, boards);
618    if !pin_ctx.is_empty() {
619        context.push_str(&pin_ctx);
620    }
621
622    let chunks = rag.retrieve(user_msg, boards, chunk_limit);
623    if chunks.is_empty() && pin_ctx.is_empty() {
624        return String::new();
625    }
626
627    if !chunks.is_empty() {
628        context.push_str("[Hardware documentation]\n");
629    }
630    for chunk in chunks {
631        let board_tag = chunk.board.as_deref().unwrap_or("generic");
632        let _ = writeln!(
633            context,
634            "--- {} ({}) ---\n{}\n",
635            chunk.source, board_tag, chunk.content
636        );
637    }
638    context.push('\n');
639    context
640}
641
642// Tool execution moved to `super::tool_execution`.
643pub use super::tool_execution::{
644    ToolExecutionOutcome, execute_tools_parallel, execute_tools_sequential,
645    should_execute_tools_in_parallel,
646};
647
648/// Build assistant history entry in JSON format for native tool-call APIs.
649/// `convert_messages` in the OpenRouter model_provider parses this JSON to reconstruct
650/// the proper `NativeMessage` with structured `tool_calls`.
651fn build_native_assistant_history(
652    text: &str,
653    tool_calls: &[ToolCall],
654    reasoning_content: Option<&str>,
655) -> String {
656    let calls_json: Vec<serde_json::Value> = tool_calls
657        .iter()
658        .map(|tc| {
659            serde_json::json!({
660                "id": tc.id,
661                "name": tc.name,
662                "arguments": tc.arguments,
663            })
664        })
665        .collect();
666
667    let content = if text.trim().is_empty() {
668        serde_json::Value::Null
669    } else {
670        serde_json::Value::String(text.trim().to_string())
671    };
672
673    let mut obj = serde_json::json!({
674        "content": content,
675        "tool_calls": calls_json,
676    });
677
678    if let Some(rc) = reasoning_content {
679        obj.as_object_mut().unwrap().insert(
680            "reasoning_content".to_string(),
681            serde_json::Value::String(rc.to_string()),
682        );
683    }
684
685    obj.to_string()
686}
687
688fn resolve_display_text(
689    response_text: &str,
690    parsed_text: &str,
691    has_tool_calls: bool,
692    has_native_tool_calls: bool,
693) -> String {
694    if has_tool_calls {
695        if !parsed_text.is_empty() {
696            return parsed_text.to_string();
697        }
698        if has_native_tool_calls {
699            return response_text.to_string();
700        }
701        return String::new();
702    }
703
704    if parsed_text.is_empty() {
705        response_text.to_string()
706    } else {
707        parsed_text.to_string()
708    }
709}
710
711#[derive(Debug)]
712pub struct ToolLoopCancelled;
713
714impl std::fmt::Display for ToolLoopCancelled {
715    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
716        f.write_str("tool loop cancelled")
717    }
718}
719
720impl std::error::Error for ToolLoopCancelled {}
721
722pub fn is_tool_loop_cancelled(err: &anyhow::Error) -> bool {
723    err.chain().any(|source| source.is::<ToolLoopCancelled>())
724}
725
726#[derive(Debug)]
727pub struct ModelSwitchRequested {
728    pub model_provider: String,
729    pub model: String,
730}
731
732impl std::fmt::Display for ModelSwitchRequested {
733    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
734        write!(
735            f,
736            "model switch requested to {} {}",
737            self.model_provider, self.model
738        )
739    }
740}
741
742impl std::error::Error for ModelSwitchRequested {}
743
744pub fn is_model_switch_requested(err: &anyhow::Error) -> Option<(String, String)> {
745    err.chain()
746        .filter_map(|source| source.downcast_ref::<ModelSwitchRequested>())
747        .map(|e| (e.model_provider.clone(), e.model.clone()))
748        .next()
749}
750
751#[derive(Debug, Default)]
752struct StreamedChatOutcome {
753    response_text: String,
754    /// Accumulated reasoning/thinking content from streaming deltas.
755    ///
756    /// Captured separately from `response_text` so it can be threaded into
757    /// `ChatResponse.reasoning_content` and ultimately persisted on the
758    /// `AssistantToolCalls` history entry. Required for model_providers like
759    /// DeepSeek V4 that reject follow-up requests when the assistant's
760    /// prior `reasoning_content` is missing from replayed tool-call turns
761    ///.
762    reasoning_content: String,
763    tool_calls: Vec<ToolCall>,
764    forwarded_live_deltas: bool,
765    suppressed_protocol: bool,
766    usage: Option<zeroclaw_providers::traits::TokenUsage>,
767}
768
769#[derive(Debug, Default)]
770struct StreamTextGuard {
771    // Suspicious leading chunks can split `"toolcalls"` / `<tool_call>` across
772    // deltas. Buffer just that prefix until it is clearly protocol or normal JSON.
773    pending: String,
774    pending_candidate_start: Option<usize>,
775    known_tool_names: HashSet<String>,
776    has_active_tools: bool,
777    suppress_forwarding: bool,
778    suppressed_protocol: bool,
779}
780
781impl StreamTextGuard {
782    fn new(available_tools: Option<&[crate::tools::ToolSpec]>) -> Self {
783        let available_tools = available_tools.unwrap_or(&[]);
784        let known_tool_names = available_tools
785            .iter()
786            .map(|tool| tool.name.to_ascii_lowercase())
787            .collect();
788        Self {
789            known_tool_names,
790            has_active_tools: !available_tools.is_empty(),
791            ..Self::default()
792        }
793    }
794
795    fn push(&mut self, chunk: &str) -> Option<String> {
796        if self.suppress_forwarding || chunk.is_empty() {
797            return None;
798        }
799
800        if self.pending.is_empty() && !starts_suspicious_protocol_prefix(chunk) {
801            if let Some(start) = find_embedded_protocol_candidate_start(chunk) {
802                self.pending_candidate_start = Some(start);
803                self.pending.push_str(&chunk[start..]);
804                return if self.should_suppress_protocol_candidate(&self.pending) {
805                    self.suppress_protocol();
806                    None
807                } else {
808                    self.pending.insert_str(0, &chunk[..start]);
809                    self.evaluate_pending(false)
810                };
811            }
812            if let Some(start) = find_incomplete_protocol_candidate_start(chunk) {
813                self.pending_candidate_start = Some(start);
814                self.pending.push_str(chunk);
815                return None;
816            }
817            return Some(chunk.to_string());
818        }
819
820        self.pending.push_str(chunk);
821        self.evaluate_pending(false)
822    }
823
824    fn finish(&mut self) -> Option<String> {
825        if self.suppress_forwarding || self.pending.is_empty() {
826            return None;
827        }
828        if let Some(release) = self.evaluate_pending(true) {
829            return Some(release);
830        }
831        if self.suppressed_protocol || self.pending.is_empty() {
832            return None;
833        }
834        if looks_like_malformed_tool_protocol_envelope_for_known_tools(
835            &self.pending,
836            &self.known_tool_names,
837        ) {
838            self.suppress_protocol();
839            return None;
840        }
841        Some(std::mem::take(&mut self.pending))
842    }
843
844    fn evaluate_pending(&mut self, finalizing: bool) -> Option<String> {
845        let candidate = self
846            .pending_candidate_start
847            .and_then(|start| self.pending.get(start..))
848            .unwrap_or(&self.pending);
849
850        if !finalizing && starts_suspicious_tag_or_fence_prefix(candidate) {
851            return None;
852        }
853
854        if self.should_suppress_protocol_candidate(candidate) {
855            self.suppress_protocol();
856            return None;
857        }
858
859        if let Some(is_protocol) =
860            complete_json_fence_protocol_state(candidate, &self.known_tool_names)
861        {
862            if is_protocol && self.has_active_tools {
863                self.suppress_protocol();
864                return None;
865            }
866            self.pending_candidate_start = None;
867            return Some(std::mem::take(&mut self.pending));
868        }
869
870        if complete_non_protocol_json(candidate, &self.known_tool_names) {
871            self.pending_candidate_start = None;
872            return Some(std::mem::take(&mut self.pending));
873        }
874
875        None
876    }
877
878    fn suppress_protocol(&mut self) {
879        self.pending.clear();
880        self.pending_candidate_start = None;
881        self.suppress_forwarding = true;
882        self.suppressed_protocol = true;
883    }
884
885    fn looks_like_active_tool_json(&self, text: &str) -> bool {
886        if self.known_tool_names.is_empty() {
887            return false;
888        }
889
890        let Ok(value) = serde_json::from_str::<serde_json::Value>(text.trim()) else {
891            return false;
892        };
893
894        match value {
895            serde_json::Value::Array(items) => {
896                !items.is_empty() && items.iter().all(|item| self.is_known_tool_payload(item))
897            }
898            serde_json::Value::Object(_) => self.is_known_tool_payload(&value),
899            _ => false,
900        }
901    }
902
903    fn is_known_tool_payload(&self, value: &serde_json::Value) -> bool {
904        let Some(object) = value.as_object() else {
905            return false;
906        };
907
908        let (name, has_args) =
909            if let Some(function) = object.get("function").and_then(|value| value.as_object()) {
910                (
911                    function
912                        .get("name")
913                        .and_then(serde_json::Value::as_str)
914                        .or_else(|| object.get("name").and_then(serde_json::Value::as_str)),
915                    function.contains_key("arguments")
916                        || function.contains_key("parameters")
917                        || object.contains_key("arguments")
918                        || object.contains_key("parameters"),
919                )
920            } else {
921                (
922                    object.get("name").and_then(serde_json::Value::as_str),
923                    object.contains_key("arguments") || object.contains_key("parameters"),
924                )
925            };
926
927        let Some(name) = name.map(str::trim).filter(|name| !name.is_empty()) else {
928            return false;
929        };
930
931        has_args && self.known_tool_names.contains(&name.to_ascii_lowercase())
932    }
933
934    fn should_suppress_protocol_candidate(&self, text: &str) -> bool {
935        if looks_like_tool_protocol_example(text) {
936            return false;
937        }
938
939        if looks_like_malformed_tool_protocol_envelope_for_known_tools(text, &self.known_tool_names)
940            || contains_tool_protocol_tag_call(text)
941        {
942            return true;
943        }
944
945        if let Some(kind) = classify_tool_protocol_envelope(text) {
946            return matches!(kind, ToolProtocolEnvelopeKind::TaggedToolCall)
947                || (self.has_active_tools
948                    && (matches!(kind, ToolProtocolEnvelopeKind::ToolResult)
949                        || tool_protocol_envelope_mentions_known_tool(
950                            text,
951                            &self.known_tool_names,
952                        )));
953        }
954
955        // Parsed JSON that carries protocol-only fields but cannot yield a valid
956        // tool call is an internal protocol failure, not user-facing text.
957        if looks_like_tool_protocol_envelope(text) {
958            return true;
959        }
960
961        self.looks_like_active_tool_json(text)
962    }
963}
964
965#[derive(Debug, Default)]
966struct StreamThinkTagStripper {
967    pending: String,
968    in_think: bool,
969}
970
971impl StreamThinkTagStripper {
972    const START_TAG: &'static str = "<think>";
973    const END_TAG: &'static str = "</think>";
974
975    fn push(&mut self, chunk: &str) -> String {
976        if chunk.is_empty() {
977            return String::new();
978        }
979
980        let mut input = std::mem::take(&mut self.pending);
981        input.push_str(chunk);
982        let mut visible = String::new();
983
984        loop {
985            if self.in_think {
986                if let Some(end) = input.find(Self::END_TAG) {
987                    input = input[end + Self::END_TAG.len()..].to_string();
988                    self.in_think = false;
989                    continue;
990                }
991
992                let keep_len = longest_suffix_matching_prefix(&input, Self::END_TAG);
993                if keep_len > 0 {
994                    self.pending = input[input.len() - keep_len..].to_string();
995                }
996                return visible;
997            }
998
999            if let Some(start) = input.find(Self::START_TAG) {
1000                visible.push_str(&input[..start]);
1001                input = input[start + Self::START_TAG.len()..].to_string();
1002                self.in_think = true;
1003                continue;
1004            }
1005
1006            let keep_len = longest_suffix_matching_prefix(&input, Self::START_TAG);
1007            if keep_len > 0 {
1008                let emit_len = input.len() - keep_len;
1009                visible.push_str(&input[..emit_len]);
1010                self.pending = input[emit_len..].to_string();
1011            } else {
1012                visible.push_str(&input);
1013            }
1014            return visible;
1015        }
1016    }
1017
1018    fn finish(&mut self) -> String {
1019        if self.in_think {
1020            self.pending.clear();
1021            return String::new();
1022        }
1023        std::mem::take(&mut self.pending)
1024    }
1025}
1026
1027fn longest_suffix_matching_prefix(text: &str, pattern: &str) -> usize {
1028    (1..pattern.len())
1029        .rev()
1030        .find(|&len| text.ends_with(&pattern[..len]))
1031        .unwrap_or(0)
1032}
1033
1034fn find_embedded_protocol_candidate_start(text: &str) -> Option<usize> {
1035    let lower = text.to_ascii_lowercase();
1036    let mut earliest: Option<usize> = None;
1037
1038    for pattern in [
1039        "<tool_call",
1040        "<toolcall",
1041        "<tool-call",
1042        "<invoke",
1043        "<function",
1044        "```tool",
1045        "```invoke",
1046        "```json",
1047    ] {
1048        if let Some(idx) = lower.find(pattern) {
1049            earliest = Some(earliest.map_or(idx, |current| current.min(idx)));
1050        }
1051    }
1052
1053    for key in ["\"tool_calls\"", "\"toolcalls\"", "\"function_call\""] {
1054        if let Some(key_idx) = lower.find(key)
1055            && let Some(json_start) = text[..key_idx].rfind(['{', '['])
1056        {
1057            earliest = Some(earliest.map_or(json_start, |current| current.min(json_start)));
1058        }
1059    }
1060
1061    earliest
1062}
1063
1064fn find_incomplete_protocol_candidate_start(text: &str) -> Option<usize> {
1065    let lower = text.to_ascii_lowercase();
1066    let mut earliest: Option<usize> = None;
1067
1068    for pattern in [
1069        "<tool",
1070        "<invoke",
1071        "<function",
1072        "```tool",
1073        "```invoke",
1074        "```json",
1075    ] {
1076        if let Some(idx) = lower.rfind(pattern) {
1077            earliest = Some(earliest.map_or(idx, |current| current.min(idx)));
1078        }
1079    }
1080
1081    for delimiter in ['{', '['] {
1082        if let Some(idx) = text.rfind(delimiter) {
1083            let tail = &lower[idx..];
1084            if tail.contains("\"tool")
1085                || tail.contains("\"function")
1086                || tail.contains("\"call")
1087                || tail.len() <= 16
1088            {
1089                earliest = Some(earliest.map_or(idx, |current| current.min(idx)));
1090            }
1091        }
1092    }
1093
1094    earliest
1095}
1096
1097fn starts_suspicious_protocol_prefix(text: &str) -> bool {
1098    let trimmed = text.trim_start();
1099    if trimmed.is_empty() {
1100        return false;
1101    }
1102    let lower = trimmed.to_ascii_lowercase();
1103    lower.starts_with('{')
1104        || lower.starts_with('[')
1105        || lower.starts_with("<tool")
1106        || lower.starts_with("<invoke")
1107        || lower.starts_with("<function")
1108        || lower.starts_with("```tool")
1109        || lower.starts_with("```invoke")
1110        || lower.starts_with("```json")
1111}
1112
1113fn starts_suspicious_tag_or_fence_prefix(text: &str) -> bool {
1114    let lower = text.trim_start().to_ascii_lowercase();
1115    lower.starts_with("<tool")
1116        || lower.starts_with("<invoke")
1117        || lower.starts_with("<function")
1118        || lower.starts_with("```tool")
1119        || lower.starts_with("```invoke")
1120        || lower.starts_with("```json")
1121        || lower.starts_with("[tool_call]")
1122}
1123
1124fn complete_non_protocol_json(text: &str, known_tool_names: &HashSet<String>) -> bool {
1125    let trimmed = text.trim();
1126    (trimmed.starts_with('{') || trimmed.starts_with('['))
1127        && serde_json::from_str::<serde_json::Value>(trimmed).is_ok()
1128        && (!looks_like_tool_protocol_envelope(trimmed)
1129            || !tool_protocol_envelope_mentions_known_tool(trimmed, known_tool_names))
1130}
1131
1132fn complete_json_fence_protocol_state(
1133    text: &str,
1134    known_tool_names: &HashSet<String>,
1135) -> Option<bool> {
1136    let trimmed = text.trim();
1137    let body = json_fence_body(trimmed)?;
1138    Some(
1139        looks_like_tool_protocol_envelope(body)
1140            && tool_protocol_envelope_mentions_known_tool(body, known_tool_names),
1141    )
1142}
1143
1144fn detect_internal_protocol_without_tools(response: &str) -> Option<String> {
1145    let trimmed = response.trim();
1146    if trimmed.is_empty() {
1147        return None;
1148    }
1149    if looks_like_tool_protocol_example(trimmed) {
1150        return None;
1151    }
1152
1153    (looks_like_malformed_tool_protocol_envelope(trimmed)
1154        || contains_tool_protocol_tag_call(trimmed)
1155        || classify_tool_protocol_envelope(trimmed)
1156            .is_some_and(|kind| matches!(kind, ToolProtocolEnvelopeKind::TaggedToolCall))
1157        || (classify_tool_protocol_envelope(trimmed).is_none()
1158            && looks_like_tool_protocol_envelope(trimmed)))
1159    .then(|| {
1160        "response resembled an internal tool protocol envelope but no tools were enabled".into()
1161    })
1162}
1163
1164fn detect_tool_call_parse_issue_for_known_tools(
1165    response: &str,
1166    parsed_calls: &[ParsedToolCall],
1167    known_tool_names: &HashSet<String>,
1168) -> Option<String> {
1169    if !parsed_calls.is_empty() {
1170        return None;
1171    }
1172
1173    let trimmed = response.trim();
1174    if trimmed.is_empty() || looks_like_tool_protocol_example(trimmed) {
1175        return None;
1176    }
1177
1178    let message = "response resembled an internal tool protocol envelope but no valid tool call could be parsed";
1179
1180    if looks_like_malformed_tool_protocol_envelope_for_known_tools(trimmed, known_tool_names)
1181        || contains_tool_protocol_tag_call(trimmed)
1182    {
1183        return Some(message.into());
1184    }
1185
1186    if let Some(kind) = classify_tool_protocol_envelope(trimmed) {
1187        return (matches!(
1188            kind,
1189            ToolProtocolEnvelopeKind::TaggedToolCall | ToolProtocolEnvelopeKind::ToolResult
1190        ) || tool_protocol_envelope_mentions_known_tool(trimmed, known_tool_names))
1191        .then(|| message.into());
1192    }
1193
1194    looks_like_tool_protocol_envelope(trimmed).then(|| message.into())
1195}
1196
1197fn json_fence_body(trimmed: &str) -> Option<&str> {
1198    let rest = trimmed.strip_prefix("```")?;
1199    let first_newline = rest.find('\n')?;
1200    let language = rest[..first_newline].trim().trim_end_matches('\r');
1201    if !language.eq_ignore_ascii_case("json") {
1202        return None;
1203    }
1204
1205    let body_with_close = &rest[first_newline + 1..];
1206    let close_start = body_with_close.rfind("```")?;
1207    if !body_with_close[close_start + 3..].trim().is_empty() {
1208        return None;
1209    }
1210    Some(body_with_close[..close_start].trim())
1211}
1212
1213async fn consume_provider_streaming_response(
1214    model_provider: &dyn ModelProvider,
1215    messages: &[ChatMessage],
1216    request_tools: Option<&[crate::tools::ToolSpec]>,
1217    model: &str,
1218    temperature: Option<f64>,
1219    cancellation_token: Option<&CancellationToken>,
1220    on_delta: Option<&tokio::sync::mpsc::Sender<DraftEvent>>,
1221    strict_tool_parsing: bool,
1222) -> Result<StreamedChatOutcome> {
1223    let mut provider_stream = model_provider.stream_chat(
1224        ChatRequest {
1225            messages,
1226            tools: request_tools,
1227            thinking: zeroclaw_api::NATIVE_THINKING_OVERRIDE
1228                .try_with(Clone::clone)
1229                .ok()
1230                .flatten(),
1231        },
1232        model,
1233        temperature,
1234        zeroclaw_providers::traits::StreamOptions::new(true),
1235    );
1236    let mut outcome = StreamedChatOutcome::default();
1237    let mut delta_sender = on_delta;
1238    let mut suppress_forwarding = false;
1239    let mut text_guard = StreamTextGuard::new(request_tools);
1240    let mut think_stripper = StreamThinkTagStripper::default();
1241
1242    loop {
1243        let next_chunk = if let Some(token) = cancellation_token {
1244            tokio::select! {
1245                () = token.cancelled() => return Err(ToolLoopCancelled.into()),
1246                chunk = provider_stream.next() => chunk,
1247            }
1248        } else {
1249            provider_stream.next().await
1250        };
1251
1252        let Some(event_result) = next_chunk else {
1253            break;
1254        };
1255
1256        let event = event_result.map_err(|err| {
1257            ::zeroclaw_log::record!(
1258                WARN,
1259                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
1260                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1261                    .with_attrs(::serde_json::json!({"error": format!("{}", err)})),
1262                "model_provider stream emitted an error event"
1263            );
1264            anyhow::Error::msg(format!("model_provider stream error: {err}"))
1265        })?;
1266        match event {
1267            StreamEvent::Final => break,
1268            StreamEvent::Usage(usage) => {
1269                outcome.usage = Some(usage);
1270            }
1271            StreamEvent::ToolCall(tool_call) => {
1272                outcome.tool_calls.push(tool_call);
1273                suppress_forwarding = true;
1274                text_guard.suppress_forwarding = true;
1275            }
1276            StreamEvent::PreExecutedToolCall { .. } | StreamEvent::PreExecutedToolResult { .. } => {
1277                // Pre-executed tool events are for observability only.
1278                // They are forwarded to the gateway via turn_streamed but
1279                // do not affect the agent's tool dispatch loop.
1280            }
1281            StreamEvent::TextDelta(chunk) => {
1282                // Reasoning/thinking deltas arrive on the same `TextDelta`
1283                // event as plain text but populate `chunk.reasoning` instead
1284                // of `chunk.delta`. They must be captured into the outcome
1285                // even when `chunk.delta` is empty — otherwise model_providers
1286                // that require reasoning to round-trip on subsequent turns
1287                // (DeepSeek V4 thinking mode; see #6059) reject the next
1288                // request with a 400. Reasoning is never forwarded as a
1289                // visible response delta — it is the model's internal
1290                // monologue, kept for replay only.
1291                if let Some(reasoning) = chunk.reasoning.as_deref()
1292                    && !reasoning.is_empty()
1293                {
1294                    outcome.reasoning_content.push_str(reasoning);
1295                }
1296
1297                if chunk.delta.is_empty() {
1298                    continue;
1299                }
1300
1301                let sanitized_delta = think_stripper.push(&chunk.delta);
1302                if sanitized_delta.is_empty() {
1303                    continue;
1304                }
1305
1306                outcome.response_text.push_str(&sanitized_delta);
1307
1308                if suppress_forwarding {
1309                    continue;
1310                }
1311
1312                if strict_tool_parsing {
1313                    if let Some(tx) = delta_sender {
1314                        outcome.forwarded_live_deltas = true;
1315                        if tx.send(StreamDelta::Text(sanitized_delta)).await.is_err() {
1316                            delta_sender = None;
1317                        }
1318                    }
1319                    continue;
1320                }
1321
1322                let Some(forward_text) = text_guard.push(&sanitized_delta) else {
1323                    continue;
1324                };
1325
1326                if let Some(tx) = delta_sender {
1327                    outcome.forwarded_live_deltas = true;
1328                    if tx.send(StreamDelta::Text(forward_text)).await.is_err() {
1329                        delta_sender = None;
1330                    }
1331                }
1332            }
1333        }
1334    }
1335
1336    let trailing_delta = think_stripper.finish();
1337    if !trailing_delta.is_empty() {
1338        outcome.response_text.push_str(&trailing_delta);
1339        if !suppress_forwarding {
1340            if strict_tool_parsing {
1341                if let Some(tx) = delta_sender {
1342                    outcome.forwarded_live_deltas = true;
1343                    if tx.send(StreamDelta::Text(trailing_delta)).await.is_err() {
1344                        delta_sender = None;
1345                    }
1346                }
1347            } else if let Some(forward_text) = text_guard.push(&trailing_delta)
1348                && let Some(tx) = delta_sender
1349            {
1350                outcome.forwarded_live_deltas = true;
1351                if tx.send(StreamDelta::Text(forward_text)).await.is_err() {
1352                    delta_sender = None;
1353                }
1354            }
1355        }
1356    }
1357
1358    if let Some(forward_text) = text_guard.finish()
1359        && let Some(tx) = delta_sender
1360    {
1361        outcome.forwarded_live_deltas = true;
1362        let _ = tx.send(StreamDelta::Text(forward_text)).await;
1363    }
1364    outcome.suppressed_protocol = text_guard.suppressed_protocol;
1365
1366    Ok(outcome)
1367}
1368
1369/// Execute a single turn of the agent loop: send messages, parse tool calls,
1370/// execute tools, and loop until the LLM produces a final text response.
1371/// When `silent` is true, suppresses stdout (for channel use).
1372#[allow(clippy::too_many_arguments)]
1373pub async fn agent_turn(
1374    model_provider: &dyn ModelProvider,
1375    history: &mut Vec<ChatMessage>,
1376    tools_registry: &[Box<dyn Tool>],
1377    observer: &dyn Observer,
1378    provider_name: &str,
1379    model: &str,
1380    temperature: Option<f64>,
1381    silent: bool,
1382    channel_name: &str,
1383    channel_reply_target: Option<&str>,
1384    multimodal_config: &zeroclaw_config::schema::MultimodalConfig,
1385    max_tool_iterations: usize,
1386    approval: Option<&ApprovalManager>,
1387    excluded_tools: &[String],
1388    dedup_exempt_tools: &[String],
1389    activated_tools: Option<&std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>>,
1390    model_switch_callback: Option<ModelSwitchCallback>,
1391    strict_tool_parsing: bool,
1392    parallel_tools: bool,
1393    channel: Option<&dyn Channel>,
1394) -> Result<String> {
1395    run_tool_call_loop(
1396        model_provider,
1397        history,
1398        tools_registry,
1399        observer,
1400        provider_name,
1401        model,
1402        temperature,
1403        silent,
1404        approval,
1405        channel_name,
1406        channel_reply_target,
1407        multimodal_config,
1408        max_tool_iterations,
1409        None,
1410        None,
1411        None,
1412        excluded_tools,
1413        dedup_exempt_tools,
1414        activated_tools,
1415        model_switch_callback,
1416        &zeroclaw_config::schema::PacingConfig::default(),
1417        strict_tool_parsing,
1418        parallel_tools,
1419        0,    // max_tool_result_chars: 0 = disabled (legacy callers)
1420        0,    // context_token_budget: 0 = disabled (legacy callers)
1421        None, // shared_budget: no shared budget for legacy callers
1422        channel,
1423        None, // receipt_generator
1424        None, // collected_receipts
1425    )
1426    .await
1427}
1428
1429fn maybe_inject_channel_delivery_defaults(
1430    tool_name: &str,
1431    tool_args: &mut serde_json::Value,
1432    channel_name: &str,
1433    channel_reply_target: Option<&str>,
1434) {
1435    if tool_name != "cron_add" {
1436        return;
1437    }
1438
1439    if !AUTO_DELIVERY_DEFAULT_CHANNELS.contains(&channel_name) {
1440        return;
1441    }
1442
1443    let Some(reply_target) = channel_reply_target
1444        .map(str::trim)
1445        .filter(|value| !value.is_empty())
1446    else {
1447        return;
1448    };
1449
1450    let Some(args) = tool_args.as_object_mut() else {
1451        return;
1452    };
1453
1454    let is_agent_job = args
1455        .get("job_type")
1456        .and_then(serde_json::Value::as_str)
1457        .is_some_and(|job_type| job_type.eq_ignore_ascii_case("agent"))
1458        || args
1459            .get("prompt")
1460            .and_then(serde_json::Value::as_str)
1461            .is_some_and(|prompt| !prompt.trim().is_empty());
1462    if !is_agent_job {
1463        return;
1464    }
1465
1466    let default_delivery = || {
1467        serde_json::json!({
1468            "mode": "announce",
1469            "channel": channel_name,
1470            "to": reply_target,
1471        })
1472    };
1473
1474    match args.get_mut("delivery") {
1475        None => {
1476            args.insert("delivery".to_string(), default_delivery());
1477        }
1478        Some(serde_json::Value::Null) => {
1479            *args.get_mut("delivery").expect("delivery key exists") = default_delivery();
1480        }
1481        Some(serde_json::Value::Object(delivery)) => {
1482            if delivery
1483                .get("mode")
1484                .and_then(serde_json::Value::as_str)
1485                .is_some_and(|mode| mode.eq_ignore_ascii_case("none"))
1486            {
1487                return;
1488            }
1489
1490            delivery
1491                .entry("mode".to_string())
1492                .or_insert_with(|| serde_json::Value::String("announce".to_string()));
1493
1494            let needs_channel = delivery
1495                .get("channel")
1496                .and_then(serde_json::Value::as_str)
1497                .is_none_or(|value| value.trim().is_empty());
1498            if needs_channel {
1499                delivery.insert(
1500                    "channel".to_string(),
1501                    serde_json::Value::String(channel_name.to_string()),
1502                );
1503            }
1504
1505            let needs_target = delivery
1506                .get("to")
1507                .and_then(serde_json::Value::as_str)
1508                .is_none_or(|value| value.trim().is_empty());
1509            if needs_target {
1510                delivery.insert(
1511                    "to".to_string(),
1512                    serde_json::Value::String(reply_target.to_string()),
1513                );
1514            }
1515        }
1516        Some(_) => {}
1517    }
1518}
1519
1520// ── Agent Tool-Call Loop ──────────────────────────────────────────────────
1521// Core agentic iteration: send conversation to the LLM, parse any tool
1522// calls from the response, execute them, append results to history, and
1523// repeat until the LLM produces a final text-only answer.
1524//
1525// Loop invariant: at the start of each iteration, `history` contains the
1526// full conversation so far (system prompt + user messages + prior tool
1527// results). The loop exits when:
1528//   • the LLM returns no tool calls (final answer), or
1529//   • max_iterations is reached (runaway safety), or
1530//   • the cancellation token fires (external abort).
1531
1532/// Append a receipt footer to the response text if any receipts were collected.
1533/// Execute a single turn of the agent loop: send messages, parse tool calls,
1534/// execute tools, and loop until the LLM produces a final text response.
1535#[allow(clippy::too_many_arguments)]
1536pub async fn run_tool_call_loop(
1537    model_provider: &dyn ModelProvider,
1538    history: &mut Vec<ChatMessage>,
1539    tools_registry: &[Box<dyn Tool>],
1540    observer: &dyn Observer,
1541    provider_name: &str,
1542    model: &str,
1543    temperature: Option<f64>,
1544    silent: bool,
1545    approval: Option<&ApprovalManager>,
1546    channel_name: &str,
1547    channel_reply_target: Option<&str>,
1548    multimodal_config: &zeroclaw_config::schema::MultimodalConfig,
1549    max_tool_iterations: usize,
1550    cancellation_token: Option<CancellationToken>,
1551    on_delta: Option<tokio::sync::mpsc::Sender<DraftEvent>>,
1552    hooks: Option<&crate::hooks::HookRunner>,
1553    excluded_tools: &[String],
1554    dedup_exempt_tools: &[String],
1555    activated_tools: Option<&std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>>,
1556    model_switch_callback: Option<ModelSwitchCallback>,
1557    pacing: &zeroclaw_config::schema::PacingConfig,
1558    strict_tool_parsing: bool,
1559    parallel_tools: bool,
1560    max_tool_result_chars: usize,
1561    context_token_budget: usize,
1562    shared_budget: Option<Arc<std::sync::atomic::AtomicUsize>>,
1563    channel: Option<&dyn Channel>,
1564    receipt_generator: Option<&crate::agent::tool_receipts::ReceiptGenerator>,
1565    collected_receipts: Option<&std::sync::Mutex<Vec<String>>>,
1566) -> Result<String> {
1567    let max_iterations = if max_tool_iterations == 0 {
1568        DEFAULT_MAX_TOOL_ITERATIONS
1569    } else {
1570        max_tool_iterations
1571    };
1572
1573    let turn_id = Uuid::new_v4().to_string();
1574    let loop_started_at = Instant::now();
1575    let loop_ignore_tools: HashSet<&str> = pacing
1576        .loop_ignore_tools
1577        .iter()
1578        .map(String::as_str)
1579        .collect();
1580    let mut consecutive_identical_outputs: usize = 0;
1581    let mut last_tool_output_hash: Option<u64> = None;
1582
1583    let mut loop_detector = crate::agent::loop_detector::LoopDetector::new(
1584        crate::agent::loop_detector::LoopDetectorConfig {
1585            enabled: pacing.loop_detection_enabled,
1586            window_size: pacing.loop_detection_window_size,
1587            max_repeats: pacing.loop_detection_max_repeats,
1588        },
1589    );
1590
1591    // Accumulated display text across all tool-loop calls.
1592    let mut accumulated_display_text = String::new();
1593    let mut malformed_tool_protocol_retries: usize = 0;
1594
1595    for iteration in 0..max_iterations {
1596        let mut seen_tool_signatures: HashSet<(String, String)> = HashSet::new();
1597
1598        if cancellation_token
1599            .as_ref()
1600            .is_some_and(CancellationToken::is_cancelled)
1601        {
1602            return Err(ToolLoopCancelled.into());
1603        }
1604
1605        // Shared iteration budget: parent + subagents share a global counter
1606        if let Some(ref budget) = shared_budget {
1607            let remaining = budget.load(std::sync::atomic::Ordering::Relaxed);
1608            if remaining == 0 {
1609                ::zeroclaw_log::record!(
1610                    WARN,
1611                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1612                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1613                        .with_attrs(::serde_json::json!({"iteration": iteration})),
1614                    "Shared iteration budget exhausted at iteration "
1615                );
1616                break;
1617            }
1618            budget.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
1619        }
1620
1621        // Preemptive context management: trim history before it overflows
1622        if context_token_budget > 0 {
1623            let estimated = estimate_history_tokens(history);
1624            if estimated > context_token_budget {
1625                ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"estimated": estimated, "budget": context_token_budget, "iteration": iteration + 1})), "Preemptive context trim: estimated tokens exceed budget");
1626                let chars_saved = fast_trim_tool_results(history, 4);
1627                if chars_saved > 0 {
1628                    ::zeroclaw_log::record!(
1629                        INFO,
1630                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1631                            .with_attrs(::serde_json::json!({"chars_saved": chars_saved})),
1632                        "Preemptive fast-trim applied"
1633                    );
1634                }
1635                // If still over budget, use the history pruner for deeper cleanup
1636                let recheck = estimate_history_tokens(history);
1637                if recheck > context_token_budget {
1638                    let stats = crate::agent::history_pruner::prune_history(
1639                        history,
1640                        &crate::agent::history_pruner::HistoryPrunerConfig {
1641                            enabled: true,
1642                            max_tokens: context_token_budget,
1643                            keep_recent: 4,
1644                            collapse_tool_results: true,
1645                        },
1646                    );
1647                    if stats.dropped_messages > 0 || stats.collapsed_pairs > 0 {
1648                        ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"collapsed": stats.collapsed_pairs, "dropped": stats.dropped_messages})), "Preemptive history prune applied");
1649                    }
1650                }
1651            }
1652        }
1653
1654        // Remove orphaned tool-role messages whose assistant (tool_calls)
1655        // counterpart was dropped by proactive trimming, context compression,
1656        // or session history reloading.  Without this, model_providers like MiniMax
1657        // reject the request with "tool result's tool id not found" (bug #5743).
1658        let pruned_in_loop = crate::agent::history_pruner::remove_orphaned_tool_messages(history);
1659        if !pruned_in_loop.is_empty() {
1660            ::zeroclaw_log::record!(
1661                WARN,
1662                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1663                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1664                    .with_attrs(::serde_json::json!({
1665                        "removed": pruned_in_loop.removed,
1666                        "orphan_tool_call_ids": pruned_in_loop.orphan_tool_call_ids,
1667                    })),
1668                "remove_orphaned_tool_messages fired inside run_tool_call_loop: \
1669                 assistant tool_use blocks and/or tool_results were stripped from \
1670                 the live history. If this fires mid-conversation the model loses \
1671                 the in-flight tool work and acts like it just woke up."
1672            );
1673        }
1674        normalize_system_messages(history);
1675
1676        // Check if model switch was requested via model_switch tool
1677        if let Some(ref callback) = model_switch_callback
1678            && let Ok(guard) = callback.lock()
1679            && let Some((new_model_provider, new_model)) = guard.as_ref()
1680            && (new_model_provider != provider_name || new_model != model)
1681        {
1682            ::zeroclaw_log::record!(
1683                INFO,
1684                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1685                &format!(
1686                    "Model switch detected: {} {} -> {} {}",
1687                    provider_name, model, new_model_provider, new_model
1688                )
1689            );
1690            return Err(ModelSwitchRequested {
1691                model_provider: new_model_provider.clone(),
1692                model: new_model.clone(),
1693            }
1694            .into());
1695        }
1696
1697        // Rebuild tool_specs each iteration so newly activated deferred tools appear.
1698        let mut tool_specs: Vec<crate::tools::ToolSpec> = tools_registry
1699            .iter()
1700            .filter(|tool| !excluded_tools.iter().any(|ex| ex == tool.name()))
1701            .map(|tool| tool.spec())
1702            .collect();
1703        if let Some(at) = activated_tools {
1704            for spec in at.lock().unwrap().tool_specs() {
1705                if !excluded_tools.iter().any(|ex| ex == &spec.name) {
1706                    tool_specs.push(spec);
1707                }
1708            }
1709        }
1710        let known_tool_names: HashSet<String> = tool_specs
1711            .iter()
1712            .map(|tool| tool.name.to_ascii_lowercase())
1713            .collect();
1714        let use_native_tools = model_provider.supports_native_tools() && !tool_specs.is_empty();
1715
1716        let image_marker_count = multimodal::count_image_markers(history);
1717        // Image markers that came from the user (inbound attachments), as
1718        // opposed to tool results. A missing vision capability is handled
1719        // differently for the two: a user image must surface an error (we
1720        // cannot silently ignore what the user sent), while a tool-result
1721        // image may degrade to text-only.
1722        let user_image_marker_count = multimodal::count_user_image_markers(history);
1723
1724        // ── Vision model_provider routing ──────────────────────────
1725        // When the default model_provider lacks vision support but a dedicated
1726        // vision_model_provider is configured, create it on demand and use it
1727        // for this iteration. When no vision route exists at all, either
1728        // surface a capability error (the user sent an image) or degrade
1729        // gracefully (the markers came only from tool results) — see the
1730        // no-vision-route branch below and `degrade_strip_images`.
1731        let mut degrade_strip_images = false;
1732        let vision_model_provider_box: Option<Box<dyn ModelProvider>> = if image_marker_count > 0
1733            && !model_provider.supports_vision()
1734        {
1735            if let Some(ref vp) = multimodal_config.vision_model_provider {
1736                let vp_instance =
1737                    zeroclaw_providers::create_model_provider(vp, None).map_err(|e| {
1738                        ::zeroclaw_log::record!(
1739                            ERROR,
1740                            ::zeroclaw_log::Event::new(
1741                                module_path!(),
1742                                ::zeroclaw_log::Action::Fail
1743                            )
1744                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1745                            .with_attrs(::serde_json::json!({
1746                                "vision_provider": vp,
1747                                "error": format!("{}", e),
1748                            })),
1749                            "vision model_provider construction failed"
1750                        );
1751                        anyhow::Error::msg(format!(
1752                            "failed to create vision model_provider '{vp}': {e}"
1753                        ))
1754                    })?;
1755                if !vp_instance.supports_vision() {
1756                    // Operator misconfiguration (named a non-vision provider as
1757                    // the vision route) — surface it loudly rather than silently
1758                    // degrading.
1759                    return Err(ProviderCapabilityError {
1760                        model_provider: vp.clone(),
1761                        capability: "vision".to_string(),
1762                        message: format!(
1763                            "configured vision_model_provider '{vp}' does not support vision input"
1764                        ),
1765                    }
1766                    .into());
1767                }
1768                Some(vp_instance)
1769            } else if user_image_marker_count > 0 {
1770                // The user sent an image we cannot see. Surface a capability
1771                // error so the attachment is not silently ignored — channels
1772                // render this back to the user (e.g. "⚠️ Error … does not
1773                // support vision"). Configuring a `vision_model_provider`
1774                // routes around it.
1775                return Err(ProviderCapabilityError {
1776                        model_provider: provider_name.to_string(),
1777                        capability: "vision".to_string(),
1778                        message: format!(
1779                            "received {image_marker_count} image marker(s), but this model_provider does not support vision input"
1780                        ),
1781                    }
1782                    .into());
1783            } else {
1784                // Markers came only from tool results (e.g. `image_info`,
1785                // `screenshot`, `image_gen`). Previously this aborted the
1786                // entire turn with a capability error, which turned an
1787                // otherwise successful tool call (e.g. `image_info`, which
1788                // always returns useful metadata text alongside its `[IMAGE:]`
1789                // marker) into a hard failure. Instead, degrade: strip the
1790                // image markers from the messages sent to the text-only
1791                // provider while preserving the surrounding text, so the
1792                // conversation continues and the model still receives any
1793                // accompanying metadata/caption.
1794                ::zeroclaw_log::record!(
1795                    WARN,
1796                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1797                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1798                        .with_attrs(::serde_json::json!({
1799                            "model_provider": provider_name,
1800                            "image_marker_count": image_marker_count,
1801                        })),
1802                    "no vision route for tool-result image marker(s); degrading to text-only (markers stripped)"
1803                );
1804                degrade_strip_images = true;
1805                None
1806            }
1807        } else {
1808            None
1809        };
1810
1811        let (active_model_provider, active_model_provider_name, active_model): (
1812            &dyn ModelProvider,
1813            &str,
1814            &str,
1815        ) = if let Some(ref vp_box) = vision_model_provider_box {
1816            let vp_name = multimodal_config
1817                .vision_model_provider
1818                .as_deref()
1819                .unwrap_or(provider_name);
1820            let vm = multimodal_config.vision_model.as_deref().unwrap_or(model);
1821            (vp_box.as_ref(), vp_name, vm)
1822        } else {
1823            (model_provider, provider_name, model)
1824        };
1825
1826        let prepared_messages = if degrade_strip_images {
1827            // Text-only fallback: replace every media marker with a
1828            // `[media attachment]` placeholder so no filesystem path or data
1829            // URI reaches the text-only provider, while surrounding text
1830            // (captions, tool metadata) survives.
1831            let stripped: Vec<ChatMessage> = history
1832                .iter()
1833                .map(|m| ChatMessage {
1834                    role: m.role.clone(),
1835                    content: multimodal::strip_media_markers(&m.content),
1836                })
1837                .collect();
1838            multimodal::prepare_messages_for_provider(&stripped, multimodal_config).await?
1839        } else {
1840            multimodal::prepare_messages_for_provider(history, multimodal_config).await?
1841        };
1842
1843        // ── Progress: LLM thinking ────────────────────────────
1844        if let Some(ref tx) = on_delta {
1845            let phase = if iteration == 0 {
1846                "\u{1f914} Thinking...\n".to_string()
1847            } else {
1848                format!("\u{1f914} Thinking (round {})...\n", iteration + 1)
1849            };
1850            let _ = tx.send(StreamDelta::Status(phase)).await;
1851        }
1852
1853        observer.record_event(&ObserverEvent::LlmRequest {
1854            model_provider: active_model_provider_name.to_string(),
1855            model: active_model.to_string(),
1856            messages_count: history.len(),
1857        });
1858        {
1859            let _provider_guard =
1860                ::zeroclaw_log::attribution_span!(active_model_provider).entered();
1861            ::zeroclaw_log::record!(
1862                INFO,
1863                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Send)
1864                    .with_attrs(::serde_json::json!({
1865                        "iteration": iteration + 1,
1866                        "messages_count": history.len(),
1867                        "model": active_model,
1868                        "trace_id": turn_id,
1869                    })),
1870                "llm_request"
1871            );
1872        }
1873
1874        let llm_started_at = Instant::now();
1875
1876        // Fire void hook before LLM call
1877        if let Some(hooks) = hooks {
1878            hooks.fire_llm_input(history, model).await;
1879        }
1880
1881        // Budget enforcement — block if limit exceeded (no-op when not scoped)
1882        if let Some(BudgetCheck::Exceeded {
1883            current_usd,
1884            limit_usd,
1885            period,
1886        }) = check_tool_loop_budget()
1887        {
1888            ::zeroclaw_log::record!(
1889                WARN,
1890                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
1891                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1892                    .with_attrs(::serde_json::json!({
1893                        "current_usd": current_usd,
1894                        "limit_usd": limit_usd,
1895                        "period": format!("{period:?}"),
1896                    })),
1897                "tool-call loop budget exceeded"
1898            );
1899            anyhow::bail!(
1900                "Budget exceeded: ${:.4} of ${:.2} {:?} limit. Cannot make further API calls until the budget resets.",
1901                current_usd,
1902                limit_usd,
1903                period
1904            );
1905        }
1906
1907        // Unified path via ModelProvider::chat so provider-specific native tool logic
1908        // (OpenAI/Anthropic/OpenRouter/compatible adapters) is honored.
1909        let request_tools = if use_native_tools {
1910            Some(tool_specs.as_slice())
1911        } else {
1912            None
1913        };
1914        let should_consume_provider_stream = on_delta.is_some()
1915            && model_provider.supports_streaming()
1916            && (request_tools.is_none() || model_provider.supports_streaming_tool_events());
1917        ::zeroclaw_log::record!(DEBUG, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"has_on_delta": on_delta.is_some(), "supports_streaming": model_provider.supports_streaming(), "should_consume_provider_stream": should_consume_provider_stream})), &format!("Streaming decision for iteration {}", iteration + 1));
1918        let mut streamed_live_deltas = false;
1919        let mut streamed_protocol_suppressed = false;
1920
1921        let chat_result = if should_consume_provider_stream {
1922            use ::zeroclaw_log::Instrument;
1923            let provider_span = ::zeroclaw_log::attribution_span!(active_model_provider);
1924            let stream_future = ::zeroclaw_log::scope!(
1925                model: active_model,
1926                =>
1927                consume_provider_streaming_response(
1928                    active_model_provider,
1929                    &prepared_messages.messages,
1930                    request_tools,
1931                    active_model,
1932                    temperature,
1933                    cancellation_token.as_ref(),
1934                    on_delta.as_ref(),
1935                    strict_tool_parsing,
1936                )
1937            )
1938            .instrument(provider_span);
1939            match stream_future.await {
1940                Ok(streamed) => {
1941                    streamed_live_deltas = streamed.forwarded_live_deltas;
1942                    streamed_protocol_suppressed = streamed.suppressed_protocol;
1943                    let reasoning_content = if streamed.reasoning_content.is_empty() {
1944                        None
1945                    } else {
1946                        Some(streamed.reasoning_content)
1947                    };
1948                    Ok(zeroclaw_providers::ChatResponse {
1949                        text: Some(streamed.response_text),
1950                        tool_calls: streamed.tool_calls,
1951                        usage: streamed.usage,
1952                        reasoning_content,
1953                    })
1954                }
1955                Err(stream_err) => {
1956                    ::zeroclaw_log::record!(
1957                        WARN,
1958                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
1959                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1960                            .with_attrs(::serde_json::json!({
1961                                "model": active_model,
1962                                "iteration": iteration + 1,
1963                                "error": scrub_credentials(&stream_err.to_string()),
1964                                "trace_id": turn_id,
1965                            })),
1966                        "llm_stream_fallback: provider stream failed, falling back to non-streaming chat"
1967                    );
1968                    {
1969                        use ::zeroclaw_log::Instrument;
1970                        let provider_span =
1971                            ::zeroclaw_log::attribution_span!(active_model_provider);
1972                        let chat_future = ::zeroclaw_log::scope!(
1973                            model: active_model,
1974                            =>
1975                            active_model_provider.chat(
1976                                ChatRequest {
1977                                    messages: &prepared_messages.messages,
1978                                    tools: request_tools,
1979                                    thinking: zeroclaw_api::NATIVE_THINKING_OVERRIDE
1980                                        .try_with(Clone::clone)
1981                                        .ok()
1982                                        .flatten(),
1983                                },
1984                                active_model,
1985                                temperature,
1986                            )
1987                        )
1988                        .instrument(provider_span);
1989                        if let Some(token) = cancellation_token.as_ref() {
1990                            tokio::select! {
1991                                () = token.cancelled() => Err(ToolLoopCancelled.into()),
1992                                result = chat_future => result,
1993                            }
1994                        } else {
1995                            chat_future.await
1996                        }
1997                    }
1998                }
1999            }
2000        } else {
2001            // Non-streaming path: wrap with optional per-step timeout from
2002            // pacing config to catch hung model responses.
2003            use ::zeroclaw_log::Instrument;
2004            let provider_span = ::zeroclaw_log::attribution_span!(active_model_provider);
2005            let chat_future = ::zeroclaw_log::scope!(
2006                model: active_model,
2007                =>
2008                active_model_provider.chat(
2009                    ChatRequest {
2010                        messages: &prepared_messages.messages,
2011                        tools: request_tools,
2012                        thinking: zeroclaw_api::NATIVE_THINKING_OVERRIDE
2013                            .try_with(Clone::clone)
2014                            .ok()
2015                            .flatten(),
2016                    },
2017                    active_model,
2018                    temperature,
2019                )
2020            )
2021            .instrument(provider_span);
2022
2023            match pacing.step_timeout_secs {
2024                Some(step_secs) if step_secs > 0 => {
2025                    let step_timeout = Duration::from_secs(step_secs);
2026                    if let Some(token) = cancellation_token.as_ref() {
2027                        tokio::select! {
2028                            () = token.cancelled() => return Err(ToolLoopCancelled.into()),
2029                            result = tokio::time::timeout(step_timeout, chat_future) => {
2030                                match result {
2031                                    Ok(inner) => inner,
2032                                    Err(_) => anyhow::bail!(
2033                                        "LLM inference step timed out after {step_secs}s (step_timeout_secs)"
2034                                    ),
2035                                }
2036                            },
2037                        }
2038                    } else {
2039                        match tokio::time::timeout(step_timeout, chat_future).await {
2040                            Ok(inner) => inner,
2041                            Err(_) => anyhow::bail!(
2042                                "LLM inference step timed out after {step_secs}s (step_timeout_secs)"
2043                            ),
2044                        }
2045                    }
2046                }
2047                _ => {
2048                    if let Some(token) = cancellation_token.as_ref() {
2049                        tokio::select! {
2050                            () = token.cancelled() => return Err(ToolLoopCancelled.into()),
2051                            result = chat_future => result,
2052                        }
2053                    } else {
2054                        chat_future.await
2055                    }
2056                }
2057            }
2058        };
2059
2060        let (
2061            response_text,
2062            parsed_text,
2063            tool_calls,
2064            assistant_history_content,
2065            native_tool_calls,
2066            parse_issue_detected,
2067            protocol_suppressed,
2068            response_streamed_live,
2069        ) = match chat_result {
2070            Ok(resp) => {
2071                let (resp_input_tokens, resp_output_tokens) = resp
2072                    .usage
2073                    .as_ref()
2074                    .map(|u| (u.input_tokens, u.output_tokens))
2075                    .unwrap_or((None, None));
2076
2077                observer.record_event(&ObserverEvent::LlmResponse {
2078                    model_provider: provider_name.to_string(),
2079                    model: model.to_string(),
2080                    duration: llm_started_at.elapsed(),
2081                    success: true,
2082                    error_message: None,
2083                    input_tokens: resp_input_tokens,
2084                    output_tokens: resp_output_tokens,
2085                });
2086
2087                // Record cost via task-local tracker (no-op when not scoped)
2088                let _ = resp
2089                    .usage
2090                    .as_ref()
2091                    .and_then(|usage| record_tool_loop_cost_usage(provider_name, model, usage));
2092
2093                let response_text = strip_think_tags(resp.text_or_empty());
2094                // First try native structured tool calls (OpenAI-format).
2095                // Fall back to text-based parsing (XML tags, markdown blocks,
2096                // GLM format) only if the model_provider returned no native calls —
2097                // this ensures we support both native and prompt-guided models.
2098                let mut calls: Vec<ParsedToolCall> = if tool_specs.is_empty() {
2099                    Vec::new()
2100                } else {
2101                    resp.tool_calls
2102                        .iter()
2103                        .map(|call| ParsedToolCall {
2104                            name: call.name.clone(),
2105                            arguments: serde_json::from_str::<serde_json::Value>(&call.arguments)
2106                                .unwrap_or_else(|_| {
2107                                    serde_json::Value::Object(serde_json::Map::new())
2108                                }),
2109                            tool_call_id: Some(call.id.clone()),
2110                        })
2111                        .collect()
2112                };
2113                let mut parsed_text = String::new();
2114
2115                if calls.is_empty()
2116                    && !tool_specs.is_empty()
2117                    && !strict_tool_parsing
2118                    && !looks_like_tool_protocol_example(&response_text)
2119                {
2120                    let (fallback_text, fallback_calls) = parse_tool_calls(&response_text);
2121                    let filtered_calls: Vec<ParsedToolCall> = fallback_calls
2122                        .into_iter()
2123                        .filter(|call| known_tool_names.contains(&call.name.to_ascii_lowercase()))
2124                        .collect();
2125                    if !fallback_text.is_empty() && !filtered_calls.is_empty() {
2126                        parsed_text = fallback_text;
2127                    }
2128                    calls = filtered_calls;
2129                }
2130
2131                let parse_issue = if strict_tool_parsing {
2132                    None
2133                } else if tool_specs.is_empty() {
2134                    detect_internal_protocol_without_tools(&response_text).or_else(|| {
2135                        streamed_protocol_suppressed.then(|| {
2136                            "streaming text guard suppressed an internal tool protocol envelope"
2137                                .to_string()
2138                        })
2139                    })
2140                } else {
2141                    detect_tool_call_parse_issue_for_known_tools(
2142                        &response_text,
2143                        &calls,
2144                        &known_tool_names,
2145                    )
2146                    .or_else(|| {
2147                        streamed_protocol_suppressed.then(|| {
2148                            "streaming text guard suppressed an internal tool protocol envelope"
2149                                .to_string()
2150                        })
2151                    })
2152                };
2153                if let Some(ref issue) = parse_issue {
2154                    ::zeroclaw_log::record!(
2155                        WARN,
2156                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
2157                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
2158                            .with_attrs(::serde_json::json!({
2159                                "model": model,
2160                                "iteration": iteration + 1,
2161                                "issue": issue.as_str(),
2162                                "response": scrub_credentials(&response_text),
2163                                "trace_id": turn_id,
2164                            })),
2165                        "tool_call_parse_issue"
2166                    );
2167                }
2168
2169                ::zeroclaw_log::record!(
2170                    INFO,
2171                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Receive)
2172                        .with_outcome(::zeroclaw_log::EventOutcome::Success)
2173                        .with_duration(
2174                            u64::try_from(llm_started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
2175                        )
2176                        .with_attrs(::serde_json::json!({
2177                            "model": model,
2178                            "iteration": iteration + 1,
2179                            "input_tokens": resp_input_tokens,
2180                            "output_tokens": resp_output_tokens,
2181                            "raw_response": scrub_credentials(&response_text),
2182                            "native_tool_calls": resp.tool_calls.len(),
2183                            "parsed_tool_calls": calls.len(),
2184                            "trace_id": turn_id,
2185                        })),
2186                    "llm_response"
2187                );
2188
2189                // Preserve native tool call IDs in assistant history so role=tool
2190                // follow-up messages can reference the exact call id.
2191                let reasoning_content = resp.reasoning_content.clone();
2192                let assistant_history_content = if resp.tool_calls.is_empty() {
2193                    if use_native_tools {
2194                        build_native_assistant_history_from_parsed_calls(
2195                            &response_text,
2196                            &calls,
2197                            reasoning_content.as_deref(),
2198                        )
2199                        .unwrap_or_else(|| response_text.clone())
2200                    } else {
2201                        response_text.clone()
2202                    }
2203                } else {
2204                    build_native_assistant_history(
2205                        &response_text,
2206                        &resp.tool_calls,
2207                        reasoning_content.as_deref(),
2208                    )
2209                };
2210
2211                let native_calls = resp.tool_calls;
2212                (
2213                    response_text,
2214                    parsed_text,
2215                    calls,
2216                    assistant_history_content,
2217                    native_calls,
2218                    parse_issue.is_some(),
2219                    streamed_protocol_suppressed,
2220                    streamed_live_deltas,
2221                )
2222            }
2223            Err(e) => {
2224                let safe_error = zeroclaw_providers::sanitize_api_error(&e.to_string());
2225                observer.record_event(&ObserverEvent::LlmResponse {
2226                    model_provider: provider_name.to_string(),
2227                    model: model.to_string(),
2228                    duration: llm_started_at.elapsed(),
2229                    success: false,
2230                    error_message: Some(safe_error.clone()),
2231                    input_tokens: None,
2232                    output_tokens: None,
2233                });
2234                ::zeroclaw_log::record!(
2235                    WARN,
2236                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
2237                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
2238                        .with_duration(
2239                            u64::try_from(llm_started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
2240                        )
2241                        .with_attrs(::serde_json::json!({
2242                            "model": model,
2243                            "iteration": iteration + 1,
2244                            "error": safe_error,
2245                            "trace_id": turn_id,
2246                        })),
2247                    "llm_response"
2248                );
2249
2250                // Context overflow recovery: trim history and retry
2251                if zeroclaw_providers::reliable::is_context_window_exceeded(&e) {
2252                    ::zeroclaw_log::record!(
2253                        WARN,
2254                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2255                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2256                            .with_attrs(::serde_json::json!({"iteration": iteration + 1})),
2257                        "Context window exceeded, attempting in-loop recovery"
2258                    );
2259
2260                    // Step 1: fast-trim old tool results (cheap)
2261                    let chars_saved = fast_trim_tool_results(history, 4);
2262                    if chars_saved > 0 {
2263                        ::zeroclaw_log::record!(
2264                            INFO,
2265                            ::zeroclaw_log::Event::new(
2266                                module_path!(),
2267                                ::zeroclaw_log::Action::Note
2268                            )
2269                            .with_attrs(::serde_json::json!({"chars_saved": chars_saved})),
2270                            "Context recovery: trimmed old tool results, retrying"
2271                        );
2272                        continue;
2273                    }
2274
2275                    // Step 2: emergency drop oldest non-system messages
2276                    let dropped = emergency_history_trim(history, 4);
2277                    if dropped > 0 {
2278                        ::zeroclaw_log::record!(
2279                            INFO,
2280                            ::zeroclaw_log::Event::new(
2281                                module_path!(),
2282                                ::zeroclaw_log::Action::Note
2283                            )
2284                            .with_attrs(::serde_json::json!({"dropped": dropped})),
2285                            "Context recovery: dropped old messages, retrying"
2286                        );
2287                        continue;
2288                    }
2289
2290                    // Nothing left to trim — truly unrecoverable
2291                    ::zeroclaw_log::record!(
2292                        ERROR,
2293                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
2294                            .with_outcome(::zeroclaw_log::EventOutcome::Failure),
2295                        "Context overflow unrecoverable: no trimmable messages"
2296                    );
2297                }
2298
2299                return Err(e);
2300            }
2301        };
2302
2303        let display_text = resolve_display_text(
2304            &response_text,
2305            &parsed_text,
2306            !tool_calls.is_empty(),
2307            !native_tool_calls.is_empty(),
2308        );
2309
2310        // Native provider tool_calls are converted into parsed `tool_calls`
2311        // above; if this branch is reached there is no valid native call to run.
2312        if tool_calls.is_empty() && parse_issue_detected {
2313            malformed_tool_protocol_retries += 1;
2314            ::zeroclaw_log::record!(
2315                WARN,
2316                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
2317                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
2318                    .with_attrs(serde_json::json!({
2319                        "channel": channel_name,
2320                        "model_provider": provider_name,
2321                        "model": model,
2322                        "trace_id": turn_id,
2323                        "error": "malformed internal tool protocol omitted from channel output",
2324                    })),
2325                "tool_call_parse_feedback"
2326            );
2327            ::zeroclaw_log::record!(
2328                DEBUG,
2329                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2330                    .with_attrs(serde_json::json!({
2331                    "iteration": iteration + 1,
2332                    "retry": malformed_tool_protocol_retries,
2333                    "max_retries": MAX_MALFORMED_TOOL_PROTOCOL_RETRIES,
2334                    "response_excerpt": truncate_with_ellipsis(
2335                        &scrub_credentials(&response_text),
2336                        600
2337                    ),
2338                    })),
2339                "tool_call_parse_feedback_details"
2340            );
2341
2342            if malformed_tool_protocol_retries <= MAX_MALFORMED_TOOL_PROTOCOL_RETRIES {
2343                // This is model feedback, not a tool result: malformed protocol
2344                // output has no valid tool_call_id to attach a role=tool message to.
2345                history.push(ChatMessage::user(
2346                    "[Tool call parse error]\n\
2347                     Your previous response looked like an internal tool-call protocol payload, \
2348                     but ZeroClaw could not parse it into a valid tool call. Use the supported \
2349                     tool-call schema, or answer in natural language if no tool is needed."
2350                        .to_string(),
2351                ));
2352                continue;
2353            }
2354
2355            let fallback =
2356                crate::i18n::get_required_cli_string("channel-runtime-malformed-tool-output");
2357            accumulated_display_text.push_str(&fallback);
2358            if let Some(ref tx) = on_delta {
2359                let _ = tx.send(StreamDelta::Text(fallback.to_string())).await;
2360            }
2361            history.push(ChatMessage::assistant(fallback.to_string()));
2362            return Ok(accumulated_display_text);
2363        }
2364
2365        // ── Progress: LLM responded ─────────────────────────────
2366        if let Some(ref tx) = on_delta {
2367            let llm_secs = llm_started_at.elapsed().as_secs();
2368            if !tool_calls.is_empty() {
2369                let _ = tx
2370                    .send(StreamDelta::Status(format!(
2371                        "\u{1f4ac} Got {} tool call(s) ({llm_secs}s)\n",
2372                        tool_calls.len()
2373                    )))
2374                    .await;
2375            }
2376        }
2377
2378        if tool_calls.is_empty() {
2379            ::zeroclaw_log::record!(
2380                INFO,
2381                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Complete)
2382                    .with_outcome(::zeroclaw_log::EventOutcome::Success)
2383                    .with_attrs(::serde_json::json!({
2384                        "model": model,
2385                        "iteration": iteration + 1,
2386                        "text": scrub_credentials(&display_text),
2387                        "trace_id": turn_id,
2388                    })),
2389                "turn_final_response"
2390            );
2391            // No tool calls — this is the final response.
2392            accumulated_display_text.push_str(&display_text);
2393
2394            // If text wasn't streamed live, send it now via post-hoc chunking.
2395            // When streamed live, the channel already received the deltas.
2396            if let Some(ref tx) = on_delta
2397                && !response_streamed_live
2398                && !protocol_suppressed
2399            {
2400                let mut chunk = String::new();
2401                for word in display_text.split_inclusive(char::is_whitespace) {
2402                    if cancellation_token
2403                        .as_ref()
2404                        .is_some_and(CancellationToken::is_cancelled)
2405                    {
2406                        return Err(ToolLoopCancelled.into());
2407                    }
2408                    chunk.push_str(word);
2409                    if chunk.len() >= STREAM_CHUNK_MIN_CHARS
2410                        && tx
2411                            .send(StreamDelta::Text(std::mem::take(&mut chunk)))
2412                            .await
2413                            .is_err()
2414                    {
2415                        break;
2416                    }
2417                }
2418                if !chunk.is_empty() {
2419                    let _ = tx.send(StreamDelta::Text(chunk)).await;
2420                }
2421            }
2422
2423            history.push(ChatMessage::assistant(response_text.clone()));
2424            return Ok(accumulated_display_text);
2425        }
2426
2427        // Do not accumulate intermediate-turn display text into the final
2428        // channel response. Native tool-call providers may emit narration or
2429        // scratchpad-like text alongside tool calls; draft-capable channels
2430        // can still see it live through `on_delta` below, but the final
2431        // delivered response must only contain the final assistant turn.
2432
2433        // Native tool-call model_providers can return assistant text separately from
2434        // the structured call payload; relay it to draft-capable channels.
2435        if !display_text.is_empty() {
2436            if !native_tool_calls.is_empty()
2437                && let Some(ref tx) = on_delta
2438            {
2439                let mut narration = display_text.clone();
2440                if !narration.ends_with('\n') {
2441                    narration.push('\n');
2442                }
2443                let _ = tx.send(StreamDelta::Text(narration)).await;
2444            }
2445            if !silent {
2446                print!("{display_text}");
2447                let _ = std::io::stdout().flush();
2448            }
2449        }
2450
2451        // Execute tool calls and build results. `individual_results` tracks per-call output so
2452        // native-mode history can emit one role=tool message per tool call with the correct ID.
2453        //
2454        // When multiple tool calls are present and interactive CLI approval is not needed, run
2455        // tool executions concurrently for lower wall-clock latency.
2456        let mut tool_results = String::new();
2457        let mut individual_results: Vec<(Option<String>, String)> = Vec::new();
2458        let mut ordered_results: Vec<Option<(String, Option<String>, ToolExecutionOutcome)>> =
2459            (0..tool_calls.len()).map(|_| None).collect();
2460        let allow_parallel_execution =
2461            parallel_tools && should_execute_tools_in_parallel(&tool_calls, approval);
2462        let mut executable_indices: Vec<usize> = Vec::new();
2463        let mut executable_calls: Vec<ParsedToolCall> = Vec::new();
2464
2465        for (idx, call) in tool_calls.iter().enumerate() {
2466            // ── Hook: before_tool_call (modifying) ──────────
2467            let mut tool_name = call.name.clone();
2468            let mut tool_args = call.arguments.clone();
2469            if let Some(hooks) = hooks {
2470                match hooks
2471                    .run_before_tool_call(tool_name.clone(), tool_args.clone())
2472                    .await
2473                {
2474                    crate::hooks::HookResult::Cancel(reason) => {
2475                        ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"tool": call.name, "reason": reason.to_string()})), "tool call cancelled by hook");
2476                        let cancelled = format!("Cancelled by hook: {reason}");
2477                        ::zeroclaw_log::record!(
2478                            WARN,
2479                            ::zeroclaw_log::Event::new(
2480                                module_path!(),
2481                                ::zeroclaw_log::Action::Cancel
2482                            )
2483                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
2484                            .with_attrs(::serde_json::json!({
2485                                "model": model,
2486                                "iteration": iteration + 1,
2487                                "tool": call.name,
2488                                "arguments": scrub_credentials(&tool_args.to_string()),
2489                                "result": cancelled,
2490                                "trace_id": turn_id,
2491                            })),
2492                            "tool_call_result"
2493                        );
2494                        if let Some(ref tx) = on_delta {
2495                            let _ = tx
2496                                .send(StreamDelta::Status(format!(
2497                                    "\u{274c} {}: {}\n",
2498                                    call.name,
2499                                    truncate_with_ellipsis(&scrub_credentials(&cancelled), 200)
2500                                )))
2501                                .await;
2502                        }
2503                        ordered_results[idx] = Some((
2504                            call.name.clone(),
2505                            call.tool_call_id.clone(),
2506                            ToolExecutionOutcome {
2507                                output: cancelled,
2508                                success: false,
2509                                error_reason: Some(scrub_credentials(&reason)),
2510                                duration: Duration::ZERO,
2511                                receipt: None,
2512                            },
2513                        ));
2514                        continue;
2515                    }
2516                    crate::hooks::HookResult::Continue((name, args)) => {
2517                        tool_name = name;
2518                        tool_args = args;
2519                    }
2520                }
2521            }
2522
2523            maybe_inject_channel_delivery_defaults(
2524                &tool_name,
2525                &mut tool_args,
2526                channel_name,
2527                channel_reply_target,
2528            );
2529
2530            super::set_runtime_approved_arg(&tool_name, &mut tool_args, false);
2531
2532            // ── Approval hook ────────────────────────────────
2533            let mut approval_requirement = approval
2534                .map(|mgr| mgr.approval_requirement(&tool_name))
2535                .unwrap_or(ApprovalRequirement::NotRequired);
2536            if let Some(mgr) = approval
2537                && approval_requirement == ApprovalRequirement::Prompt
2538            {
2539                let request = ApprovalRequest {
2540                    tool_name: tool_name.clone(),
2541                    arguments: tool_args.clone(),
2542                };
2543
2544                // Interactive CLI: prompt the operator.
2545                // Non-interactive (channels): try the channel's inline
2546                // approval (e.g. Telegram inline keyboard) before falling
2547                // back to auto-deny.
2548                let decision = if mgr.is_non_interactive() {
2549                    let channel_decision = if let Some(ch) = channel {
2550                        let ch_request = zeroclaw_api::channel::ChannelApprovalRequest {
2551                            tool_name: request.tool_name.clone(),
2552                            arguments_summary: crate::approval::summarize_args(&request.arguments),
2553                            raw_arguments: Some(request.arguments.clone()),
2554                        };
2555                        let recipient = channel_reply_target.unwrap_or_default();
2556                        match ch.request_approval(recipient, &ch_request).await {
2557                            Ok(Some(r)) => Some(r),
2558                            Ok(None) => None,
2559                            Err(e) => {
2560                                ::zeroclaw_log::record!(
2561                                    WARN,
2562                                    ::zeroclaw_log::Event::new(
2563                                        module_path!(),
2564                                        ::zeroclaw_log::Action::Note
2565                                    )
2566                                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2567                                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
2568                                    "Channel approval request failed"
2569                                );
2570                                None
2571                            }
2572                        }
2573                    } else {
2574                        None
2575                    };
2576                    match channel_decision {
2577                        Some(zeroclaw_api::channel::ChannelApprovalResponse::Approve) => {
2578                            ApprovalResponse::Yes
2579                        }
2580                        Some(zeroclaw_api::channel::ChannelApprovalResponse::AlwaysApprove) => {
2581                            ApprovalResponse::Always
2582                        }
2583                        Some(zeroclaw_api::channel::ChannelApprovalResponse::Deny) => {
2584                            ApprovalResponse::No
2585                        }
2586                        Some(zeroclaw_api::channel::ChannelApprovalResponse::DenyWithEdit {
2587                            replacement,
2588                        }) => ApprovalResponse::ReplaceWith(replacement),
2589                        // Channel doesn't support approval — auto-deny.
2590                        None => ApprovalResponse::No,
2591                    }
2592                } else {
2593                    mgr.prompt_cli(&request)
2594                };
2595
2596                mgr.record_decision(&tool_name, &tool_args, &decision, channel_name);
2597
2598                if decision == ApprovalResponse::No {
2599                    let denied = "Denied by user.".to_string();
2600                    ::zeroclaw_log::record!(
2601                        WARN,
2602                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
2603                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
2604                            .with_attrs(::serde_json::json!({
2605                                "model": model,
2606                                "iteration": iteration + 1,
2607                                "tool": tool_name.clone(),
2608                                "arguments": scrub_credentials(&tool_args.to_string()),
2609                                "result": denied,
2610                                "trace_id": turn_id,
2611                            })),
2612                        "tool_call_result"
2613                    );
2614                    if let Some(ref tx) = on_delta {
2615                        let _ = tx
2616                            .send(StreamDelta::Status(format!(
2617                                "\u{274c} {}: {}\n",
2618                                tool_name, denied
2619                            )))
2620                            .await;
2621                    }
2622                    ordered_results[idx] = Some((
2623                        tool_name.clone(),
2624                        call.tool_call_id.clone(),
2625                        ToolExecutionOutcome {
2626                            output: denied.clone(),
2627                            success: false,
2628                            error_reason: Some(denied),
2629                            duration: Duration::ZERO,
2630                            receipt: None,
2631                        },
2632                    ));
2633                    continue;
2634                }
2635
2636                if let ApprovalResponse::ReplaceWith(replacement) = &decision {
2637                    if let Some(ref tx) = on_delta {
2638                        let _ = tx
2639                            .send(StreamDelta::Status(format!(
2640                                "\u{270f} {}: replaced by user\n",
2641                                tool_name
2642                            )))
2643                            .await;
2644                    }
2645                    ::zeroclaw_log::record!(
2646                        INFO,
2647                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2648                            .with_outcome(::zeroclaw_log::EventOutcome::Success)
2649                            .with_attrs(::serde_json::json!({
2650                                "model": model,
2651                                "iteration": iteration + 1,
2652                                "tool": tool_name.clone(),
2653                                "arguments": scrub_credentials(&tool_args.to_string()),
2654                                "replaced": true,
2655                                "output": scrub_credentials(replacement),
2656                                "trace_id": turn_id,
2657                            })),
2658                        "tool_call_result"
2659                    );
2660                    ordered_results[idx] = Some((
2661                        tool_name.clone(),
2662                        call.tool_call_id.clone(),
2663                        ToolExecutionOutcome {
2664                            output: crate::approval::sanitize_tool_replacement(replacement),
2665                            success: true,
2666                            error_reason: None,
2667                            duration: Duration::ZERO,
2668                            receipt: None,
2669                        },
2670                    ));
2671                    continue;
2672                }
2673
2674                if matches!(decision, ApprovalResponse::Yes | ApprovalResponse::Always) {
2675                    approval_requirement = ApprovalRequirement::Approved;
2676                }
2677            }
2678            super::set_runtime_approved_arg(
2679                &tool_name,
2680                &mut tool_args,
2681                approval_requirement == ApprovalRequirement::Approved,
2682            );
2683
2684            let signature = {
2685                let canonical_args = canonicalize_json_for_tool_signature(&tool_args);
2686                let args_json =
2687                    serde_json::to_string(&canonical_args).unwrap_or_else(|_| "{}".to_string());
2688                (tool_name.trim().to_ascii_lowercase(), args_json)
2689            };
2690            let dedup_exempt = dedup_exempt_tools.iter().any(|e| e == &tool_name)
2691                || crate::tools::REENTRANT_AGENT_TOOLS.contains(&tool_name.as_str());
2692            if !dedup_exempt && !seen_tool_signatures.insert(signature) {
2693                let duplicate = format!(
2694                    "Skipped duplicate tool call '{tool_name}' with identical arguments in this turn."
2695                );
2696                ::zeroclaw_log::record!(
2697                    INFO,
2698                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Skip)
2699                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
2700                        .with_attrs(::serde_json::json!({
2701                            "model": model,
2702                            "iteration": iteration + 1,
2703                            "tool": tool_name.clone(),
2704                            "arguments": scrub_credentials(&tool_args.to_string()),
2705                            "result": duplicate,
2706                            "deduplicated": true,
2707                            "trace_id": turn_id,
2708                        })),
2709                    "tool_call_result"
2710                );
2711                if let Some(ref tx) = on_delta {
2712                    let _ = tx
2713                        .send(StreamDelta::Status(format!(
2714                            "\u{274c} {}: {}\n",
2715                            tool_name, duplicate
2716                        )))
2717                        .await;
2718                }
2719                ordered_results[idx] = Some((
2720                    tool_name.clone(),
2721                    call.tool_call_id.clone(),
2722                    ToolExecutionOutcome {
2723                        output: duplicate.clone(),
2724                        success: false,
2725                        error_reason: Some(duplicate),
2726                        duration: Duration::ZERO,
2727                        receipt: None,
2728                    },
2729                ));
2730                continue;
2731            }
2732
2733            ::zeroclaw_log::record!(
2734                INFO,
2735                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Start)
2736                    .with_attrs(::serde_json::json!({
2737                        "model": model,
2738                        "iteration": iteration + 1,
2739                        "tool": tool_name.clone(),
2740                        "arguments": scrub_credentials(&tool_args.to_string()),
2741                        "trace_id": turn_id,
2742                    })),
2743                "tool_call_start"
2744            );
2745
2746            // ── Progress: tool start ────────────────────────────
2747            if let Some(ref tx) = on_delta {
2748                let hint = {
2749                    let raw = match tool_name.as_str() {
2750                        "shell" => tool_args.get("command").and_then(|v| v.as_str()),
2751                        "file_read" | "file_write" => {
2752                            tool_args.get("path").and_then(|v| v.as_str())
2753                        }
2754                        _ => tool_args
2755                            .get("action")
2756                            .and_then(|v| v.as_str())
2757                            .or_else(|| tool_args.get("query").and_then(|v| v.as_str())),
2758                    };
2759                    match raw {
2760                        Some(s) => truncate_with_ellipsis(s, 60),
2761                        None => String::new(),
2762                    }
2763                };
2764                let progress = if hint.is_empty() {
2765                    format!("\u{23f3} {}\n", tool_name)
2766                } else {
2767                    format!("\u{23f3} {}: {hint}\n", tool_name)
2768                };
2769                ::zeroclaw_log::record!(
2770                    DEBUG,
2771                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2772                        .with_attrs(::serde_json::json!({"tool": tool_name})),
2773                    "Sending progress start to draft"
2774                );
2775                let _ = tx.send(StreamDelta::Status(progress)).await;
2776            }
2777
2778            executable_indices.push(idx);
2779            executable_calls.push(ParsedToolCall {
2780                name: tool_name,
2781                arguments: tool_args,
2782                tool_call_id: call.tool_call_id.clone(),
2783            });
2784        }
2785
2786        let executed_outcomes = if allow_parallel_execution && executable_calls.len() > 1 {
2787            execute_tools_parallel(
2788                &executable_calls,
2789                tools_registry,
2790                activated_tools,
2791                observer,
2792                cancellation_token.as_ref(),
2793                receipt_generator,
2794            )
2795            .await?
2796        } else {
2797            execute_tools_sequential(
2798                &executable_calls,
2799                tools_registry,
2800                activated_tools,
2801                observer,
2802                cancellation_token.as_ref(),
2803                receipt_generator,
2804            )
2805            .await?
2806        };
2807
2808        for ((idx, call), outcome) in executable_indices
2809            .iter()
2810            .zip(executable_calls.iter())
2811            .zip(executed_outcomes)
2812        {
2813            ::zeroclaw_log::record!(
2814                INFO,
2815                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Complete)
2816                    .with_outcome(if outcome.success {
2817                        ::zeroclaw_log::EventOutcome::Success
2818                    } else {
2819                        ::zeroclaw_log::EventOutcome::Failure
2820                    })
2821                    .with_duration(u64::try_from(outcome.duration.as_millis()).unwrap_or(u64::MAX),)
2822                    .with_attrs(::serde_json::json!({
2823                        "model": model,
2824                        "iteration": iteration + 1,
2825                        "tool": call.name.clone(),
2826                        "error_reason": outcome.error_reason,
2827                        "output": scrub_credentials(&outcome.output),
2828                        "trace_id": turn_id,
2829                    })),
2830                "tool_call_result"
2831            );
2832
2833            // ── Hook: after_tool_call (void) ─────────────────
2834            if let Some(hooks) = hooks {
2835                let tool_result_obj = crate::tools::ToolResult {
2836                    success: outcome.success,
2837                    output: outcome.output.clone(),
2838                    error: None,
2839                };
2840                hooks
2841                    .fire_after_tool_call(&call.name, &tool_result_obj, outcome.duration)
2842                    .await;
2843            }
2844
2845            // ── Progress: tool completion ───────────────────────
2846            if let Some(ref tx) = on_delta {
2847                let secs = outcome.duration.as_secs();
2848                let progress_msg = if outcome.success {
2849                    format!("\u{2705} {} ({secs}s)\n", call.name)
2850                } else if let Some(ref reason) = outcome.error_reason {
2851                    format!(
2852                        "\u{274c} {} ({secs}s): {}\n",
2853                        call.name,
2854                        truncate_with_ellipsis(reason, 200)
2855                    )
2856                } else {
2857                    format!("\u{274c} {} ({secs}s)\n", call.name)
2858                };
2859                ::zeroclaw_log::record!(
2860                    DEBUG,
2861                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2862                        .with_attrs(::serde_json::json!({"tool": call.name, "secs": secs})),
2863                    "Sending progress complete to draft"
2864                );
2865                let _ = tx.send(StreamDelta::Status(progress_msg)).await;
2866            }
2867
2868            ordered_results[*idx] = Some((call.name.clone(), call.tool_call_id.clone(), outcome));
2869        }
2870
2871        // Collect tool results and build per-tool output for loop detection.
2872        // Only non-ignored tool outputs contribute to the identical-output hash.
2873        let mut detection_relevant_output = String::new();
2874        // Use enumerate *before* filter_map so result_index stays aligned with
2875        // tool_calls even when some ordered_results entries are None.
2876        for (result_index, (tool_name, tool_call_id, outcome)) in ordered_results
2877            .into_iter()
2878            .enumerate()
2879            .filter_map(|(i, opt)| opt.map(|v| (i, v)))
2880        {
2881            if !loop_ignore_tools.contains(tool_name.as_str()) {
2882                detection_relevant_output.push_str(&outcome.output);
2883
2884                // Feed the pattern-based loop detector with name + args + result.
2885                let args = tool_calls
2886                    .get(result_index)
2887                    .map(|c| &c.arguments)
2888                    .unwrap_or(&serde_json::Value::Null);
2889                let det_result = loop_detector.record(&tool_name, args, &outcome.output);
2890                match det_result {
2891                    crate::agent::loop_detector::LoopDetectionResult::Ok => {}
2892                    crate::agent::loop_detector::LoopDetectionResult::Warning(ref msg) => {
2893                        ::zeroclaw_log::record!(
2894                            WARN,
2895                            ::zeroclaw_log::Event::new(
2896                                module_path!(),
2897                                ::zeroclaw_log::Action::Note
2898                            )
2899                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2900                            .with_attrs(
2901                                ::serde_json::json!({"tool": tool_name, "msg": msg.to_string()})
2902                            ),
2903                            "loop detector warning"
2904                        );
2905                        append_or_merge_system_message(history, format!("[Loop Detection] {msg}"));
2906                    }
2907                    crate::agent::loop_detector::LoopDetectionResult::Block(ref msg) => {
2908                        ::zeroclaw_log::record!(
2909                            WARN,
2910                            ::zeroclaw_log::Event::new(
2911                                module_path!(),
2912                                ::zeroclaw_log::Action::Note
2913                            )
2914                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2915                            .with_attrs(
2916                                ::serde_json::json!({"tool": tool_name, "msg": msg.to_string()})
2917                            ),
2918                            "loop detector blocked tool call"
2919                        );
2920                        // Replace the tool output with the block message.
2921                        // We still continue the loop so the LLM sees the block feedback.
2922                        append_or_merge_system_message(
2923                            history,
2924                            format!("[Loop Detection — BLOCKED] {msg}"),
2925                        );
2926                    }
2927                    crate::agent::loop_detector::LoopDetectionResult::Break(msg) => {
2928                        ::zeroclaw_log::record!(
2929                            WARN,
2930                            ::zeroclaw_log::Event::new(
2931                                module_path!(),
2932                                ::zeroclaw_log::Action::Fail
2933                            )
2934                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
2935                            .with_attrs(::serde_json::json!({
2936                                "model": model,
2937                                "iteration": iteration + 1,
2938                                "tool": tool_name,
2939                                "message": msg,
2940                                "trace_id": turn_id,
2941                            })),
2942                            "loop_detector_circuit_breaker"
2943                        );
2944                        anyhow::bail!("Agent loop aborted by loop detector: {msg}");
2945                    }
2946                }
2947            }
2948            let canonical_output = canonicalize_tool_result_media_markers(&outcome.output);
2949            let mut result_output = truncate_tool_result(&canonical_output, max_tool_result_chars);
2950            // Append HMAC receipt to tool result when receipts are enabled
2951            if let Some(ref receipt) = outcome.receipt {
2952                ::zeroclaw_log::record!(
2953                    DEBUG,
2954                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2955                        .with_attrs(::serde_json::json!({"tool": tool_name, "receipt": receipt})),
2956                    "Tool receipt generated"
2957                );
2958                result_output = format!("{result_output}\n\n[receipt: {receipt}]");
2959                if let Some(store) = collected_receipts
2960                    && let Ok(mut v) = store.lock()
2961                {
2962                    v.push(format!("{tool_name}: {receipt}"));
2963                }
2964            }
2965            individual_results.push((tool_call_id, result_output.clone()));
2966            let _ = writeln!(
2967                tool_results,
2968                "<tool_result name=\"{}\">\n{}\n</tool_result>",
2969                tool_name, result_output
2970            );
2971        }
2972
2973        // ── Time-gated loop detection ──────────────────────────
2974        // When pacing.loop_detection_min_elapsed_secs is set, identical-output
2975        // loop detection activates after the task has been running that long.
2976        // This avoids false-positive aborts on long-running browser/research
2977        // workflows while keeping aggressive protection for quick tasks.
2978        // When not configured, identical-output detection is disabled (preserving
2979        // existing behavior where only max_iterations prevents runaway loops).
2980        let loop_detection_active = match pacing.loop_detection_min_elapsed_secs {
2981            Some(min_secs) => loop_started_at.elapsed() >= Duration::from_secs(min_secs),
2982            None => false, // disabled when not configured (backwards compatible)
2983        };
2984
2985        if loop_detection_active && !detection_relevant_output.is_empty() {
2986            use std::hash::{Hash, Hasher};
2987            let mut hasher = std::collections::hash_map::DefaultHasher::new();
2988            detection_relevant_output.hash(&mut hasher);
2989            let current_hash = hasher.finish();
2990
2991            if last_tool_output_hash == Some(current_hash) {
2992                consecutive_identical_outputs += 1;
2993            } else {
2994                consecutive_identical_outputs = 0;
2995                last_tool_output_hash = Some(current_hash);
2996            }
2997
2998            // Bail if we see 3+ consecutive identical tool outputs (clear runaway).
2999            if consecutive_identical_outputs >= 3 {
3000                ::zeroclaw_log::record!(
3001                    WARN,
3002                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
3003                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
3004                        .with_attrs(::serde_json::json!({
3005                            "model": model,
3006                            "iteration": iteration + 1,
3007                            "consecutive_identical": consecutive_identical_outputs,
3008                            "trace_id": turn_id,
3009                        })),
3010                    "tool_loop_identical_output_abort"
3011                );
3012                anyhow::bail!(
3013                    "Agent loop aborted: identical tool output detected {} consecutive times",
3014                    consecutive_identical_outputs
3015                );
3016            }
3017        }
3018
3019        // Add assistant message with tool calls + tool results to history.
3020        // Native mode: use JSON-structured messages so convert_messages() can
3021        // reconstruct proper OpenAI-format tool_calls and tool result messages.
3022        // Prompt mode: use XML-based text format as before.
3023        history.push(ChatMessage::assistant(assistant_history_content));
3024        if native_tool_calls.is_empty() {
3025            let all_results_have_ids = use_native_tools
3026                && !individual_results.is_empty()
3027                && individual_results
3028                    .iter()
3029                    .all(|(tool_call_id, _)| tool_call_id.is_some());
3030            if all_results_have_ids {
3031                for (tool_call_id, result) in &individual_results {
3032                    let tool_msg = serde_json::json!({
3033                        "tool_call_id": tool_call_id,
3034                        "content": result,
3035                    });
3036                    history.push(ChatMessage::tool(tool_msg.to_string()));
3037                }
3038            } else {
3039                history.push(ChatMessage::user(format!("[Tool results]\n{tool_results}")));
3040            }
3041        } else {
3042            // `zip` would drop trailing results on any length divergence,
3043            // leaving a native tool_use id with no matching tool_result.
3044            // Pair on each result's own id instead.
3045            for (idx, (tool_call_id, result)) in individual_results.iter().enumerate() {
3046                let resolved_id = tool_call_id
3047                    .clone()
3048                    .or_else(|| native_tool_calls.get(idx).map(|call| call.id.clone()));
3049                let tool_msg = serde_json::json!({
3050                    "tool_call_id": resolved_id,
3051                    "content": result,
3052                });
3053                history.push(ChatMessage::tool(tool_msg.to_string()));
3054            }
3055        }
3056    }
3057
3058    ::zeroclaw_log::record!(
3059        WARN,
3060        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
3061            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
3062            .with_attrs(::serde_json::json!({
3063                "model": model,
3064                "max_iterations": max_iterations,
3065                "trace_id": turn_id,
3066            })),
3067        "tool_loop_exhausted"
3068    );
3069
3070    // Graceful shutdown: ask the LLM for a final summary without tools
3071    ::zeroclaw_log::record!(
3072        WARN,
3073        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3074            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
3075            .with_attrs(::serde_json::json!({"max_iterations": max_iterations})),
3076        "Max iterations reached, requesting final summary"
3077    );
3078    history.push(ChatMessage::user(
3079        "You have reached the maximum number of tool iterations. \
3080         Please provide your best answer based on the work completed so far. \
3081         Summarize what you accomplished and what remains to be done."
3082            .to_string(),
3083    ));
3084
3085    let summary_request = zeroclaw_providers::ChatRequest {
3086        messages: history,
3087        tools: None, // No tools — force a text response
3088        thinking: zeroclaw_api::NATIVE_THINKING_OVERRIDE
3089            .try_with(Clone::clone)
3090            .ok()
3091            .flatten(),
3092    };
3093    let summary_future = model_provider.chat(summary_request, model, temperature);
3094    let summary_call = match pacing.step_timeout_secs {
3095        Some(step_secs) if step_secs > 0 => {
3096            let step_timeout = Duration::from_secs(step_secs);
3097            if let Some(token) = cancellation_token.as_ref() {
3098                tokio::select! {
3099                    () = token.cancelled() => return Err(ToolLoopCancelled.into()),
3100                    result = tokio::time::timeout(step_timeout, summary_future) => match result {
3101                        Ok(inner) => inner,
3102                        Err(_) => anyhow::bail!(
3103                            "Final summary LLM call timed out after {step_secs}s (step_timeout_secs)"
3104                        ),
3105                    },
3106                }
3107            } else {
3108                match tokio::time::timeout(step_timeout, summary_future).await {
3109                    Ok(inner) => inner,
3110                    Err(_) => anyhow::bail!(
3111                        "Final summary LLM call timed out after {step_secs}s (step_timeout_secs)"
3112                    ),
3113                }
3114            }
3115        }
3116        _ => {
3117            if let Some(token) = cancellation_token.as_ref() {
3118                tokio::select! {
3119                    () = token.cancelled() => return Err(ToolLoopCancelled.into()),
3120                    result = summary_future => result,
3121                }
3122            } else {
3123                summary_future.await
3124            }
3125        }
3126    };
3127    match summary_call {
3128        Ok(resp) => {
3129            let text = resp.text.unwrap_or_default();
3130            if text.is_empty() {
3131                anyhow::bail!("Agent exceeded maximum tool iterations ({max_iterations})")
3132            }
3133            accumulated_display_text.push_str(&text);
3134            Ok(accumulated_display_text)
3135        }
3136        Err(e) => {
3137            ::zeroclaw_log::record!(
3138                ERROR,
3139                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
3140                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
3141                    .with_attrs(::serde_json::json!({
3142                        "model": model,
3143                        "provider": provider_name,
3144                        "max_iterations": max_iterations,
3145                        "trace_id": turn_id,
3146                        "error": format!("{e}"),
3147                    })),
3148                "final summary LLM call failed after iteration exhaustion; bailing"
3149            );
3150            anyhow::bail!("Agent exceeded maximum tool iterations ({max_iterations})")
3151        }
3152    }
3153}
3154
3155/// Build the tool instruction block for the system prompt so the LLM knows
3156/// how to invoke tools.
3157pub fn build_tool_instructions(tools_registry: &[Box<dyn Tool>]) -> String {
3158    build_tool_instructions_for_tools(tools_registry.iter().map(|tool| tool.as_ref()))
3159}
3160
3161/// Build tool instructions for the subset of registered tools that are
3162/// effective for the current prompt.
3163pub fn build_tool_instructions_for_names(
3164    tools_registry: &[Box<dyn Tool>],
3165    effective_tool_names: &HashSet<&str>,
3166) -> String {
3167    build_tool_instructions_for_tools(
3168        tools_registry
3169            .iter()
3170            .map(|tool| tool.as_ref())
3171            .filter(|tool| effective_tool_names.contains(tool.name())),
3172    )
3173}
3174
3175fn build_tool_instructions_for_tools<'a>(tools: impl IntoIterator<Item = &'a dyn Tool>) -> String {
3176    let tools: Vec<&dyn Tool> = tools.into_iter().collect();
3177    if tools.is_empty() {
3178        return String::new();
3179    }
3180
3181    let mut instructions = String::new();
3182    instructions.push_str("\n## Tool Use Protocol\n\n");
3183    instructions.push_str("To use a tool, wrap a JSON object in <tool_call></tool_call> tags:\n\n");
3184    instructions.push_str("```\n<tool_call>\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n```\n\n");
3185    instructions.push_str(
3186        "CRITICAL: Output actual <tool_call> tags—never describe steps or give examples.\n\n",
3187    );
3188    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");
3189    instructions.push_str("You may use multiple tool calls in a single response. ");
3190    instructions.push_str("After tool execution, results appear in <tool_result> tags. ");
3191    instructions
3192        .push_str("Continue reasoning with the results until you can give a final answer.\n\n");
3193    instructions.push_str("### Available Tools\n\n");
3194
3195    for tool in tools {
3196        let desc = tool.description();
3197        let _ = writeln!(
3198            instructions,
3199            "**{}**: {}\nParameters: `{}`\n",
3200            tool.name(),
3201            desc,
3202            tool.parameters_schema()
3203        );
3204    }
3205
3206    instructions
3207}
3208
3209fn retain_registered_tool_descriptions(
3210    tool_descs: &mut Vec<(&str, &str)>,
3211    tools_registry: &[Box<dyn Tool>],
3212) {
3213    let registered_tool_names: HashSet<&str> =
3214        tools_registry.iter().map(|tool| tool.name()).collect();
3215    tool_descs.retain(|(name, _)| registered_tool_names.contains(name));
3216}
3217
3218pub fn apply_text_tool_prompt_policy(
3219    native_tools: bool,
3220    strict_tool_parsing: bool,
3221    tool_descs: &mut Vec<(&str, &str)>,
3222    deferred_section: &mut String,
3223) -> bool {
3224    let expose_text_tool_protocol = !native_tools && !strict_tool_parsing;
3225    if !native_tools && strict_tool_parsing {
3226        tool_descs.clear();
3227        deferred_section.clear();
3228    }
3229    expose_text_tool_protocol
3230}
3231
3232// ── CLI Entrypoint ───────────────────────────────────────────────────────
3233// Wires up all subsystems (observer, runtime, security, memory, tools,
3234// model_provider, hardware RAG, peripherals) and enters either single-shot or
3235// interactive REPL mode. The interactive loop manages history compaction
3236// and hard trimming to keep the context window bounded.
3237
3238/// Optional per-call overrides for [`run`].
3239///
3240/// SubAgent spawn paths use this to inject the validated child policy
3241/// returned from [`SecurityPolicy::ensure_no_escalation_beyond`] (and,
3242/// once caller-supplied allowlist narrowing lands, the
3243/// validated agent-scoped memory wrapper). Without this hook the run
3244/// path rebuilds both surfaces from config, so the validator's
3245/// guarantees never reach the agent loop. `None` on either field
3246/// preserves the from-config behavior — the same shape as a fresh
3247/// interactive launch.
3248#[derive(Default)]
3249pub struct AgentRunOverrides {
3250    pub security: Option<Arc<SecurityPolicy>>,
3251    pub memory: Option<Arc<dyn Memory>>,
3252    /// `true` when the run is a SubAgent invocation. SubAgents must not
3253    /// spawn further subagents (depth-1 cap). The agent loop reads this
3254    /// when constructing the `spawn_subagent` tool so the depth-cap
3255    /// refusal fires at the tool, not after a child run is already
3256    /// underway. Default `false` keeps top-level / cron-launched /
3257    /// CLI-launched agents at depth 0.
3258    pub is_subagent: bool,
3259}
3260
3261/// Build the dotted provider ref (`"openai.qwertfoozp"`) from the agent's
3262/// configured `model_provider` field. Returns `None` when the agent has no
3263/// `model_provider` set or when the ref does not resolve to a known alias.
3264///
3265/// Using the full dotted ref (rather than just the family type) ensures the
3266/// alias-aware factory path is taken, so config fields such as
3267/// `requires_openai_auth` reach `dispatch_family_factory` instead of being
3268/// silently dropped.
3269fn agent_provider_composite(
3270    config: &zeroclaw_config::schema::Config,
3271    agent_alias: &str,
3272) -> Option<String> {
3273    config
3274        .resolved_model_provider_for_agent(agent_alias)
3275        .map(|(ty, alias, _)| format!("{ty}.{alias}"))
3276}
3277
3278/// Resolve (api_key, uri) for `provider_name`, preferring the alias-specific
3279/// config when `provider_name` is a dotted `<family>.<alias>` reference.
3280/// Falls back to `fallback` (the agent's configured provider) for bare family
3281/// names or when the alias isn't found.
3282///
3283/// This prevents `-p openai.shartgpt` (OAuth, no key) from inheriting the
3284/// agent's current provider key (e.g. an xai key), which would trigger the
3285/// API key prefix-mismatch preflight and block providers that authenticate
3286/// via OAuth rather than an explicit API key.
3287fn api_key_and_uri_for_provider(
3288    config: &zeroclaw_config::schema::Config,
3289    provider_name: &str,
3290    fallback: Option<&zeroclaw_config::schema::ModelProviderConfig>,
3291) -> (Option<String>, Option<String>) {
3292    if let Some((fam, al)) = provider_name.split_once('.')
3293        && let Some(entry) = config.providers.models.find(fam, al)
3294    {
3295        return (entry.api_key.clone(), entry.uri.clone());
3296    }
3297    (
3298        fallback.and_then(|e| e.api_key.clone()),
3299        fallback.and_then(|e| e.uri.clone()),
3300    )
3301}
3302
3303#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
3304pub async fn run(
3305    config: Config,
3306    agent_alias: &str,
3307    message: Option<String>,
3308    provider_override: Option<String>,
3309    model_override: Option<String>,
3310    temperature: Option<f64>,
3311    peripheral_overrides: Vec<String>,
3312    interactive: bool,
3313    session_state_file: Option<PathBuf>,
3314    allowed_tools: Option<Vec<String>>,
3315    overrides: AgentRunOverrides,
3316) -> Result<String> {
3317    use ::zeroclaw_log::Instrument;
3318    let agent = config
3319        .agent(agent_alias)
3320        .with_context(|| format!("agents.{agent_alias} is not configured"))?
3321        .clone();
3322    crate::agent::thinking::validate_thinking_config(&agent.resolved.thinking);
3323    let risk_profile = config
3324        .risk_profile_for_agent(agent_alias)
3325        .with_context(|| {
3326            format!(
3327                "agents.{agent_alias}.risk_profile does not name a configured risk_profiles entry"
3328            )
3329        })?
3330        .clone();
3331    let memory_composite = {
3332        use zeroclaw_config::multi_agent::MemoryBackendKind;
3333        match agent.memory.backend {
3334            MemoryBackendKind::Markdown => format!("markdown.{agent_alias}"),
3335            MemoryBackendKind::None => "none".to_string(),
3336            _ => {
3337                let raw = config.memory.backend.trim();
3338                if raw.is_empty() || raw.eq_ignore_ascii_case("none") {
3339                    "none".to_string()
3340                } else {
3341                    let (kind, alias) = raw.split_once('.').unwrap_or((raw, "default"));
3342                    format!("{kind}.{alias}")
3343                }
3344            }
3345        }
3346    };
3347    let __zc_alias = agent_alias.to_string();
3348    let __zc_attribution_span =
3349        ::zeroclaw_log::attribution_span!(&crate::agent::AgentAttribution(__zc_alias.as_str()));
3350    let __zc_scope_span = ::zeroclaw_log::info_span!(
3351        target: "zeroclaw_log_internal_scope",
3352        "zeroclaw_scope",
3353        risk_profile = %agent.risk_profile,
3354        runtime_profile = %agent.runtime_profile,
3355        memory_namespace = %memory_composite,
3356    );
3357    let __zc_body = async move {
3358        let agent_alias: &str = __zc_alias.as_str();
3359        // ── Effective per-agent runtime tunables ──────────────────────
3360        // Profile values (when set) override the agent's inline fields.
3361        // See `Config::effective_*` helpers for precedence rules.
3362        let _eff_max_tool_iterations = config.effective_max_tool_iterations(agent_alias);
3363        let eff_max_history_messages = config.effective_max_history_messages(agent_alias);
3364        let eff_max_context_tokens = config.effective_max_context_tokens(agent_alias);
3365        let eff_compact_context = config.effective_compact_context(agent_alias);
3366        let eff_max_system_prompt_chars = config.effective_max_system_prompt_chars(agent_alias);
3367        let _eff_max_tool_result_chars = config.effective_max_tool_result_chars(agent_alias);
3368        let _eff_tool_call_dedup_exempt = config.effective_tool_call_dedup_exempt(agent_alias);
3369        let base_observer = observability::create_observer(&config.observability);
3370        let observer: Arc<dyn Observer> = Arc::from(base_observer);
3371        let runtime: Arc<dyn platform::RuntimeAdapter> =
3372            Arc::from(platform::create_runtime(&config.runtime)?);
3373        let is_subagent_caller = overrides.is_subagent;
3374        let security = match overrides.security {
3375            Some(sec) => sec,
3376            None => Arc::new(SecurityPolicy::for_agent(&config, agent_alias)?),
3377        };
3378
3379        let agent_provider_resolved = config
3380            .resolved_model_provider_for_agent(agent_alias)
3381            .map(|(ty, alias, cfg)| (ty, alias.to_string(), cfg.clone()));
3382        let agent_model_provider = agent_provider_resolved.as_ref().map(|(_, _, cfg)| cfg);
3383
3384        // ── Memory (the brain) ────────────────────────────────────────
3385        // Per-agent memory: the inner backend is the install-wide store
3386        // (or, for Markdown agents, the agent's own dir composed with
3387        // peer dirs); the wrapper stamps every store with the bound
3388        // agent's UUID and filters every recall by the resolved
3389        // `read_memory_from` allowlist. When the caller supplies a
3390        // pre-built memory handle (SubAgent narrowing path), use that
3391        // instead so the validator's allowlist subset reaches the loop.
3392        let mem: Arc<dyn Memory> = match overrides.memory {
3393            Some(m) => m,
3394            None => {
3395                zeroclaw_memory::create_memory_for_agent(
3396                    &config,
3397                    agent_alias,
3398                    agent_model_provider.and_then(|e| e.api_key.as_deref()),
3399                )
3400                .await?
3401            }
3402        };
3403        ::zeroclaw_log::record!(
3404            INFO,
3405            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3406                .with_attrs(::serde_json::json!({"backend": mem.name()})),
3407            "Memory initialized"
3408        );
3409
3410        // ── Peripherals (merge peripheral tools into registry) ─
3411        if !peripheral_overrides.is_empty() {
3412            ::zeroclaw_log::record!(
3413                INFO,
3414                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3415                    .with_attrs(::serde_json::json!({"peripherals": peripheral_overrides})),
3416                "Peripheral overrides from CLI (config boards take precedence)"
3417            );
3418        }
3419
3420        // ── Tools (including memory tools and peripherals) ────────────
3421        let (composio_key, composio_entity_id) = if config.composio.enabled {
3422            (
3423                config.composio.api_key.as_deref(),
3424                Some(config.composio.entity_id.as_str()),
3425            )
3426        } else {
3427            (None, None)
3428        };
3429        let all_tools_result = tools::all_tools_with_runtime(
3430            Arc::new(config.clone()),
3431            &security,
3432            &risk_profile,
3433            agent_alias,
3434            runtime,
3435            mem.clone(),
3436            composio_key,
3437            composio_entity_id,
3438            &config.browser,
3439            &config.http_request,
3440            &config.web_fetch,
3441            &config.data_dir,
3442            &config.agents,
3443            agent_model_provider.and_then(|e| e.api_key.as_deref()),
3444            &config,
3445            None,
3446            is_subagent_caller,
3447            None,
3448        );
3449        let mut tools_registry = all_tools_result.tools;
3450        let delegate_handle = all_tools_result.delegate_handle;
3451        let unfiltered_tool_arcs = all_tools_result.unfiltered_tool_arcs;
3452        let ask_user_handle = all_tools_result.ask_user_handle;
3453        let reaction_handle = all_tools_result.reaction_handle;
3454        let poll_handle = all_tools_result.poll_handle;
3455        let escalate_handle = all_tools_result.escalate_handle;
3456
3457        // Populate all channel-driven tool handles from the registered factory.
3458        let count = seed_channel_handles(
3459            &ask_user_handle,
3460            &reaction_handle,
3461            &poll_handle,
3462            &escalate_handle,
3463        );
3464        if count > 0 {
3465            ::zeroclaw_log::record!(
3466                INFO,
3467                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3468                    .with_attrs(::serde_json::json!({"count": count})),
3469                &format!("Registered {} channel(s) for CLI agent", count),
3470            );
3471        }
3472
3473        let peripheral_tools: Vec<Box<dyn Tool>> = if let Some(f) = PERIPHERAL_TOOLS_FN.get() {
3474            f(config.peripherals.clone()).await.unwrap_or_default()
3475        } else {
3476            vec![]
3477        };
3478        if !peripheral_tools.is_empty() {
3479            ::zeroclaw_log::record!(
3480                INFO,
3481                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3482                    .with_attrs(::serde_json::json!({"count": peripheral_tools.len()})),
3483                "Peripheral tools added"
3484            );
3485            tools_registry.extend(peripheral_tools);
3486        }
3487
3488        // ── Capability-based tool access control ─────────────────────
3489        // Two-gate filter: parent agent's SecurityPolicy
3490        // (`allowed_tools` + `excluded_tools`) AND the caller-supplied
3491        // `allowed_tools` parameter. Both must admit a tool name for
3492        // the tool to survive. `None` on either gate is unrestricted
3493        // for that gate alone.
3494        let before_filter = tools_registry.len();
3495        apply_policy_tool_filter(
3496            &mut tools_registry,
3497            Some(security.as_ref()),
3498            allowed_tools.as_deref(),
3499        );
3500        if tools_registry.len() != before_filter {
3501            ::zeroclaw_log::record!(
3502                INFO,
3503                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3504                    .with_attrs(::serde_json::json!({
3505                        "before": before_filter,
3506                        "retained": tools_registry.len(),
3507                        "policy_allowed": security.allowed_tools.as_ref().map(|v| v.len()),
3508                        "policy_excluded": security.excluded_tools.as_ref().map(|v| v.len()),
3509                        "caller_allowed": allowed_tools.as_ref().map(|v| v.len()),
3510                    })),
3511                "Applied capability-based tool access filter"
3512            );
3513        }
3514
3515        // ── Wire MCP tools (non-fatal) — CLI path ────────────────────
3516        // NOTE: MCP tools are injected after built-in tool filtering
3517        // (filter_primary_agent_tools_or_fail / agent.allowed_tools / agent.denied_tools).
3518        // MCP registration and deferred discovery then apply the same policy
3519        // explicitly so denied MCP tools never surface in context or delegate handles.
3520        //
3521        // When `deferred_loading` is enabled, MCP tools are NOT added to the registry
3522        // eagerly. Instead, a `tool_search` built-in is registered so the LLM can
3523        // fetch schemas on demand. This reduces context window waste.
3524        let mut deferred_section = String::new();
3525        let mut activated_handle: Option<
3526            std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>,
3527        > = None;
3528        // Resolution-only MCP wrappers for skill MCP elevation (kind = "mcp").
3529        let mut mcp_elevation_arcs: Vec<std::sync::Arc<dyn Tool>> = Vec::new();
3530        if config.mcp.enabled && !config.mcp.servers.is_empty() {
3531            ::zeroclaw_log::record!(
3532                INFO,
3533                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
3534                &format!(
3535                    "Initializing MCP client — {} server(s) configured",
3536                    config.mcp.servers.len()
3537                )
3538            );
3539            match crate::tools::McpRegistry::connect_all(&config.mcp.servers).await {
3540                Ok(registry) => {
3541                    let registry = std::sync::Arc::new(registry);
3542                    mcp_elevation_arcs = crate::tools::collect_mcp_elevation_arcs(&registry).await;
3543                    let mcp_policy =
3544                        mcp_tool_access_policy(security.as_ref(), allowed_tools.as_deref());
3545                    if config.mcp.deferred_loading {
3546                        // Deferred path: build stubs and register tool_search
3547                        let deferred_set = crate::tools::DeferredMcpToolSet::from_registry(
3548                            std::sync::Arc::clone(&registry),
3549                        )
3550                        .await;
3551                        ::zeroclaw_log::record!(
3552                            INFO,
3553                            ::zeroclaw_log::Event::new(
3554                                module_path!(),
3555                                ::zeroclaw_log::Action::Note
3556                            ),
3557                            &format!(
3558                                "MCP deferred: {} tool stub(s) from {} server(s)",
3559                                deferred_set.len(),
3560                                registry.server_count()
3561                            )
3562                        );
3563                        let allowed_stub_count = mcp_allowed_tool_count(
3564                            deferred_set
3565                                .stubs
3566                                .iter()
3567                                .map(|stub| stub.prefixed_name.as_str()),
3568                            mcp_policy.as_ref(),
3569                        );
3570                        deferred_section = crate::tools::build_deferred_tools_section_filtered(
3571                            &deferred_set,
3572                            mcp_policy.as_ref(),
3573                        );
3574                        if allowed_stub_count > 0 {
3575                            let activated = std::sync::Arc::new(std::sync::Mutex::new(
3576                                crate::tools::ActivatedToolSet::new(),
3577                            ));
3578                            activated_handle = Some(std::sync::Arc::clone(&activated));
3579                            let mut tool_search =
3580                                crate::tools::ToolSearchTool::new(deferred_set, activated);
3581                            if let Some(policy) = mcp_policy {
3582                                tool_search = tool_search.with_access_policy(policy);
3583                            }
3584                            tools_registry.push(Box::new(tool_search));
3585                        }
3586                    } else {
3587                        // Eager path: register only MCP tools admitted by the
3588                        // same policy used by deferred MCP discovery.
3589                        let names = registry.tool_names();
3590                        let mut registered = 0usize;
3591                        let mut skipped = 0usize;
3592                        for name in names {
3593                            if !eager_mcp_tool_allowed(&name, mcp_policy.as_ref()) {
3594                                skipped += 1;
3595                                continue;
3596                            }
3597                            if let Some(def) = registry.get_tool_def(&name).await {
3598                                let wrapper: std::sync::Arc<dyn Tool> =
3599                                    std::sync::Arc::new(crate::tools::McpToolWrapper::new(
3600                                        name,
3601                                        def,
3602                                        std::sync::Arc::clone(&registry),
3603                                    ));
3604                                if register_eager_mcp_tool_if_allowed(
3605                                    wrapper,
3606                                    &mut tools_registry,
3607                                    delegate_handle.as_ref(),
3608                                    mcp_policy.as_ref(),
3609                                ) {
3610                                    registered += 1;
3611                                }
3612                            }
3613                        }
3614                        ::zeroclaw_log::record!(
3615                            INFO,
3616                            ::zeroclaw_log::Event::new(
3617                                module_path!(),
3618                                ::zeroclaw_log::Action::Note
3619                            ),
3620                            &format!(
3621                                "MCP: {} tool(s) registered from {} server(s), {} skipped by policy",
3622                                registered,
3623                                registry.server_count(),
3624                                skipped
3625                            )
3626                        );
3627                    }
3628                }
3629                Err(e) => {
3630                    ::zeroclaw_log::record!(
3631                        ERROR,
3632                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
3633                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
3634                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
3635                        "MCP registry failed to initialize"
3636                    );
3637                }
3638            }
3639        }
3640
3641        // ── Resolve model_provider ─────────────────────────────────────────
3642        let agent_provider_ref = agent_provider_composite(&config, agent_alias);
3643        let mut provider_name = provider_override
3644            .as_deref()
3645            .or(agent_provider_ref.as_deref())
3646            .ok_or_else(|| {
3647                ::zeroclaw_log::record!(
3648                    ERROR,
3649                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
3650                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
3651                        .with_attrs(::serde_json::json!({"agent_alias": agent_alias})),
3652                    "agent loop refused: agent.model_provider unresolved and no --provider override"
3653                );
3654                anyhow::Error::msg(format!(
3655                    "agents.{agent_alias}.model_provider does not resolve and no provider override \
3656                     was passed on the CLI. Either set `[agents.{agent_alias}] model_provider` or \
3657                     pass --provider."
3658                ))
3659            })?
3660            .to_string();
3661
3662        let mut model_name = match model_override
3663            .as_deref()
3664            .or(agent_model_provider.and_then(|e| e.model.as_deref()))
3665        {
3666            Some(m) => m.to_string(),
3667            None => anyhow::bail!(
3668                "no model configured for agent {agent_alias}: \
3669             [providers.models.{provider_name}.<alias>].model is unset and --model was not passed"
3670            ),
3671        };
3672
3673        {
3674            let span = zeroclaw_log::Span::current();
3675            let mp_composite = match agent_provider_resolved.as_ref() {
3676                Some((ty, alias, _)) => format!("{ty}.{alias}"),
3677                None => provider_name.clone(),
3678            };
3679            span.record("model_provider", mp_composite.as_str());
3680            span.record("model", model_name.as_str());
3681        }
3682
3683        let provider_runtime_options = match agent_provider_resolved.as_ref() {
3684            Some((ty, alias, _)) => {
3685                zeroclaw_providers::provider_runtime_options_for_alias(&config, ty, alias)
3686            }
3687            None => zeroclaw_providers::provider_runtime_options_for_agent(&config, agent_alias),
3688        };
3689
3690        // Resolve api_key and uri from the actual provider being constructed.
3691        // For dotted aliases (e.g. "openai.shartgpt"), look up the alias-specific
3692        // config so a -p override does not leak the agent's current provider key
3693        // (e.g. an xai key) to a different provider family that doesn't expect it.
3694        let (initial_api_key, initial_uri) =
3695            api_key_and_uri_for_provider(&config, &provider_name, agent_model_provider);
3696        let mut model_provider: Box<dyn ModelProvider> =
3697            zeroclaw_providers::create_routed_model_provider_with_options(
3698                &config,
3699                &provider_name,
3700                initial_api_key.as_deref(),
3701                initial_uri.as_deref(),
3702                &config.reliability,
3703                &config.model_routes,
3704                &model_name,
3705                &provider_runtime_options,
3706            )?;
3707
3708        let model_switch_callback = get_model_switch_state();
3709
3710        observer.record_event(&ObserverEvent::AgentStart {
3711            model_provider: provider_name.to_string(),
3712            model: model_name.to_string(),
3713        });
3714
3715        // ── Hardware RAG (datasheet retrieval when peripherals + datasheet_dir) ──
3716        let hardware_rag: Option<crate::rag::HardwareRag> = config
3717            .peripherals
3718            .datasheet_dir
3719            .as_ref()
3720            .filter(|d| !d.trim().is_empty())
3721            .map(|dir| crate::rag::HardwareRag::load(&config.data_dir, dir.trim()))
3722            .and_then(Result::ok)
3723            .filter(|r: &crate::rag::HardwareRag| !r.is_empty());
3724        if let Some(ref rag) = hardware_rag {
3725            ::zeroclaw_log::record!(
3726                INFO,
3727                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3728                    .with_attrs(::serde_json::json!({"chunks": rag.len()})),
3729                "Hardware RAG loaded"
3730            );
3731        }
3732
3733        let board_names: Vec<String> = config
3734            .peripherals
3735            .boards
3736            .iter()
3737            .map(|b| b.board.clone())
3738            .collect();
3739
3740        // ── Initialize locale-aware tool descriptions ──────────────────
3741        let i18n_locale = config
3742            .locale
3743            .as_deref()
3744            .filter(|s| !s.is_empty())
3745            .map(ToString::to_string)
3746            .unwrap_or_else(crate::i18n::detect_locale);
3747        crate::i18n::init(&i18n_locale);
3748
3749        // ── Build system prompt from workspace MD files (OpenClaw framework) ──
3750        let skills = crate::skills::load_skills_for_agent(&config.data_dir, &config, agent_alias);
3751
3752        // Register skill-defined tools as callable tool specs in the tool registry
3753        // so the LLM can invoke them via native function calling, not just XML prompts.
3754        // Resolution registry = built-in arcs + resolution-only MCP wrappers, so
3755        // skill elevation (kind = "builtin" / "mcp") can resolve either target.
3756        let skill_resolution_registry: Vec<std::sync::Arc<dyn Tool>> = unfiltered_tool_arcs
3757            .iter()
3758            .cloned()
3759            .chain(mcp_elevation_arcs.iter().cloned())
3760            .collect();
3761        tools::register_skill_tools_with_context(
3762            &mut tools_registry,
3763            &skills,
3764            security.clone(),
3765            &skill_resolution_registry,
3766        );
3767
3768        let mut tool_descs: Vec<(&str, &str)> = vec![
3769            (
3770                "shell",
3771                "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.",
3772            ),
3773            (
3774                "file_read",
3775                "Read file contents. Use when: inspecting project files, configs, logs. Don't use when: a targeted search is enough.",
3776            ),
3777            (
3778                "file_write",
3779                "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.",
3780            ),
3781            (
3782                "memory_store",
3783                "Save to memory. Use when: preserving durable preferences, decisions, key context. Don't use when: information is transient/noisy/sensitive without need.",
3784            ),
3785            (
3786                "memory_recall",
3787                "Search memory. Use when: retrieving prior decisions, user preferences, historical context. Don't use when: answer is already in current context.",
3788            ),
3789            (
3790                "memory_forget",
3791                "Delete a memory entry. Use when: memory is incorrect/stale or explicitly requested for removal. Don't use when: impact is uncertain.",
3792            ),
3793        ];
3794        if matches!(
3795            config.skills.prompt_injection_mode,
3796            zeroclaw_config::schema::SkillsPromptInjectionMode::Compact
3797        ) {
3798            tool_descs.push((
3799            "read_skill",
3800            "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.",
3801        ));
3802        }
3803        tool_descs.push((
3804        "cron_add",
3805        "Create a cron job. Supports schedule kinds: cron, at, every; and job types: shell or agent.",
3806    ));
3807        tool_descs.push((
3808            "cron_list",
3809            "List all cron jobs with schedule, status, and metadata.",
3810        ));
3811        tool_descs.push(("cron_remove", "Remove a cron job by job_id."));
3812        tool_descs.push((
3813        "cron_update",
3814        "Patch a cron job (schedule, enabled, command/prompt, model, delivery, session_target).",
3815    ));
3816        tool_descs.push((
3817            "cron_run",
3818            "Force-run a cron job immediately and record a run history entry.",
3819        ));
3820        tool_descs.push(("cron_runs", "Show recent run history for a cron job."));
3821        tool_descs.push((
3822        "screenshot",
3823        "Capture a screenshot of the current screen. Returns file path and base64-encoded PNG. Use when: visual verification, UI inspection, debugging displays.",
3824    ));
3825        tool_descs.push((
3826        "image_info",
3827        "Read image file metadata (format, dimensions, size) and optionally base64-encode it. Use when: inspecting images, preparing visual data for analysis.",
3828    ));
3829        if config.browser.enabled {
3830            tool_descs.push((
3831                "browser_open",
3832                "Open approved HTTPS URLs in system browser (allowlist-only, no scraping)",
3833            ));
3834        }
3835        if config.composio.enabled {
3836            tool_descs.push((
3837            "composio",
3838            "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.",
3839        ));
3840        }
3841        tool_descs.push((
3842        "schedule",
3843        "Manage scheduled tasks (create/list/get/cancel/pause/resume). Supports recurring cron and one-shot delays.",
3844    ));
3845        tool_descs.push((
3846        "model_routing_config",
3847        "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'.",
3848    ));
3849        if !config.agents.is_empty() {
3850            tool_descs.push((
3851            "delegate",
3852            "Delegate a sub-task to a specialized agent. Use when: task needs different model/capability, or to parallelize work.",
3853        ));
3854        }
3855        if config.peripherals.enabled && !config.peripherals.boards.is_empty() {
3856            tool_descs.push((
3857            "gpio_read",
3858            "Read GPIO pin value (0 or 1) on connected hardware (STM32, Arduino). Use when: checking sensor/button state, LED status.",
3859        ));
3860            tool_descs.push((
3861            "gpio_write",
3862            "Set GPIO pin high (1) or low (0) on connected hardware. Use when: turning LED on/off, controlling actuators.",
3863        ));
3864            tool_descs.push((
3865            "arduino_upload",
3866            "Upload agent-generated Arduino sketch. Use when: user asks for 'make a heart', 'blink pattern', or custom LED behavior on Arduino. You write the full .ino code; ZeroClaw compiles and uploads it. Pin 13 = built-in LED on Uno.",
3867        ));
3868            tool_descs.push((
3869            "hardware_memory_map",
3870            "Return flash and RAM address ranges for connected hardware. Use when: user asks for 'upper and lower memory addresses', 'memory map', or 'readable addresses'.",
3871        ));
3872            tool_descs.push((
3873            "hardware_board_info",
3874            "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'.",
3875        ));
3876            tool_descs.push((
3877            "hardware_memory_read",
3878            "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).",
3879        ));
3880            tool_descs.push((
3881            "hardware_capabilities",
3882            "Query connected hardware for reported GPIO pins and LED pin. Use when: user asks what pins are available.",
3883        ));
3884        }
3885        retain_registered_tool_descriptions(&mut tool_descs, &tools_registry);
3886        let bootstrap_max_chars = if eff_compact_context {
3887            Some(6000)
3888        } else {
3889            None
3890        };
3891        let native_tools = model_provider.supports_native_tools();
3892        let expose_text_tool_protocol = apply_text_tool_prompt_policy(
3893            native_tools,
3894            agent.resolved.strict_tool_parsing,
3895            &mut tool_descs,
3896            &mut deferred_section,
3897        );
3898        let agent_workspace = config.agent_workspace_dir(agent_alias);
3899        let mut system_prompt =
3900            crate::agent::system_prompt::build_system_prompt_with_mode_and_autonomy(
3901                &agent_workspace,
3902                &model_name,
3903                &tool_descs,
3904                &skills,
3905                Some(&agent.identity),
3906                bootstrap_max_chars,
3907                Some(&risk_profile),
3908                native_tools,
3909                config.skills.prompt_injection_mode,
3910                eff_compact_context,
3911                eff_max_system_prompt_chars,
3912                true,
3913            );
3914
3915        // Append structured tool-use instructions with schemas (only for non-native model_providers)
3916        if expose_text_tool_protocol {
3917            system_prompt.push_str(&build_tool_instructions(&tools_registry));
3918        }
3919
3920        // Append deferred MCP tool names so the LLM knows what is available
3921        if !deferred_section.is_empty() {
3922            system_prompt.push('\n');
3923            system_prompt.push_str(&deferred_section);
3924        }
3925
3926        // ── Approval manager (supervised mode) ───────────────────────
3927        let approval_manager = if interactive {
3928            Some(ApprovalManager::from_risk_profile(&risk_profile))
3929        } else {
3930            None
3931        };
3932        let channel_name = if interactive { "cli" } else { "daemon" };
3933        let memory_session_id = session_state_file.as_deref().and_then(|path| {
3934            let raw = path.to_string_lossy().trim().to_string();
3935            if raw.is_empty() {
3936                None
3937            } else {
3938                // Match the sanitized form persisted by memory backend migrations.
3939                Some(zeroclaw_api::session_keys::sanitize_session_key(&format!(
3940                    "cli:{raw}"
3941                )))
3942            }
3943        });
3944
3945        // ── Cost tracking context (scoped for CLI / cron / web agents) ──
3946        let cost_tracking_context: Option<ToolLoopCostTrackingContext> =
3947            crate::cost::CostTracker::get_or_init_global(config.cost.clone(), &config.data_dir)
3948                .map(|tracker| {
3949                    let pricing: crate::agent::cost::ModelProviderPricing = config
3950                        .providers
3951                        .models
3952                        .iter_entries()
3953                        .map(|(type_k, alias_k, profile)| {
3954                            (format!("{type_k}.{alias_k}"), profile.pricing.clone())
3955                        })
3956                        .filter(|(_, p)| !p.is_empty())
3957                        .collect();
3958                    ToolLoopCostTrackingContext::new(tracker, Arc::new(pricing))
3959                        .with_agent_alias(agent_alias)
3960                });
3961
3962        // ── Execute ──────────────────────────────────────────────────
3963        let start = Instant::now();
3964
3965        let mut final_output = String::new();
3966
3967        // Save the base system prompt before any thinking modifications so
3968        // the interactive loop can restore it between turns.
3969        let base_system_prompt = system_prompt.clone();
3970
3971        if let Some(msg) = message {
3972            // ── Parse thinking directive from user message ─────────
3973            let (thinking_directive, effective_msg) =
3974                match crate::agent::thinking::parse_thinking_directive(&msg) {
3975                    Some((level, remaining)) => {
3976                        ::zeroclaw_log::record!(
3977                            INFO,
3978                            ::zeroclaw_log::Event::new(
3979                                module_path!(),
3980                                ::zeroclaw_log::Action::Note
3981                            )
3982                            .with_attrs(::serde_json::json!({"thinking_level": level})),
3983                            "Thinking directive parsed from message"
3984                        );
3985                        (Some(level), remaining)
3986                    }
3987                    None => (None, msg.clone()),
3988                };
3989            let thinking_level = crate::agent::thinking::resolve_thinking_level(
3990                thinking_directive,
3991                None,
3992                &agent.resolved.thinking,
3993            );
3994            let thinking_params = crate::agent::thinking::apply_thinking_level_with_config(
3995                thinking_level,
3996                &agent.resolved.thinking,
3997            );
3998            let effective_temperature: Option<f64> = temperature.map(|t| {
3999                crate::agent::thinking::clamp_temperature(
4000                    t + thinking_params.temperature_adjustment,
4001                )
4002            });
4003
4004            // Prepend thinking system prompt prefix when present.
4005            if let Some(ref prefix) = thinking_params.system_prompt_prefix {
4006                system_prompt = format!("{prefix}\n\n{system_prompt}");
4007            }
4008
4009            if let Some(suggestion) = crate::skills::render_missing_skill_install_suggestion(
4010                &effective_msg,
4011                &skills,
4012                &config.data_dir,
4013                config.skills.install_suggestions.enabled,
4014            ) {
4015                final_output = suggestion;
4016                println!("{final_output}");
4017                observer.record_event(&ObserverEvent::TurnComplete);
4018                return Ok(final_output);
4019            }
4020
4021            // Auto-save user message to memory (skip short/trivial messages)
4022            if config.memory.auto_save
4023                && effective_msg.chars().count() >= AUTOSAVE_MIN_MESSAGE_CHARS
4024                && !zeroclaw_memory::should_skip_autosave_content(&effective_msg)
4025            {
4026                let user_key = autosave_memory_key("user_msg");
4027                let _ = mem
4028                    .store(
4029                        &user_key,
4030                        &effective_msg,
4031                        MemoryCategory::Conversation,
4032                        memory_session_id.as_deref(),
4033                    )
4034                    .await;
4035            }
4036
4037            // Inject memory + hardware RAG context into user message.
4038            // Exclude Conversation-category memories when:
4039            //   - non-interactive (cron, daemon heartbeat): chat history must
4040            //     not leak into autonomous executions / #5456, OR
4041            //   - no session scope is available (memory_session_id is None):
4042            //     without a session filter, Conversation entries from other
4043            //     channels (Matrix, Discord, …) would bleed into this session.
4044            let exclude_conv = !interactive || memory_session_id.is_none();
4045            let mem_context = build_context(
4046                mem.as_ref(),
4047                &effective_msg,
4048                config.memory.min_relevance_score,
4049                memory_session_id.as_deref(),
4050                exclude_conv,
4051            )
4052            .await;
4053            let rag_limit = if eff_compact_context { 2 } else { 5 };
4054            let hw_context = hardware_rag
4055                .as_ref()
4056                .map(|r| build_hardware_context(r, &effective_msg, &board_names, rag_limit))
4057                .unwrap_or_default();
4058            let context = format!("{mem_context}{hw_context}");
4059            let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z");
4060            let enriched = if context.is_empty() {
4061                format!("[{now}] {effective_msg}")
4062            } else {
4063                format!("{context}[{now}] {effective_msg}")
4064            };
4065
4066            let mut history = vec![
4067                ChatMessage::system(&system_prompt),
4068                ChatMessage::user(&enriched),
4069            ];
4070
4071            // Prune history for token efficiency (when enabled).
4072            if agent.resolved.history_pruning.enabled {
4073                let _stats = crate::agent::history_pruner::prune_history(
4074                    &mut history,
4075                    &agent.resolved.history_pruning,
4076                );
4077            }
4078
4079            // Compute per-turn excluded MCP tools from tool_filter_groups.
4080            let excluded_tools = compute_excluded_mcp_tools(
4081                &tools_registry,
4082                &agent.resolved.tool_filter_groups,
4083                &effective_msg,
4084            );
4085
4086            #[allow(unused_assignments)]
4087            let mut response = String::new();
4088            loop {
4089                match zeroclaw_api::NATIVE_THINKING_OVERRIDE
4090                    .scope(
4091                        thinking_params.native_thinking,
4092                        TOOL_LOOP_COST_TRACKING_CONTEXT.scope(
4093                            cost_tracking_context.clone(),
4094                            run_tool_call_loop(
4095                                model_provider.as_ref(),
4096                                &mut history,
4097                                &tools_registry,
4098                                observer.as_ref(),
4099                                &provider_name,
4100                                &model_name,
4101                                effective_temperature,
4102                                false,
4103                                approval_manager.as_ref(),
4104                                channel_name,
4105                                None,
4106                                &config.multimodal,
4107                                agent.resolved.max_tool_iterations,
4108                                None,
4109                                None,
4110                                None,
4111                                &excluded_tools,
4112                                &agent.resolved.tool_call_dedup_exempt,
4113                                activated_handle.as_ref(),
4114                                Some(model_switch_callback.clone()),
4115                                &config.pacing,
4116                                agent.resolved.strict_tool_parsing,
4117                                agent.resolved.parallel_tools,
4118                                agent.resolved.max_tool_result_chars,
4119                                agent.resolved.max_context_tokens,
4120                                None, // shared_budget
4121                                None, // channel: CLI mode — uses prompt_cli
4122                                None, // receipt_generator
4123                                None, // collected_receipts
4124                            ),
4125                        ),
4126                    )
4127                    .await
4128                {
4129                    Ok(resp) => {
4130                        response = resp;
4131                        break;
4132                    }
4133                    Err(e) => {
4134                        if let Some((new_model_provider, new_model)) = is_model_switch_requested(&e)
4135                        {
4136                            ::zeroclaw_log::record!(
4137                                INFO,
4138                                ::zeroclaw_log::Event::new(
4139                                    module_path!(),
4140                                    ::zeroclaw_log::Action::Note
4141                                ),
4142                                &format!(
4143                                    "Model switch requested, switching from {} {} to {} {}",
4144                                    provider_name, model_name, new_model_provider, new_model
4145                                )
4146                            );
4147
4148                            let (switch_api_key, switch_uri) = api_key_and_uri_for_provider(
4149                                &config,
4150                                &new_model_provider,
4151                                agent_model_provider,
4152                            );
4153                            model_provider =
4154                                zeroclaw_providers::create_routed_model_provider_with_options(
4155                                    &config,
4156                                    &new_model_provider,
4157                                    switch_api_key.as_deref(),
4158                                    switch_uri.as_deref(),
4159                                    &config.reliability,
4160                                    &config.model_routes,
4161                                    &new_model,
4162                                    &zeroclaw_providers::options_for_provider_ref(
4163                                        &config,
4164                                        &new_model_provider,
4165                                        &zeroclaw_providers::provider_runtime_options_for_agent(
4166                                            &config,
4167                                            agent_alias,
4168                                        ),
4169                                    ),
4170                                )?;
4171
4172                            provider_name = new_model_provider;
4173                            model_name = new_model;
4174
4175                            clear_model_switch_request();
4176
4177                            observer.record_event(&ObserverEvent::AgentStart {
4178                                model_provider: provider_name.to_string(),
4179                                model: model_name.to_string(),
4180                            });
4181
4182                            continue;
4183                        }
4184                        return Err(e);
4185                    }
4186                }
4187            }
4188
4189            // After successful multi-step execution, attempt autonomous skill creation.
4190            if config.skills.skill_creation.enabled {
4191                let tool_calls = crate::skills::creator::extract_tool_calls_from_history(&history);
4192                if tool_calls.len() >= 2 {
4193                    let creator = crate::skills::creator::SkillCreator::new(
4194                        config.data_dir.clone(),
4195                        config.skills.skill_creation.clone(),
4196                    );
4197                    match creator.create_from_execution(&msg, &tool_calls, None).await {
4198                        Ok(Some(slug)) => {
4199                            ::zeroclaw_log::record!(
4200                                INFO,
4201                                ::zeroclaw_log::Event::new(
4202                                    module_path!(),
4203                                    ::zeroclaw_log::Action::Note
4204                                )
4205                                .with_attrs(::serde_json::json!({"slug": slug})),
4206                                "Auto-created skill from execution"
4207                            );
4208                        }
4209                        Ok(None) => {
4210                            ::zeroclaw_log::record!(
4211                                DEBUG,
4212                                ::zeroclaw_log::Event::new(
4213                                    module_path!(),
4214                                    ::zeroclaw_log::Action::Note
4215                                ),
4216                                "Skill creation skipped (duplicate or disabled)"
4217                            );
4218                        }
4219                        Err(e) => ::zeroclaw_log::record!(
4220                            WARN,
4221                            ::zeroclaw_log::Event::new(
4222                                module_path!(),
4223                                ::zeroclaw_log::Action::Note
4224                            )
4225                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
4226                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
4227                            "Skill creation failed"
4228                        ),
4229                    }
4230                }
4231            }
4232            final_output = response;
4233            println!("{final_output}");
4234            observer.record_event(&ObserverEvent::TurnComplete);
4235        } else {
4236            println!("🦀 ZeroClaw Interactive Mode");
4237            println!("Type /help for commands.\n");
4238            let cli = CLI_CHANNEL_FN.get().expect(
4239                "CLI channel factory not registered — call register_cli_channel_fn at startup",
4240            )();
4241
4242            // Persistent conversation history across turns
4243            let mut history = if let Some(path) = session_state_file.as_deref() {
4244                load_interactive_session_history(path, &system_prompt)?
4245            } else {
4246                vec![ChatMessage::system(&system_prompt)]
4247            };
4248
4249            loop {
4250                print!("> ");
4251                let _ = std::io::stdout().flush();
4252
4253                // Read raw bytes to avoid UTF-8 validation errors when PTY
4254                // transport splits multi-byte characters at frame boundaries
4255                // (e.g. CJK input with spaces over kubectl exec / SSH).
4256                let mut raw = Vec::new();
4257                match std::io::BufRead::read_until(&mut std::io::stdin().lock(), b'\n', &mut raw) {
4258                    Ok(0) => break,
4259                    Ok(_) => {}
4260                    Err(e) => {
4261                        eprintln!("\nError reading input: {e}\n");
4262                        break;
4263                    }
4264                }
4265                let input = String::from_utf8_lossy(&raw).into_owned();
4266
4267                let user_input = input.trim().to_string();
4268                if user_input.is_empty() {
4269                    continue;
4270                }
4271                match user_input.as_str() {
4272                    "/quit" | "/exit" => break,
4273                    "/help" => {
4274                        println!("Available commands:");
4275                        println!("  /help             Show this help message");
4276                        println!("  /clear /new       Clear conversation history");
4277                        println!("  /quit /exit       Exit interactive mode");
4278                        println!(
4279                            "  /think:<level>    Set reasoning depth (off|minimal|low|medium|high|max)\n"
4280                        );
4281                        continue;
4282                    }
4283                    "/clear" | "/new" => {
4284                        println!(
4285                            "This will clear the current conversation and delete all session memory."
4286                        );
4287                        println!("Core memories (long-term facts/preferences) will be preserved.");
4288                        print!("Continue? [y/N] ");
4289                        let _ = std::io::stdout().flush();
4290
4291                        let mut confirm_raw = Vec::new();
4292                        if std::io::BufRead::read_until(
4293                            &mut std::io::stdin().lock(),
4294                            b'\n',
4295                            &mut confirm_raw,
4296                        )
4297                        .is_err()
4298                        {
4299                            continue;
4300                        }
4301                        let confirm = String::from_utf8_lossy(&confirm_raw);
4302                        if !matches!(confirm.trim().to_lowercase().as_str(), "y" | "yes") {
4303                            println!("Cancelled.\n");
4304                            continue;
4305                        }
4306
4307                        history.clear();
4308                        history.push(ChatMessage::system(&system_prompt));
4309                        // Clear conversation and daily memory
4310                        let mut cleared = 0;
4311                        for category in [MemoryCategory::Conversation, MemoryCategory::Daily] {
4312                            let entries = mem.list(Some(&category), None).await.unwrap_or_default();
4313                            for entry in entries {
4314                                if mem.forget(&entry.key).await.unwrap_or(false) {
4315                                    cleared += 1;
4316                                }
4317                            }
4318                        }
4319                        if cleared > 0 {
4320                            println!("Conversation cleared ({cleared} memory entries removed).\n");
4321                        } else {
4322                            println!("Conversation cleared.\n");
4323                        }
4324                        if let Some(path) = session_state_file.as_deref() {
4325                            save_interactive_session_history(path, &history)?;
4326                        }
4327                        continue;
4328                    }
4329                    _ => {}
4330                }
4331
4332                // ── Parse thinking directive from interactive input ───
4333                let (thinking_directive, effective_input) =
4334                    match crate::agent::thinking::parse_thinking_directive(&user_input) {
4335                        Some((level, remaining)) => {
4336                            ::zeroclaw_log::record!(
4337                                INFO,
4338                                ::zeroclaw_log::Event::new(
4339                                    module_path!(),
4340                                    ::zeroclaw_log::Action::Note
4341                                )
4342                                .with_attrs(::serde_json::json!({"thinking_level": level})),
4343                                "Thinking directive parsed"
4344                            );
4345                            (Some(level), remaining)
4346                        }
4347                        None => (None, user_input.clone()),
4348                    };
4349                let thinking_level = crate::agent::thinking::resolve_thinking_level(
4350                    thinking_directive,
4351                    None,
4352                    &agent.resolved.thinking,
4353                );
4354                let thinking_params = crate::agent::thinking::apply_thinking_level_with_config(
4355                    thinking_level,
4356                    &agent.resolved.thinking,
4357                );
4358                let turn_temperature: Option<f64> = temperature.map(|t| {
4359                    crate::agent::thinking::clamp_temperature(
4360                        t + thinking_params.temperature_adjustment,
4361                    )
4362                });
4363
4364                // For non-Medium levels, temporarily patch the system prompt with prefix.
4365                let turn_system_prompt;
4366                if let Some(ref prefix) = thinking_params.system_prompt_prefix {
4367                    turn_system_prompt = format!("{prefix}\n\n{system_prompt}");
4368                    // Update the system message in history for this turn.
4369                    if let Some(sys_msg) = history.first_mut()
4370                        && sys_msg.role == "system"
4371                    {
4372                        sys_msg.content = turn_system_prompt.clone();
4373                    }
4374                }
4375
4376                if let Some(suggestion) = crate::skills::render_missing_skill_install_suggestion(
4377                    &effective_input,
4378                    &skills,
4379                    &config.data_dir,
4380                    config.skills.install_suggestions.enabled,
4381                ) {
4382                    final_output = suggestion;
4383                    if let Err(e) = zeroclaw_api::channel::Channel::send(
4384                        &*cli,
4385                        &zeroclaw_api::channel::SendMessage::new(
4386                            format!("\n{final_output}\n"),
4387                            "user",
4388                        ),
4389                    )
4390                    .await
4391                    {
4392                        eprintln!("\nError sending CLI response: {e}\n");
4393                    }
4394                    observer.record_event(&ObserverEvent::TurnComplete);
4395                    if thinking_params.system_prompt_prefix.is_some()
4396                        && let Some(sys_msg) = history.first_mut()
4397                        && sys_msg.role == "system"
4398                    {
4399                        sys_msg.content.clone_from(&base_system_prompt);
4400                    }
4401                    continue;
4402                }
4403
4404                // Auto-save conversation turns (skip short/trivial messages)
4405                if config.memory.auto_save
4406                    && effective_input.chars().count() >= AUTOSAVE_MIN_MESSAGE_CHARS
4407                    && !zeroclaw_memory::should_skip_autosave_content(&effective_input)
4408                {
4409                    let user_key = autosave_memory_key("user_msg");
4410                    let _ = mem
4411                        .store(
4412                            &user_key,
4413                            &effective_input,
4414                            MemoryCategory::Conversation,
4415                            memory_session_id.as_deref(),
4416                        )
4417                        .await;
4418                }
4419
4420                // Inject memory + hardware RAG context into user message.
4421                // Keep Conversation memories only when a session scope is
4422                // available; without one, cross-channel entries (Matrix,
4423                // Discord, …) would bleed into this interactive session.
4424                let mem_context = build_context(
4425                    mem.as_ref(),
4426                    &effective_input,
4427                    config.memory.min_relevance_score,
4428                    memory_session_id.as_deref(),
4429                    memory_session_id.is_none(),
4430                )
4431                .await;
4432                let rag_limit = if eff_compact_context { 2 } else { 5 };
4433                let hw_context = hardware_rag
4434                    .as_ref()
4435                    .map(|r| build_hardware_context(r, &effective_input, &board_names, rag_limit))
4436                    .unwrap_or_default();
4437                let context = format!("{mem_context}{hw_context}");
4438                let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z");
4439                let enriched = if context.is_empty() {
4440                    format!("[{now}] {effective_input}")
4441                } else {
4442                    format!("{context}[{now}] {effective_input}")
4443                };
4444
4445                history.push(ChatMessage::user(&enriched));
4446
4447                // Compute per-turn excluded MCP tools from tool_filter_groups.
4448                let excluded_tools = compute_excluded_mcp_tools(
4449                    &tools_registry,
4450                    &agent.resolved.tool_filter_groups,
4451                    &effective_input,
4452                );
4453
4454                // Set up streaming channel so tool progress and response
4455                // content are printed progressively instead of buffered.
4456                let (delta_tx, mut delta_rx) = tokio::sync::mpsc::channel::<DraftEvent>(64);
4457                let content_was_streamed =
4458                    std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
4459                let content_streamed_flag = content_was_streamed.clone();
4460                let is_tty = std::io::IsTerminal::is_terminal(&std::io::stderr());
4461
4462                let consumer_handle = zeroclaw_spawn::spawn!(async move {
4463                    use std::io::Write;
4464                    while let Some(event) = delta_rx.recv().await {
4465                        match event {
4466                            StreamDelta::Status(text) => {
4467                                if is_tty {
4468                                    let _ = write!(std::io::stderr(), "\x1b[2m{text}\x1b[0m");
4469                                } else {
4470                                    let _ = write!(std::io::stderr(), "{text}");
4471                                }
4472                                let _ = std::io::stderr().flush();
4473                            }
4474                            StreamDelta::Text(text) => {
4475                                content_streamed_flag
4476                                    .store(true, std::sync::atomic::Ordering::Relaxed);
4477                                print!("{text}");
4478                                let _ = std::io::stdout().flush();
4479                            }
4480                        }
4481                    }
4482                });
4483
4484                // Ctrl+C cancels the in-flight turn instead of killing the process.
4485                let cancel_token = CancellationToken::new();
4486                let cancel_token_clone = cancel_token.clone();
4487                let ctrlc_handle = zeroclaw_spawn::spawn!(async move {
4488                    if tokio::signal::ctrl_c().await.is_ok() {
4489                        cancel_token_clone.cancel();
4490                    }
4491                });
4492
4493                let response = loop {
4494                    match zeroclaw_api::NATIVE_THINKING_OVERRIDE
4495                        .scope(
4496                            thinking_params.native_thinking,
4497                            TOOL_LOOP_COST_TRACKING_CONTEXT.scope(
4498                                cost_tracking_context.clone(),
4499                                run_tool_call_loop(
4500                                    model_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                                    agent.resolved.max_tool_iterations,
4513                                    Some(cancel_token.clone()),
4514                                    Some(delta_tx.clone()),
4515                                    None,
4516                                    &excluded_tools,
4517                                    &agent.resolved.tool_call_dedup_exempt,
4518                                    activated_handle.as_ref(),
4519                                    Some(model_switch_callback.clone()),
4520                                    &config.pacing,
4521                                    agent.resolved.strict_tool_parsing,
4522                                    agent.resolved.parallel_tools,
4523                                    agent.resolved.max_tool_result_chars,
4524                                    agent.resolved.max_context_tokens,
4525                                    None, // shared_budget
4526                                    None, // channel: interactive CLI — uses prompt_cli
4527                                    None, // receipt_generator
4528                                    None, // collected_receipts
4529                                ),
4530                            ),
4531                        )
4532                        .await
4533                    {
4534                        Ok(resp) => break resp,
4535                        Err(e) => {
4536                            if is_tool_loop_cancelled(&e) {
4537                                eprintln!("\n\x1b[2m(cancelled)\x1b[0m");
4538                                break String::new();
4539                            }
4540                            if let Some((new_model_provider, new_model)) =
4541                                is_model_switch_requested(&e)
4542                            {
4543                                ::zeroclaw_log::record!(
4544                                    INFO,
4545                                    ::zeroclaw_log::Event::new(
4546                                        module_path!(),
4547                                        ::zeroclaw_log::Action::Note
4548                                    ),
4549                                    &format!(
4550                                        "Model switch requested, switching from {} {} to {} {}",
4551                                        provider_name, model_name, new_model_provider, new_model
4552                                    )
4553                                );
4554
4555                                let (switch_api_key2, switch_uri2) = api_key_and_uri_for_provider(
4556                                    &config,
4557                                    &new_model_provider,
4558                                    agent_model_provider,
4559                                );
4560                                model_provider =
4561                                    zeroclaw_providers::create_routed_model_provider_with_options(
4562                                        &config,
4563                                        &new_model_provider,
4564                                        switch_api_key2.as_deref(),
4565                                        switch_uri2.as_deref(),
4566                                        &config.reliability,
4567                                        &config.model_routes,
4568                                        &new_model,
4569                                        &zeroclaw_providers::options_for_provider_ref(
4570                                            &config,
4571                                            &new_model_provider,
4572                                            &zeroclaw_providers::provider_runtime_options_for_agent(
4573                                                &config,
4574                                                agent_alias,
4575                                            ),
4576                                        ),
4577                                    )?;
4578
4579                                provider_name = new_model_provider;
4580                                model_name = new_model;
4581
4582                                clear_model_switch_request();
4583
4584                                observer.record_event(&ObserverEvent::AgentStart {
4585                                    model_provider: provider_name.to_string(),
4586                                    model: model_name.to_string(),
4587                                });
4588
4589                                continue;
4590                            }
4591                            // Context overflow recovery: compress and retry
4592                            if zeroclaw_providers::reliable::is_context_window_exceeded(&e) {
4593                                ::zeroclaw_log::record!(
4594                                    WARN,
4595                                    ::zeroclaw_log::Event::new(
4596                                        module_path!(),
4597                                        ::zeroclaw_log::Action::Note
4598                                    )
4599                                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
4600                                    "Context overflow in interactive loop, attempting recovery"
4601                                );
4602                                let mut compressor =
4603                                    crate::agent::context_compressor::ContextCompressor::new(
4604                                        agent.resolved.context_compression.clone(),
4605                                        eff_max_context_tokens,
4606                                    )
4607                                    .with_memory(mem.clone());
4608                                let error_msg = format!("{e}");
4609                                match compressor
4610                                    .compress_on_error(
4611                                        &mut history,
4612                                        model_provider.as_ref(),
4613                                        &model_name,
4614                                        temperature,
4615                                        &error_msg,
4616                                    )
4617                                    .await
4618                                {
4619                                    Ok(true) => {
4620                                        ::zeroclaw_log::record!(
4621                                            INFO,
4622                                            ::zeroclaw_log::Event::new(
4623                                                module_path!(),
4624                                                ::zeroclaw_log::Action::Note
4625                                            ),
4626                                            "Context recovered via compression, retrying turn"
4627                                        );
4628                                        continue;
4629                                    }
4630                                    Ok(false) => {
4631                                        ::zeroclaw_log::record!(
4632                                            WARN,
4633                                            ::zeroclaw_log::Event::new(
4634                                                module_path!(),
4635                                                ::zeroclaw_log::Action::Note
4636                                            )
4637                                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
4638                                            "Compression ran but couldn't reduce enough"
4639                                        );
4640                                    }
4641                                    Err(compress_err) => {
4642                                        ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"error": format!("{}", compress_err)})), "Compression failed during recovery");
4643                                    }
4644                                }
4645                            }
4646
4647                            eprintln!("\nError: {e}\n");
4648                            break String::new();
4649                        }
4650                    }
4651                };
4652
4653                // Clean up: stop the Ctrl+C listener and flush streaming events.
4654                ctrlc_handle.abort();
4655                drop(delta_tx);
4656                let _ = consumer_handle.await;
4657
4658                final_output = response;
4659                if content_was_streamed.load(std::sync::atomic::Ordering::Relaxed) {
4660                    println!();
4661                } else if let Err(e) = zeroclaw_api::channel::Channel::send(
4662                    &*cli,
4663                    &zeroclaw_api::channel::SendMessage::new(format!("\n{final_output}\n"), "user"),
4664                )
4665                .await
4666                {
4667                    eprintln!("\nError sending CLI response: {e}\n");
4668                }
4669                observer.record_event(&ObserverEvent::TurnComplete);
4670
4671                // Context compression before hard trimming to preserve long-context signal.
4672                {
4673                    let compressor = crate::agent::context_compressor::ContextCompressor::new(
4674                        agent.resolved.context_compression.clone(),
4675                        eff_max_context_tokens,
4676                    )
4677                    .with_memory(mem.clone());
4678                    match compressor
4679                        .compress_if_needed(
4680                            &mut history,
4681                            model_provider.as_ref(),
4682                            &model_name,
4683                            temperature,
4684                        )
4685                        .await
4686                    {
4687                        Ok(result) if result.compressed => {
4688                            ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"passes": result.passes_used, "before": result.tokens_before, "after": result.tokens_after})), "Context compression complete");
4689                        }
4690                        Ok(_) => {} // No compression needed
4691                        Err(e) => {
4692                            ::zeroclaw_log::record!(
4693                                WARN,
4694                                ::zeroclaw_log::Event::new(
4695                                    module_path!(),
4696                                    ::zeroclaw_log::Action::Note
4697                                )
4698                                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
4699                                .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
4700                                "Context compression failed, falling back to history trim"
4701                            );
4702                            trim_history(&mut history, eff_max_history_messages / 2);
4703                        }
4704                    }
4705                }
4706
4707                // Hard cap as a safety net.
4708                trim_history(&mut history, eff_max_history_messages);
4709
4710                // Restore base system prompt (remove per-turn thinking prefix).
4711                if thinking_params.system_prompt_prefix.is_some()
4712                    && let Some(sys_msg) = history.first_mut()
4713                    && sys_msg.role == "system"
4714                {
4715                    sys_msg.content.clone_from(&base_system_prompt);
4716                }
4717
4718                if let Some(path) = session_state_file.as_deref() {
4719                    save_interactive_session_history(path, &history)?;
4720                }
4721            }
4722        }
4723
4724        let duration = start.elapsed();
4725        observer.record_event(&ObserverEvent::AgentEnd {
4726            model_provider: provider_name.to_string(),
4727            model: model_name.to_string(),
4728            duration,
4729            tokens_used: None,
4730            cost_usd: None,
4731        });
4732
4733        Ok(final_output)
4734    };
4735    __zc_body
4736        .instrument(__zc_scope_span)
4737        .instrument(__zc_attribution_span)
4738        .await
4739}
4740
4741/// Process a single message through the full agent (with tools, peripherals, memory).
4742/// Used by channels (Telegram, Discord, etc.) to enable hardware and tool use.
4743pub async fn process_message(
4744    config: Config,
4745    agent_alias: &str,
4746    message: &str,
4747    session_id: Option<&str>,
4748) -> Result<String> {
4749    use ::zeroclaw_log::Instrument;
4750    let agent = config
4751        .agent(agent_alias)
4752        .with_context(|| format!("agents.{agent_alias} is not configured"))?
4753        .clone();
4754    crate::agent::thinking::validate_thinking_config(&agent.resolved.thinking);
4755    let risk_profile = config
4756        .risk_profile_for_agent(agent_alias)
4757        .with_context(|| {
4758            format!(
4759                "agents.{agent_alias}.risk_profile does not name a configured risk_profiles entry"
4760            )
4761        })?
4762        .clone();
4763    let memory_composite = {
4764        use zeroclaw_config::multi_agent::MemoryBackendKind;
4765        match agent.memory.backend {
4766            MemoryBackendKind::Markdown => format!("markdown.{agent_alias}"),
4767            MemoryBackendKind::None => "none".to_string(),
4768            _ => {
4769                let raw = config.memory.backend.trim();
4770                if raw.is_empty() || raw.eq_ignore_ascii_case("none") {
4771                    "none".to_string()
4772                } else {
4773                    let (kind, alias) = raw.split_once('.').unwrap_or((raw, "default"));
4774                    format!("{kind}.{alias}")
4775                }
4776            }
4777        }
4778    };
4779    let __zc_alias = agent_alias.to_string();
4780    let __zc_message = message.to_string();
4781    let __zc_session_id = session_id.map(str::to_string);
4782    let __zc_attribution_span =
4783        ::zeroclaw_log::attribution_span!(&crate::agent::AgentAttribution(__zc_alias.as_str()));
4784    let __zc_scope_span = ::zeroclaw_log::info_span!(
4785        target: "zeroclaw_log_internal_scope",
4786        "zeroclaw_scope",
4787        risk_profile = %agent.risk_profile,
4788        runtime_profile = %agent.runtime_profile,
4789        memory_namespace = %memory_composite,
4790    );
4791    let __zc_body = async move {
4792        let agent_alias: &str = __zc_alias.as_str();
4793        let message: &str = __zc_message.as_str();
4794        let session_id: Option<&str> = __zc_session_id.as_deref();
4795
4796        // ── Effective per-agent runtime tunables ──────────────────────
4797        // Profile values (when set) override the agent's inline fields.
4798        // See `Config::effective_*` helpers for precedence rules.
4799        let _eff_max_tool_iterations = config.effective_max_tool_iterations(agent_alias);
4800        let _eff_max_history_messages = config.effective_max_history_messages(agent_alias);
4801        let _eff_max_context_tokens = config.effective_max_context_tokens(agent_alias);
4802        let eff_compact_context = config.effective_compact_context(agent_alias);
4803        let eff_max_system_prompt_chars = config.effective_max_system_prompt_chars(agent_alias);
4804        let _eff_max_tool_result_chars = config.effective_max_tool_result_chars(agent_alias);
4805        let _eff_tool_call_dedup_exempt = config.effective_tool_call_dedup_exempt(agent_alias);
4806
4807        let observer: Arc<dyn Observer> =
4808            Arc::from(observability::create_observer(&config.observability));
4809        let runtime: Arc<dyn platform::RuntimeAdapter> =
4810            Arc::from(platform::create_runtime(&config.runtime)?);
4811        let security = Arc::new(SecurityPolicy::for_agent(&config, agent_alias)?);
4812        let (provider_name, provider_alias, agent_model_provider) = match config
4813            .resolved_model_provider_for_agent(agent_alias)
4814        {
4815            Some(resolved) => (resolved.0, resolved.1.to_string(), Some(resolved.2.clone())),
4816            None => {
4817                let agent_ref = agent.model_provider.as_str();
4818                if !agent_ref.is_empty() {
4819                    anyhow::bail!(
4820                        "agents.{agent_alias}.model_provider = \"{agent_ref}\" does not resolve to \
4821                     a configured [providers.models.<type>.<alias>] entry"
4822                    );
4823                }
4824                anyhow::bail!(
4825                    "agents.{agent_alias}.model_provider is empty \u{2014} set it to a configured \
4826                 \"<type>.<alias>\" (e.g. \"anthropic.{agent_alias}\")"
4827                );
4828            }
4829        };
4830        let approval_manager = ApprovalManager::for_non_interactive(&risk_profile);
4831        let mem: Arc<dyn Memory> = zeroclaw_memory::create_memory_for_agent(
4832            &config,
4833            agent_alias,
4834            agent_model_provider
4835                .as_ref()
4836                .and_then(|e| e.api_key.as_deref()),
4837        )
4838        .await?;
4839
4840        let (composio_key, composio_entity_id) = if config.composio.enabled {
4841            (
4842                config.composio.api_key.as_deref(),
4843                Some(config.composio.entity_id.as_str()),
4844            )
4845        } else {
4846            (None, None)
4847        };
4848        let all_tools_result_pm = tools::all_tools_with_runtime(
4849            Arc::new(config.clone()),
4850            &security,
4851            &risk_profile,
4852            agent_alias,
4853            runtime,
4854            mem.clone(),
4855            composio_key,
4856            composio_entity_id,
4857            &config.browser,
4858            &config.http_request,
4859            &config.web_fetch,
4860            &config.data_dir,
4861            &config.agents,
4862            agent_model_provider
4863                .as_ref()
4864                .and_then(|e| e.api_key.as_deref()),
4865            &config,
4866            None,
4867            false,
4868            None,
4869        );
4870        let mut tools_registry = all_tools_result_pm.tools;
4871        let delegate_handle_pm = all_tools_result_pm.delegate_handle;
4872        let unfiltered_tool_arcs_pm = all_tools_result_pm.unfiltered_tool_arcs;
4873        let ask_user_handle_pm = all_tools_result_pm.ask_user_handle;
4874        let reaction_handle_pm = all_tools_result_pm.reaction_handle;
4875        let poll_handle_pm = all_tools_result_pm.poll_handle;
4876        let escalate_handle_pm = all_tools_result_pm.escalate_handle;
4877
4878        // Populate all channel-driven tool handles from the registered factory.
4879        let count = seed_channel_handles(
4880            &ask_user_handle_pm,
4881            &reaction_handle_pm,
4882            &poll_handle_pm,
4883            &escalate_handle_pm,
4884        );
4885        if count > 0 {
4886            ::zeroclaw_log::record!(
4887                INFO,
4888                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
4889                    .with_attrs(::serde_json::json!({"count": count})),
4890                &format!("Registered {} channel(s) for process_message agent", count),
4891            );
4892        }
4893        let peripheral_tools: Vec<Box<dyn Tool>> = if let Some(f) = PERIPHERAL_TOOLS_FN.get() {
4894            f(config.peripherals.clone()).await.unwrap_or_default()
4895        } else {
4896            vec![]
4897        };
4898        tools_registry.extend(peripheral_tools);
4899
4900        // ── Capability-based tool access control ─────────────────────
4901        // Mirror the `run()` path: apply the SecurityPolicy filter
4902        // (allowed_tools + excluded_tools) so daemon-provisioned agents get
4903        // the same restriction as CLI-invoked agents. Extracted into
4904        // `filter_channel_builtin_tools` so the production path is
4905        // regression-tested (see process_message_policy_filters_eager_builtins).
4906        filter_channel_builtin_tools(&mut tools_registry, security.as_ref());
4907
4908        // ── Wire MCP tools (non-fatal) — process_message path ────────
4909        // NOTE: Same ordering contract as the CLI path above. MCP tools are
4910        // initialized after built-in filtering, then registration/discovery is
4911        // gated explicitly by the agent's security policy.
4912        let mut deferred_section = String::new();
4913        let mut activated_handle_pm: Option<
4914            std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>,
4915        > = None;
4916        // Resolution-only MCP wrappers for skill MCP elevation (kind = "mcp").
4917        let mut mcp_elevation_arcs: Vec<std::sync::Arc<dyn Tool>> = Vec::new();
4918        if config.mcp.enabled && !config.mcp.servers.is_empty() {
4919            ::zeroclaw_log::record!(
4920                INFO,
4921                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
4922                &format!(
4923                    "Initializing MCP client — {} server(s) configured",
4924                    config.mcp.servers.len()
4925                )
4926            );
4927            match crate::tools::McpRegistry::connect_all(&config.mcp.servers).await {
4928                Ok(registry) => {
4929                    let registry = std::sync::Arc::new(registry);
4930                    mcp_elevation_arcs = crate::tools::collect_mcp_elevation_arcs(&registry).await;
4931                    let mcp_policy_pm = mcp_tool_access_policy(security.as_ref(), None);
4932                    if config.mcp.deferred_loading {
4933                        let deferred_set = crate::tools::DeferredMcpToolSet::from_registry(
4934                            std::sync::Arc::clone(&registry),
4935                        )
4936                        .await;
4937                        ::zeroclaw_log::record!(
4938                            INFO,
4939                            ::zeroclaw_log::Event::new(
4940                                module_path!(),
4941                                ::zeroclaw_log::Action::Note
4942                            ),
4943                            &format!(
4944                                "MCP deferred: {} tool stub(s) from {} server(s)",
4945                                deferred_set.len(),
4946                                registry.server_count()
4947                            )
4948                        );
4949                        let allowed_stub_count_pm = mcp_allowed_tool_count(
4950                            deferred_set
4951                                .stubs
4952                                .iter()
4953                                .map(|stub| stub.prefixed_name.as_str()),
4954                            mcp_policy_pm.as_ref(),
4955                        );
4956                        deferred_section = crate::tools::build_deferred_tools_section_filtered(
4957                            &deferred_set,
4958                            mcp_policy_pm.as_ref(),
4959                        );
4960                        if allowed_stub_count_pm > 0 {
4961                            let activated = std::sync::Arc::new(std::sync::Mutex::new(
4962                                crate::tools::ActivatedToolSet::new(),
4963                            ));
4964                            activated_handle_pm = Some(std::sync::Arc::clone(&activated));
4965                            let mut tool_search_pm =
4966                                crate::tools::ToolSearchTool::new(deferred_set, activated);
4967                            if let Some(policy) = mcp_policy_pm {
4968                                tool_search_pm = tool_search_pm.with_access_policy(policy);
4969                            }
4970                            tools_registry.push(Box::new(tool_search_pm));
4971                        }
4972                    } else {
4973                        let names = registry.tool_names();
4974                        let mut registered = 0usize;
4975                        let mut skipped = 0usize;
4976                        for name in names {
4977                            if !eager_mcp_tool_allowed(&name, mcp_policy_pm.as_ref()) {
4978                                skipped += 1;
4979                                continue;
4980                            }
4981                            if let Some(def) = registry.get_tool_def(&name).await {
4982                                let wrapper: std::sync::Arc<dyn Tool> =
4983                                    std::sync::Arc::new(crate::tools::McpToolWrapper::new(
4984                                        name,
4985                                        def,
4986                                        std::sync::Arc::clone(&registry),
4987                                    ));
4988                                if register_eager_mcp_tool_if_allowed(
4989                                    wrapper,
4990                                    &mut tools_registry,
4991                                    delegate_handle_pm.as_ref(),
4992                                    mcp_policy_pm.as_ref(),
4993                                ) {
4994                                    registered += 1;
4995                                }
4996                            }
4997                        }
4998                        ::zeroclaw_log::record!(
4999                            INFO,
5000                            ::zeroclaw_log::Event::new(
5001                                module_path!(),
5002                                ::zeroclaw_log::Action::Note
5003                            ),
5004                            &format!(
5005                                "MCP: {} tool(s) registered from {} server(s), {} skipped by policy",
5006                                registered,
5007                                registry.server_count(),
5008                                skipped
5009                            )
5010                        );
5011                    }
5012                }
5013                Err(e) => {
5014                    ::zeroclaw_log::record!(
5015                        ERROR,
5016                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
5017                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
5018                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
5019                        "MCP registry failed to initialize"
5020                    );
5021                }
5022            }
5023        }
5024
5025        let model_name = match agent_model_provider
5026            .as_ref()
5027            .and_then(|e| e.model.as_deref())
5028            .map(str::trim)
5029            .filter(|m| !m.is_empty())
5030        {
5031            Some(m) => m.to_string(),
5032            None => anyhow::bail!(
5033                "agents.{agent_alias}.model_provider resolves to a model_provider entry with no \
5034             `model` set. Configure [providers.models.{provider_name}.<alias>] model = \"...\"."
5035            ),
5036        };
5037        let provider_runtime_options = zeroclaw_providers::provider_runtime_options_for_alias(
5038            &config,
5039            provider_name,
5040            provider_alias.as_str(),
5041        );
5042        let model_provider: Box<dyn ModelProvider> =
5043            zeroclaw_providers::create_routed_model_provider_with_options(
5044                &config,
5045                &format!("{provider_name}.{provider_alias}"),
5046                agent_model_provider
5047                    .as_ref()
5048                    .and_then(|e| e.api_key.as_deref()),
5049                agent_model_provider.as_ref().and_then(|e| e.uri.as_deref()),
5050                &config.reliability,
5051                &config.model_routes,
5052                &model_name,
5053                &provider_runtime_options,
5054            )?;
5055
5056        let hardware_rag: Option<crate::rag::HardwareRag> = config
5057            .peripherals
5058            .datasheet_dir
5059            .as_ref()
5060            .filter(|d| !d.trim().is_empty())
5061            .map(|dir| crate::rag::HardwareRag::load(&config.data_dir, dir.trim()))
5062            .and_then(Result::ok)
5063            .filter(|r: &crate::rag::HardwareRag| !r.is_empty());
5064        let board_names: Vec<String> = config
5065            .peripherals
5066            .boards
5067            .iter()
5068            .map(|b| b.board.clone())
5069            .collect();
5070
5071        // ── Initialize locale-aware tool descriptions ──────────────────
5072        let i18n_locale = config
5073            .locale
5074            .as_deref()
5075            .filter(|s| !s.is_empty())
5076            .map(ToString::to_string)
5077            .unwrap_or_else(crate::i18n::detect_locale);
5078        crate::i18n::init(&i18n_locale);
5079
5080        let skills = crate::skills::load_skills_for_agent(&config.data_dir, &config, agent_alias);
5081
5082        // Register skill-defined tools as callable tool specs (process_message path).
5083        // Resolution registry = built-in arcs + resolution-only MCP wrappers.
5084        let skill_resolution_registry: Vec<std::sync::Arc<dyn Tool>> = unfiltered_tool_arcs_pm
5085            .iter()
5086            .cloned()
5087            .chain(mcp_elevation_arcs.iter().cloned())
5088            .collect();
5089        tools::register_skill_tools_with_context(
5090            &mut tools_registry,
5091            &skills,
5092            security.clone(),
5093            &skill_resolution_registry,
5094        );
5095
5096        let mut tool_descs: Vec<(&str, &str)> = vec![
5097            ("shell", "Execute terminal commands."),
5098            ("file_read", "Read file contents."),
5099            ("file_write", "Write file contents."),
5100            ("memory_store", "Save to memory."),
5101            ("memory_recall", "Search memory."),
5102            ("memory_forget", "Delete a memory entry."),
5103            (
5104                "model_routing_config",
5105                "Configure default model, scenario routing, and delegate agents.",
5106            ),
5107            ("screenshot", "Capture a screenshot."),
5108            ("image_info", "Read image metadata."),
5109        ];
5110        if matches!(
5111            config.skills.prompt_injection_mode,
5112            zeroclaw_config::schema::SkillsPromptInjectionMode::Compact
5113        ) {
5114            tool_descs.push((
5115                "read_skill",
5116                "Load the full source for an available skill by name.",
5117            ));
5118        }
5119        if config.browser.enabled {
5120            tool_descs.push(("browser_open", "Open approved URLs in browser."));
5121        }
5122        if config.composio.enabled {
5123            tool_descs.push(("composio", "Execute actions on 1000+ apps via Composio."));
5124        }
5125        if config.peripherals.enabled && !config.peripherals.boards.is_empty() {
5126            tool_descs.push(("gpio_read", "Read GPIO pin value on connected hardware."));
5127            tool_descs.push((
5128                "gpio_write",
5129                "Set GPIO pin high or low on connected hardware.",
5130            ));
5131            tool_descs.push((
5132            "arduino_upload",
5133            "Upload Arduino sketch. Use for 'make a heart', custom patterns. You write full .ino code; ZeroClaw uploads it.",
5134        ));
5135            tool_descs.push((
5136            "hardware_memory_map",
5137            "Return flash and RAM address ranges. Use when user asks for memory addresses or memory map.",
5138        ));
5139            tool_descs.push((
5140            "hardware_board_info",
5141            "Return full board info (chip, architecture, memory map). Use when user asks for board info, what board, connected hardware, or chip info.",
5142        ));
5143            tool_descs.push((
5144            "hardware_memory_read",
5145            "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.",
5146        ));
5147            tool_descs.push((
5148            "hardware_capabilities",
5149            "Query connected hardware for reported GPIO pins and LED pin. Use when user asks what pins are available.",
5150        ));
5151        }
5152
5153        // ── Compute final effective tool set BEFORE prompt construction ──
5154        // This ensures the system prompt, tool instructions, and channel target
5155        // injection all reflect the same policy-filtered tool set that will be
5156        // used at execution time. Without this, the prompt could advertise
5157        // tools (and their target identifiers) that the execution denylist
5158        // would block — a control boundary violation.
5159        //
5160        // Note: compute_excluded_mcp_tools uses the raw message here (before
5161        // thinking directive stripping). This is safe — dynamic tool filter
5162        // keyword matching works the same, and risk-profile excluded_tools
5163        // are message-independent.
5164        let mut excluded_tools = compute_excluded_mcp_tools(
5165            &tools_registry,
5166            &agent.resolved.tool_filter_groups,
5167            message,
5168        );
5169        {
5170            let active_profile = &risk_profile;
5171            if active_profile.level != AutonomyLevel::Full {
5172                excluded_tools.extend(active_profile.excluded_tools.iter().cloned());
5173            }
5174        }
5175
5176        // Filter tool descriptions to match the effective set.
5177        tool_descs.retain(|(name, _)| !excluded_tools.iter().any(|ex| ex == name));
5178
5179        // Derive effective tool names from the filtered set so prompt builders
5180        // and channel target guards see the correct state.
5181        let effective_tool_names: HashSet<&str> = tools_registry
5182            .iter()
5183            .map(|tool| tool.name())
5184            .filter(|name| !excluded_tools.iter().any(|ex| ex == *name))
5185            .collect();
5186        tool_descs.retain(|(name, _)| effective_tool_names.contains(name));
5187
5188        let bootstrap_max_chars = if eff_compact_context {
5189            Some(6000)
5190        } else {
5191            None
5192        };
5193        let native_tools = model_provider.supports_native_tools();
5194        let expose_text_tool_protocol = apply_text_tool_prompt_policy(
5195            native_tools,
5196            agent.resolved.strict_tool_parsing,
5197            &mut tool_descs,
5198            &mut deferred_section,
5199        );
5200        let agent_workspace = config.agent_workspace_dir(agent_alias);
5201        let mut system_prompt =
5202            crate::agent::system_prompt::build_system_prompt_with_mode_and_autonomy(
5203                &agent_workspace,
5204                &model_name,
5205                &tool_descs,
5206                &skills,
5207                Some(&agent.identity),
5208                bootstrap_max_chars,
5209                Some(&risk_profile),
5210                native_tools,
5211                config.skills.prompt_injection_mode,
5212                eff_compact_context,
5213                eff_max_system_prompt_chars,
5214                false,
5215            );
5216        if expose_text_tool_protocol {
5217            system_prompt.push_str(&build_tool_instructions_for_names(
5218                &tools_registry,
5219                &effective_tool_names,
5220            ));
5221        }
5222        if !deferred_section.is_empty() {
5223            system_prompt.push('\n');
5224            system_prompt.push_str(&deferred_section);
5225        }
5226
5227        // ── Parse thinking directive from user message ─────────────
5228        let (thinking_directive, effective_message) =
5229            match crate::agent::thinking::parse_thinking_directive(message) {
5230                Some((level, remaining)) => {
5231                    ::zeroclaw_log::record!(
5232                        INFO,
5233                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
5234                            .with_attrs(::serde_json::json!({"thinking_level": level})),
5235                        "Thinking directive parsed from message"
5236                    );
5237                    (Some(level), remaining)
5238                }
5239                None => (None, message.to_string()),
5240            };
5241        let thinking_level = crate::agent::thinking::resolve_thinking_level(
5242            thinking_directive,
5243            None,
5244            &agent.resolved.thinking,
5245        );
5246        let thinking_params = crate::agent::thinking::apply_thinking_level_with_config(
5247            thinking_level,
5248            &agent.resolved.thinking,
5249        );
5250        let effective_temperature: Option<f64> = agent_model_provider
5251            .as_ref()
5252            .and_then(|e| e.temperature)
5253            .map(|t| {
5254                crate::agent::thinking::clamp_temperature(
5255                    t + thinking_params.temperature_adjustment,
5256                )
5257            });
5258
5259        // Prepend thinking system prompt prefix when present.
5260        if let Some(ref prefix) = thinking_params.system_prompt_prefix {
5261            system_prompt = format!("{prefix}\n\n{system_prompt}");
5262        }
5263
5264        let effective_msg_ref = effective_message.as_str();
5265        if let Some(suggestion) = crate::skills::render_missing_skill_install_suggestion(
5266            effective_msg_ref,
5267            &skills,
5268            &config.data_dir,
5269            config.skills.install_suggestions.enabled,
5270        ) {
5271            return Ok(suggestion);
5272        }
5273
5274        // process_message is the channel entrypoint (Discord, Telegram, gateway,
5275        // etc.) — recall is scoped to the channel's session_id, so retrieving the
5276        // user's own Conversation history within their session is intended.
5277        let mem_context = build_context(
5278            mem.as_ref(),
5279            effective_msg_ref,
5280            config.memory.min_relevance_score,
5281            session_id,
5282            false,
5283        )
5284        .await;
5285        let rag_limit = if eff_compact_context { 2 } else { 5 };
5286        let hw_context = hardware_rag
5287            .as_ref()
5288            .map(|r| build_hardware_context(r, effective_msg_ref, &board_names, rag_limit))
5289            .unwrap_or_default();
5290        let context = format!("{mem_context}{hw_context}");
5291        let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z");
5292        let enriched = if context.is_empty() {
5293            format!("[{now}] {effective_message}")
5294        } else {
5295            format!("{context}[{now}] {effective_message}")
5296        };
5297
5298        let mut history = vec![
5299            ChatMessage::system(&system_prompt),
5300            ChatMessage::user(&enriched),
5301        ];
5302        let mut excluded_tools = compute_excluded_mcp_tools(
5303            &tools_registry,
5304            &agent.resolved.tool_filter_groups,
5305            effective_msg_ref,
5306        );
5307        {
5308            let active_profile = &risk_profile;
5309            if active_profile.level != AutonomyLevel::Full {
5310                excluded_tools.extend(active_profile.excluded_tools.iter().cloned());
5311            }
5312        }
5313
5314        zeroclaw_api::NATIVE_THINKING_OVERRIDE
5315            .scope(
5316                thinking_params.native_thinking,
5317                agent_turn(
5318                    model_provider.as_ref(),
5319                    &mut history,
5320                    &tools_registry,
5321                    observer.as_ref(),
5322                    provider_name,
5323                    &model_name,
5324                    effective_temperature,
5325                    true,
5326                    "daemon",
5327                    None,
5328                    &config.multimodal,
5329                    agent.resolved.max_tool_iterations,
5330                    Some(&approval_manager),
5331                    &excluded_tools,
5332                    &agent.resolved.tool_call_dedup_exempt,
5333                    activated_handle_pm.as_ref(),
5334                    None,
5335                    agent.resolved.strict_tool_parsing,
5336                    agent.resolved.parallel_tools,
5337                    None, // channel: process_message path has no channel ref
5338                ),
5339            )
5340            .await
5341    };
5342    __zc_body
5343        .instrument(__zc_scope_span)
5344        .instrument(__zc_attribution_span)
5345        .await
5346}
5347
5348#[cfg(test)]
5349mod tests {
5350    use super::{
5351        apply_text_tool_prompt_policy, emergency_history_trim, estimate_history_tokens,
5352        fast_trim_tool_results, load_interactive_session_history,
5353        maybe_inject_channel_delivery_defaults, save_interactive_session_history,
5354        truncate_tool_result,
5355    };
5356    use crate::agent::history::{DEFAULT_MAX_HISTORY_MESSAGES, InteractiveSessionState};
5357    use crate::agent::tool_execution::execute_one_tool;
5358    use tempfile::tempdir;
5359    use zeroclaw_providers::ChatMessage;
5360    use zeroclaw_tool_call_parser::parse_tool_calls;
5361
5362    zeroclaw_api::mock_tool_attribution!(
5363        CountingTool,
5364        EmptySuccessTool,
5365        RecordingArgsTool,
5366        DelayTool,
5367        FailingTool,
5368        NamedMockTool,
5369    );
5370
5371    // ── maybe_inject_channel_delivery_defaults tests ──────────────
5372
5373    #[test]
5374    fn cron_delivery_defaults_include_dingtalk_channel() {
5375        let mut args = serde_json::json!({
5376            "job_type": "agent",
5377            "prompt": "remind me later",
5378            "schedule": { "kind": "every", "every_ms": 60000 }
5379        });
5380
5381        maybe_inject_channel_delivery_defaults("cron_add", &mut args, "dingtalk", Some("chat-42"));
5382
5383        assert_eq!(
5384            args["delivery"],
5385            serde_json::json!({
5386                "mode": "announce",
5387                "channel": "dingtalk",
5388                "to": "chat-42",
5389            })
5390        );
5391    }
5392
5393    #[test]
5394    fn cron_delivery_defaults_do_not_guess_webhook_shape() {
5395        let mut args = serde_json::json!({
5396            "job_type": "agent",
5397            "prompt": "remind me later",
5398            "schedule": { "kind": "every", "every_ms": 60000 }
5399        });
5400
5401        maybe_inject_channel_delivery_defaults("cron_add", &mut args, "webhook", Some("thread-42"));
5402
5403        assert!(
5404            args.get("delivery").is_none(),
5405            "webhook delivery needs sender/thread context and must not reuse reply_target as to"
5406        );
5407    }
5408
5409    // ── truncate_tool_result tests ────────────────────────────────
5410
5411    #[test]
5412    fn truncate_tool_result_short_passthrough() {
5413        let output = "short output";
5414        assert_eq!(truncate_tool_result(output, 100), output);
5415    }
5416
5417    #[test]
5418    fn truncate_tool_result_exact_boundary() {
5419        let output = "a".repeat(100);
5420        assert_eq!(truncate_tool_result(&output, 100), output);
5421    }
5422
5423    #[test]
5424    fn truncate_tool_result_zero_disables() {
5425        let output = "a".repeat(200_000);
5426        assert_eq!(truncate_tool_result(&output, 0), output);
5427    }
5428
5429    #[test]
5430    fn truncate_tool_result_truncates_with_marker() {
5431        let output = "a".repeat(200);
5432        let result = truncate_tool_result(&output, 100);
5433        assert!(result.contains("[... "));
5434        assert!(result.contains("characters truncated ...]\n\n"));
5435        // Head should be ~2/3 of 100 = 66, tail ~1/3 = 34
5436        assert!(result.starts_with("aaa"));
5437        assert!(result.ends_with("aaa"));
5438        // Result should be shorter than original
5439        assert!(result.len() < output.len());
5440    }
5441
5442    #[test]
5443    fn truncate_tool_result_preserves_head_tail_ratio() {
5444        let output: String = (0u32..1000)
5445            .map(|i| char::from(b'a' + (i % 26) as u8))
5446            .collect();
5447        let result = truncate_tool_result(&output, 300);
5448        // Head = 2/3 of 300 = 200 chars, tail = 100 chars
5449        // Find the marker
5450        let marker_start = result.find("[... ").unwrap();
5451        let marker_end = result.find("characters truncated ...]\n\n").unwrap()
5452            + "characters truncated ...]\n\n".len();
5453        let head = &result[..marker_start - 2]; // subtract \n\n
5454        let tail = &result[marker_end..];
5455        assert!(
5456            head.len() >= 190 && head.len() <= 210,
5457            "head len={}",
5458            head.len()
5459        );
5460        assert!(
5461            tail.len() >= 90 && tail.len() <= 110,
5462            "tail len={}",
5463            tail.len()
5464        );
5465    }
5466
5467    #[test]
5468    fn truncate_tool_result_utf8_boundary_safety() {
5469        // Create string with multi-byte chars: each emoji is 4 bytes
5470        let output = "🦀".repeat(100); // 400 bytes
5471        // This should not panic even with a limit that falls mid-char
5472        let result = truncate_tool_result(&output, 50);
5473        assert!(result.contains("[... "));
5474        // Verify the result is valid UTF-8 (would panic otherwise)
5475        let _ = result.len();
5476    }
5477
5478    #[test]
5479    fn truncate_tool_result_very_small_max() {
5480        let output = "abcdefghijklmnopqrstuvwxyz";
5481        // With max=5, head=3 tail=2 — result includes marker overhead
5482        // but should not panic and should contain truncation marker
5483        let result = truncate_tool_result(output, 5);
5484        assert!(result.contains("[... "));
5485        // Head (3 chars) + tail (2 chars) from original should be preserved
5486        assert!(result.starts_with("abc"));
5487        assert!(result.ends_with("yz"));
5488    }
5489
5490    // ── truncate_tool_message tests ─────────────────────────────
5491
5492    #[test]
5493    fn truncate_tool_message_preserves_json_structure() {
5494        use crate::agent::history::truncate_tool_message;
5495        let big_content = "x".repeat(5000);
5496        let msg = serde_json::json!({
5497            "tool_call_id": "call_abc123",
5498            "content": big_content,
5499        })
5500        .to_string();
5501        let result = truncate_tool_message(&msg, 2000);
5502        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
5503        assert_eq!(parsed["tool_call_id"], "call_abc123");
5504        assert!(parsed["content"].as_str().unwrap().contains("[... "));
5505    }
5506
5507    #[test]
5508    fn truncate_tool_message_plain_text_fallback() {
5509        use crate::agent::history::truncate_tool_message;
5510        let plain = "a".repeat(5000);
5511        let result = truncate_tool_message(&plain, 2000);
5512        assert!(result.contains("[... "));
5513        assert!(result.len() < 5000);
5514    }
5515
5516    #[test]
5517    fn truncate_tool_message_short_passthrough() {
5518        use crate::agent::history::truncate_tool_message;
5519        let msg = r#"{"tool_call_id":"call_1","content":"ok"}"#;
5520        assert_eq!(truncate_tool_message(msg, 2000), msg);
5521    }
5522
5523    // ── fast_trim_tool_results tests ────────────────────────────
5524
5525    #[test]
5526    fn fast_trim_protects_recent_messages() {
5527        let mut history = vec![
5528            ChatMessage::system("sys"),
5529            ChatMessage::tool("a".repeat(5000)),
5530            ChatMessage::tool("b".repeat(5000)),
5531            ChatMessage::user("recent user msg"),
5532            ChatMessage::tool("c".repeat(5000)), // recent, should be protected
5533        ];
5534        // protect_last_n = 2 → last 2 messages protected
5535        let saved = fast_trim_tool_results(&mut history, 2);
5536        assert!(saved > 0);
5537        // First two tool messages should be trimmed
5538        assert!(history[1].content.len() <= 2100);
5539        assert!(history[2].content.len() <= 2100);
5540        // Last tool message (protected) should be unchanged
5541        assert_eq!(history[4].content.len(), 5000);
5542    }
5543
5544    #[test]
5545    fn fast_trim_skips_non_tool_messages() {
5546        let mut history = vec![
5547            ChatMessage::system("sys"),
5548            ChatMessage::user("a".repeat(5000)),
5549            ChatMessage::assistant("b".repeat(5000)),
5550        ];
5551        let saved = fast_trim_tool_results(&mut history, 0);
5552        assert_eq!(saved, 0);
5553        assert_eq!(history[1].content.len(), 5000);
5554        assert_eq!(history[2].content.len(), 5000);
5555    }
5556
5557    #[test]
5558    fn fast_trim_small_tool_results_unchanged() {
5559        let mut history = vec![
5560            ChatMessage::system("sys"),
5561            ChatMessage::tool("short result"),
5562        ];
5563        let saved = fast_trim_tool_results(&mut history, 0);
5564        assert_eq!(saved, 0);
5565        assert_eq!(history[1].content, "short result");
5566    }
5567
5568    // ── emergency_history_trim tests ──────────────────────────────
5569
5570    #[test]
5571    fn emergency_trim_preserves_system() {
5572        let mut history = vec![
5573            ChatMessage::system("sys"),
5574            ChatMessage::user("msg1"),
5575            ChatMessage::assistant("resp1"),
5576            ChatMessage::user("msg2"),
5577            ChatMessage::assistant("resp2"),
5578            ChatMessage::user("msg3"),
5579        ];
5580        let dropped = emergency_history_trim(&mut history, 2);
5581        assert!(dropped > 0);
5582        // System message should always be preserved
5583        assert_eq!(history[0].role, "system");
5584        assert_eq!(history[0].content, "sys");
5585        // Last 2 messages should be preserved
5586        let len = history.len();
5587        assert_eq!(history[len - 1].content, "msg3");
5588    }
5589
5590    #[test]
5591    fn emergency_trim_preserves_recent() {
5592        let mut history = vec![
5593            ChatMessage::system("sys"),
5594            ChatMessage::user("old1"),
5595            ChatMessage::user("old2"),
5596            ChatMessage::user("recent1"),
5597            ChatMessage::user("recent2"),
5598        ];
5599        let dropped = emergency_history_trim(&mut history, 2);
5600        assert!(dropped > 0);
5601        // Last 2 should be preserved
5602        let len = history.len();
5603        assert_eq!(history[len - 1].content, "recent2");
5604        assert_eq!(history[len - 2].content, "recent1");
5605    }
5606
5607    #[test]
5608    fn emergency_trim_nothing_to_drop() {
5609        let mut history = vec![
5610            ChatMessage::system("sys"),
5611            ChatMessage::user("only user msg"),
5612        ];
5613        // protect_last = 1, system is protected → only 1 droppable
5614        // target_drop = 2/3 = 0 → nothing dropped
5615        let dropped = emergency_history_trim(&mut history, 1);
5616        assert_eq!(dropped, 0);
5617    }
5618
5619    // ── estimate_history_tokens tests ─────────────────────────────
5620
5621    #[test]
5622    fn estimate_tokens_empty_history() {
5623        let history: Vec<ChatMessage> = vec![];
5624        assert_eq!(estimate_history_tokens(&history), 0);
5625    }
5626
5627    #[test]
5628    fn estimate_tokens_single_message() {
5629        // 40 chars → 40.div_ceil(4) + 4 = 10 + 4 = 14 tokens
5630        let msg = "a".repeat(40);
5631        let history = vec![ChatMessage::user(&msg)];
5632        let est = estimate_history_tokens(&history);
5633        assert_eq!(est, 14);
5634    }
5635
5636    #[test]
5637    fn estimate_tokens_multiple_messages() {
5638        let history = vec![
5639            ChatMessage::system("system prompt here"), // 18 chars → 18/4=4 +4=8 (div_ceil: 5+4=9)
5640            ChatMessage::user("hello"),                // 5 chars → 5/4=1 +4=5 (div_ceil: 2+4=6)
5641            ChatMessage::assistant("world"),           // 5 chars → 5/4=1 +4=5 (div_ceil: 2+4=6)
5642        ];
5643        let est = estimate_history_tokens(&history);
5644        // Each message: content_len.div_ceil(4) + 4
5645        // 18.div_ceil(4)=5, 5.div_ceil(4)=2, 5.div_ceil(4)=2 → 5+4 + 2+4 + 2+4 = 21
5646        assert_eq!(est, 21);
5647    }
5648
5649    #[test]
5650    fn estimate_tokens_large_tool_result() {
5651        let big = "x".repeat(40_000);
5652        let history = vec![ChatMessage::tool(&big)];
5653        let est = estimate_history_tokens(&history);
5654        // 40000.div_ceil(4) + 4 = 10000 + 4 = 10004
5655        assert_eq!(est, 10_004);
5656    }
5657
5658    // ── shared_budget tests ───────────────────────────────────────
5659
5660    #[test]
5661    fn shared_budget_decrement_logic() {
5662        use std::sync::Arc;
5663        use std::sync::atomic::{AtomicUsize, Ordering};
5664
5665        let budget = Arc::new(AtomicUsize::new(3));
5666
5667        // Simulate 3 iterations decrementing
5668        for i in 0..3 {
5669            let remaining = budget.load(Ordering::Relaxed);
5670            assert!(remaining > 0, "Budget should be >0 at iteration {i}");
5671            budget.fetch_sub(1, Ordering::Relaxed);
5672        }
5673
5674        // Budget should now be 0
5675        assert_eq!(budget.load(Ordering::Relaxed), 0);
5676    }
5677
5678    #[test]
5679    fn shared_budget_none_has_no_effect() {
5680        // When shared_budget is None, the check is simply skipped
5681        let budget: Option<Arc<std::sync::atomic::AtomicUsize>> = None;
5682        assert!(budget.is_none());
5683    }
5684
5685    // ── existing tests ────────────────────────────────────────────
5686
5687    #[test]
5688    fn interactive_session_state_round_trips_history() {
5689        let dir = tempdir().unwrap();
5690        let path = dir.path().join("session.json");
5691        let history = vec![
5692            ChatMessage::system("system"),
5693            ChatMessage::user("hello"),
5694            ChatMessage::assistant("hi"),
5695        ];
5696
5697        save_interactive_session_history(&path, &history).unwrap();
5698        let restored = load_interactive_session_history(&path, "fallback").unwrap();
5699
5700        assert_eq!(restored.len(), 3);
5701        assert_eq!(restored[0].role, "system");
5702        assert_eq!(restored[1].content, "hello");
5703        assert_eq!(restored[2].content, "hi");
5704    }
5705
5706    #[test]
5707    fn interactive_session_state_adds_missing_system_prompt() {
5708        let dir = tempdir().unwrap();
5709        let path = dir.path().join("session.json");
5710        let payload = serde_json::to_string_pretty(&InteractiveSessionState {
5711            version: 1,
5712            history: vec![ChatMessage::user("orphan")],
5713        })
5714        .unwrap();
5715        std::fs::write(&path, payload).unwrap();
5716
5717        let restored = load_interactive_session_history(&path, "fallback system").unwrap();
5718
5719        assert_eq!(restored[0].role, "system");
5720        assert_eq!(restored[0].content, "fallback system");
5721        assert_eq!(restored[1].content, "orphan");
5722    }
5723
5724    #[test]
5725    fn load_interactive_session_merges_non_leading_system_messages() {
5726        let dir = tempdir().unwrap();
5727        let path = dir.path().join("session.json");
5728        let payload = serde_json::to_string_pretty(&InteractiveSessionState {
5729            version: 1,
5730            history: vec![
5731                ChatMessage::system("base system"),
5732                ChatMessage::user("first question"),
5733                ChatMessage::assistant("first answer"),
5734                ChatMessage::system("late loop-detection guidance"),
5735                ChatMessage::user("follow-up"),
5736            ],
5737        })
5738        .unwrap();
5739        std::fs::write(&path, payload).unwrap();
5740
5741        let restored = load_interactive_session_history(&path, "fallback").unwrap();
5742
5743        assert_eq!(
5744            restored
5745                .iter()
5746                .filter(|message| message.role == "system")
5747                .count(),
5748            1,
5749            "loaded session must not contain non-leading system messages: {:?}",
5750            restored
5751                .iter()
5752                .map(|message| message.role.as_str())
5753                .collect::<Vec<_>>()
5754        );
5755        assert_eq!(restored[0].role, "system");
5756        assert!(restored[0].content.contains("base system"));
5757        assert!(restored[0].content.contains("late loop-detection guidance"));
5758        assert_eq!(
5759            restored
5760                .iter()
5761                .map(|message| message.role.as_str())
5762                .collect::<Vec<_>>(),
5763            vec!["system", "user", "assistant", "user"]
5764        );
5765    }
5766
5767    #[test]
5768    fn load_interactive_session_replaces_empty_system_messages_with_fallback() {
5769        let dir = tempdir().unwrap();
5770        let path = dir.path().join("session.json");
5771        let payload = serde_json::to_string_pretty(&InteractiveSessionState {
5772            version: 1,
5773            history: vec![
5774                ChatMessage::system(""),
5775                ChatMessage::user("follow-up"),
5776                ChatMessage::system(""),
5777            ],
5778        })
5779        .unwrap();
5780        std::fs::write(&path, payload).unwrap();
5781
5782        let restored = load_interactive_session_history(&path, "fallback system").unwrap();
5783
5784        assert_eq!(
5785            restored
5786                .iter()
5787                .map(|message| (message.role.as_str(), message.content.as_str()))
5788                .collect::<Vec<_>>(),
5789            vec![("system", "fallback system"), ("user", "follow-up")]
5790        );
5791    }
5792
5793    /// Regression test for issue #5813: a persisted session whose assistant
5794    /// (tool_use) was lost to compaction must self-heal on load so the next
5795    /// API call doesn't fail with "unexpected tool_use_id found in tool_result
5796    /// blocks".
5797    #[test]
5798    fn load_interactive_session_heals_orphaned_tool_result() {
5799        let dir = tempdir().unwrap();
5800        let path = dir.path().join("session.json");
5801        let orphan_tool = ChatMessage::tool(
5802            r#"{"tool_call_id":"toolu_01OrphanFromCompaction","content":"stale result"}"#,
5803        );
5804        let payload = serde_json::to_string_pretty(&InteractiveSessionState {
5805            version: 1,
5806            history: vec![
5807                ChatMessage::system("sys"),
5808                orphan_tool,
5809                ChatMessage::user("next question"),
5810            ],
5811        })
5812        .unwrap();
5813        std::fs::write(&path, payload).unwrap();
5814
5815        let restored = load_interactive_session_history(&path, "fallback").unwrap();
5816
5817        assert!(
5818            !restored.iter().any(|m| m.role == "tool"),
5819            "orphaned tool_result should be removed on load; got roles {:?}",
5820            restored.iter().map(|m| &m.role).collect::<Vec<_>>()
5821        );
5822    }
5823
5824    use super::*;
5825    use async_trait::async_trait;
5826    use base64::{Engine as _, engine::general_purpose::STANDARD};
5827    use std::collections::VecDeque;
5828    use std::sync::atomic::{AtomicUsize, Ordering};
5829    use std::sync::{Arc, Mutex};
5830    use std::time::Duration;
5831
5832    #[test]
5833    fn scrub_credentials_redacts_bearer_token() {
5834        let input = "API_KEY=sk-1234567890abcdef; token: 1234567890; password=\"secret123456\"";
5835        let scrubbed = scrub_credentials(input);
5836        assert!(scrubbed.contains("API_KEY=sk-1*[REDACTED]"));
5837        assert!(scrubbed.contains("token: 1234*[REDACTED]"));
5838        assert!(scrubbed.contains("password=\"secr*[REDACTED]\""));
5839        assert!(!scrubbed.contains("abcdef"));
5840        assert!(!scrubbed.contains("secret123456"));
5841    }
5842
5843    #[test]
5844    fn scrub_credentials_redacts_json_api_key() {
5845        let input = r#"{"api_key": "sk-1234567890", "other": "public"}"#;
5846        let scrubbed = scrub_credentials(input);
5847        assert!(scrubbed.contains("\"api_key\": \"sk-1*[REDACTED]\""));
5848        assert!(scrubbed.contains("public"));
5849    }
5850
5851    #[tokio::test]
5852    async fn execute_one_tool_does_not_panic_on_utf8_boundary() {
5853        let call_arguments = (0..600)
5854            .map(|n| serde_json::json!({ "content": format!("{}:tail", "a".repeat(n)) }))
5855            .find(|args| {
5856                let raw = args.to_string();
5857                raw.len() > 300 && !raw.is_char_boundary(300)
5858            })
5859            .expect("should produce a sample whose byte index 300 is not a char boundary");
5860
5861        let observer = NoopObserver;
5862        let result = execute_one_tool(
5863            "unknown_tool",
5864            call_arguments,
5865            None,
5866            &[],
5867            None,
5868            &observer,
5869            None,
5870            None,
5871        )
5872        .await;
5873        assert!(result.is_ok(), "execute_one_tool should not panic or error");
5874
5875        let outcome = result.unwrap();
5876        assert!(!outcome.success);
5877        assert!(outcome.output.contains("Unknown tool: unknown_tool"));
5878    }
5879
5880    #[tokio::test]
5881    async fn execute_one_tool_resolves_unique_activated_tool_suffix() {
5882        let observer = NoopObserver;
5883        let invocations = Arc::new(AtomicUsize::new(0));
5884        let activated = Arc::new(std::sync::Mutex::new(crate::tools::ActivatedToolSet::new()));
5885        let activated_tool: Arc<dyn Tool> = Arc::new(CountingTool::new(
5886            "docker-mcp__extract_text",
5887            Arc::clone(&invocations),
5888        ));
5889        activated
5890            .lock()
5891            .unwrap()
5892            .activate("docker-mcp__extract_text".into(), activated_tool);
5893
5894        let outcome = execute_one_tool(
5895            "extract_text",
5896            serde_json::json!({ "value": "ok" }),
5897            None,
5898            &[],
5899            Some(&activated),
5900            &observer,
5901            None,
5902            None, // receipt_generator
5903        )
5904        .await
5905        .expect("suffix alias should execute the unique activated tool");
5906
5907        assert!(outcome.success);
5908        assert_eq!(outcome.output, "counted:ok");
5909        assert_eq!(invocations.load(Ordering::SeqCst), 1);
5910    }
5911
5912    #[tokio::test]
5913    async fn execute_one_tool_normalizes_empty_success_output() {
5914        let observer = NoopObserver;
5915        let tools: Vec<Box<dyn Tool>> = vec![Box::new(EmptySuccessTool)];
5916
5917        let outcome = execute_one_tool(
5918            "empty_success",
5919            serde_json::json!({}),
5920            None,
5921            &tools,
5922            None,
5923            &observer,
5924            None,
5925            None, // receipt_generator
5926        )
5927        .await
5928        .expect("empty successful tool output should still execute");
5929
5930        assert!(outcome.success);
5931        assert_eq!(outcome.output, "(no output)");
5932        assert!(outcome.error_reason.is_none());
5933    }
5934    use crate::observability::NoopObserver;
5935    use tempfile::TempDir;
5936    use zeroclaw_api::model_provider::{
5937        ProviderCapabilities, StreamChunk, StreamEvent, StreamOptions,
5938    };
5939    use zeroclaw_memory::{Memory, MemoryCategory, SqliteMemory};
5940    use zeroclaw_providers::ChatResponse;
5941    use zeroclaw_providers::router::{Route, RouterModelProvider};
5942
5943    macro_rules! impl_test_model_provider_attribution {
5944        ($ty:ty) => {
5945            impl ::zeroclaw_api::attribution::Attributable for $ty {
5946                fn role(&self) -> ::zeroclaw_api::attribution::Role {
5947                    ::zeroclaw_api::attribution::Role::Provider(
5948                        ::zeroclaw_api::attribution::ProviderKind::Model(
5949                            ::zeroclaw_api::attribution::ModelProviderKind::Custom,
5950                        ),
5951                    )
5952                }
5953
5954                fn alias(&self) -> &str {
5955                    stringify!($ty)
5956                }
5957            }
5958        };
5959    }
5960
5961    struct NonVisionModelProvider {
5962        calls: Arc<AtomicUsize>,
5963    }
5964
5965    #[async_trait]
5966    impl ModelProvider for NonVisionModelProvider {
5967        async fn chat_with_system(
5968            &self,
5969            _system_prompt: Option<&str>,
5970            _message: &str,
5971            _model: &str,
5972            _temperature: Option<f64>,
5973        ) -> anyhow::Result<String> {
5974            self.calls.fetch_add(1, Ordering::SeqCst);
5975            Ok("ok".to_string())
5976        }
5977    }
5978    impl ::zeroclaw_api::attribution::Attributable for NonVisionModelProvider {
5979        fn role(&self) -> ::zeroclaw_api::attribution::Role {
5980            ::zeroclaw_api::attribution::Role::Provider(
5981                ::zeroclaw_api::attribution::ProviderKind::Model(
5982                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
5983                ),
5984            )
5985        }
5986        fn alias(&self) -> &str {
5987            "NonVisionModelProvider"
5988        }
5989    }
5990
5991    struct VisionModelProvider {
5992        calls: Arc<AtomicUsize>,
5993    }
5994
5995    #[async_trait]
5996    impl ModelProvider for VisionModelProvider {
5997        fn capabilities(&self) -> ProviderCapabilities {
5998            ProviderCapabilities {
5999                native_tool_calling: false,
6000                vision: true,
6001                prompt_caching: false,
6002                extended_thinking: false,
6003            }
6004        }
6005
6006        async fn chat_with_system(
6007            &self,
6008            _system_prompt: Option<&str>,
6009            _message: &str,
6010            _model: &str,
6011            _temperature: Option<f64>,
6012        ) -> anyhow::Result<String> {
6013            self.calls.fetch_add(1, Ordering::SeqCst);
6014            Ok("ok".to_string())
6015        }
6016
6017        async fn chat(
6018            &self,
6019            request: ChatRequest<'_>,
6020            _model: &str,
6021            _temperature: Option<f64>,
6022        ) -> anyhow::Result<ChatResponse> {
6023            self.calls.fetch_add(1, Ordering::SeqCst);
6024            let marker_count =
6025                zeroclaw_providers::multimodal::count_image_markers(request.messages);
6026            if marker_count == 0 {
6027                anyhow::bail!("expected image markers in request messages");
6028            }
6029
6030            if request.tools.is_some() {
6031                anyhow::bail!("no tools should be attached for this test");
6032            }
6033
6034            Ok(ChatResponse {
6035                text: Some("vision-ok".to_string()),
6036                tool_calls: Vec::new(),
6037                usage: None,
6038                reasoning_content: None,
6039            })
6040        }
6041    }
6042    impl ::zeroclaw_api::attribution::Attributable for VisionModelProvider {
6043        fn role(&self) -> ::zeroclaw_api::attribution::Role {
6044            ::zeroclaw_api::attribution::Role::Provider(
6045                ::zeroclaw_api::attribution::ProviderKind::Model(
6046                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
6047                ),
6048            )
6049        }
6050        fn alias(&self) -> &str {
6051            "VisionModelProvider"
6052        }
6053    }
6054
6055    struct ScriptedModelProvider {
6056        responses: Arc<Mutex<VecDeque<ChatResponse>>>,
6057        capabilities: ProviderCapabilities,
6058    }
6059
6060    impl ScriptedModelProvider {
6061        fn from_text_responses(responses: Vec<&str>) -> Self {
6062            let scripted = responses
6063                .into_iter()
6064                .map(|text| ChatResponse {
6065                    text: Some(text.to_string()),
6066                    tool_calls: Vec::new(),
6067                    usage: None,
6068                    reasoning_content: None,
6069                })
6070                .collect();
6071            Self {
6072                responses: Arc::new(Mutex::new(scripted)),
6073                capabilities: ProviderCapabilities::default(),
6074            }
6075        }
6076
6077        fn with_native_tool_support(mut self) -> Self {
6078            self.capabilities.native_tool_calling = true;
6079            self
6080        }
6081
6082        /// Build a native-tool-calling provider: one turn of structured
6083        /// `tool_calls`, then a plain-text turn.
6084        fn from_native_tool_calls(calls: Vec<(&str, &str, &str)>, final_text: &str) -> Self {
6085            let tool_turn = ChatResponse {
6086                text: None,
6087                tool_calls: calls
6088                    .into_iter()
6089                    .map(|(id, name, args)| ToolCall {
6090                        id: id.to_string(),
6091                        name: name.to_string(),
6092                        arguments: args.to_string(),
6093                        extra_content: None,
6094                    })
6095                    .collect(),
6096                usage: None,
6097                reasoning_content: None,
6098            };
6099            let final_turn = ChatResponse {
6100                text: Some(final_text.to_string()),
6101                tool_calls: Vec::new(),
6102                usage: None,
6103                reasoning_content: None,
6104            };
6105            let capabilities = ProviderCapabilities {
6106                native_tool_calling: true,
6107                ..ProviderCapabilities::default()
6108            };
6109            Self {
6110                responses: Arc::new(Mutex::new(VecDeque::from(vec![tool_turn, final_turn]))),
6111                capabilities,
6112            }
6113        }
6114    }
6115
6116    #[async_trait]
6117    impl ModelProvider for ScriptedModelProvider {
6118        fn capabilities(&self) -> ProviderCapabilities {
6119            self.capabilities.clone()
6120        }
6121
6122        async fn chat_with_system(
6123            &self,
6124            _system_prompt: Option<&str>,
6125            _message: &str,
6126            _model: &str,
6127            _temperature: Option<f64>,
6128        ) -> anyhow::Result<String> {
6129            anyhow::bail!("chat_with_system should not be used in scripted model_provider tests");
6130        }
6131
6132        async fn chat(
6133            &self,
6134            _request: ChatRequest<'_>,
6135            _model: &str,
6136            _temperature: Option<f64>,
6137        ) -> anyhow::Result<ChatResponse> {
6138            let mut responses = self
6139                .responses
6140                .lock()
6141                .expect("responses lock should be valid");
6142            responses
6143                .pop_front()
6144                .ok_or_else(|| anyhow::Error::msg("scripted model_provider exhausted responses"))
6145        }
6146    }
6147    impl ::zeroclaw_api::attribution::Attributable for ScriptedModelProvider {
6148        fn role(&self) -> ::zeroclaw_api::attribution::Role {
6149            ::zeroclaw_api::attribution::Role::Provider(
6150                ::zeroclaw_api::attribution::ProviderKind::Model(
6151                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
6152                ),
6153            )
6154        }
6155        fn alias(&self) -> &str {
6156            "ScriptedModelProvider"
6157        }
6158    }
6159
6160    struct RecordingModelProvider {
6161        requests: Arc<Mutex<Vec<Vec<ChatMessage>>>>,
6162        capabilities: ProviderCapabilities,
6163    }
6164
6165    impl RecordingModelProvider {
6166        fn new() -> Self {
6167            Self {
6168                requests: Arc::new(Mutex::new(Vec::new())),
6169                capabilities: ProviderCapabilities::default(),
6170            }
6171        }
6172
6173        fn with_vision_support(mut self) -> Self {
6174            self.capabilities.vision = true;
6175            self
6176        }
6177    }
6178
6179    #[async_trait]
6180    impl ModelProvider for RecordingModelProvider {
6181        fn capabilities(&self) -> ProviderCapabilities {
6182            self.capabilities.clone()
6183        }
6184
6185        async fn chat_with_system(
6186            &self,
6187            _system_prompt: Option<&str>,
6188            _message: &str,
6189            _model: &str,
6190            _temperature: Option<f64>,
6191        ) -> anyhow::Result<String> {
6192            anyhow::bail!("chat_with_system should not be used in recording provider tests");
6193        }
6194
6195        async fn chat(
6196            &self,
6197            request: ChatRequest<'_>,
6198            _model: &str,
6199            _temperature: Option<f64>,
6200        ) -> anyhow::Result<ChatResponse> {
6201            self.requests
6202                .lock()
6203                .expect("requests lock should be valid")
6204                .push(request.messages.to_vec());
6205            Ok(ChatResponse {
6206                text: Some("done".to_string()),
6207                tool_calls: Vec::new(),
6208                usage: None,
6209                reasoning_content: None,
6210            })
6211        }
6212    }
6213    impl ::zeroclaw_api::attribution::Attributable for RecordingModelProvider {
6214        fn role(&self) -> ::zeroclaw_api::attribution::Role {
6215            ::zeroclaw_api::attribution::Role::Provider(
6216                ::zeroclaw_api::attribution::ProviderKind::Model(
6217                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
6218                ),
6219            )
6220        }
6221        fn alias(&self) -> &str {
6222            "RecordingModelProvider"
6223        }
6224    }
6225
6226    struct StreamingScriptedModelProvider {
6227        responses: Arc<Mutex<VecDeque<String>>>,
6228        stream_calls: Arc<AtomicUsize>,
6229        chat_calls: Arc<AtomicUsize>,
6230    }
6231
6232    impl StreamingScriptedModelProvider {
6233        fn from_text_responses(responses: Vec<&str>) -> Self {
6234            Self {
6235                responses: Arc::new(Mutex::new(
6236                    responses.into_iter().map(ToString::to_string).collect(),
6237                )),
6238                stream_calls: Arc::new(AtomicUsize::new(0)),
6239                chat_calls: Arc::new(AtomicUsize::new(0)),
6240            }
6241        }
6242    }
6243
6244    #[async_trait]
6245    impl ModelProvider for StreamingScriptedModelProvider {
6246        async fn chat_with_system(
6247            &self,
6248            _system_prompt: Option<&str>,
6249            _message: &str,
6250            _model: &str,
6251            _temperature: Option<f64>,
6252        ) -> anyhow::Result<String> {
6253            anyhow::bail!(
6254                "chat_with_system should not be used in streaming scripted model_provider tests"
6255            );
6256        }
6257
6258        async fn chat(
6259            &self,
6260            _request: ChatRequest<'_>,
6261            _model: &str,
6262            _temperature: Option<f64>,
6263        ) -> anyhow::Result<ChatResponse> {
6264            self.chat_calls.fetch_add(1, Ordering::SeqCst);
6265            anyhow::bail!("chat should not be called when streaming succeeds")
6266        }
6267
6268        fn supports_streaming(&self) -> bool {
6269            true
6270        }
6271
6272        fn stream_chat_with_history(
6273            &self,
6274            _messages: &[ChatMessage],
6275            _model: &str,
6276            _temperature: Option<f64>,
6277            options: StreamOptions,
6278        ) -> futures_util::stream::BoxStream<
6279            'static,
6280            zeroclaw_providers::traits::StreamResult<StreamChunk>,
6281        > {
6282            self.stream_calls.fetch_add(1, Ordering::SeqCst);
6283            if !options.enabled {
6284                return Box::pin(futures_util::stream::empty());
6285            }
6286
6287            let response = self
6288                .responses
6289                .lock()
6290                .expect("responses lock should be valid")
6291                .pop_front()
6292                .unwrap_or_default();
6293
6294            Box::pin(futures_util::stream::iter(vec![
6295                Ok(StreamChunk::delta(response)),
6296                Ok(StreamChunk::final_chunk()),
6297            ]))
6298        }
6299    }
6300    impl ::zeroclaw_api::attribution::Attributable for StreamingScriptedModelProvider {
6301        fn role(&self) -> ::zeroclaw_api::attribution::Role {
6302            ::zeroclaw_api::attribution::Role::Provider(
6303                ::zeroclaw_api::attribution::ProviderKind::Model(
6304                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
6305                ),
6306            )
6307        }
6308        fn alias(&self) -> &str {
6309            "StreamingScriptedModelProvider"
6310        }
6311    }
6312
6313    enum NativeStreamTurn {
6314        ToolCall(ToolCall),
6315        Text(String),
6316        TextChunks(Vec<String>),
6317        /// Emit a single text delta with associated reasoning content. Used by
6318        /// regression tests for issue #6059 (DeepSeek V4 thinking-mode replay).
6319        TextWithReasoning {
6320            text: String,
6321            reasoning: String,
6322        },
6323    }
6324
6325    struct StreamingNativeToolEventModelProvider {
6326        turns: Arc<Mutex<VecDeque<NativeStreamTurn>>>,
6327        stream_calls: Arc<AtomicUsize>,
6328        stream_tool_requests: Arc<AtomicUsize>,
6329        chat_calls: Arc<AtomicUsize>,
6330    }
6331
6332    impl StreamingNativeToolEventModelProvider {
6333        fn with_turns(turns: Vec<NativeStreamTurn>) -> Self {
6334            Self {
6335                turns: Arc::new(Mutex::new(turns.into())),
6336                stream_calls: Arc::new(AtomicUsize::new(0)),
6337                stream_tool_requests: Arc::new(AtomicUsize::new(0)),
6338                chat_calls: Arc::new(AtomicUsize::new(0)),
6339            }
6340        }
6341    }
6342
6343    #[async_trait]
6344    impl ModelProvider for StreamingNativeToolEventModelProvider {
6345        fn capabilities(&self) -> ProviderCapabilities {
6346            ProviderCapabilities {
6347                native_tool_calling: true,
6348                vision: false,
6349                prompt_caching: false,
6350                extended_thinking: false,
6351            }
6352        }
6353
6354        async fn chat_with_system(
6355            &self,
6356            _system_prompt: Option<&str>,
6357            _message: &str,
6358            _model: &str,
6359            _temperature: Option<f64>,
6360        ) -> anyhow::Result<String> {
6361            anyhow::bail!(
6362                "chat_with_system should not be used in streaming native tool event model_provider tests"
6363            );
6364        }
6365
6366        async fn chat(
6367            &self,
6368            _request: ChatRequest<'_>,
6369            _model: &str,
6370            _temperature: Option<f64>,
6371        ) -> anyhow::Result<ChatResponse> {
6372            self.chat_calls.fetch_add(1, Ordering::SeqCst);
6373            anyhow::bail!("chat should not be called when native streaming events succeed")
6374        }
6375
6376        fn supports_streaming(&self) -> bool {
6377            true
6378        }
6379
6380        fn supports_streaming_tool_events(&self) -> bool {
6381            true
6382        }
6383
6384        fn stream_chat(
6385            &self,
6386            request: ChatRequest<'_>,
6387            _model: &str,
6388            _temperature: Option<f64>,
6389            options: StreamOptions,
6390        ) -> futures_util::stream::BoxStream<
6391            'static,
6392            zeroclaw_providers::traits::StreamResult<StreamEvent>,
6393        > {
6394            self.stream_calls.fetch_add(1, Ordering::SeqCst);
6395            if request.tools.is_some_and(|tools| !tools.is_empty()) {
6396                self.stream_tool_requests.fetch_add(1, Ordering::SeqCst);
6397            }
6398            if !options.enabled {
6399                return Box::pin(futures_util::stream::empty());
6400            }
6401
6402            let turn = self
6403                .turns
6404                .lock()
6405                .expect("turns lock should be valid")
6406                .pop_front()
6407                .expect("streaming turns should have scripted output");
6408            match turn {
6409                NativeStreamTurn::ToolCall(tool_call) => {
6410                    Box::pin(futures_util::stream::iter(vec![
6411                        Ok(StreamEvent::ToolCall(tool_call)),
6412                        Ok(StreamEvent::Final),
6413                    ]))
6414                }
6415                NativeStreamTurn::Text(text) => Box::pin(futures_util::stream::iter(vec![
6416                    Ok(StreamEvent::TextDelta(StreamChunk::delta(text))),
6417                    Ok(StreamEvent::Final),
6418                ])),
6419                NativeStreamTurn::TextChunks(chunks) => {
6420                    let mut events: Vec<_> = chunks
6421                        .into_iter()
6422                        .map(|text| Ok(StreamEvent::TextDelta(StreamChunk::delta(text))))
6423                        .collect();
6424                    events.push(Ok(StreamEvent::Final));
6425                    Box::pin(futures_util::stream::iter(events))
6426                }
6427                NativeStreamTurn::TextWithReasoning { text, reasoning } => {
6428                    Box::pin(futures_util::stream::iter(vec![
6429                        Ok(StreamEvent::TextDelta(StreamChunk::reasoning(reasoning))),
6430                        Ok(StreamEvent::TextDelta(StreamChunk::delta(text))),
6431                        Ok(StreamEvent::Final),
6432                    ]))
6433                }
6434            }
6435        }
6436    }
6437    impl ::zeroclaw_api::attribution::Attributable for StreamingNativeToolEventModelProvider {
6438        fn role(&self) -> ::zeroclaw_api::attribution::Role {
6439            ::zeroclaw_api::attribution::Role::Provider(
6440                ::zeroclaw_api::attribution::ProviderKind::Model(
6441                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
6442                ),
6443            )
6444        }
6445        fn alias(&self) -> &str {
6446            "StreamingNativeToolEventModelProvider"
6447        }
6448    }
6449
6450    struct RouteAwareStreamingModelProvider {
6451        response: String,
6452        stream_calls: Arc<AtomicUsize>,
6453        chat_calls: Arc<AtomicUsize>,
6454        last_model: Arc<Mutex<String>>,
6455    }
6456
6457    impl RouteAwareStreamingModelProvider {
6458        fn new(response: &str) -> Self {
6459            Self {
6460                response: response.to_string(),
6461                stream_calls: Arc::new(AtomicUsize::new(0)),
6462                chat_calls: Arc::new(AtomicUsize::new(0)),
6463                last_model: Arc::new(Mutex::new(String::new())),
6464            }
6465        }
6466    }
6467
6468    #[async_trait]
6469    impl ModelProvider for RouteAwareStreamingModelProvider {
6470        async fn chat_with_system(
6471            &self,
6472            _system_prompt: Option<&str>,
6473            _message: &str,
6474            _model: &str,
6475            _temperature: Option<f64>,
6476        ) -> anyhow::Result<String> {
6477            anyhow::bail!("chat_with_system should not be used in route-aware stream tests");
6478        }
6479
6480        async fn chat(
6481            &self,
6482            _request: ChatRequest<'_>,
6483            _model: &str,
6484            _temperature: Option<f64>,
6485        ) -> anyhow::Result<ChatResponse> {
6486            self.chat_calls.fetch_add(1, Ordering::SeqCst);
6487            anyhow::bail!("chat should not be called when routed streaming succeeds")
6488        }
6489
6490        fn supports_streaming(&self) -> bool {
6491            true
6492        }
6493
6494        fn stream_chat_with_history(
6495            &self,
6496            _messages: &[ChatMessage],
6497            model: &str,
6498            _temperature: Option<f64>,
6499            options: StreamOptions,
6500        ) -> futures_util::stream::BoxStream<
6501            'static,
6502            zeroclaw_providers::traits::StreamResult<StreamChunk>,
6503        > {
6504            self.stream_calls.fetch_add(1, Ordering::SeqCst);
6505            *self
6506                .last_model
6507                .lock()
6508                .expect("last_model lock should be valid") = model.to_string();
6509            if !options.enabled {
6510                return Box::pin(futures_util::stream::empty());
6511            }
6512
6513            Box::pin(futures_util::stream::iter(vec![
6514                Ok(StreamChunk::delta(self.response.clone())),
6515                Ok(StreamChunk::final_chunk()),
6516            ]))
6517        }
6518    }
6519    impl ::zeroclaw_api::attribution::Attributable for RouteAwareStreamingModelProvider {
6520        fn role(&self) -> ::zeroclaw_api::attribution::Role {
6521            ::zeroclaw_api::attribution::Role::Provider(
6522                ::zeroclaw_api::attribution::ProviderKind::Model(
6523                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
6524                ),
6525            )
6526        }
6527        fn alias(&self) -> &str {
6528            "RouteAwareStreamingModelProvider"
6529        }
6530    }
6531
6532    struct CountingTool {
6533        name: String,
6534        invocations: Arc<AtomicUsize>,
6535    }
6536
6537    impl CountingTool {
6538        fn new(name: &str, invocations: Arc<AtomicUsize>) -> Self {
6539            Self {
6540                name: name.to_string(),
6541                invocations,
6542            }
6543        }
6544    }
6545
6546    #[async_trait]
6547    impl Tool for CountingTool {
6548        fn name(&self) -> &str {
6549            &self.name
6550        }
6551
6552        fn description(&self) -> &str {
6553            "Counts executions for loop-stability tests"
6554        }
6555
6556        fn parameters_schema(&self) -> serde_json::Value {
6557            serde_json::json!({
6558                "type": "object",
6559                "properties": {
6560                    "value": { "type": "string" }
6561                }
6562            })
6563        }
6564
6565        async fn execute(
6566            &self,
6567            args: serde_json::Value,
6568        ) -> anyhow::Result<crate::tools::ToolResult> {
6569            self.invocations.fetch_add(1, Ordering::SeqCst);
6570            let value = args
6571                .get("value")
6572                .and_then(serde_json::Value::as_str)
6573                .unwrap_or_default();
6574            Ok(crate::tools::ToolResult {
6575                success: true,
6576                output: format!("counted:{value}"),
6577                error: None,
6578            })
6579        }
6580    }
6581
6582    struct EmptySuccessTool;
6583
6584    #[async_trait]
6585    impl Tool for EmptySuccessTool {
6586        fn name(&self) -> &str {
6587            "empty_success"
6588        }
6589
6590        fn description(&self) -> &str {
6591            "Returns success with no stdout"
6592        }
6593
6594        fn parameters_schema(&self) -> serde_json::Value {
6595            serde_json::json!({
6596                "type": "object",
6597                "properties": {}
6598            })
6599        }
6600
6601        async fn execute(
6602            &self,
6603            _args: serde_json::Value,
6604        ) -> anyhow::Result<crate::tools::ToolResult> {
6605            Ok(crate::tools::ToolResult {
6606                success: true,
6607                output: String::new(),
6608                error: None,
6609            })
6610        }
6611    }
6612
6613    struct RecordingArgsTool {
6614        name: String,
6615        recorded_args: Arc<Mutex<Vec<serde_json::Value>>>,
6616    }
6617
6618    impl RecordingArgsTool {
6619        fn new(name: &str, recorded_args: Arc<Mutex<Vec<serde_json::Value>>>) -> Self {
6620            Self {
6621                name: name.to_string(),
6622                recorded_args,
6623            }
6624        }
6625    }
6626
6627    #[async_trait]
6628    impl Tool for RecordingArgsTool {
6629        fn name(&self) -> &str {
6630            &self.name
6631        }
6632
6633        fn description(&self) -> &str {
6634            "Records tool arguments for regression tests"
6635        }
6636
6637        fn parameters_schema(&self) -> serde_json::Value {
6638            serde_json::json!({
6639                "type": "object",
6640                "properties": {
6641                    "prompt": { "type": "string" },
6642                    "schedule": { "type": "object" },
6643                    "delivery": { "type": "object" }
6644                }
6645            })
6646        }
6647
6648        async fn execute(
6649            &self,
6650            args: serde_json::Value,
6651        ) -> anyhow::Result<crate::tools::ToolResult> {
6652            self.recorded_args
6653                .lock()
6654                .expect("recorded args lock should be valid")
6655                .push(args.clone());
6656            Ok(crate::tools::ToolResult {
6657                success: true,
6658                output: args.to_string(),
6659                error: None,
6660            })
6661        }
6662    }
6663
6664    struct DelayTool {
6665        name: String,
6666        delay_ms: u64,
6667        active: Arc<AtomicUsize>,
6668        max_active: Arc<AtomicUsize>,
6669    }
6670
6671    impl DelayTool {
6672        fn new(
6673            name: &str,
6674            delay_ms: u64,
6675            active: Arc<AtomicUsize>,
6676            max_active: Arc<AtomicUsize>,
6677        ) -> Self {
6678            Self {
6679                name: name.to_string(),
6680                delay_ms,
6681                active,
6682                max_active,
6683            }
6684        }
6685    }
6686
6687    #[async_trait]
6688    impl Tool for DelayTool {
6689        fn name(&self) -> &str {
6690            &self.name
6691        }
6692
6693        fn description(&self) -> &str {
6694            "Delay tool for testing parallel tool execution"
6695        }
6696
6697        fn parameters_schema(&self) -> serde_json::Value {
6698            serde_json::json!({
6699                "type": "object",
6700                "properties": {
6701                    "value": { "type": "string" }
6702                },
6703                "required": ["value"]
6704            })
6705        }
6706
6707        async fn execute(
6708            &self,
6709            args: serde_json::Value,
6710        ) -> anyhow::Result<crate::tools::ToolResult> {
6711            let now_active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
6712            self.max_active.fetch_max(now_active, Ordering::SeqCst);
6713
6714            tokio::time::sleep(Duration::from_millis(self.delay_ms)).await;
6715
6716            self.active.fetch_sub(1, Ordering::SeqCst);
6717
6718            let value = args
6719                .get("value")
6720                .and_then(serde_json::Value::as_str)
6721                .unwrap_or_default()
6722                .to_string();
6723
6724            Ok(crate::tools::ToolResult {
6725                success: true,
6726                output: format!("ok:{value}"),
6727                error: None,
6728            })
6729        }
6730    }
6731
6732    /// A tool that always returns a failure with a given error reason.
6733    struct FailingTool {
6734        tool_name: String,
6735        error_reason: String,
6736    }
6737
6738    impl FailingTool {
6739        #[allow(dead_code)]
6740        fn new(name: &str, error_reason: &str) -> Self {
6741            Self {
6742                tool_name: name.to_string(),
6743                error_reason: error_reason.to_string(),
6744            }
6745        }
6746    }
6747
6748    #[async_trait]
6749    impl Tool for FailingTool {
6750        fn name(&self) -> &str {
6751            &self.tool_name
6752        }
6753
6754        fn description(&self) -> &str {
6755            "A tool that always fails for testing failure surfacing"
6756        }
6757
6758        fn parameters_schema(&self) -> serde_json::Value {
6759            serde_json::json!({
6760                "type": "object",
6761                "properties": {
6762                    "command": { "type": "string" }
6763                }
6764            })
6765        }
6766
6767        async fn execute(
6768            &self,
6769            _args: serde_json::Value,
6770        ) -> anyhow::Result<crate::tools::ToolResult> {
6771            Ok(crate::tools::ToolResult {
6772                success: false,
6773                output: String::new(),
6774                error: Some(self.error_reason.clone()),
6775            })
6776        }
6777    }
6778
6779    /// A **user-supplied** image on a non-vision provider with no configured
6780    /// `vision_model_provider` must surface a structured capability error
6781    /// (channels render it back to the user) — we never silently ignore an
6782    /// image the user actually sent.
6783    #[tokio::test]
6784    async fn run_tool_call_loop_returns_structured_error_for_non_vision_provider() {
6785        let calls = Arc::new(AtomicUsize::new(0));
6786        let model_provider = NonVisionModelProvider {
6787            calls: Arc::clone(&calls),
6788        };
6789
6790        let mut history = vec![ChatMessage::user(
6791            "please inspect [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
6792        )];
6793        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6794        let observer = NoopObserver;
6795
6796        let err = run_tool_call_loop(
6797            &model_provider,
6798            &mut history,
6799            &tools_registry,
6800            &observer,
6801            "mock-provider",
6802            "mock-model",
6803            Some(0.0),
6804            true,
6805            None,
6806            "cli",
6807            None,
6808            &zeroclaw_config::schema::MultimodalConfig::default(),
6809            3,
6810            None,
6811            None,
6812            None,
6813            &[],
6814            &[],
6815            None,
6816            None,
6817            &zeroclaw_config::schema::PacingConfig::default(),
6818            false,
6819            false, // parallel_tools
6820            0,
6821            0,
6822            None,
6823            None, // channel
6824            None, // receipt_generator
6825            None, // collected_receipts
6826        )
6827        .await
6828        .expect_err("user image on a non-vision provider should error");
6829
6830        assert!(err.to_string().contains("provider_capability_error"));
6831        assert!(err.to_string().contains("capability=vision"));
6832        assert_eq!(calls.load(Ordering::SeqCst), 0);
6833    }
6834
6835    #[tokio::test]
6836    async fn run_tool_call_loop_skips_oversized_image_payload() {
6837        let model_provider = RecordingModelProvider::new().with_vision_support();
6838        let recorded_requests = Arc::clone(&model_provider.requests);
6839
6840        let oversized_payload = STANDARD.encode(vec![0_u8; (1024 * 1024) + 1]);
6841        let mut history = vec![ChatMessage::user(format!(
6842            "[IMAGE:data:image/png;base64,{oversized_payload}]"
6843        ))];
6844
6845        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6846        let observer = NoopObserver;
6847        let multimodal = zeroclaw_config::schema::MultimodalConfig {
6848            max_images: 4,
6849            max_image_size_mb: 1,
6850            allow_remote_fetch: false,
6851            ..Default::default()
6852        };
6853
6854        let result = run_tool_call_loop(
6855            &model_provider,
6856            &mut history,
6857            &tools_registry,
6858            &observer,
6859            "mock-provider",
6860            "mock-model",
6861            Some(0.0),
6862            true,
6863            None,
6864            "cli",
6865            None,
6866            &multimodal,
6867            3,
6868            None,
6869            None,
6870            None,
6871            &[],
6872            &[],
6873            None,
6874            None,
6875            &zeroclaw_config::schema::PacingConfig::default(),
6876            false,
6877            false, // parallel_tools
6878            0,
6879            0,
6880            None,
6881            None, // channel
6882            None, // receipt_generator
6883            None, // collected_receipts
6884        )
6885        .await
6886        .expect("oversized payload should be skipped and continue as text-only");
6887
6888        assert_eq!(result, "done");
6889        let requests = recorded_requests
6890            .lock()
6891            .expect("recorded requests lock should be valid");
6892        assert_eq!(requests.len(), 1);
6893        assert_eq!(requests[0].len(), 1);
6894        assert!(
6895            requests[0][0]
6896                .content
6897                .contains("1 attached image(s) could not be loaded")
6898        );
6899        assert!(!requests[0][0].content.contains("[IMAGE:"));
6900        assert!(!requests[0][0].content.contains(&oversized_payload));
6901    }
6902
6903    #[tokio::test]
6904    async fn run_tool_call_loop_accepts_valid_multimodal_request_flow() {
6905        let calls = Arc::new(AtomicUsize::new(0));
6906        let model_provider = VisionModelProvider {
6907            calls: Arc::clone(&calls),
6908        };
6909
6910        let mut history = vec![ChatMessage::user(
6911            "Analyze this [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
6912        )];
6913        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6914        let observer = NoopObserver;
6915
6916        let result = run_tool_call_loop(
6917            &model_provider,
6918            &mut history,
6919            &tools_registry,
6920            &observer,
6921            "mock-provider",
6922            "mock-model",
6923            Some(0.0),
6924            true,
6925            None,
6926            "cli",
6927            None,
6928            &zeroclaw_config::schema::MultimodalConfig::default(),
6929            3,
6930            None,
6931            None,
6932            None,
6933            &[],
6934            &[],
6935            None,
6936            None,
6937            &zeroclaw_config::schema::PacingConfig::default(),
6938            false,
6939            false, // parallel_tools
6940            0,
6941            0,
6942            None,
6943            None, // channel
6944            None, // receipt_generator
6945            None, // collected_receipts
6946        )
6947        .await
6948        .expect("valid multimodal payload should pass");
6949
6950        assert_eq!(result, "vision-ok");
6951        assert_eq!(calls.load(Ordering::SeqCst), 1);
6952    }
6953
6954    /// A **tool-result** image marker (e.g. from `image_info`/`screenshot`)
6955    /// on a non-vision provider with no `vision_model_provider` must NOT abort
6956    /// the turn. The user did not send an image, so the loop degrades
6957    /// gracefully: markers are stripped and the text-only provider is still
6958    /// called so the conversation continues (and any accompanying
6959    /// text/metadata survives).
6960    #[tokio::test]
6961    async fn run_tool_call_loop_degrades_tool_result_image_for_non_vision_provider() {
6962        let calls = Arc::new(AtomicUsize::new(0));
6963        let model_provider = NonVisionModelProvider {
6964            calls: Arc::clone(&calls),
6965        };
6966
6967        // Marker lives in a tool result, not a user message.
6968        let mut history = vec![
6969            ChatMessage::user("inspect the screenshot".to_string()),
6970            ChatMessage::tool(
6971                "File: /tmp/x.png\n[IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
6972            ),
6973        ];
6974        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6975        let observer = NoopObserver;
6976
6977        let result = run_tool_call_loop(
6978            &model_provider,
6979            &mut history,
6980            &tools_registry,
6981            &observer,
6982            "mock-provider",
6983            "mock-model",
6984            Some(0.0),
6985            true,
6986            None,
6987            "cli",
6988            None,
6989            &zeroclaw_config::schema::MultimodalConfig::default(),
6990            3,
6991            None,
6992            None,
6993            None,
6994            &[],
6995            &[],
6996            None,
6997            None,
6998            &zeroclaw_config::schema::PacingConfig::default(),
6999            false,
7000            false, // parallel_tools
7001            0,
7002            0,
7003            None,
7004            None, // channel
7005            None, // receipt_generator
7006            None, // collected_receipts
7007        )
7008        .await
7009        .expect("text-only fallback should succeed, not abort the turn");
7010
7011        // Provider was invoked (no hard capability error) and returned text.
7012        assert_eq!(result, "ok");
7013        assert_eq!(calls.load(Ordering::SeqCst), 1);
7014    }
7015
7016    /// When `vision_model_provider` is set but the model_provider factory cannot resolve
7017    /// the name, a descriptive error should be returned (not the generic
7018    /// capability error).
7019    #[tokio::test]
7020    async fn run_tool_call_loop_vision_provider_creation_failure() {
7021        let calls = Arc::new(AtomicUsize::new(0));
7022        let model_provider = NonVisionModelProvider {
7023            calls: Arc::clone(&calls),
7024        };
7025
7026        let mut history = vec![ChatMessage::user(
7027            "inspect [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
7028        )];
7029        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
7030        let observer = NoopObserver;
7031
7032        let multimodal = zeroclaw_config::schema::MultimodalConfig {
7033            vision_model_provider: Some("nonexistent-provider-xyz".to_string()),
7034            vision_model: Some("some-model".to_string()),
7035            ..Default::default()
7036        };
7037
7038        let err = run_tool_call_loop(
7039            &model_provider,
7040            &mut history,
7041            &tools_registry,
7042            &observer,
7043            "mock-provider",
7044            "mock-model",
7045            Some(0.0),
7046            true,
7047            None,
7048            "cli",
7049            None,
7050            &multimodal,
7051            3,
7052            None,
7053            None,
7054            None,
7055            &[],
7056            &[],
7057            None,
7058            None,
7059            &zeroclaw_config::schema::PacingConfig::default(),
7060            false,
7061            false, // parallel_tools
7062            0,
7063            0,
7064            None,
7065            None, // channel
7066            None, // receipt_generator
7067            None, // collected_receipts
7068        )
7069        .await
7070        .expect_err("should fail when vision model_provider cannot be created");
7071
7072        assert!(
7073            err.to_string()
7074                .contains("failed to create vision model_provider"),
7075            "expected creation failure error, got: {}",
7076            err
7077        );
7078        assert_eq!(calls.load(Ordering::SeqCst), 0);
7079    }
7080
7081    /// Messages without image markers should use the default model_provider even
7082    /// when `vision_model_provider` is configured.
7083    #[tokio::test]
7084    async fn run_tool_call_loop_no_images_uses_default_provider() {
7085        let model_provider = ScriptedModelProvider::from_text_responses(vec!["hello world"]);
7086
7087        let mut history = vec![ChatMessage::user("just text, no images".to_string())];
7088        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
7089        let observer = NoopObserver;
7090
7091        let multimodal = zeroclaw_config::schema::MultimodalConfig {
7092            vision_model_provider: Some("nonexistent-provider-xyz".to_string()),
7093            vision_model: Some("some-model".to_string()),
7094            ..Default::default()
7095        };
7096
7097        // Even though vision_model_provider points to a nonexistent model_provider, this
7098        // should succeed because there are no image markers to trigger routing.
7099        let result = run_tool_call_loop(
7100            &model_provider,
7101            &mut history,
7102            &tools_registry,
7103            &observer,
7104            "scripted",
7105            "scripted-model",
7106            Some(0.0),
7107            true,
7108            None,
7109            "cli",
7110            None,
7111            &multimodal,
7112            3,
7113            None,
7114            None,
7115            None,
7116            &[],
7117            &[],
7118            None,
7119            None,
7120            &zeroclaw_config::schema::PacingConfig::default(),
7121            false,
7122            false, // parallel_tools
7123            0,
7124            0,
7125            None,
7126            None, // channel
7127            None, // receipt_generator
7128            None, // collected_receipts
7129        )
7130        .await
7131        .expect("text-only messages should succeed with default model_provider");
7132
7133        assert_eq!(result, "hello world");
7134    }
7135
7136    /// When `vision_model_provider` is set but `vision_model` is not, the default
7137    /// model should be used as fallback for the vision model_provider.
7138    #[tokio::test]
7139    async fn run_tool_call_loop_vision_provider_without_model_falls_back() {
7140        let calls = Arc::new(AtomicUsize::new(0));
7141        let model_provider = NonVisionModelProvider {
7142            calls: Arc::clone(&calls),
7143        };
7144
7145        let mut history = vec![ChatMessage::user(
7146            "look [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
7147        )];
7148        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
7149        let observer = NoopObserver;
7150
7151        // vision_model_provider set but vision_model is None — the code should
7152        // fall back to the default model. Since the model_provider name is invalid,
7153        // we just verify the error path references the correct model_provider.
7154        let multimodal = zeroclaw_config::schema::MultimodalConfig {
7155            vision_model_provider: Some("nonexistent-provider-xyz".to_string()),
7156            vision_model: None,
7157            ..Default::default()
7158        };
7159
7160        let err = run_tool_call_loop(
7161            &model_provider,
7162            &mut history,
7163            &tools_registry,
7164            &observer,
7165            "mock-provider",
7166            "mock-model",
7167            Some(0.0),
7168            true,
7169            None,
7170            "cli",
7171            None,
7172            &multimodal,
7173            3,
7174            None,
7175            None,
7176            None,
7177            &[],
7178            &[],
7179            None,
7180            None,
7181            &zeroclaw_config::schema::PacingConfig::default(),
7182            false,
7183            false, // parallel_tools
7184            0,
7185            0,
7186            None,
7187            None, // channel
7188            None, // receipt_generator
7189            None, // collected_receipts
7190        )
7191        .await
7192        .expect_err("should fail due to nonexistent vision model_provider");
7193
7194        // Verify the routing was attempted (not the generic capability error).
7195        assert!(
7196            err.to_string()
7197                .contains("failed to create vision model_provider"),
7198            "expected creation failure, got: {}",
7199            err
7200        );
7201    }
7202
7203    /// Empty `[IMAGE:]` markers (which are preserved as literal text by the
7204    /// parser) should not trigger vision model_provider routing.
7205    #[tokio::test]
7206    async fn run_tool_call_loop_empty_image_markers_use_default_provider() {
7207        let model_provider = ScriptedModelProvider::from_text_responses(vec!["handled"]);
7208
7209        let mut history = vec![ChatMessage::user(
7210            "empty marker [IMAGE:] should be ignored".to_string(),
7211        )];
7212        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
7213        let observer = NoopObserver;
7214
7215        let multimodal = zeroclaw_config::schema::MultimodalConfig {
7216            vision_model_provider: Some("nonexistent-provider-xyz".to_string()),
7217            ..Default::default()
7218        };
7219
7220        let result = run_tool_call_loop(
7221            &model_provider,
7222            &mut history,
7223            &tools_registry,
7224            &observer,
7225            "scripted",
7226            "scripted-model",
7227            Some(0.0),
7228            true,
7229            None,
7230            "cli",
7231            None,
7232            &multimodal,
7233            3,
7234            None,
7235            None,
7236            None,
7237            &[],
7238            &[],
7239            None,
7240            None,
7241            &zeroclaw_config::schema::PacingConfig::default(),
7242            false,
7243            false, // parallel_tools
7244            0,
7245            0,
7246            None,
7247            None, // channel
7248            None, // receipt_generator
7249            None, // collected_receipts
7250        )
7251        .await
7252        .expect("empty image markers should not trigger vision routing");
7253
7254        assert_eq!(result, "handled");
7255    }
7256
7257    /// Multiple image markers should still trigger vision routing when
7258    /// vision_model_provider is configured.
7259    #[tokio::test]
7260    async fn run_tool_call_loop_multiple_images_trigger_vision_routing() {
7261        let calls = Arc::new(AtomicUsize::new(0));
7262        let model_provider = NonVisionModelProvider {
7263            calls: Arc::clone(&calls),
7264        };
7265
7266        let mut history = vec![ChatMessage::user(
7267            "two images [IMAGE:data:image/png;base64,aQ==] and [IMAGE:data:image/png;base64,bQ==]"
7268                .to_string(),
7269        )];
7270        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
7271        let observer = NoopObserver;
7272
7273        let multimodal = zeroclaw_config::schema::MultimodalConfig {
7274            vision_model_provider: Some("nonexistent-provider-xyz".to_string()),
7275            vision_model: Some("llava:7b".to_string()),
7276            ..Default::default()
7277        };
7278
7279        let err = run_tool_call_loop(
7280            &model_provider,
7281            &mut history,
7282            &tools_registry,
7283            &observer,
7284            "mock-provider",
7285            "mock-model",
7286            Some(0.0),
7287            true,
7288            None,
7289            "cli",
7290            None,
7291            &multimodal,
7292            3,
7293            None,
7294            None,
7295            None,
7296            &[],
7297            &[],
7298            None,
7299            None,
7300            &zeroclaw_config::schema::PacingConfig::default(),
7301            false,
7302            false, // parallel_tools
7303            0,
7304            0,
7305            None,
7306            None, // channel
7307            None, // receipt_generator
7308            None, // collected_receipts
7309        )
7310        .await
7311        .expect_err("should attempt vision model_provider creation for multiple images");
7312
7313        assert!(
7314            err.to_string()
7315                .contains("failed to create vision model_provider"),
7316            "expected creation failure for multiple images, got: {}",
7317            err
7318        );
7319    }
7320
7321    #[test]
7322    fn should_execute_tools_in_parallel_returns_false_for_single_call() {
7323        let calls = vec![ParsedToolCall {
7324            name: "file_read".to_string(),
7325            arguments: serde_json::json!({"path": "a.txt"}),
7326            tool_call_id: None,
7327        }];
7328
7329        assert!(!should_execute_tools_in_parallel(&calls, None));
7330    }
7331
7332    #[test]
7333    fn should_execute_tools_in_parallel_returns_false_when_approval_is_required() {
7334        let calls = vec![
7335            ParsedToolCall {
7336                name: "shell".to_string(),
7337                arguments: serde_json::json!({"command": "pwd"}),
7338                tool_call_id: None,
7339            },
7340            ParsedToolCall {
7341                name: "http_request".to_string(),
7342                arguments: serde_json::json!({"url": "https://example.com"}),
7343                tool_call_id: None,
7344            },
7345        ];
7346        let approval_cfg = zeroclaw_config::schema::RiskProfileConfig::default();
7347        let approval_mgr = ApprovalManager::from_risk_profile(&approval_cfg);
7348
7349        assert!(!should_execute_tools_in_parallel(
7350            &calls,
7351            Some(&approval_mgr)
7352        ));
7353    }
7354
7355    #[test]
7356    fn should_execute_tools_in_parallel_returns_true_when_cli_has_no_interactive_approvals() {
7357        let calls = vec![
7358            ParsedToolCall {
7359                name: "shell".to_string(),
7360                arguments: serde_json::json!({"command": "pwd"}),
7361                tool_call_id: None,
7362            },
7363            ParsedToolCall {
7364                name: "http_request".to_string(),
7365                arguments: serde_json::json!({"url": "https://example.com"}),
7366                tool_call_id: None,
7367            },
7368        ];
7369        let approval_cfg = zeroclaw_config::schema::RiskProfileConfig {
7370            level: crate::security::AutonomyLevel::Full,
7371            ..zeroclaw_config::schema::RiskProfileConfig::default()
7372        };
7373        let approval_mgr = ApprovalManager::from_risk_profile(&approval_cfg);
7374
7375        assert!(should_execute_tools_in_parallel(
7376            &calls,
7377            Some(&approval_mgr)
7378        ));
7379    }
7380
7381    #[tokio::test]
7382    async fn run_tool_call_loop_executes_multiple_tools_with_ordered_results() {
7383        let model_provider = ScriptedModelProvider::from_text_responses(vec![
7384            r#"<tool_call>
7385{"name":"delay_a","arguments":{"value":"A"}}
7386</tool_call>
7387<tool_call>
7388{"name":"delay_b","arguments":{"value":"B"}}
7389</tool_call>"#,
7390            "done",
7391        ]);
7392
7393        let active = Arc::new(AtomicUsize::new(0));
7394        let max_active = Arc::new(AtomicUsize::new(0));
7395        let tools_registry: Vec<Box<dyn Tool>> = vec![
7396            Box::new(DelayTool::new(
7397                "delay_a",
7398                200,
7399                Arc::clone(&active),
7400                Arc::clone(&max_active),
7401            )),
7402            Box::new(DelayTool::new(
7403                "delay_b",
7404                200,
7405                Arc::clone(&active),
7406                Arc::clone(&max_active),
7407            )),
7408        ];
7409
7410        let approval_cfg = zeroclaw_config::schema::RiskProfileConfig {
7411            level: crate::security::AutonomyLevel::Full,
7412            ..zeroclaw_config::schema::RiskProfileConfig::default()
7413        };
7414        let approval_mgr = ApprovalManager::from_risk_profile(&approval_cfg);
7415
7416        let mut history = vec![
7417            ChatMessage::system("test-system"),
7418            ChatMessage::user("run tool calls"),
7419        ];
7420        let observer = NoopObserver;
7421
7422        let result = run_tool_call_loop(
7423            &model_provider,
7424            &mut history,
7425            &tools_registry,
7426            &observer,
7427            "mock-provider",
7428            "mock-model",
7429            Some(0.0),
7430            true,
7431            Some(&approval_mgr),
7432            "telegram",
7433            None,
7434            &zeroclaw_config::schema::MultimodalConfig::default(),
7435            4,
7436            None,
7437            None,
7438            None,
7439            &[],
7440            &[],
7441            None,
7442            None,
7443            &zeroclaw_config::schema::PacingConfig::default(),
7444            false,
7445            false, // parallel_tools
7446            0,
7447            0,
7448            None,
7449            None, // channel
7450            None, // receipt_generator
7451            None, // collected_receipts
7452        )
7453        .await
7454        .expect("parallel execution should complete");
7455
7456        assert!(
7457            result.ends_with("done"),
7458            "result should end with 'done', got: {result}"
7459        );
7460        assert!(
7461            max_active.load(Ordering::SeqCst) >= 1,
7462            "tools should execute successfully"
7463        );
7464
7465        let tool_results_message = history
7466            .iter()
7467            .find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]"))
7468            .expect("tool results message should be present");
7469        let idx_a = tool_results_message
7470            .content
7471            .find("name=\"delay_a\"")
7472            .expect("delay_a result should be present");
7473        let idx_b = tool_results_message
7474            .content
7475            .find("name=\"delay_b\"")
7476            .expect("delay_b result should be present");
7477        assert!(
7478            idx_a < idx_b,
7479            "tool results should preserve input order for tool call mapping"
7480        );
7481    }
7482
7483    /// Regression: a native provider emitting multiple parallel tool calls
7484    /// in one turn must yield one role=tool message per call, each keyed to
7485    /// its own tool_call_id and output.
7486    #[tokio::test]
7487    async fn run_tool_call_loop_native_emits_tool_message_per_parallel_call() {
7488        let model_provider = ScriptedModelProvider::from_native_tool_calls(
7489            vec![
7490                ("call_a", "delay_a", r#"{"value":"A"}"#),
7491                ("call_b", "delay_b", r#"{"value":"B"}"#),
7492                ("call_c", "delay_c", r#"{"value":"C"}"#),
7493            ],
7494            "done",
7495        );
7496
7497        let active = Arc::new(AtomicUsize::new(0));
7498        let max_active = Arc::new(AtomicUsize::new(0));
7499        let tools_registry: Vec<Box<dyn Tool>> = vec![
7500            Box::new(DelayTool::new(
7501                "delay_a",
7502                10,
7503                Arc::clone(&active),
7504                Arc::clone(&max_active),
7505            )),
7506            Box::new(DelayTool::new(
7507                "delay_b",
7508                10,
7509                Arc::clone(&active),
7510                Arc::clone(&max_active),
7511            )),
7512            Box::new(DelayTool::new(
7513                "delay_c",
7514                10,
7515                Arc::clone(&active),
7516                Arc::clone(&max_active),
7517            )),
7518        ];
7519
7520        let approval_cfg = zeroclaw_config::schema::RiskProfileConfig {
7521            level: crate::security::AutonomyLevel::Full,
7522            ..zeroclaw_config::schema::RiskProfileConfig::default()
7523        };
7524        let approval_mgr = ApprovalManager::from_risk_profile(&approval_cfg);
7525
7526        let mut history = vec![
7527            ChatMessage::system("test-system"),
7528            ChatMessage::user("run three tool calls"),
7529        ];
7530        let observer = NoopObserver;
7531
7532        let result = run_tool_call_loop(
7533            &model_provider,
7534            &mut history,
7535            &tools_registry,
7536            &observer,
7537            "mock-provider",
7538            "mock-model",
7539            Some(0.0),
7540            true,
7541            Some(&approval_mgr),
7542            "telegram",
7543            None,
7544            &zeroclaw_config::schema::MultimodalConfig::default(),
7545            4,
7546            None,
7547            None,
7548            None,
7549            &[],
7550            &[],
7551            None,
7552            None,
7553            &zeroclaw_config::schema::PacingConfig::default(),
7554            false,
7555            true, // parallel_tools
7556            0,
7557            0,
7558            None,
7559            None,
7560            None,
7561            None,
7562        )
7563        .await
7564        .expect("native parallel execution should complete");
7565
7566        assert!(result.ends_with("done"), "got: {result}");
7567
7568        let tool_messages: Vec<&ChatMessage> =
7569            history.iter().filter(|msg| msg.role == "tool").collect();
7570        assert_eq!(
7571            tool_messages.len(),
7572            3,
7573            "every parallel native call must yield its own role=tool message, got: {tool_messages:?}"
7574        );
7575
7576        for (id, value) in [("call_a", "A"), ("call_b", "B"), ("call_c", "C")] {
7577            let msg = tool_messages
7578                .iter()
7579                .find(|m| m.content.contains(id))
7580                .unwrap_or_else(|| panic!("missing tool message for {id}: {tool_messages:?}"));
7581            assert!(
7582                msg.content.contains(&format!("ok:{value}")),
7583                "tool_call_id {id} must carry its own output ok:{value}, got: {}",
7584                msg.content
7585            );
7586        }
7587    }
7588
7589    #[tokio::test]
7590    async fn run_tool_call_loop_injects_channel_delivery_defaults_for_cron_add() {
7591        let model_provider = ScriptedModelProvider::from_text_responses(vec![
7592            r#"<tool_call>
7593{"name":"cron_add","arguments":{"job_type":"agent","prompt":"remind me later","schedule":{"kind":"every","every_ms":60000}}}
7594</tool_call>"#,
7595            "done",
7596        ]);
7597
7598        let recorded_args = Arc::new(Mutex::new(Vec::new()));
7599        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(RecordingArgsTool::new(
7600            "cron_add",
7601            Arc::clone(&recorded_args),
7602        ))];
7603
7604        let mut history = vec![
7605            ChatMessage::system("test-system"),
7606            ChatMessage::user("schedule a reminder"),
7607        ];
7608        let observer = NoopObserver;
7609
7610        let result = run_tool_call_loop(
7611            &model_provider,
7612            &mut history,
7613            &tools_registry,
7614            &observer,
7615            "mock-provider",
7616            "mock-model",
7617            Some(0.0),
7618            true,
7619            None,
7620            "telegram",
7621            Some("chat-42"),
7622            &zeroclaw_config::schema::MultimodalConfig::default(),
7623            4,
7624            None,
7625            None,
7626            None,
7627            &[],
7628            &[],
7629            None,
7630            None,
7631            &zeroclaw_config::schema::PacingConfig::default(),
7632            false,
7633            false, // parallel_tools
7634            0,
7635            0,
7636            None,
7637            None, // channel
7638            None, // receipt_generator
7639            None, // collected_receipts
7640        )
7641        .await
7642        .expect("cron_add delivery defaults should be injected");
7643
7644        assert!(
7645            result.ends_with("done"),
7646            "result should end with 'done', got: {result}"
7647        );
7648
7649        let recorded = recorded_args
7650            .lock()
7651            .expect("recorded args lock should be valid");
7652        let delivery = recorded[0]["delivery"].clone();
7653        assert_eq!(
7654            delivery,
7655            serde_json::json!({
7656                "mode": "announce",
7657                "channel": "telegram",
7658                "to": "chat-42",
7659            })
7660        );
7661    }
7662
7663    #[tokio::test]
7664    async fn run_tool_call_loop_preserves_explicit_cron_delivery_none() {
7665        let model_provider = ScriptedModelProvider::from_text_responses(vec![
7666            r#"<tool_call>
7667{"name":"cron_add","arguments":{"job_type":"agent","prompt":"run silently","schedule":{"kind":"every","every_ms":60000},"delivery":{"mode":"none"}}}
7668</tool_call>"#,
7669            "done",
7670        ]);
7671
7672        let recorded_args = Arc::new(Mutex::new(Vec::new()));
7673        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(RecordingArgsTool::new(
7674            "cron_add",
7675            Arc::clone(&recorded_args),
7676        ))];
7677
7678        let mut history = vec![
7679            ChatMessage::system("test-system"),
7680            ChatMessage::user("schedule a quiet cron job"),
7681        ];
7682        let observer = NoopObserver;
7683
7684        let result = run_tool_call_loop(
7685            &model_provider,
7686            &mut history,
7687            &tools_registry,
7688            &observer,
7689            "mock-provider",
7690            "mock-model",
7691            Some(0.0),
7692            true,
7693            None,
7694            "telegram",
7695            Some("chat-42"),
7696            &zeroclaw_config::schema::MultimodalConfig::default(),
7697            4,
7698            None,
7699            None,
7700            None,
7701            &[],
7702            &[],
7703            None,
7704            None,
7705            &zeroclaw_config::schema::PacingConfig::default(),
7706            false,
7707            false, // parallel_tools
7708            0,
7709            0,
7710            None,
7711            None, // channel
7712            None, // receipt_generator
7713            None, // collected_receipts
7714        )
7715        .await
7716        .expect("explicit delivery mode should be preserved");
7717
7718        assert!(
7719            result.ends_with("done"),
7720            "result should end with 'done', got: {result}"
7721        );
7722
7723        let recorded = recorded_args
7724            .lock()
7725            .expect("recorded args lock should be valid");
7726        assert_eq!(recorded[0]["delivery"], serde_json::json!({"mode": "none"}));
7727    }
7728
7729    #[tokio::test]
7730    async fn run_tool_call_loop_deduplicates_repeated_tool_calls() {
7731        let model_provider = ScriptedModelProvider::from_text_responses(vec![
7732            r#"<tool_call>
7733{"name":"count_tool","arguments":{"value":"A"}}
7734</tool_call>
7735<tool_call>
7736{"name":"count_tool","arguments":{"value":"A"}}
7737</tool_call>"#,
7738            "done",
7739        ]);
7740
7741        let invocations = Arc::new(AtomicUsize::new(0));
7742        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
7743            "count_tool",
7744            Arc::clone(&invocations),
7745        ))];
7746
7747        let mut history = vec![
7748            ChatMessage::system("test-system"),
7749            ChatMessage::user("run tool calls"),
7750        ];
7751        let observer = NoopObserver;
7752
7753        let result = run_tool_call_loop(
7754            &model_provider,
7755            &mut history,
7756            &tools_registry,
7757            &observer,
7758            "mock-provider",
7759            "mock-model",
7760            Some(0.0),
7761            true,
7762            None,
7763            "cli",
7764            None,
7765            &zeroclaw_config::schema::MultimodalConfig::default(),
7766            4,
7767            None,
7768            None,
7769            None,
7770            &[],
7771            &[],
7772            None,
7773            None,
7774            &zeroclaw_config::schema::PacingConfig::default(),
7775            false,
7776            false, // parallel_tools
7777            0,
7778            0,
7779            None,
7780            None, // channel
7781            None, // receipt_generator
7782            None, // collected_receipts
7783        )
7784        .await
7785        .expect("loop should finish after deduplicating repeated calls");
7786
7787        assert!(
7788            result.ends_with("done"),
7789            "result should end with 'done', got: {result}"
7790        );
7791        assert_eq!(
7792            invocations.load(Ordering::SeqCst),
7793            1,
7794            "duplicate tool call with same args should not execute twice"
7795        );
7796
7797        let tool_results = history
7798            .iter()
7799            .find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]"))
7800            .expect("prompt-mode tool result payload should be present");
7801        assert!(tool_results.content.contains("counted:A"));
7802        assert!(tool_results.content.contains("Skipped duplicate tool call"));
7803    }
7804
7805    #[tokio::test]
7806    async fn run_tool_call_loop_allows_low_risk_shell_in_non_interactive_mode() {
7807        let model_provider = ScriptedModelProvider::from_text_responses(vec![
7808            r#"<tool_call>
7809{"name":"shell","arguments":{"command":"echo hello"}}
7810</tool_call>"#,
7811            "done",
7812        ]);
7813
7814        let tmp = TempDir::new().expect("temp dir");
7815        let security = Arc::new(crate::security::SecurityPolicy {
7816            autonomy: crate::security::AutonomyLevel::Supervised,
7817            workspace_dir: tmp.path().to_path_buf(),
7818            ..crate::security::SecurityPolicy::default()
7819        });
7820        let runtime: Arc<dyn crate::platform::RuntimeAdapter> =
7821            Arc::new(crate::platform::NativeRuntime::new());
7822        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(
7823            crate::tools::shell::ShellTool::new(security, runtime),
7824        )];
7825
7826        let mut history = vec![
7827            ChatMessage::system("test-system"),
7828            ChatMessage::user("run shell"),
7829        ];
7830        let observer = NoopObserver;
7831        let approval_mgr = ApprovalManager::for_non_interactive(
7832            &zeroclaw_config::schema::RiskProfileConfig::default(),
7833        );
7834
7835        let result = run_tool_call_loop(
7836            &model_provider,
7837            &mut history,
7838            &tools_registry,
7839            &observer,
7840            "mock-provider",
7841            "mock-model",
7842            Some(0.0),
7843            true,
7844            Some(&approval_mgr),
7845            "telegram",
7846            None,
7847            &zeroclaw_config::schema::MultimodalConfig::default(),
7848            4,
7849            None,
7850            None,
7851            None,
7852            &[],
7853            &[],
7854            None,
7855            None,
7856            &zeroclaw_config::schema::PacingConfig::default(),
7857            false,
7858            false, // parallel_tools
7859            0,
7860            0,
7861            None,
7862            None, // channel
7863            None, // receipt_generator
7864            None, // collected_receipts
7865        )
7866        .await
7867        .expect("non-interactive shell should succeed for low-risk command");
7868
7869        assert!(
7870            result.ends_with("done"),
7871            "result should end with 'done', got: {result}"
7872        );
7873
7874        let tool_results = history
7875            .iter()
7876            .find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]"))
7877            .expect("tool results message should be present");
7878        assert!(tool_results.content.contains("hello"));
7879        assert!(!tool_results.content.contains("Denied by user."));
7880    }
7881
7882    #[tokio::test]
7883    async fn run_tool_call_loop_dedup_exempt_allows_repeated_calls() {
7884        let model_provider = ScriptedModelProvider::from_text_responses(vec![
7885            r#"<tool_call>
7886{"name":"count_tool","arguments":{"value":"A"}}
7887</tool_call>
7888<tool_call>
7889{"name":"count_tool","arguments":{"value":"A"}}
7890</tool_call>"#,
7891            "done",
7892        ]);
7893
7894        let invocations = Arc::new(AtomicUsize::new(0));
7895        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
7896            "count_tool",
7897            Arc::clone(&invocations),
7898        ))];
7899
7900        let mut history = vec![
7901            ChatMessage::system("test-system"),
7902            ChatMessage::user("run tool calls"),
7903        ];
7904        let observer = NoopObserver;
7905        let exempt = vec!["count_tool".to_string()];
7906
7907        let result = run_tool_call_loop(
7908            &model_provider,
7909            &mut history,
7910            &tools_registry,
7911            &observer,
7912            "mock-provider",
7913            "mock-model",
7914            Some(0.0),
7915            true,
7916            None,
7917            "cli",
7918            None,
7919            &zeroclaw_config::schema::MultimodalConfig::default(),
7920            4,
7921            None,
7922            None,
7923            None,
7924            &[],
7925            &exempt,
7926            None,
7927            None,
7928            &zeroclaw_config::schema::PacingConfig::default(),
7929            false,
7930            false, // parallel_tools
7931            0,
7932            0,
7933            None,
7934            None, // channel
7935            None, // receipt_generator
7936            None, // collected_receipts
7937        )
7938        .await
7939        .expect("loop should finish with exempt tool executing twice");
7940
7941        assert!(
7942            result.ends_with("done"),
7943            "result should end with 'done', got: {result}"
7944        );
7945        assert_eq!(
7946            invocations.load(Ordering::SeqCst),
7947            2,
7948            "exempt tool should execute both duplicate calls"
7949        );
7950
7951        let tool_results = history
7952            .iter()
7953            .find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]"))
7954            .expect("prompt-mode tool result payload should be present");
7955        assert!(
7956            !tool_results.content.contains("Skipped duplicate tool call"),
7957            "exempt tool calls should not be suppressed"
7958        );
7959    }
7960
7961    /// Identical-prompt calls to re-entrant agent tools (spawn_subagent /
7962    /// delegate) must both run even with no config exemption — fan-out is
7963    /// intentional, not a duplicate to collapse.
7964    #[tokio::test]
7965    async fn run_tool_call_loop_reentrant_agent_tools_are_dedup_exempt_by_default() {
7966        let model_provider = ScriptedModelProvider::from_text_responses(vec![
7967            r#"<tool_call>
7968{"name":"spawn_subagent","arguments":{"prompt":"same"}}
7969</tool_call>
7970<tool_call>
7971{"name":"spawn_subagent","arguments":{"prompt":"same"}}
7972</tool_call>"#,
7973            "done",
7974        ]);
7975
7976        let invocations = Arc::new(AtomicUsize::new(0));
7977        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
7978            "spawn_subagent",
7979            Arc::clone(&invocations),
7980        ))];
7981
7982        let mut history = vec![
7983            ChatMessage::system("test-system"),
7984            ChatMessage::user("fan out two identical subagents"),
7985        ];
7986        let observer = NoopObserver;
7987
7988        let result = run_tool_call_loop(
7989            &model_provider,
7990            &mut history,
7991            &tools_registry,
7992            &observer,
7993            "mock-provider",
7994            "mock-model",
7995            Some(0.0),
7996            true,
7997            None,
7998            "cli",
7999            None,
8000            &zeroclaw_config::schema::MultimodalConfig::default(),
8001            4,
8002            None,
8003            None,
8004            None,
8005            &[],
8006            &[], // no config-provided dedup exemptions
8007            None,
8008            None,
8009            &zeroclaw_config::schema::PacingConfig::default(),
8010            false,
8011            false,
8012            0,
8013            0,
8014            None,
8015            None,
8016            None,
8017            None,
8018        )
8019        .await
8020        .expect("loop should finish running both identical subagent calls");
8021
8022        assert!(result.ends_with("done"), "got: {result}");
8023        assert_eq!(
8024            invocations.load(Ordering::SeqCst),
8025            2,
8026            "both identical spawn_subagent calls must execute"
8027        );
8028    }
8029
8030    #[tokio::test]
8031    async fn run_tool_call_loop_dedup_exempt_only_affects_listed_tools() {
8032        let model_provider = ScriptedModelProvider::from_text_responses(vec![
8033            r#"<tool_call>
8034{"name":"count_tool","arguments":{"value":"A"}}
8035</tool_call>
8036<tool_call>
8037{"name":"count_tool","arguments":{"value":"A"}}
8038</tool_call>
8039<tool_call>
8040{"name":"other_tool","arguments":{"value":"B"}}
8041</tool_call>
8042<tool_call>
8043{"name":"other_tool","arguments":{"value":"B"}}
8044</tool_call>"#,
8045            "done",
8046        ]);
8047
8048        let count_invocations = Arc::new(AtomicUsize::new(0));
8049        let other_invocations = Arc::new(AtomicUsize::new(0));
8050        let tools_registry: Vec<Box<dyn Tool>> = vec![
8051            Box::new(CountingTool::new(
8052                "count_tool",
8053                Arc::clone(&count_invocations),
8054            )),
8055            Box::new(CountingTool::new(
8056                "other_tool",
8057                Arc::clone(&other_invocations),
8058            )),
8059        ];
8060
8061        let mut history = vec![
8062            ChatMessage::system("test-system"),
8063            ChatMessage::user("run tool calls"),
8064        ];
8065        let observer = NoopObserver;
8066        let exempt = vec!["count_tool".to_string()];
8067
8068        let _result = run_tool_call_loop(
8069            &model_provider,
8070            &mut history,
8071            &tools_registry,
8072            &observer,
8073            "mock-provider",
8074            "mock-model",
8075            Some(0.0),
8076            true,
8077            None,
8078            "cli",
8079            None,
8080            &zeroclaw_config::schema::MultimodalConfig::default(),
8081            4,
8082            None,
8083            None,
8084            None,
8085            &[],
8086            &exempt,
8087            None,
8088            None,
8089            &zeroclaw_config::schema::PacingConfig::default(),
8090            false,
8091            false, // parallel_tools
8092            0,
8093            0,
8094            None,
8095            None, // channel
8096            None, // receipt_generator
8097            None, // collected_receipts
8098        )
8099        .await
8100        .expect("loop should complete");
8101
8102        assert_eq!(
8103            count_invocations.load(Ordering::SeqCst),
8104            2,
8105            "exempt tool should execute both calls"
8106        );
8107        assert_eq!(
8108            other_invocations.load(Ordering::SeqCst),
8109            1,
8110            "non-exempt tool should still be deduped"
8111        );
8112    }
8113
8114    #[tokio::test]
8115    async fn run_tool_call_loop_native_mode_preserves_fallback_tool_call_ids() {
8116        let model_provider = ScriptedModelProvider::from_text_responses(vec![
8117            r#"{"content":"Need to call tool","tool_calls":[{"id":"call_abc","name":"count_tool","arguments":"{\"value\":\"X\"}"}]}"#,
8118            "done",
8119        ])
8120        .with_native_tool_support();
8121
8122        let invocations = Arc::new(AtomicUsize::new(0));
8123        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
8124            "count_tool",
8125            Arc::clone(&invocations),
8126        ))];
8127
8128        let mut history = vec![
8129            ChatMessage::system("test-system"),
8130            ChatMessage::user("run tool calls"),
8131        ];
8132        let observer = NoopObserver;
8133
8134        let result = run_tool_call_loop(
8135            &model_provider,
8136            &mut history,
8137            &tools_registry,
8138            &observer,
8139            "mock-provider",
8140            "mock-model",
8141            Some(0.0),
8142            true,
8143            None,
8144            "cli",
8145            None,
8146            &zeroclaw_config::schema::MultimodalConfig::default(),
8147            4,
8148            None,
8149            None,
8150            None,
8151            &[],
8152            &[],
8153            None,
8154            None,
8155            &zeroclaw_config::schema::PacingConfig::default(),
8156            false,
8157            false, // parallel_tools
8158            0,
8159            0,
8160            None,
8161            None, // channel
8162            None, // receipt_generator
8163            None, // collected_receipts
8164        )
8165        .await
8166        .expect("native fallback id flow should complete");
8167
8168        assert!(
8169            result.ends_with("done"),
8170            "result should end with 'done', got: {result}"
8171        );
8172        assert_eq!(invocations.load(Ordering::SeqCst), 1);
8173        assert!(
8174            history.iter().any(|msg| {
8175                msg.role == "tool" && msg.content.contains("\"tool_call_id\":\"call_abc\"")
8176            }),
8177            "tool result should preserve parsed fallback tool_call_id in native mode"
8178        );
8179        assert!(
8180            history
8181                .iter()
8182                .all(|msg| !(msg.role == "user" && msg.content.starts_with("[Tool results]"))),
8183            "native mode should use role=tool history instead of prompt fallback wrapper"
8184        );
8185    }
8186
8187    #[tokio::test]
8188    async fn run_tool_call_loop_retries_malformed_tool_protocol_without_leaking_json() {
8189        let provider = ScriptedModelProvider::from_text_responses(vec![
8190            r#"{"toolcalls":[{"name":"count_tool","arguments":{"value":"X"}}]}"#,
8191            "Recovered answer.",
8192        ]);
8193        let invocations = Arc::new(AtomicUsize::new(0));
8194        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
8195            "count_tool",
8196            Arc::clone(&invocations),
8197        ))];
8198        let mut history = vec![
8199            ChatMessage::system("test-system"),
8200            ChatMessage::user("run tool calls"),
8201        ];
8202        let observer = NoopObserver;
8203
8204        let result = run_tool_call_loop(
8205            &provider,
8206            &mut history,
8207            &tools_registry,
8208            &observer,
8209            "mock-provider",
8210            "mock-model",
8211            Some(0.0),
8212            true,
8213            None,
8214            "matrix",
8215            None,
8216            &zeroclaw_config::schema::MultimodalConfig::default(),
8217            4,
8218            None,
8219            None,
8220            None,
8221            &[],
8222            &[],
8223            None,
8224            None,
8225            &zeroclaw_config::schema::PacingConfig::default(),
8226            false,
8227            false, // parallel_tools
8228            0,
8229            0,
8230            None,
8231            None, // channel
8232            None, // receipt_generator
8233            None, // collected_receipts
8234        )
8235        .await
8236        .expect("malformed tool protocol should retry and recover");
8237
8238        assert_eq!(result, "Recovered answer.");
8239        assert!(!result.contains("toolcalls"));
8240        assert_eq!(
8241            invocations.load(Ordering::SeqCst),
8242            0,
8243            "malformed alias payload should not execute as a tool call"
8244        );
8245        assert!(
8246            history
8247                .iter()
8248                .any(|msg| msg.role == "user" && msg.content.contains("[Tool call parse error]")),
8249            "history should include internal parser feedback for the model"
8250        );
8251    }
8252
8253    #[tokio::test]
8254    async fn run_tool_call_loop_preserves_unknown_function_call_json_with_tools() {
8255        let business_json =
8256            r#"{"type":"function_call","name":"support_case","arguments":{"id":"A1"}}"#;
8257        let provider = ScriptedModelProvider::from_text_responses(vec![business_json]);
8258        let invocations = Arc::new(AtomicUsize::new(0));
8259        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
8260            "count_tool",
8261            Arc::clone(&invocations),
8262        ))];
8263        let mut history = vec![
8264            ChatMessage::system("test-system"),
8265            ChatMessage::user("return a support case JSON object"),
8266        ];
8267        let observer = NoopObserver;
8268
8269        let result = run_tool_call_loop(
8270            &provider,
8271            &mut history,
8272            &tools_registry,
8273            &observer,
8274            "mock-provider",
8275            "mock-model",
8276            Some(0.0),
8277            true,
8278            None,
8279            "matrix",
8280            None,
8281            &zeroclaw_config::schema::MultimodalConfig::default(),
8282            4,
8283            None,
8284            None,
8285            None,
8286            &[],
8287            &[],
8288            None,
8289            None,
8290            &zeroclaw_config::schema::PacingConfig::default(),
8291            false,
8292            false, // parallel_tools
8293            0,
8294            0,
8295            None,
8296            None, // channel
8297            None, // receipt_generator
8298            None, // collected_receipts
8299        )
8300        .await
8301        .expect("business JSON should be returned as normal text");
8302
8303        assert_eq!(result, business_json);
8304        assert_eq!(
8305            invocations.load(Ordering::SeqCst),
8306            0,
8307            "business JSON must not execute any runtime tool"
8308        );
8309        assert!(
8310            history
8311                .iter()
8312                .all(|msg| !msg.content.contains("[Tool call parse error]")),
8313            "business JSON must not trigger internal parser feedback"
8314        );
8315    }
8316
8317    #[tokio::test]
8318    async fn run_tool_call_loop_preserves_malformed_unknown_tool_calls_json_with_tools() {
8319        let business_json = r#"{"tool_calls":[{"name":"support_case","arguments":{"id":"A1"}}"#;
8320        let provider = ScriptedModelProvider::from_text_responses(vec![business_json]);
8321        let invocations = Arc::new(AtomicUsize::new(0));
8322        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
8323            "count_tool",
8324            Arc::clone(&invocations),
8325        ))];
8326        let mut history = vec![
8327            ChatMessage::system("test-system"),
8328            ChatMessage::user("return a partial support case JSON object"),
8329        ];
8330        let observer = NoopObserver;
8331
8332        let result = run_tool_call_loop(
8333            &provider,
8334            &mut history,
8335            &tools_registry,
8336            &observer,
8337            "mock-provider",
8338            "mock-model",
8339            Some(0.0),
8340            true,
8341            None,
8342            "matrix",
8343            None,
8344            &zeroclaw_config::schema::MultimodalConfig::default(),
8345            4,
8346            None,
8347            None,
8348            None,
8349            &[],
8350            &[],
8351            None,
8352            None,
8353            &zeroclaw_config::schema::PacingConfig::default(),
8354            false,
8355            false, // parallel_tools
8356            0,
8357            0,
8358            None,
8359            None, // channel
8360            None, // receipt_generator
8361            None, // collected_receipts
8362        )
8363        .await
8364        .expect("unknown business JSON should be returned as normal text");
8365
8366        assert_eq!(result, business_json);
8367        assert_eq!(
8368            invocations.load(Ordering::SeqCst),
8369            0,
8370            "business JSON must not execute any runtime tool"
8371        );
8372        assert!(
8373            history
8374                .iter()
8375                .all(|msg| !msg.content.contains("[Tool call parse error]")),
8376            "business JSON must not trigger internal parser feedback"
8377        );
8378    }
8379
8380    #[tokio::test]
8381    async fn run_tool_call_loop_falls_back_after_repeated_malformed_tool_protocol() {
8382        let provider = ScriptedModelProvider::from_text_responses(vec![
8383            r#"{"toolcalls":[{"call_id":"call_1","arguments":{"value":"X"}}]}"#,
8384            r#"{"toolcalls":[{"call_id":"call_2","arguments":{"value":"Y"}}]}"#,
8385            r#"{"toolcalls":[{"call_id":"call_3","arguments":{"value":"Z"}}]}"#,
8386        ]);
8387        let invocations = Arc::new(AtomicUsize::new(0));
8388        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
8389            "count_tool",
8390            Arc::clone(&invocations),
8391        ))];
8392        let mut history = vec![
8393            ChatMessage::system("test-system"),
8394            ChatMessage::user("run tool calls"),
8395        ];
8396        let observer = NoopObserver;
8397
8398        let result = run_tool_call_loop(
8399            &provider,
8400            &mut history,
8401            &tools_registry,
8402            &observer,
8403            "mock-provider",
8404            "mock-model",
8405            Some(0.0),
8406            true,
8407            None,
8408            "matrix",
8409            None,
8410            &zeroclaw_config::schema::MultimodalConfig::default(),
8411            6,
8412            None,
8413            None,
8414            None,
8415            &[],
8416            &[],
8417            None,
8418            None,
8419            &zeroclaw_config::schema::PacingConfig::default(),
8420            false,
8421            false, // parallel_tools
8422            0,
8423            0,
8424            None,
8425            None, // channel
8426            None, // receipt_generator
8427            None, // collected_receipts
8428        )
8429        .await
8430        .expect("malformed tool protocol should return a safe fallback");
8431
8432        assert_eq!(
8433            result,
8434            crate::i18n::get_required_cli_string("channel-runtime-malformed-tool-output")
8435        );
8436        assert!(!result.contains("toolcalls"));
8437        assert_eq!(
8438            invocations.load(Ordering::SeqCst),
8439            0,
8440            "malformed protocol should never be executed as a tool call"
8441        );
8442        let feedback_count = history
8443            .iter()
8444            .filter(|msg| msg.role == "user" && msg.content.contains("[Tool call parse error]"))
8445            .count();
8446        assert_eq!(feedback_count, MAX_MALFORMED_TOOL_PROTOCOL_RETRIES);
8447    }
8448
8449    #[tokio::test]
8450    async fn run_tool_call_loop_streams_toolcalls_reference_json_when_no_tools_are_enabled() {
8451        let reference_json = r#"{"toolcalls":[{"name":"count_tool","arguments":{"value":"X"}}]}"#;
8452        let provider = StreamingScriptedModelProvider::from_text_responses(vec![reference_json]);
8453        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
8454        let mut history = vec![
8455            ChatMessage::system("test-system"),
8456            ChatMessage::user("return a toolcalls reference JSON object"),
8457        ];
8458        let observer = NoopObserver;
8459        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(16);
8460
8461        let result = run_tool_call_loop(
8462            &provider,
8463            &mut history,
8464            &tools_registry,
8465            &observer,
8466            "mock-provider",
8467            "mock-model",
8468            Some(0.0),
8469            true,
8470            None,
8471            "matrix",
8472            None,
8473            &zeroclaw_config::schema::MultimodalConfig::default(),
8474            4,
8475            None,
8476            Some(tx),
8477            None,
8478            &[],
8479            &[],
8480            None,
8481            None,
8482            &zeroclaw_config::schema::PacingConfig::default(),
8483            false,
8484            false, // parallel_tools
8485            0,
8486            0,
8487            None,
8488            None, // channel
8489            None, // receipt_generator
8490            None, // collected_receipts
8491        )
8492        .await
8493        .expect("toolcalls reference JSON should remain visible without tools");
8494
8495        let mut visible_deltas = String::new();
8496        while let Some(delta) = rx.recv().await {
8497            if let StreamDelta::Text(text) = delta {
8498                visible_deltas.push_str(&text);
8499            }
8500        }
8501
8502        assert_eq!(result, reference_json);
8503        assert_eq!(visible_deltas, reference_json);
8504        assert!(
8505            history
8506                .iter()
8507                .all(|msg| !msg.content.contains("[Tool call parse error]")),
8508            "toolcalls reference JSON must not trigger internal parser feedback"
8509        );
8510    }
8511
8512    #[tokio::test]
8513    async fn run_tool_call_loop_returns_toolcalls_reference_json_when_no_tools_are_enabled() {
8514        let reference_json = r#"{"toolcalls":[{"name":"count_tool","arguments":{"value":"X"}}]}"#;
8515        let provider = ScriptedModelProvider::from_text_responses(vec![reference_json]);
8516        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
8517        let mut history = vec![
8518            ChatMessage::system("test-system"),
8519            ChatMessage::user("return a toolcalls reference JSON object"),
8520        ];
8521        let observer = NoopObserver;
8522
8523        let result = run_tool_call_loop(
8524            &provider,
8525            &mut history,
8526            &tools_registry,
8527            &observer,
8528            "mock-provider",
8529            "mock-model",
8530            Some(0.0),
8531            true,
8532            None,
8533            "cli",
8534            None,
8535            &zeroclaw_config::schema::MultimodalConfig::default(),
8536            4,
8537            None,
8538            None,
8539            None,
8540            &[],
8541            &[],
8542            None,
8543            None,
8544            &zeroclaw_config::schema::PacingConfig::default(),
8545            false,
8546            false, // parallel_tools
8547            0,
8548            0,
8549            None,
8550            None, // channel
8551            None, // receipt_generator
8552            None, // collected_receipts
8553        )
8554        .await
8555        .expect("toolcalls reference JSON should remain visible without tools");
8556
8557        assert_eq!(result, reference_json);
8558        assert!(
8559            history
8560                .iter()
8561                .all(|msg| !msg.content.contains("[Tool call parse error]")),
8562            "toolcalls reference JSON must not trigger internal parser feedback"
8563        );
8564    }
8565
8566    #[tokio::test]
8567    async fn run_tool_call_loop_returns_schema_json_array_when_no_tools_are_enabled() {
8568        let schema = r#"[{"name":"planner","parameters":{"goal":"string"}}]"#;
8569        let provider = ScriptedModelProvider::from_text_responses(vec![schema]);
8570        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
8571        let mut history = vec![
8572            ChatMessage::system("test-system"),
8573            ChatMessage::user("return a JSON schema array"),
8574        ];
8575        let observer = NoopObserver;
8576
8577        let result = run_tool_call_loop(
8578            &provider,
8579            &mut history,
8580            &tools_registry,
8581            &observer,
8582            "mock-provider",
8583            "mock-model",
8584            Some(0.0),
8585            true,
8586            None,
8587            "cli",
8588            None,
8589            &zeroclaw_config::schema::MultimodalConfig::default(),
8590            4,
8591            None,
8592            None,
8593            None,
8594            &[],
8595            &[],
8596            None,
8597            None,
8598            &zeroclaw_config::schema::PacingConfig::default(),
8599            false,
8600            false, // parallel_tools
8601            0,
8602            0,
8603            None,
8604            None, // channel
8605            None, // receipt_generator
8606            None, // collected_receipts
8607        )
8608        .await
8609        .expect("schema JSON should remain visible without tools");
8610
8611        assert_eq!(result, schema);
8612        assert!(
8613            history
8614                .iter()
8615                .all(|msg| !msg.content.contains("[Tool call parse error]")),
8616            "plain schema JSON must not trigger internal parser feedback"
8617        );
8618    }
8619
8620    #[tokio::test]
8621    async fn run_tool_call_loop_returns_tool_calls_audit_json_when_no_tools_are_enabled() {
8622        let audit_json =
8623            r#"{"tool_calls":[{"id":"case-1","status":"queued","service":"billing"}]}"#;
8624        let provider = ScriptedModelProvider::from_text_responses(vec![audit_json]);
8625        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
8626        let mut history = vec![
8627            ChatMessage::system("test-system"),
8628            ChatMessage::user("return a tool call audit JSON object"),
8629        ];
8630        let observer = NoopObserver;
8631
8632        let result = run_tool_call_loop(
8633            &provider,
8634            &mut history,
8635            &tools_registry,
8636            &observer,
8637            "mock-provider",
8638            "mock-model",
8639            Some(0.0),
8640            true,
8641            None,
8642            "cli",
8643            None,
8644            &zeroclaw_config::schema::MultimodalConfig::default(),
8645            4,
8646            None,
8647            None,
8648            None,
8649            &[],
8650            &[],
8651            None,
8652            None,
8653            &zeroclaw_config::schema::PacingConfig::default(),
8654            false,
8655            false, // parallel_tools
8656            0,
8657            0,
8658            None,
8659            None, // channel
8660            None, // receipt_generator
8661            None, // collected_receipts
8662        )
8663        .await
8664        .expect("audit JSON should remain visible without tools");
8665
8666        assert_eq!(result, audit_json);
8667        assert!(
8668            history
8669                .iter()
8670                .all(|msg| !msg.content.contains("[Tool call parse error]")),
8671            "business tool_calls JSON must not trigger internal parser feedback"
8672        );
8673    }
8674
8675    #[tokio::test]
8676    async fn run_tool_call_loop_returns_function_call_reference_json_when_no_tools_are_enabled() {
8677        let reference_json =
8678            r#"{"type":"function_call","name":"support_case","arguments":{"id":"A1"}}"#;
8679        let provider = ScriptedModelProvider::from_text_responses(vec![reference_json]);
8680        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
8681        let mut history = vec![
8682            ChatMessage::system("test-system"),
8683            ChatMessage::user("return a function_call reference JSON object"),
8684        ];
8685        let observer = NoopObserver;
8686
8687        let result = run_tool_call_loop(
8688            &provider,
8689            &mut history,
8690            &tools_registry,
8691            &observer,
8692            "mock-provider",
8693            "mock-model",
8694            Some(0.0),
8695            true,
8696            None,
8697            "cli",
8698            None,
8699            &zeroclaw_config::schema::MultimodalConfig::default(),
8700            4,
8701            None,
8702            None,
8703            None,
8704            &[],
8705            &[],
8706            None,
8707            None,
8708            &zeroclaw_config::schema::PacingConfig::default(),
8709            false,
8710            false, // parallel_tools
8711            0,
8712            0,
8713            None,
8714            None, // channel
8715            None, // receipt_generator
8716            None, // collected_receipts
8717        )
8718        .await
8719        .expect("reference JSON should remain visible without tools");
8720
8721        assert_eq!(result, reference_json);
8722        assert!(
8723            history
8724                .iter()
8725                .all(|msg| !msg.content.contains("[Tool call parse error]")),
8726            "reference function_call JSON must not trigger internal parser feedback"
8727        );
8728    }
8729
8730    #[tokio::test]
8731    async fn run_tool_call_loop_returns_tool_call_tag_example_when_no_tools_are_enabled() {
8732        let example = r#"<tool_call>
8733{"name":"shell","arguments":{"command":"pwd"}}
8734</tool_call>
8735This is an example, not an invocation."#;
8736        let provider = ScriptedModelProvider::from_text_responses(vec![example]);
8737        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
8738        let mut history = vec![
8739            ChatMessage::system("test-system"),
8740            ChatMessage::user("show a tool_call tag example"),
8741        ];
8742        let observer = NoopObserver;
8743
8744        let result = run_tool_call_loop(
8745            &provider,
8746            &mut history,
8747            &tools_registry,
8748            &observer,
8749            "mock-provider",
8750            "mock-model",
8751            Some(0.0),
8752            true,
8753            None,
8754            "cli",
8755            None,
8756            &zeroclaw_config::schema::MultimodalConfig::default(),
8757            4,
8758            None,
8759            None,
8760            None,
8761            &[],
8762            &[],
8763            None,
8764            None,
8765            &zeroclaw_config::schema::PacingConfig::default(),
8766            false,
8767            false, // parallel_tools
8768            0,
8769            0,
8770            None,
8771            None, // channel
8772            None, // receipt_generator
8773            None, // collected_receipts
8774        )
8775        .await
8776        .expect("tool_call tag examples should remain visible without tools");
8777
8778        assert_eq!(result, example);
8779        assert!(
8780            history
8781                .iter()
8782                .all(|msg| !msg.content.contains("[Tool call parse error]")),
8783            "tool_call tag examples must not trigger internal parser feedback"
8784        );
8785    }
8786
8787    #[tokio::test]
8788    async fn run_tool_call_loop_streams_tool_call_fenced_example_with_registered_tool() {
8789        let example = r#"```tool_call
8790{"name":"count_tool","arguments":{"value":"X"}}
8791```
8792This is an example, not an invocation."#;
8793        let provider = StreamingScriptedModelProvider::from_text_responses(vec![example]);
8794        let invocations = Arc::new(AtomicUsize::new(0));
8795        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
8796            "count_tool",
8797            Arc::clone(&invocations),
8798        ))];
8799        let mut history = vec![
8800            ChatMessage::system("test-system"),
8801            ChatMessage::user("show a registered tool_call fenced example"),
8802        ];
8803        let observer = NoopObserver;
8804        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(16);
8805
8806        let result = run_tool_call_loop(
8807            &provider,
8808            &mut history,
8809            &tools_registry,
8810            &observer,
8811            "mock-provider",
8812            "mock-model",
8813            Some(0.0),
8814            true,
8815            None,
8816            "matrix",
8817            None,
8818            &zeroclaw_config::schema::MultimodalConfig::default(),
8819            4,
8820            None,
8821            Some(tx),
8822            None,
8823            &[],
8824            &[],
8825            None,
8826            None,
8827            &zeroclaw_config::schema::PacingConfig::default(),
8828            false,
8829            false, // parallel_tools
8830            0,
8831            0,
8832            None,
8833            None, // channel
8834            None, // receipt_generator
8835            None, // collected_receipts
8836        )
8837        .await
8838        .expect("registered tool_call fenced examples should remain visible");
8839
8840        let mut visible_deltas = String::new();
8841        while let Some(delta) = rx.recv().await {
8842            if let StreamDelta::Text(text) = delta {
8843                visible_deltas.push_str(&text);
8844            }
8845        }
8846
8847        assert_eq!(result, example);
8848        assert_eq!(visible_deltas, example);
8849        assert_eq!(
8850            invocations.load(Ordering::SeqCst),
8851            0,
8852            "tool-call examples must not execute registered tools"
8853        );
8854        assert!(
8855            history
8856                .iter()
8857                .all(|msg| !msg.content.contains("[Tool call parse error]")),
8858            "tool-call examples must not trigger internal parser feedback"
8859        );
8860    }
8861
8862    #[tokio::test]
8863    async fn run_tool_call_loop_returns_tool_call_tag_example_with_registered_tool() {
8864        let example = r#"<tool_call>
8865{"name":"count_tool","arguments":{"value":"X"}}
8866</tool_call>
8867This is an example, not an invocation."#;
8868        let provider = ScriptedModelProvider::from_text_responses(vec![example]);
8869        let invocations = Arc::new(AtomicUsize::new(0));
8870        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
8871            "count_tool",
8872            Arc::clone(&invocations),
8873        ))];
8874        let mut history = vec![
8875            ChatMessage::system("test-system"),
8876            ChatMessage::user("show a registered tool_call tag example"),
8877        ];
8878        let observer = NoopObserver;
8879
8880        let result = run_tool_call_loop(
8881            &provider,
8882            &mut history,
8883            &tools_registry,
8884            &observer,
8885            "mock-provider",
8886            "mock-model",
8887            Some(0.0),
8888            true,
8889            None,
8890            "cli",
8891            None,
8892            &zeroclaw_config::schema::MultimodalConfig::default(),
8893            4,
8894            None,
8895            None,
8896            None,
8897            &[],
8898            &[],
8899            None,
8900            None,
8901            &zeroclaw_config::schema::PacingConfig::default(),
8902            false,
8903            false, // parallel_tools
8904            0,
8905            0,
8906            None,
8907            None, // channel
8908            None, // receipt_generator
8909            None, // collected_receipts
8910        )
8911        .await
8912        .expect("registered tool_call tag examples should remain visible");
8913
8914        assert_eq!(result, example);
8915        assert_eq!(
8916            invocations.load(Ordering::SeqCst),
8917            0,
8918            "tool-call tag examples must not execute registered tools"
8919        );
8920    }
8921
8922    #[tokio::test]
8923    async fn run_tool_call_loop_retries_tagged_tool_call_with_trailing_text_without_tools() {
8924        let leaked = r#"<tool_call>
8925{"name":"shell","arguments":{"command":"pwd"}}
8926</tool_call>
8927Done."#;
8928        let provider =
8929            ScriptedModelProvider::from_text_responses(vec![leaked, "Recovered answer."]);
8930        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
8931        let mut history = vec![
8932            ChatMessage::system("test-system"),
8933            ChatMessage::user("run without tools"),
8934        ];
8935        let observer = NoopObserver;
8936
8937        let result = run_tool_call_loop(
8938            &provider,
8939            &mut history,
8940            &tools_registry,
8941            &observer,
8942            "mock-provider",
8943            "mock-model",
8944            Some(0.0),
8945            true,
8946            None,
8947            "cli",
8948            None,
8949            &zeroclaw_config::schema::MultimodalConfig::default(),
8950            4,
8951            None,
8952            None,
8953            None,
8954            &[],
8955            &[],
8956            None,
8957            None,
8958            &zeroclaw_config::schema::PacingConfig::default(),
8959            false,
8960            false, // parallel_tools
8961            0,
8962            0,
8963            None,
8964            None, // channel
8965            None, // receipt_generator
8966            None, // collected_receipts
8967        )
8968        .await
8969        .expect("tagged tool protocol with trailing text should retry and recover");
8970
8971        assert_eq!(result, "Recovered answer.");
8972        assert!(!result.contains("<tool_call>"));
8973        assert!(
8974            history
8975                .iter()
8976                .any(|msg| msg.role == "user" && msg.content.contains("[Tool call parse error]")),
8977            "tagged tool protocol with trailing text must trigger internal parser feedback"
8978        );
8979    }
8980
8981    #[tokio::test]
8982    async fn run_tool_call_loop_retries_embedded_fenced_tool_call_without_tools() {
8983        let leaked = r#"Let me call it:
8984```tool_call
8985{"name":"shell","arguments":{"command":"pwd"}}
8986```
8987Done."#;
8988        let provider =
8989            ScriptedModelProvider::from_text_responses(vec![leaked, "Recovered answer."]);
8990        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
8991        let mut history = vec![
8992            ChatMessage::system("test-system"),
8993            ChatMessage::user("run without tools"),
8994        ];
8995        let observer = NoopObserver;
8996
8997        let result = run_tool_call_loop(
8998            &provider,
8999            &mut history,
9000            &tools_registry,
9001            &observer,
9002            "mock-provider",
9003            "mock-model",
9004            Some(0.0),
9005            true,
9006            None,
9007            "matrix",
9008            None,
9009            &zeroclaw_config::schema::MultimodalConfig::default(),
9010            4,
9011            None,
9012            None,
9013            None,
9014            &[],
9015            &[],
9016            None,
9017            None,
9018            &zeroclaw_config::schema::PacingConfig::default(),
9019            false,
9020            false, // parallel_tools
9021            0,
9022            0,
9023            None,
9024            None, // channel
9025            None, // receipt_generator
9026            None, // collected_receipts
9027        )
9028        .await
9029        .expect("embedded fenced tool protocol should retry and recover");
9030
9031        assert_eq!(result, "Recovered answer.");
9032        assert!(!result.contains("```tool_call"));
9033        assert!(
9034            history
9035                .iter()
9036                .any(|msg| msg.role == "user" && msg.content.contains("[Tool call parse error]")),
9037            "embedded fenced tool protocol must trigger internal parser feedback"
9038        );
9039    }
9040
9041    #[tokio::test]
9042    async fn run_tool_call_loop_retries_malformed_tool_protocol_fenced_call_without_tools() {
9043        let leaked = r#"```tool_call
9044{"name":"shell","arguments":{"command":"pwd"}}
9045```"#;
9046        let provider =
9047            ScriptedModelProvider::from_text_responses(vec![leaked, "Recovered answer."]);
9048        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
9049        let mut history = vec![
9050            ChatMessage::system("test-system"),
9051            ChatMessage::user("run without tools"),
9052        ];
9053        let observer = NoopObserver;
9054
9055        let result = run_tool_call_loop(
9056            &provider,
9057            &mut history,
9058            &tools_registry,
9059            &observer,
9060            "mock-provider",
9061            "mock-model",
9062            Some(0.0),
9063            true,
9064            None,
9065            "cli",
9066            None,
9067            &zeroclaw_config::schema::MultimodalConfig::default(),
9068            4,
9069            None,
9070            None,
9071            None,
9072            &[],
9073            &[],
9074            None,
9075            None,
9076            &zeroclaw_config::schema::PacingConfig::default(),
9077            false,
9078            false, // parallel_tools
9079            0,
9080            0,
9081            None,
9082            None, // channel
9083            None, // receipt_generator
9084            None, // collected_receipts
9085        )
9086        .await
9087        .expect("standalone tool_call fence should retry and recover without tools");
9088
9089        assert_eq!(result, "Recovered answer.");
9090        assert!(!result.contains("```tool_call"));
9091        assert!(
9092            history
9093                .iter()
9094                .any(|msg| msg.role == "user" && msg.content.contains("[Tool call parse error]")),
9095            "standalone tool_call fence must trigger internal parser feedback"
9096        );
9097    }
9098
9099    #[tokio::test]
9100    async fn run_tool_call_loop_streams_tool_call_fenced_example_when_no_tools_are_enabled() {
9101        let example = r#"```tool_call
9102{"name":"shell","arguments":{"command":"pwd"}}
9103```
9104This is an example, not an invocation."#;
9105        let provider = StreamingScriptedModelProvider::from_text_responses(vec![example]);
9106        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
9107        let mut history = vec![
9108            ChatMessage::system("test-system"),
9109            ChatMessage::user("show a tool_call fenced example"),
9110        ];
9111        let observer = NoopObserver;
9112        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(16);
9113
9114        let result = run_tool_call_loop(
9115            &provider,
9116            &mut history,
9117            &tools_registry,
9118            &observer,
9119            "mock-provider",
9120            "mock-model",
9121            Some(0.0),
9122            true,
9123            None,
9124            "matrix",
9125            None,
9126            &zeroclaw_config::schema::MultimodalConfig::default(),
9127            4,
9128            None,
9129            Some(tx),
9130            None,
9131            &[],
9132            &[],
9133            None,
9134            None,
9135            &zeroclaw_config::schema::PacingConfig::default(),
9136            false,
9137            false, // parallel_tools
9138            0,
9139            0,
9140            None,
9141            None, // channel
9142            None, // receipt_generator
9143            None, // collected_receipts
9144        )
9145        .await
9146        .expect("tool_call fenced examples should remain visible without tools");
9147
9148        let mut visible_deltas = String::new();
9149        while let Some(delta) = rx.recv().await {
9150            if let StreamDelta::Text(text) = delta {
9151                visible_deltas.push_str(&text);
9152            }
9153        }
9154
9155        assert_eq!(result, example);
9156        assert_eq!(visible_deltas, example);
9157        assert!(
9158            history
9159                .iter()
9160                .all(|msg| !msg.content.contains("[Tool call parse error]")),
9161            "tool_call fenced examples must not trigger internal parser feedback"
9162        );
9163    }
9164
9165    #[tokio::test]
9166    async fn run_tool_call_loop_streams_split_tool_call_fenced_example_when_no_tools_are_enabled() {
9167        struct SplitFencedExampleProvider;
9168        impl_test_model_provider_attribution!(SplitFencedExampleProvider);
9169
9170        #[async_trait]
9171        impl ModelProvider for SplitFencedExampleProvider {
9172            async fn chat_with_system(
9173                &self,
9174                _system_prompt: Option<&str>,
9175                _message: &str,
9176                _model: &str,
9177                _temperature: Option<f64>,
9178            ) -> anyhow::Result<String> {
9179                anyhow::bail!("not used in this test")
9180            }
9181
9182            async fn chat(
9183                &self,
9184                _request: ChatRequest<'_>,
9185                _model: &str,
9186                _temperature: Option<f64>,
9187            ) -> anyhow::Result<ChatResponse> {
9188                anyhow::bail!("chat should not be called when streaming succeeds")
9189            }
9190
9191            fn supports_streaming(&self) -> bool {
9192                true
9193            }
9194
9195            fn stream_chat(
9196                &self,
9197                _request: ChatRequest<'_>,
9198                _model: &str,
9199                _temperature: Option<f64>,
9200                _options: StreamOptions,
9201            ) -> futures_util::stream::BoxStream<
9202                'static,
9203                zeroclaw_providers::traits::StreamResult<StreamEvent>,
9204            > {
9205                Box::pin(futures_util::stream::iter(vec![
9206                    Ok(StreamEvent::TextDelta(StreamChunk::delta(
9207                        "```tool_call\n{\"name\":\"shell\",\"arguments\":{\"command\":\"pwd\"}}\n```",
9208                    ))),
9209                    Ok(StreamEvent::TextDelta(StreamChunk::delta(
9210                        "\nThis is an example, not an invocation.",
9211                    ))),
9212                    Ok(StreamEvent::Final),
9213                ]))
9214            }
9215        }
9216
9217        let example = r#"```tool_call
9218{"name":"shell","arguments":{"command":"pwd"}}
9219```
9220This is an example, not an invocation."#;
9221        let provider = SplitFencedExampleProvider;
9222        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
9223        let mut history = vec![
9224            ChatMessage::system("test-system"),
9225            ChatMessage::user("show a split tool_call fenced example"),
9226        ];
9227        let observer = NoopObserver;
9228        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(16);
9229
9230        let result = run_tool_call_loop(
9231            &provider,
9232            &mut history,
9233            &tools_registry,
9234            &observer,
9235            "mock-provider",
9236            "mock-model",
9237            Some(0.0),
9238            true,
9239            None,
9240            "matrix",
9241            None,
9242            &zeroclaw_config::schema::MultimodalConfig::default(),
9243            4,
9244            None,
9245            Some(tx),
9246            None,
9247            &[],
9248            &[],
9249            None,
9250            None,
9251            &zeroclaw_config::schema::PacingConfig::default(),
9252            false,
9253            false, // parallel_tools
9254            0,
9255            0,
9256            None,
9257            None, // channel
9258            None, // receipt_generator
9259            None, // collected_receipts
9260        )
9261        .await
9262        .expect("split tool_call fenced examples should remain visible without tools");
9263
9264        let mut visible_deltas = String::new();
9265        while let Some(delta) = rx.recv().await {
9266            if let StreamDelta::Text(text) = delta {
9267                visible_deltas.push_str(&text);
9268            }
9269        }
9270
9271        assert_eq!(result, example);
9272        assert_eq!(visible_deltas, example);
9273        assert!(
9274            history
9275                .iter()
9276                .all(|msg| !msg.content.contains("[Tool call parse error]")),
9277            "split tool_call fenced examples must not trigger internal parser feedback"
9278        );
9279    }
9280
9281    #[tokio::test]
9282    async fn run_tool_call_loop_streams_json_fenced_tool_protocol_example_when_no_tools_are_enabled()
9283     {
9284        let example = r#"```json
9285{"tool_calls":[{"name":"shell","arguments":{"command":"pwd"}}]}
9286```
9287This is an example, not an invocation."#;
9288        let provider = StreamingScriptedModelProvider::from_text_responses(vec![example]);
9289        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
9290        let mut history = vec![
9291            ChatMessage::system("test-system"),
9292            ChatMessage::user("show a JSON tool_calls example"),
9293        ];
9294        let observer = NoopObserver;
9295        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(16);
9296
9297        let result = run_tool_call_loop(
9298            &provider,
9299            &mut history,
9300            &tools_registry,
9301            &observer,
9302            "mock-provider",
9303            "mock-model",
9304            Some(0.0),
9305            true,
9306            None,
9307            "matrix",
9308            None,
9309            &zeroclaw_config::schema::MultimodalConfig::default(),
9310            4,
9311            None,
9312            Some(tx),
9313            None,
9314            &[],
9315            &[],
9316            None,
9317            None,
9318            &zeroclaw_config::schema::PacingConfig::default(),
9319            false,
9320            false, // parallel_tools
9321            0,
9322            0,
9323            None,
9324            None, // channel
9325            None, // receipt_generator
9326            None, // collected_receipts
9327        )
9328        .await
9329        .expect("JSON-fenced tool protocol examples should remain visible without tools");
9330
9331        let mut visible_deltas = String::new();
9332        while let Some(delta) = rx.recv().await {
9333            if let StreamDelta::Text(text) = delta {
9334                visible_deltas.push_str(&text);
9335            }
9336        }
9337
9338        assert_eq!(result, example);
9339        assert_eq!(visible_deltas, example);
9340        assert!(
9341            history
9342                .iter()
9343                .all(|msg| !msg.content.contains("[Tool call parse error]")),
9344            "JSON-fenced tool protocol examples must not trigger internal parser feedback"
9345        );
9346    }
9347
9348    #[tokio::test]
9349    async fn run_tool_call_loop_executes_streamed_tool_call_fence_without_draft_leak() {
9350        let provider = StreamingScriptedModelProvider::from_text_responses(vec![
9351            r#"```tool_call
9352{"name":"count_tool","arguments":{"value":"X"}}
9353```"#,
9354            "Final answer.",
9355        ]);
9356        let invocations = Arc::new(AtomicUsize::new(0));
9357        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
9358            "count_tool",
9359            Arc::clone(&invocations),
9360        ))];
9361        let mut history = vec![
9362            ChatMessage::system("test-system"),
9363            ChatMessage::user("use the tool"),
9364        ];
9365        let observer = NoopObserver;
9366        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(16);
9367
9368        let result = run_tool_call_loop(
9369            &provider,
9370            &mut history,
9371            &tools_registry,
9372            &observer,
9373            "mock-provider",
9374            "mock-model",
9375            Some(0.0),
9376            true,
9377            None,
9378            "matrix",
9379            None,
9380            &zeroclaw_config::schema::MultimodalConfig::default(),
9381            4,
9382            None,
9383            Some(tx),
9384            None,
9385            &[],
9386            &[],
9387            None,
9388            None,
9389            &zeroclaw_config::schema::PacingConfig::default(),
9390            false,
9391            false, // parallel_tools
9392            0,
9393            0,
9394            None,
9395            None, // channel
9396            None, // receipt_generator
9397            None, // collected_receipts
9398        )
9399        .await
9400        .expect("streamed fenced tool call should execute and continue");
9401
9402        let mut visible_deltas = String::new();
9403        while let Some(delta) = rx.recv().await {
9404            if let StreamDelta::Text(text) = delta {
9405                visible_deltas.push_str(&text);
9406            }
9407        }
9408
9409        assert_eq!(result, "Final answer.");
9410        assert_eq!(invocations.load(Ordering::SeqCst), 1);
9411        assert_eq!(visible_deltas, "Final answer.");
9412        assert!(
9413            !visible_deltas.contains("```tool_call"),
9414            "streamed fenced tool call must not reach draft updates before execution"
9415        );
9416    }
9417
9418    #[tokio::test]
9419    async fn run_tool_call_loop_sanitizes_native_tool_call_text_before_display_and_history() {
9420        let model_provider = ScriptedModelProvider {
9421            responses: Arc::new(Mutex::new(VecDeque::from(vec![
9422                ChatResponse {
9423                    text: Some(
9424                        "<think>private chain of thought</think>Task started. Waiting 30 seconds before checking status."
9425                            .into(),
9426                    ),
9427                    tool_calls: vec![ToolCall {
9428                        id: "call_wait".into(),
9429                        name: "count_tool".into(),
9430                        arguments: r#"{"value":"A"}"#.into(),
9431                        extra_content: None,
9432                    }],
9433                    usage: None,
9434                    reasoning_content: Some("provider reasoning".into()),
9435                },
9436                ChatResponse {
9437                    text: Some("Final answer".into()),
9438                    tool_calls: Vec::new(),
9439                    usage: None,
9440                    reasoning_content: None,
9441                },
9442            ]))),
9443            capabilities: ProviderCapabilities {
9444                native_tool_calling: true,
9445                ..ProviderCapabilities::default()
9446            },
9447        };
9448
9449        let invocations = Arc::new(AtomicUsize::new(0));
9450        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
9451            "count_tool",
9452            Arc::clone(&invocations),
9453        ))];
9454
9455        let mut history = vec![
9456            ChatMessage::system("test-system"),
9457            ChatMessage::user("run tool calls"),
9458        ];
9459        let observer = NoopObserver;
9460        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
9461
9462        let result = run_tool_call_loop(
9463            &model_provider,
9464            &mut history,
9465            &tools_registry,
9466            &observer,
9467            "mock-provider",
9468            "mock-model",
9469            Some(0.0),
9470            true,
9471            None,
9472            "telegram",
9473            None,
9474            &zeroclaw_config::schema::MultimodalConfig::default(),
9475            4,
9476            None,
9477            Some(tx),
9478            None,
9479            &[],
9480            &[],
9481            None,
9482            None,
9483            &zeroclaw_config::schema::PacingConfig::default(),
9484            false,
9485            false, // parallel_tools
9486            0,
9487            0,
9488            None,
9489            None, // channel
9490            None, // receipt_generator
9491            None, // collected_receipts
9492        )
9493        .await
9494        .expect("native tool-call text should be relayed through on_delta");
9495
9496        let mut deltas: Vec<DraftEvent> = Vec::new();
9497        while let Some(delta) = rx.recv().await {
9498            deltas.push(delta);
9499        }
9500
9501        assert!(
9502            deltas
9503                .iter()
9504                .any(|delta| matches!(delta, StreamDelta::Text(t) if t == "Task started. Waiting 30 seconds before checking status.\n")),
9505            "native assistant text should be sanitized and relayed to on_delta"
9506        );
9507        assert!(
9508            deltas
9509                .iter()
9510                .any(|delta| matches!(delta, StreamDelta::Status(t) if t.starts_with("\u{1f4ac} Got 1 tool call(s)"))),
9511            "tool-call progress line should still be relayed"
9512        );
9513        assert_eq!(
9514            result, "Final answer",
9515            "final delivered result should not include intermediate tool-call narration"
9516        );
9517        assert!(!result.contains("private chain of thought"));
9518        assert!(!result.contains("<think>"));
9519        assert!(
9520            deltas.iter().all(|delta| match delta {
9521                StreamDelta::Status(text) | StreamDelta::Text(text) =>
9522                    !text.contains("private chain of thought") && !text.contains("<think>"),
9523            }),
9524            "draft deltas must not expose inline think tags: {deltas:?}"
9525        );
9526        let assistant_tool_history = history
9527            .iter()
9528            .find(|message| message.content.contains("\"tool_calls\""))
9529            .expect("native tool-call turn should persist assistant history");
9530        let parsed: serde_json::Value =
9531            serde_json::from_str(&assistant_tool_history.content).unwrap();
9532        assert_eq!(
9533            parsed["content"].as_str(),
9534            Some("Task started. Waiting 30 seconds before checking status.")
9535        );
9536        assert_eq!(
9537            parsed["reasoning_content"].as_str(),
9538            Some("provider reasoning")
9539        );
9540        assert!(
9541            !assistant_tool_history
9542                .content
9543                .contains("private chain of thought")
9544        );
9545        assert!(!assistant_tool_history.content.contains("<think>"));
9546        assert_eq!(invocations.load(Ordering::SeqCst), 1);
9547    }
9548
9549    #[tokio::test]
9550    async fn run_tool_call_loop_consumes_provider_stream_for_final_response() {
9551        let model_provider =
9552            StreamingScriptedModelProvider::from_text_responses(vec!["streamed final answer"]);
9553        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
9554        let mut history = vec![
9555            ChatMessage::system("test-system"),
9556            ChatMessage::user("say hi"),
9557        ];
9558        let observer = NoopObserver;
9559        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(32);
9560
9561        let result = run_tool_call_loop(
9562            &model_provider,
9563            &mut history,
9564            &tools_registry,
9565            &observer,
9566            "mock-provider",
9567            "mock-model",
9568            Some(0.0),
9569            true,
9570            None,
9571            "telegram",
9572            None,
9573            &zeroclaw_config::schema::MultimodalConfig::default(),
9574            4,
9575            None,
9576            Some(tx),
9577            None,
9578            &[],
9579            &[],
9580            None,
9581            None,
9582            &zeroclaw_config::schema::PacingConfig::default(),
9583            false,
9584            false, // parallel_tools
9585            0,
9586            0,
9587            None,
9588            None, // channel
9589            None, // receipt_generator
9590            None, // collected_receipts
9591        )
9592        .await
9593        .expect("streaming model_provider should complete");
9594
9595        let mut visible_deltas = String::new();
9596        while let Some(delta) = rx.recv().await {
9597            match delta {
9598                StreamDelta::Status(_) => {}
9599                StreamDelta::Text(text) => {
9600                    visible_deltas.push_str(&text);
9601                }
9602            }
9603        }
9604
9605        assert_eq!(result, "streamed final answer");
9606        assert_eq!(
9607            visible_deltas, "streamed final answer",
9608            "draft should receive upstream deltas once without post-hoc duplication"
9609        );
9610        assert_eq!(model_provider.stream_calls.load(Ordering::SeqCst), 1);
9611        assert_eq!(model_provider.chat_calls.load(Ordering::SeqCst), 0);
9612    }
9613
9614    #[tokio::test]
9615    async fn run_tool_call_loop_streaming_path_preserves_tool_loop_semantics() {
9616        let model_provider = StreamingScriptedModelProvider::from_text_responses(vec![
9617            r#"<tool_call>
9618{"name":"count_tool","arguments":{"value":"A"}}
9619</tool_call>"#,
9620            "done",
9621        ]);
9622        let invocations = Arc::new(AtomicUsize::new(0));
9623        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
9624            "count_tool",
9625            Arc::clone(&invocations),
9626        ))];
9627        let mut history = vec![
9628            ChatMessage::system("test-system"),
9629            ChatMessage::user("run tool calls"),
9630        ];
9631        let observer = NoopObserver;
9632        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(64);
9633
9634        let result = run_tool_call_loop(
9635            &model_provider,
9636            &mut history,
9637            &tools_registry,
9638            &observer,
9639            "mock-provider",
9640            "mock-model",
9641            Some(0.0),
9642            true,
9643            None,
9644            "telegram",
9645            None,
9646            &zeroclaw_config::schema::MultimodalConfig::default(),
9647            5,
9648            None,
9649            Some(tx),
9650            None,
9651            &[],
9652            &[],
9653            None,
9654            None,
9655            &zeroclaw_config::schema::PacingConfig::default(),
9656            false,
9657            false, // parallel_tools
9658            0,
9659            0,
9660            None,
9661            None, // channel
9662            None, // receipt_generator
9663            None, // collected_receipts
9664        )
9665        .await
9666        .expect("streaming tool loop should execute tool and finish");
9667
9668        let mut visible_deltas = String::new();
9669        while let Some(delta) = rx.recv().await {
9670            match delta {
9671                StreamDelta::Status(_) => {}
9672                StreamDelta::Text(text) => {
9673                    visible_deltas.push_str(&text);
9674                }
9675            }
9676        }
9677
9678        assert!(
9679            result.ends_with("done"),
9680            "result should end with 'done', got: {result}"
9681        );
9682        assert_eq!(invocations.load(Ordering::SeqCst), 1);
9683        assert_eq!(model_provider.stream_calls.load(Ordering::SeqCst), 2);
9684        assert_eq!(model_provider.chat_calls.load(Ordering::SeqCst), 0);
9685        assert_eq!(visible_deltas, "done");
9686        assert!(
9687            !visible_deltas.contains("<tool_call"),
9688            "draft text should not leak streamed tool payload markers"
9689        );
9690    }
9691
9692    #[tokio::test]
9693    async fn consume_provider_streaming_response_buffers_split_tool_protocol_markers() {
9694        struct SplitToolProtocolProvider;
9695        impl_test_model_provider_attribution!(SplitToolProtocolProvider);
9696
9697        #[async_trait]
9698        impl ModelProvider for SplitToolProtocolProvider {
9699            async fn chat_with_system(
9700                &self,
9701                _system_prompt: Option<&str>,
9702                _message: &str,
9703                _model: &str,
9704                _temperature: Option<f64>,
9705            ) -> anyhow::Result<String> {
9706                anyhow::bail!("not used in this test")
9707            }
9708
9709            async fn chat(
9710                &self,
9711                _request: ChatRequest<'_>,
9712                _model: &str,
9713                _temperature: Option<f64>,
9714            ) -> anyhow::Result<ChatResponse> {
9715                anyhow::bail!("not used in this test")
9716            }
9717
9718            fn supports_streaming(&self) -> bool {
9719                true
9720            }
9721
9722            fn stream_chat(
9723                &self,
9724                _request: ChatRequest<'_>,
9725                _model: &str,
9726                _temperature: Option<f64>,
9727                _options: StreamOptions,
9728            ) -> futures_util::stream::BoxStream<
9729                'static,
9730                zeroclaw_providers::traits::StreamResult<StreamEvent>,
9731            > {
9732                Box::pin(futures_util::stream::iter(vec![
9733                    Ok(StreamEvent::TextDelta(StreamChunk::delta(r#"{"tool"#))),
9734                    Ok(StreamEvent::TextDelta(StreamChunk::delta(
9735                        r#"calls":[{"name":"count_tool","arguments":{"value":"X"}}]}"#,
9736                    ))),
9737                    Ok(StreamEvent::Final),
9738                ]))
9739            }
9740        }
9741
9742        let provider = SplitToolProtocolProvider;
9743        let messages = vec![ChatMessage::user("hi")];
9744        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(8);
9745
9746        let outcome = consume_provider_streaming_response(
9747            &provider,
9748            &messages,
9749            Some(&[crate::tools::ToolSpec {
9750                name: "count_tool".to_string(),
9751                description: "Count values".to_string(),
9752                parameters: serde_json::json!({"type": "object"}),
9753            }]),
9754            "mock-model",
9755            Some(0.0),
9756            None,
9757            Some(&tx),
9758            false,
9759        )
9760        .await
9761        .expect("streaming should finish");
9762        drop(tx);
9763
9764        let mut visible_deltas = String::new();
9765        while let Some(delta) = rx.recv().await {
9766            if let StreamDelta::Text(text) = delta {
9767                visible_deltas.push_str(&text);
9768            }
9769        }
9770
9771        assert!(outcome.response_text.contains("\"toolcalls\""));
9772        assert_eq!(
9773            visible_deltas, "",
9774            "split internal protocol markers must not reach draft updates"
9775        );
9776    }
9777
9778    #[tokio::test]
9779    async fn consume_provider_streaming_response_buffers_top_level_tool_call_array() {
9780        struct TopLevelToolArrayProvider;
9781        impl_test_model_provider_attribution!(TopLevelToolArrayProvider);
9782
9783        #[async_trait]
9784        impl ModelProvider for TopLevelToolArrayProvider {
9785            async fn chat_with_system(
9786                &self,
9787                _system_prompt: Option<&str>,
9788                _message: &str,
9789                _model: &str,
9790                _temperature: Option<f64>,
9791            ) -> anyhow::Result<String> {
9792                anyhow::bail!("not used in this test")
9793            }
9794
9795            async fn chat(
9796                &self,
9797                _request: ChatRequest<'_>,
9798                _model: &str,
9799                _temperature: Option<f64>,
9800            ) -> anyhow::Result<ChatResponse> {
9801                anyhow::bail!("not used in this test")
9802            }
9803
9804            fn supports_streaming(&self) -> bool {
9805                true
9806            }
9807
9808            fn stream_chat(
9809                &self,
9810                _request: ChatRequest<'_>,
9811                _model: &str,
9812                _temperature: Option<f64>,
9813                _options: StreamOptions,
9814            ) -> futures_util::stream::BoxStream<
9815                'static,
9816                zeroclaw_providers::traits::StreamResult<StreamEvent>,
9817            > {
9818                Box::pin(futures_util::stream::iter(vec![
9819                    Ok(StreamEvent::TextDelta(StreamChunk::delta(
9820                        r#"[{"name":"count_tool","arguments":{"value":"X"}}]"#,
9821                    ))),
9822                    Ok(StreamEvent::Final),
9823                ]))
9824            }
9825        }
9826
9827        let provider = TopLevelToolArrayProvider;
9828        let messages = vec![ChatMessage::user("hi")];
9829        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(8);
9830
9831        let outcome = consume_provider_streaming_response(
9832            &provider,
9833            &messages,
9834            Some(&[crate::tools::ToolSpec {
9835                name: "count_tool".to_string(),
9836                description: "Count values".to_string(),
9837                parameters: serde_json::json!({"type": "object"}),
9838            }]),
9839            "mock-model",
9840            Some(0.0),
9841            None,
9842            Some(&tx),
9843            false,
9844        )
9845        .await
9846        .expect("streaming should finish");
9847        drop(tx);
9848
9849        let mut visible_deltas = String::new();
9850        while let Some(delta) = rx.recv().await {
9851            if let StreamDelta::Text(text) = delta {
9852                visible_deltas.push_str(&text);
9853            }
9854        }
9855
9856        assert!(outcome.response_text.contains("\"name\""));
9857        assert_eq!(
9858            visible_deltas, "",
9859            "top-level tool-call arrays must not reach draft updates"
9860        );
9861    }
9862
9863    #[tokio::test]
9864    async fn consume_provider_streaming_response_preserves_schema_array_without_tools() {
9865        let provider = StreamingScriptedModelProvider::from_text_responses(vec![
9866            r#"[{"name":"planner","parameters":{"goal":"string"}}]"#,
9867        ]);
9868        let messages = vec![ChatMessage::user("return a JSON schema array")];
9869        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(8);
9870
9871        let outcome = consume_provider_streaming_response(
9872            &provider,
9873            &messages,
9874            None,
9875            "mock-model",
9876            Some(0.0),
9877            None,
9878            Some(&tx),
9879            false,
9880        )
9881        .await
9882        .expect("streaming should finish");
9883        drop(tx);
9884
9885        let mut visible_deltas = String::new();
9886        while let Some(delta) = rx.recv().await {
9887            if let StreamDelta::Text(text) = delta {
9888                visible_deltas.push_str(&text);
9889            }
9890        }
9891
9892        assert_eq!(
9893            outcome.response_text,
9894            r#"[{"name":"planner","parameters":{"goal":"string"}}]"#
9895        );
9896        assert_eq!(visible_deltas, outcome.response_text);
9897    }
9898
9899    #[tokio::test]
9900    async fn consume_provider_streaming_response_preserves_unknown_function_call_json_with_tools() {
9901        let response = r#"{"type":"function_call","name":"support_case","arguments":{"id":"A1"}}"#;
9902        let provider = StreamingScriptedModelProvider::from_text_responses(vec![response]);
9903        let messages = vec![ChatMessage::user("return a support case JSON object")];
9904        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(8);
9905
9906        let outcome = consume_provider_streaming_response(
9907            &provider,
9908            &messages,
9909            Some(&[crate::tools::ToolSpec {
9910                name: "count_tool".to_string(),
9911                description: "Count values".to_string(),
9912                parameters: serde_json::json!({"type": "object"}),
9913            }]),
9914            "mock-model",
9915            Some(0.0),
9916            None,
9917            Some(&tx),
9918            false,
9919        )
9920        .await
9921        .expect("streaming should finish");
9922        drop(tx);
9923
9924        let mut visible_deltas = String::new();
9925        while let Some(delta) = rx.recv().await {
9926            if let StreamDelta::Text(text) = delta {
9927                visible_deltas.push_str(&text);
9928            }
9929        }
9930
9931        assert_eq!(outcome.response_text, response);
9932        assert_eq!(visible_deltas, response);
9933    }
9934
9935    #[tokio::test]
9936    async fn consume_provider_streaming_response_preserves_malformed_unknown_tool_calls_json_with_tools()
9937     {
9938        let response = r#"{"tool_calls":[{"name":"support_case","arguments":{"id":"A1"}}"#;
9939        let provider = StreamingScriptedModelProvider::from_text_responses(vec![response]);
9940        let messages = vec![ChatMessage::user(
9941            "return a partial support case JSON object",
9942        )];
9943        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(8);
9944
9945        let outcome = consume_provider_streaming_response(
9946            &provider,
9947            &messages,
9948            Some(&[crate::tools::ToolSpec {
9949                name: "count_tool".to_string(),
9950                description: "Count values".to_string(),
9951                parameters: serde_json::json!({"type": "object"}),
9952            }]),
9953            "mock-model",
9954            Some(0.0),
9955            None,
9956            Some(&tx),
9957            false,
9958        )
9959        .await
9960        .expect("streaming should finish");
9961        drop(tx);
9962
9963        let mut visible_deltas = String::new();
9964        while let Some(delta) = rx.recv().await {
9965            if let StreamDelta::Text(text) = delta {
9966                visible_deltas.push_str(&text);
9967            }
9968        }
9969
9970        assert_eq!(outcome.response_text, response);
9971        assert_eq!(visible_deltas, response);
9972        assert!(
9973            !outcome.suppressed_protocol,
9974            "unknown business JSON must not be suppressed as internal protocol"
9975        );
9976    }
9977
9978    #[tokio::test]
9979    async fn consume_provider_streaming_response_buffers_malformed_tool_protocol_json() {
9980        struct MalformedToolProtocolProvider;
9981        impl_test_model_provider_attribution!(MalformedToolProtocolProvider);
9982
9983        #[async_trait]
9984        impl ModelProvider for MalformedToolProtocolProvider {
9985            async fn chat_with_system(
9986                &self,
9987                _system_prompt: Option<&str>,
9988                _message: &str,
9989                _model: &str,
9990                _temperature: Option<f64>,
9991            ) -> anyhow::Result<String> {
9992                anyhow::bail!("not used in this test")
9993            }
9994
9995            async fn chat(
9996                &self,
9997                _request: ChatRequest<'_>,
9998                _model: &str,
9999                _temperature: Option<f64>,
10000            ) -> anyhow::Result<ChatResponse> {
10001                anyhow::bail!("not used in this test")
10002            }
10003
10004            fn supports_streaming(&self) -> bool {
10005                true
10006            }
10007
10008            fn stream_chat(
10009                &self,
10010                _request: ChatRequest<'_>,
10011                _model: &str,
10012                _temperature: Option<f64>,
10013                _options: StreamOptions,
10014            ) -> futures_util::stream::BoxStream<
10015                'static,
10016                zeroclaw_providers::traits::StreamResult<StreamEvent>,
10017            > {
10018                Box::pin(futures_util::stream::iter(vec![
10019                    Ok(StreamEvent::TextDelta(StreamChunk::delta(r#"{"tool_"#))),
10020                    Ok(StreamEvent::TextDelta(StreamChunk::delta(
10021                        r#"calls":[{"call_id":"call_1","arguments":{"value":"X"}}]}"#,
10022                    ))),
10023                    Ok(StreamEvent::Final),
10024                ]))
10025            }
10026        }
10027
10028        let provider = MalformedToolProtocolProvider;
10029        let messages = vec![ChatMessage::user("hi")];
10030        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(8);
10031
10032        let outcome = consume_provider_streaming_response(
10033            &provider,
10034            &messages,
10035            None,
10036            "mock-model",
10037            Some(0.0),
10038            None,
10039            Some(&tx),
10040            false,
10041        )
10042        .await
10043        .expect("streaming should finish");
10044        drop(tx);
10045
10046        let mut visible_deltas = String::new();
10047        while let Some(delta) = rx.recv().await {
10048            if let StreamDelta::Text(text) = delta {
10049                visible_deltas.push_str(&text);
10050            }
10051        }
10052
10053        assert!(outcome.response_text.contains("\"tool_calls\""));
10054        assert_eq!(
10055            visible_deltas, "",
10056            "malformed internal protocol JSON must not reach draft updates"
10057        );
10058    }
10059
10060    #[tokio::test]
10061    async fn consume_provider_streaming_response_drops_truncated_protocol_at_finish() {
10062        struct TruncatedProtocolProvider;
10063        impl_test_model_provider_attribution!(TruncatedProtocolProvider);
10064
10065        #[async_trait]
10066        impl ModelProvider for TruncatedProtocolProvider {
10067            async fn chat_with_system(
10068                &self,
10069                _system_prompt: Option<&str>,
10070                _message: &str,
10071                _model: &str,
10072                _temperature: Option<f64>,
10073            ) -> anyhow::Result<String> {
10074                anyhow::bail!("not used in this test")
10075            }
10076
10077            async fn chat(
10078                &self,
10079                _request: ChatRequest<'_>,
10080                _model: &str,
10081                _temperature: Option<f64>,
10082            ) -> anyhow::Result<ChatResponse> {
10083                anyhow::bail!("not used in this test")
10084            }
10085
10086            fn supports_streaming(&self) -> bool {
10087                true
10088            }
10089
10090            fn stream_chat(
10091                &self,
10092                _request: ChatRequest<'_>,
10093                _model: &str,
10094                _temperature: Option<f64>,
10095                _options: StreamOptions,
10096            ) -> futures_util::stream::BoxStream<
10097                'static,
10098                zeroclaw_providers::traits::StreamResult<StreamEvent>,
10099            > {
10100                Box::pin(futures_util::stream::iter(vec![
10101                    Ok(StreamEvent::TextDelta(StreamChunk::delta(
10102                        r#"{"tool_call_id":"call_1","content":"raw"#,
10103                    ))),
10104                    Ok(StreamEvent::Final),
10105                ]))
10106            }
10107        }
10108
10109        let provider = TruncatedProtocolProvider;
10110        let messages = vec![ChatMessage::user("hi")];
10111        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(8);
10112
10113        let outcome = consume_provider_streaming_response(
10114            &provider,
10115            &messages,
10116            None,
10117            "mock-model",
10118            Some(0.0),
10119            None,
10120            Some(&tx),
10121            false,
10122        )
10123        .await
10124        .expect("streaming should finish");
10125        drop(tx);
10126
10127        let mut visible_deltas = String::new();
10128        while let Some(delta) = rx.recv().await {
10129            if let StreamDelta::Text(text) = delta {
10130                visible_deltas.push_str(&text);
10131            }
10132        }
10133
10134        assert!(outcome.response_text.contains("\"tool_call_id\""));
10135        assert_eq!(
10136            visible_deltas, "",
10137            "truncated internal protocol must not be released at stream finish"
10138        );
10139    }
10140
10141    #[tokio::test]
10142    async fn consume_provider_streaming_response_preserves_json_fenced_tool_protocol_without_tools()
10143    {
10144        struct JsonFencedToolProtocolProvider;
10145        impl_test_model_provider_attribution!(JsonFencedToolProtocolProvider);
10146
10147        #[async_trait]
10148        impl ModelProvider for JsonFencedToolProtocolProvider {
10149            async fn chat_with_system(
10150                &self,
10151                _system_prompt: Option<&str>,
10152                _message: &str,
10153                _model: &str,
10154                _temperature: Option<f64>,
10155            ) -> anyhow::Result<String> {
10156                anyhow::bail!("not used in this test")
10157            }
10158
10159            async fn chat(
10160                &self,
10161                _request: ChatRequest<'_>,
10162                _model: &str,
10163                _temperature: Option<f64>,
10164            ) -> anyhow::Result<ChatResponse> {
10165                anyhow::bail!("not used in this test")
10166            }
10167
10168            fn supports_streaming(&self) -> bool {
10169                true
10170            }
10171
10172            fn stream_chat(
10173                &self,
10174                _request: ChatRequest<'_>,
10175                _model: &str,
10176                _temperature: Option<f64>,
10177                _options: StreamOptions,
10178            ) -> futures_util::stream::BoxStream<
10179                'static,
10180                zeroclaw_providers::traits::StreamResult<StreamEvent>,
10181            > {
10182                Box::pin(futures_util::stream::iter(vec![
10183                    Ok(StreamEvent::TextDelta(StreamChunk::delta("```json\n"))),
10184                    Ok(StreamEvent::TextDelta(StreamChunk::delta(
10185                        r#"{"tool_calls":[{"name":"count_tool","arguments":{"value":"X"}}]}"#,
10186                    ))),
10187                    Ok(StreamEvent::TextDelta(StreamChunk::delta("\n```"))),
10188                    Ok(StreamEvent::Final),
10189                ]))
10190            }
10191        }
10192
10193        let provider = JsonFencedToolProtocolProvider;
10194        let messages = vec![ChatMessage::user("hi")];
10195        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(8);
10196
10197        let outcome = consume_provider_streaming_response(
10198            &provider,
10199            &messages,
10200            None,
10201            "mock-model",
10202            Some(0.0),
10203            None,
10204            Some(&tx),
10205            false,
10206        )
10207        .await
10208        .expect("streaming should finish");
10209        drop(tx);
10210
10211        let mut visible_deltas = String::new();
10212        while let Some(delta) = rx.recv().await {
10213            if let StreamDelta::Text(text) = delta {
10214                visible_deltas.push_str(&text);
10215            }
10216        }
10217
10218        assert!(outcome.response_text.contains("\"tool_calls\""));
10219        assert_eq!(
10220            visible_deltas, outcome.response_text,
10221            "json-fenced protocol-shaped JSON should remain visible when no tools are active"
10222        );
10223    }
10224
10225    #[tokio::test]
10226    async fn consume_provider_streaming_response_buffers_tool_call_fence_with_tools() {
10227        struct ToolCallFenceProvider;
10228        impl_test_model_provider_attribution!(ToolCallFenceProvider);
10229
10230        #[async_trait]
10231        impl ModelProvider for ToolCallFenceProvider {
10232            async fn chat_with_system(
10233                &self,
10234                _system_prompt: Option<&str>,
10235                _message: &str,
10236                _model: &str,
10237                _temperature: Option<f64>,
10238            ) -> anyhow::Result<String> {
10239                anyhow::bail!("not used in this test")
10240            }
10241
10242            async fn chat(
10243                &self,
10244                _request: ChatRequest<'_>,
10245                _model: &str,
10246                _temperature: Option<f64>,
10247            ) -> anyhow::Result<ChatResponse> {
10248                anyhow::bail!("not used in this test")
10249            }
10250
10251            fn supports_streaming(&self) -> bool {
10252                true
10253            }
10254
10255            fn stream_chat(
10256                &self,
10257                _request: ChatRequest<'_>,
10258                _model: &str,
10259                _temperature: Option<f64>,
10260                _options: StreamOptions,
10261            ) -> futures_util::stream::BoxStream<
10262                'static,
10263                zeroclaw_providers::traits::StreamResult<StreamEvent>,
10264            > {
10265                Box::pin(futures_util::stream::iter(vec![
10266                    Ok(StreamEvent::TextDelta(StreamChunk::delta("```tool_call\n"))),
10267                    Ok(StreamEvent::TextDelta(StreamChunk::delta(
10268                        r#"{"name":"count_tool","arguments":{"value":"X"}}"#,
10269                    ))),
10270                    Ok(StreamEvent::TextDelta(StreamChunk::delta("\n```"))),
10271                    Ok(StreamEvent::Final),
10272                ]))
10273            }
10274        }
10275
10276        let provider = ToolCallFenceProvider;
10277        let messages = vec![ChatMessage::user("hi")];
10278        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(8);
10279
10280        let outcome = consume_provider_streaming_response(
10281            &provider,
10282            &messages,
10283            Some(&[crate::tools::ToolSpec {
10284                name: "count_tool".to_string(),
10285                description: "Count values".to_string(),
10286                parameters: serde_json::json!({"type": "object"}),
10287            }]),
10288            "mock-model",
10289            Some(0.0),
10290            None,
10291            Some(&tx),
10292            false,
10293        )
10294        .await
10295        .expect("streaming should finish");
10296        drop(tx);
10297
10298        let mut visible_deltas = String::new();
10299        while let Some(delta) = rx.recv().await {
10300            if let StreamDelta::Text(text) = delta {
10301                visible_deltas.push_str(&text);
10302            }
10303        }
10304
10305        assert!(outcome.response_text.contains("```tool_call"));
10306        assert_eq!(
10307            visible_deltas, "",
10308            "streamed tool_call fences with registered tools must not reach draft updates"
10309        );
10310    }
10311
10312    #[tokio::test]
10313    async fn consume_provider_streaming_response_preserves_plain_prefix_before_protocol_without_tools()
10314     {
10315        struct PrefixedToolProtocolProvider;
10316        impl_test_model_provider_attribution!(PrefixedToolProtocolProvider);
10317
10318        #[async_trait]
10319        impl ModelProvider for PrefixedToolProtocolProvider {
10320            async fn chat_with_system(
10321                &self,
10322                _system_prompt: Option<&str>,
10323                _message: &str,
10324                _model: &str,
10325                _temperature: Option<f64>,
10326            ) -> anyhow::Result<String> {
10327                anyhow::bail!("not used in this test")
10328            }
10329
10330            async fn chat(
10331                &self,
10332                _request: ChatRequest<'_>,
10333                _model: &str,
10334                _temperature: Option<f64>,
10335            ) -> anyhow::Result<ChatResponse> {
10336                anyhow::bail!("not used in this test")
10337            }
10338
10339            fn supports_streaming(&self) -> bool {
10340                true
10341            }
10342
10343            fn stream_chat(
10344                &self,
10345                _request: ChatRequest<'_>,
10346                _model: &str,
10347                _temperature: Option<f64>,
10348                _options: StreamOptions,
10349            ) -> futures_util::stream::BoxStream<
10350                'static,
10351                zeroclaw_providers::traits::StreamResult<StreamEvent>,
10352            > {
10353                Box::pin(futures_util::stream::iter(vec![
10354                    Ok(StreamEvent::TextDelta(StreamChunk::delta(
10355                        r#"Visible prefix {"toolcalls":[{"name":"count_tool","arguments":{"value":"X"}}]}"#,
10356                    ))),
10357                    Ok(StreamEvent::Final),
10358                ]))
10359            }
10360        }
10361
10362        let provider = PrefixedToolProtocolProvider;
10363        let messages = vec![ChatMessage::user("hi")];
10364        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(8);
10365
10366        let outcome = consume_provider_streaming_response(
10367            &provider,
10368            &messages,
10369            None,
10370            "mock-model",
10371            Some(0.0),
10372            None,
10373            Some(&tx),
10374            false,
10375        )
10376        .await
10377        .expect("streaming should finish");
10378        drop(tx);
10379
10380        let mut visible_deltas = String::new();
10381        while let Some(delta) = rx.recv().await {
10382            if let StreamDelta::Text(text) = delta {
10383                visible_deltas.push_str(&text);
10384            }
10385        }
10386
10387        assert!(outcome.response_text.contains("\"toolcalls\""));
10388        assert_eq!(
10389            visible_deltas, outcome.response_text,
10390            "prefixed protocol-shaped JSON should remain visible when no tools are active"
10391        );
10392    }
10393
10394    #[tokio::test]
10395    async fn consume_provider_streaming_response_preserves_split_protocol_after_plain_prefix_without_tools()
10396     {
10397        struct SplitPrefixedToolProtocolProvider;
10398        impl_test_model_provider_attribution!(SplitPrefixedToolProtocolProvider);
10399
10400        #[async_trait]
10401        impl ModelProvider for SplitPrefixedToolProtocolProvider {
10402            async fn chat_with_system(
10403                &self,
10404                _system_prompt: Option<&str>,
10405                _message: &str,
10406                _model: &str,
10407                _temperature: Option<f64>,
10408            ) -> anyhow::Result<String> {
10409                anyhow::bail!("not used in this test")
10410            }
10411
10412            async fn chat(
10413                &self,
10414                _request: ChatRequest<'_>,
10415                _model: &str,
10416                _temperature: Option<f64>,
10417            ) -> anyhow::Result<ChatResponse> {
10418                anyhow::bail!("not used in this test")
10419            }
10420
10421            fn supports_streaming(&self) -> bool {
10422                true
10423            }
10424
10425            fn stream_chat(
10426                &self,
10427                _request: ChatRequest<'_>,
10428                _model: &str,
10429                _temperature: Option<f64>,
10430                _options: StreamOptions,
10431            ) -> futures_util::stream::BoxStream<
10432                'static,
10433                zeroclaw_providers::traits::StreamResult<StreamEvent>,
10434            > {
10435                Box::pin(futures_util::stream::iter(vec![
10436                    Ok(StreamEvent::TextDelta(StreamChunk::delta(
10437                        r#"Visible prefix {"tool"#,
10438                    ))),
10439                    Ok(StreamEvent::TextDelta(StreamChunk::delta(
10440                        r#"calls":[{"name":"count_tool","arguments":{"value":"X"}}]}"#,
10441                    ))),
10442                    Ok(StreamEvent::Final),
10443                ]))
10444            }
10445        }
10446
10447        let provider = SplitPrefixedToolProtocolProvider;
10448        let messages = vec![ChatMessage::user("hi")];
10449        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(8);
10450
10451        let outcome = consume_provider_streaming_response(
10452            &provider,
10453            &messages,
10454            None,
10455            "mock-model",
10456            Some(0.0),
10457            None,
10458            Some(&tx),
10459            false,
10460        )
10461        .await
10462        .expect("streaming should finish");
10463        drop(tx);
10464
10465        let mut visible_deltas = String::new();
10466        while let Some(delta) = rx.recv().await {
10467            if let StreamDelta::Text(text) = delta {
10468                visible_deltas.push_str(&text);
10469            }
10470        }
10471
10472        assert!(outcome.response_text.contains("\"toolcalls\""));
10473        assert_eq!(
10474            visible_deltas, outcome.response_text,
10475            "split prefixed protocol-shaped JSON should remain visible when no tools are active"
10476        );
10477    }
10478
10479    #[tokio::test]
10480    async fn run_tool_call_loop_streams_native_tool_events_without_chat_fallback() {
10481        let model_provider = StreamingNativeToolEventModelProvider::with_turns(vec![
10482            NativeStreamTurn::ToolCall(ToolCall {
10483                id: "call_native_1".to_string(),
10484                name: "count_tool".to_string(),
10485                arguments: r#"{"value":"A"}"#.to_string(),
10486                extra_content: None,
10487            }),
10488            NativeStreamTurn::Text("done".to_string()),
10489        ]);
10490        let invocations = Arc::new(AtomicUsize::new(0));
10491        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
10492            "count_tool",
10493            Arc::clone(&invocations),
10494        ))];
10495        let mut history = vec![
10496            ChatMessage::system("test-system"),
10497            ChatMessage::user("run native tools"),
10498        ];
10499        let observer = NoopObserver;
10500        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(64);
10501
10502        let result = run_tool_call_loop(
10503            &model_provider,
10504            &mut history,
10505            &tools_registry,
10506            &observer,
10507            "mock-provider",
10508            "mock-model",
10509            Some(0.0),
10510            true,
10511            None,
10512            "telegram",
10513            None,
10514            &zeroclaw_config::schema::MultimodalConfig::default(),
10515            5,
10516            None,
10517            Some(tx),
10518            None,
10519            &[],
10520            &[],
10521            None,
10522            None,
10523            &zeroclaw_config::schema::PacingConfig::default(),
10524            false,
10525            false, // parallel_tools
10526            0,
10527            0,
10528            None,
10529            None, // channel
10530            None, // receipt_generator
10531            None, // collected_receipts
10532        )
10533        .await
10534        .expect("native streaming events should preserve tool loop semantics");
10535
10536        let mut visible_deltas = String::new();
10537        while let Some(delta) = rx.recv().await {
10538            match delta {
10539                StreamDelta::Status(_) => {}
10540                StreamDelta::Text(text) => {
10541                    visible_deltas.push_str(&text);
10542                }
10543            }
10544        }
10545
10546        assert!(
10547            result.ends_with("done"),
10548            "result should end with 'done', got: {result}"
10549        );
10550        assert_eq!(invocations.load(Ordering::SeqCst), 1);
10551        assert_eq!(model_provider.stream_calls.load(Ordering::SeqCst), 2);
10552        assert_eq!(
10553            model_provider.stream_tool_requests.load(Ordering::SeqCst),
10554            2
10555        );
10556        assert_eq!(model_provider.chat_calls.load(Ordering::SeqCst), 0);
10557        assert_eq!(visible_deltas, "done");
10558    }
10559
10560    #[tokio::test]
10561    async fn consume_provider_streaming_response_strips_split_think_tags_before_forwarding() {
10562        let model_provider =
10563            StreamingNativeToolEventModelProvider::with_turns(vec![NativeStreamTurn::TextChunks(
10564                vec![
10565                    "<thi".to_string(),
10566                    "nk>private stream reasoning</thi".to_string(),
10567                    "nk>visible answer".to_string(),
10568                ],
10569            )]);
10570        let messages = vec![ChatMessage::user("hi")];
10571        let tools = [crate::tools::ToolSpec {
10572            name: "count_tool".to_string(),
10573            description: "Count values".to_string(),
10574            parameters: serde_json::json!({"type": "object"}),
10575        }];
10576        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(8);
10577
10578        let outcome = consume_provider_streaming_response(
10579            &model_provider,
10580            &messages,
10581            Some(&tools),
10582            "mock-model",
10583            Some(0.0),
10584            None,
10585            Some(&tx),
10586            true,
10587        )
10588        .await
10589        .expect("streaming should finish");
10590        drop(tx);
10591
10592        let mut visible_deltas = String::new();
10593        while let Some(delta) = rx.recv().await {
10594            if let StreamDelta::Text(text) = delta {
10595                visible_deltas.push_str(&text);
10596            }
10597        }
10598
10599        assert_eq!(outcome.response_text, "visible answer");
10600        assert_eq!(visible_deltas, "visible answer");
10601        assert!(!outcome.response_text.contains("private stream reasoning"));
10602        assert!(!outcome.response_text.contains("<think>"));
10603        assert!(!visible_deltas.contains("private stream reasoning"));
10604        assert!(!visible_deltas.contains("<think>"));
10605    }
10606
10607    #[tokio::test]
10608    async fn run_tool_call_loop_routed_streaming_uses_live_provider_deltas_once() {
10609        let default_model_provider = RouteAwareStreamingModelProvider::new("default answer");
10610        let default_stream_calls = Arc::clone(&default_model_provider.stream_calls);
10611        let default_chat_calls = Arc::clone(&default_model_provider.chat_calls);
10612
10613        let routed_model_provider = RouteAwareStreamingModelProvider::new("routed streamed answer");
10614        let routed_stream_calls = Arc::clone(&routed_model_provider.stream_calls);
10615        let routed_chat_calls = Arc::clone(&routed_model_provider.chat_calls);
10616        let routed_last_model = Arc::clone(&routed_model_provider.last_model);
10617
10618        let router = RouterModelProvider::new(
10619            "test",
10620            vec![
10621                ("default".to_string(), Box::new(default_model_provider)),
10622                ("fast".to_string(), Box::new(routed_model_provider)),
10623            ],
10624            vec![(
10625                "fast".to_string(),
10626                Route {
10627                    provider_name: "fast".to_string(),
10628                    model: "routed-model".to_string(),
10629                },
10630            )],
10631            "default-model".to_string(),
10632        );
10633
10634        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
10635        let mut history = vec![
10636            ChatMessage::system("test-system"),
10637            ChatMessage::user("say hi"),
10638        ];
10639        let observer = NoopObserver;
10640        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(32);
10641
10642        let result = run_tool_call_loop(
10643            &router,
10644            &mut history,
10645            &tools_registry,
10646            &observer,
10647            "router",
10648            "hint:fast",
10649            Some(0.0),
10650            true,
10651            None,
10652            "telegram",
10653            None,
10654            &zeroclaw_config::schema::MultimodalConfig::default(),
10655            4,
10656            None,
10657            Some(tx),
10658            None,
10659            &[],
10660            &[],
10661            None,
10662            None,
10663            &zeroclaw_config::schema::PacingConfig::default(),
10664            false,
10665            false, // parallel_tools
10666            0,
10667            0,
10668            None,
10669            None, // channel
10670            None, // receipt_generator
10671            None, // collected_receipts
10672        )
10673        .await
10674        .expect("routed streaming model_provider should complete");
10675
10676        let mut visible_deltas = String::new();
10677        while let Some(delta) = rx.recv().await {
10678            match delta {
10679                StreamDelta::Status(_) => {}
10680                StreamDelta::Text(text) => {
10681                    visible_deltas.push_str(&text);
10682                }
10683            }
10684        }
10685
10686        assert_eq!(result, "routed streamed answer");
10687        assert_eq!(
10688            visible_deltas, "routed streamed answer",
10689            "routed draft should receive upstream deltas once without post-hoc duplication"
10690        );
10691        assert_eq!(default_stream_calls.load(Ordering::SeqCst), 0);
10692        assert_eq!(routed_stream_calls.load(Ordering::SeqCst), 1);
10693        assert_eq!(default_chat_calls.load(Ordering::SeqCst), 0);
10694        assert_eq!(routed_chat_calls.load(Ordering::SeqCst), 0);
10695        assert_eq!(
10696            routed_last_model
10697                .lock()
10698                .expect("routed_last_model lock should be valid")
10699                .as_str(),
10700            "routed-model"
10701        );
10702    }
10703
10704    #[test]
10705    fn agent_turn_executes_activated_tool_from_wrapper() {
10706        let runtime = tokio::runtime::Builder::new_current_thread()
10707            .enable_all()
10708            .build()
10709            .expect("test runtime should initialize");
10710
10711        runtime.block_on(async {
10712            let model_provider = ScriptedModelProvider::from_text_responses(vec![
10713                r#"<tool_call>
10714{"name":"pixel__get_api_health","arguments":{"value":"ok"}}
10715</tool_call>"#,
10716                "done",
10717            ]);
10718
10719            let invocations = Arc::new(AtomicUsize::new(0));
10720            let activated = Arc::new(std::sync::Mutex::new(crate::tools::ActivatedToolSet::new()));
10721            let activated_tool: Arc<dyn Tool> = Arc::new(CountingTool::new(
10722                "pixel__get_api_health",
10723                Arc::clone(&invocations),
10724            ));
10725            activated
10726                .lock()
10727                .unwrap()
10728                .activate("pixel__get_api_health".into(), activated_tool);
10729
10730            let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
10731            let mut history = vec![
10732                ChatMessage::system("test-system"),
10733                ChatMessage::user("use the activated MCP tool"),
10734            ];
10735            let observer = NoopObserver;
10736
10737            let result = agent_turn(
10738                &model_provider,
10739                &mut history,
10740                &tools_registry,
10741                &observer,
10742                "mock-provider",
10743                "mock-model",
10744                Some(0.0),
10745                true,
10746                "daemon",
10747                None,
10748                &zeroclaw_config::schema::MultimodalConfig::default(),
10749                4,
10750                None,
10751                &[],
10752                &[],
10753                Some(&activated),
10754                None,
10755                false,
10756                false, // parallel_tools
10757                None,  // channel
10758            )
10759            .await
10760            .expect("wrapper path should execute activated tools");
10761
10762            assert!(
10763                result.ends_with("done"),
10764                "result should end with 'done', got: {result}"
10765            );
10766            assert_eq!(invocations.load(Ordering::SeqCst), 1);
10767        });
10768    }
10769
10770    #[test]
10771    fn agent_turn_strict_tool_parsing_ignores_activated_tool_text_from_wrapper() {
10772        let runtime = tokio::runtime::Builder::new_current_thread()
10773            .enable_all()
10774            .build()
10775            .expect("test runtime should initialize");
10776
10777        runtime.block_on(async {
10778            let model_provider = ScriptedModelProvider::from_text_responses(vec![
10779                r#"<think>private reasoning</think>
10780<tool_call>
10781{"name":"pixel__get_api_health","arguments":{"value":"ignored"}}
10782</tool_call>"#,
10783            ]);
10784
10785            let invocations = Arc::new(AtomicUsize::new(0));
10786            let activated = Arc::new(std::sync::Mutex::new(crate::tools::ActivatedToolSet::new()));
10787            let activated_tool: Arc<dyn Tool> = Arc::new(CountingTool::new(
10788                "pixel__get_api_health",
10789                Arc::clone(&invocations),
10790            ));
10791            activated
10792                .lock()
10793                .unwrap()
10794                .activate("pixel__get_api_health".into(), activated_tool);
10795
10796            let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
10797            let mut history = vec![
10798                ChatMessage::system("test-system"),
10799                ChatMessage::user("do not infer activated tool calls from text"),
10800            ];
10801            let observer = NoopObserver;
10802
10803            let result = agent_turn(
10804                &model_provider,
10805                &mut history,
10806                &tools_registry,
10807                &observer,
10808                "mock-provider",
10809                "mock-model",
10810                Some(0.0),
10811                true,
10812                "daemon",
10813                None,
10814                &zeroclaw_config::schema::MultimodalConfig::default(),
10815                4,
10816                None,
10817                &[],
10818                &[],
10819                Some(&activated),
10820                None,
10821                true,
10822                false, // parallel_tools
10823                None,  // channel
10824            )
10825            .await
10826            .expect("strict wrapper path should preserve fallback-looking text");
10827
10828            assert_eq!(invocations.load(Ordering::SeqCst), 0);
10829            assert!(
10830                result.contains("<tool_call>"),
10831                "strict parser should return fallback-looking text, got: {result}"
10832            );
10833            assert!(
10834                !result.contains("private reasoning"),
10835                "strict parser should still strip think tags from final text, got: {result}"
10836            );
10837        });
10838    }
10839
10840    #[test]
10841    fn resolve_display_text_hides_raw_payload_for_tool_only_turns() {
10842        let display = resolve_display_text(
10843            "<tool_call>{\"name\":\"memory_store\"}</tool_call>",
10844            "",
10845            true,
10846            false,
10847        );
10848        assert!(display.is_empty());
10849    }
10850
10851    #[test]
10852    fn resolve_display_text_keeps_plain_text_for_tool_turns() {
10853        let display = resolve_display_text(
10854            "<tool_call>{\"name\":\"shell\"}</tool_call>",
10855            "Let me check that.",
10856            true,
10857            false,
10858        );
10859        assert_eq!(display, "Let me check that.");
10860    }
10861
10862    #[test]
10863    fn resolve_display_text_uses_response_text_for_native_tool_turns() {
10864        let display = resolve_display_text("Task started.", "", true, true);
10865        assert_eq!(display, "Task started.");
10866    }
10867
10868    #[test]
10869    fn resolve_display_text_uses_response_text_for_final_turns() {
10870        let display = resolve_display_text("Final answer", "", false, false);
10871        assert_eq!(display, "Final answer");
10872    }
10873
10874    #[test]
10875    fn build_tool_instructions_includes_all_tools() {
10876        use crate::security::SecurityPolicy;
10877        let security = Arc::new(SecurityPolicy::from_risk_profile(
10878            &zeroclaw_config::schema::RiskProfileConfig::default(),
10879            std::path::Path::new("/tmp"),
10880        ));
10881        let tools = tools::default_tools(security);
10882        let instructions = build_tool_instructions(&tools);
10883
10884        assert!(instructions.contains("## Tool Use Protocol"));
10885        assert!(instructions.contains("<tool_call>"));
10886        assert!(instructions.contains("shell"));
10887        assert!(instructions.contains("file_read"));
10888        assert!(instructions.contains("file_write"));
10889    }
10890
10891    #[test]
10892    fn build_tool_instructions_empty_registry_returns_empty() {
10893        let tools: Vec<Box<dyn Tool>> = vec![];
10894        let instructions = build_tool_instructions(&tools);
10895
10896        assert!(instructions.is_empty());
10897    }
10898
10899    #[test]
10900    fn tools_to_openai_format_produces_valid_schema() {
10901        use crate::security::SecurityPolicy;
10902        let security = Arc::new(SecurityPolicy::from_risk_profile(
10903            &zeroclaw_config::schema::RiskProfileConfig::default(),
10904            std::path::Path::new("/tmp"),
10905        ));
10906        let tools = tools::default_tools(security);
10907        let formatted = tools_to_openai_format(&tools);
10908
10909        assert!(!formatted.is_empty());
10910        for tool_json in &formatted {
10911            assert_eq!(tool_json["type"], "function");
10912            assert!(tool_json["function"]["name"].is_string());
10913            assert!(tool_json["function"]["description"].is_string());
10914            assert!(!tool_json["function"]["name"].as_str().unwrap().is_empty());
10915        }
10916        // Verify known tools are present
10917        let names: Vec<&str> = formatted
10918            .iter()
10919            .filter_map(|t| t["function"]["name"].as_str())
10920            .collect();
10921        assert!(names.contains(&"shell"));
10922        assert!(names.contains(&"file_read"));
10923    }
10924
10925    #[test]
10926    fn trim_history_preserves_system_prompt() {
10927        let mut history = vec![ChatMessage::system("system prompt")];
10928        for i in 0..DEFAULT_MAX_HISTORY_MESSAGES + 20 {
10929            history.push(ChatMessage::user(format!("msg {i}")));
10930        }
10931        let original_len = history.len();
10932        assert!(original_len > DEFAULT_MAX_HISTORY_MESSAGES + 1);
10933
10934        trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
10935
10936        // System prompt preserved
10937        assert_eq!(history[0].role, "system");
10938        assert_eq!(history[0].content, "system prompt");
10939        // Trimmed to limit
10940        assert_eq!(history.len(), DEFAULT_MAX_HISTORY_MESSAGES + 1); // +1 for system
10941        // Most recent messages preserved
10942        let last = &history[history.len() - 1];
10943        assert_eq!(
10944            last.content,
10945            format!("msg {}", DEFAULT_MAX_HISTORY_MESSAGES + 19)
10946        );
10947    }
10948
10949    #[test]
10950    fn trim_history_noop_when_within_limit() {
10951        let mut history = vec![
10952            ChatMessage::system("sys"),
10953            ChatMessage::user("hello"),
10954            ChatMessage::assistant("hi"),
10955        ];
10956        trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
10957        assert_eq!(history.len(), 3);
10958    }
10959
10960    #[test]
10961    fn autosave_memory_key_has_prefix_and_uniqueness() {
10962        let key1 = autosave_memory_key("user_msg");
10963        let key2 = autosave_memory_key("user_msg");
10964
10965        assert!(key1.starts_with("user_msg_"));
10966        assert!(key2.starts_with("user_msg_"));
10967        assert_ne!(key1, key2);
10968    }
10969
10970    #[tokio::test]
10971    async fn autosave_memory_keys_preserve_multiple_turns() {
10972        let tmp = TempDir::new().unwrap();
10973        let mem = SqliteMemory::new("test", tmp.path()).unwrap();
10974
10975        let key1 = autosave_memory_key("user_msg");
10976        let key2 = autosave_memory_key("user_msg");
10977
10978        mem.store(&key1, "I'm Paul", MemoryCategory::Conversation, None)
10979            .await
10980            .unwrap();
10981        mem.store(&key2, "I'm 45", MemoryCategory::Conversation, None)
10982            .await
10983            .unwrap();
10984
10985        assert_eq!(mem.count().await.unwrap(), 2);
10986
10987        let recalled = mem.recall("45", 5, None, None, None).await.unwrap();
10988        assert!(recalled.iter().any(|entry| entry.content.contains("45")));
10989    }
10990
10991    #[tokio::test]
10992    async fn build_context_ignores_legacy_assistant_autosave_entries() {
10993        let tmp = TempDir::new().unwrap();
10994        let mem = SqliteMemory::new("test", tmp.path()).unwrap();
10995        mem.store(
10996            "assistant_resp_poisoned",
10997            "User suffered a fabricated event",
10998            MemoryCategory::Daily,
10999            None,
11000        )
11001        .await
11002        .unwrap();
11003        mem.store(
11004            "user_preference",
11005            "User asked for concise status updates",
11006            MemoryCategory::Conversation,
11007            None,
11008        )
11009        .await
11010        .unwrap();
11011
11012        let context = build_context(&mem, "status updates", 0.0, None, false).await;
11013        assert!(context.contains("user_preference"));
11014        assert!(!context.contains("assistant_resp_poisoned"));
11015        assert!(!context.contains("fabricated event"));
11016    }
11017
11018    #[tokio::test]
11019    async fn build_context_ignores_user_autosave_entries() {
11020        let tmp = TempDir::new().unwrap();
11021        let mem = SqliteMemory::new("test", tmp.path()).unwrap();
11022        mem.store(
11023            "user_msg",
11024            "Original user message with full conversation history",
11025            MemoryCategory::Conversation,
11026            None,
11027        )
11028        .await
11029        .unwrap();
11030        mem.store(
11031            "user_msg_a1b2c3d4",
11032            "Follow-up user message embedding prior context verbatim",
11033            MemoryCategory::Conversation,
11034            None,
11035        )
11036        .await
11037        .unwrap();
11038        mem.store(
11039            "user_preference",
11040            "User prefers concise answers",
11041            MemoryCategory::Conversation,
11042            None,
11043        )
11044        .await
11045        .unwrap();
11046
11047        let context = build_context(&mem, "answers", 0.0, None, false).await;
11048        assert!(context.contains("user_preference"));
11049        assert!(!context.contains("user_msg"));
11050        assert!(!context.contains("embedding prior context"));
11051    }
11052
11053    /// Regression: cron / heartbeat runs must not surface chat-origin
11054    /// `Conversation` memories — the leak path the #5456 prefix filter
11055    /// missed because `agent::run` performs a second, unfiltered recall
11056    /// inside `build_context`.
11057    #[tokio::test]
11058    async fn build_context_excludes_conversation_when_flag_set() {
11059        let tmp = TempDir::new().unwrap();
11060        let mem = SqliteMemory::new("test", tmp.path()).unwrap();
11061        // A Conversation entry written by a chat channel with a non-autosave
11062        // key (autosave keys are already skipped by the existing filters).
11063        mem.store(
11064            "discord:guild:chan:msg-42",
11065            "Reminder for Alice: the API key is in 1Password vault Foo.",
11066            MemoryCategory::Conversation,
11067            Some("discord:guild:chan"),
11068        )
11069        .await
11070        .unwrap();
11071        // A non-Conversation memory that should still surface so we know the
11072        // function still does its job — only Conversation should be dropped.
11073        mem.store(
11074            "team_oncall",
11075            "Primary on-call rotates every Monday at 09:00 UTC.",
11076            MemoryCategory::Core,
11077            None,
11078        )
11079        .await
11080        .unwrap();
11081
11082        let context = build_context(&mem, "Alice on-call", 0.0, None, true).await;
11083        assert!(
11084            !context.contains("Alice"),
11085            "Conversation memory leaked into scheduled context: {context}"
11086        );
11087        assert!(
11088            !context.contains("API key"),
11089            "Conversation memory leaked into scheduled context: {context}"
11090        );
11091        assert!(
11092            context.contains("team_oncall"),
11093            "Non-Conversation memory should still surface: {context}"
11094        );
11095    }
11096
11097    // ═══════════════════════════════════════════════════════════════════════
11098    // Recovery Tests - Tool Call Parsing Edge Cases
11099    // ═══════════════════════════════════════════════════════════════════════
11100
11101    #[test]
11102    fn strip_think_tags_removes_single_block() {
11103        assert_eq!(strip_think_tags("<think>reasoning</think>Hello"), "Hello");
11104    }
11105
11106    #[test]
11107    fn strip_think_tags_removes_multiple_blocks() {
11108        assert_eq!(strip_think_tags("<think>a</think>X<think>b</think>Y"), "XY");
11109    }
11110
11111    #[test]
11112    fn strip_think_tags_handles_unclosed_block() {
11113        assert_eq!(strip_think_tags("visible<think>hidden"), "visible");
11114    }
11115
11116    #[test]
11117    fn strip_think_tags_preserves_text_without_tags() {
11118        assert_eq!(strip_think_tags("plain text"), "plain text");
11119    }
11120
11121    #[test]
11122    fn parse_tool_calls_strips_think_before_tool_call() {
11123        // Qwen regression: <think> tags before <tool_call> tags should be
11124        // stripped, allowing the tool call to be parsed correctly.
11125        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>";
11126        let (text, calls) = parse_tool_calls(response);
11127        assert_eq!(
11128            calls.len(),
11129            1,
11130            "should parse tool call after stripping think tags"
11131        );
11132        assert_eq!(calls[0].name, "shell");
11133        assert_eq!(
11134            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
11135            "ls"
11136        );
11137        assert!(text.is_empty(), "think content should not appear as text");
11138    }
11139
11140    #[test]
11141    fn parse_tool_calls_strips_think_only_returns_empty() {
11142        // When response is only <think> tags with no tool calls, should
11143        // return empty text and no calls.
11144        let response = "<think>Just thinking, no action needed</think>";
11145        let (text, calls) = parse_tool_calls(response);
11146        assert!(calls.is_empty());
11147        assert!(text.is_empty());
11148    }
11149
11150    #[test]
11151    fn parse_tool_calls_handles_qwen_think_with_multiple_tool_calls() {
11152        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>";
11153        let (_, calls) = parse_tool_calls(response);
11154        assert_eq!(calls.len(), 2);
11155        assert_eq!(
11156            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
11157            "date"
11158        );
11159        assert_eq!(
11160            calls[1].arguments.get("command").unwrap().as_str().unwrap(),
11161            "pwd"
11162        );
11163    }
11164
11165    #[test]
11166    fn strip_tool_result_blocks_preserves_clean_text() {
11167        let input = "Hello, this is a normal response.";
11168        assert_eq!(strip_tool_result_blocks(input), input);
11169    }
11170
11171    #[test]
11172    fn strip_tool_result_blocks_returns_empty_for_only_tags() {
11173        let input = "<tool_result name=\"memory_recall\" status=\"ok\">\n{}\n</tool_result>";
11174        assert_eq!(strip_tool_result_blocks(input), "");
11175    }
11176
11177    #[test]
11178    fn parse_tool_calls_handles_empty_tool_calls_array() {
11179        // Recovery: Empty tool_calls array returns original response (no tool parsing)
11180        let response = r#"{"content": "Hello", "tool_calls": []}"#;
11181        let (text, calls) = parse_tool_calls(response);
11182        // When tool_calls is empty, the entire JSON is returned as text
11183        assert!(text.contains("Hello"));
11184        assert!(calls.is_empty());
11185    }
11186
11187    #[test]
11188    fn detect_tool_call_parse_issue_flags_malformed_payloads() {
11189        let response =
11190            "<tool_call>{\"name\":\"shell\",\"arguments\":{\"command\":\"pwd\"}</tool_call>";
11191        let issue = detect_tool_call_parse_issue(response, &[]);
11192        assert!(
11193            issue.is_some(),
11194            "malformed tool payload should be flagged for diagnostics"
11195        );
11196    }
11197
11198    #[test]
11199    fn detect_tool_call_parse_issue_ignores_normal_text() {
11200        let issue = detect_tool_call_parse_issue("Thanks, done.", &[]);
11201        assert!(issue.is_none());
11202    }
11203
11204    // ═══════════════════════════════════════════════════════════════════════
11205    // Recovery Tests - History Management
11206    // ═══════════════════════════════════════════════════════════════════════
11207
11208    #[test]
11209    fn trim_history_with_no_system_prompt() {
11210        // Recovery: History without system prompt should trim correctly
11211        let mut history = vec![];
11212        for i in 0..DEFAULT_MAX_HISTORY_MESSAGES + 20 {
11213            history.push(ChatMessage::user(format!("msg {i}")));
11214        }
11215        trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
11216        assert_eq!(history.len(), DEFAULT_MAX_HISTORY_MESSAGES);
11217    }
11218
11219    #[test]
11220    fn trim_history_preserves_role_ordering() {
11221        // Recovery: After trimming, role ordering should remain consistent
11222        let mut history = vec![ChatMessage::system("system")];
11223        for i in 0..DEFAULT_MAX_HISTORY_MESSAGES + 10 {
11224            history.push(ChatMessage::user(format!("user {i}")));
11225            history.push(ChatMessage::assistant(format!("assistant {i}")));
11226        }
11227        trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
11228        assert_eq!(history[0].role, "system");
11229        assert_eq!(history[history.len() - 1].role, "assistant");
11230    }
11231
11232    #[test]
11233    fn trim_history_with_only_system_prompt() {
11234        // Recovery: Only system prompt should not be trimmed
11235        let mut history = vec![ChatMessage::system("system prompt")];
11236        trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
11237        assert_eq!(history.len(), 1);
11238    }
11239
11240    // ═══════════════════════════════════════════════════════════════════════
11241    // Recovery Tests - Arguments Parsing
11242    // ═══════════════════════════════════════════════════════════════════════
11243
11244    // ═══════════════════════════════════════════════════════════════════════
11245    // Recovery Tests - JSON Extraction
11246    // ═══════════════════════════════════════════════════════════════════════
11247
11248    // ═══════════════════════════════════════════════════════════════════════
11249    // Recovery Tests - Constants Validation
11250    // ═══════════════════════════════════════════════════════════════════════
11251
11252    const _: () = {
11253        assert!(DEFAULT_MAX_TOOL_ITERATIONS > 0);
11254        assert!(DEFAULT_MAX_TOOL_ITERATIONS <= 100);
11255        assert!(DEFAULT_MAX_HISTORY_MESSAGES > 0);
11256        assert!(DEFAULT_MAX_HISTORY_MESSAGES <= 1000);
11257    };
11258
11259    #[test]
11260    fn constants_bounds_are_compile_time_checked() {
11261        // Bounds are enforced by the const assertions above.
11262    }
11263
11264    // ═══════════════════════════════════════════════════════════════════════
11265    // Recovery Tests - Tool Call Value Parsing
11266
11267    #[test]
11268    fn parse_tool_calls_handles_unclosed_tool_call_tag() {
11269        let response = "<tool_call>{\"name\":\"shell\",\"arguments\":{\"command\":\"pwd\"}}\nDone";
11270        let (text, calls) = parse_tool_calls(response);
11271        assert_eq!(calls.len(), 1);
11272        assert_eq!(calls[0].name, "shell");
11273        assert_eq!(calls[0].arguments["command"], "pwd");
11274        assert_eq!(text, "Done");
11275    }
11276
11277    // ─────────────────────────────────────────────────────────────────────
11278    // TG4 (inline): parse_tool_calls robustness — malformed/edge-case inputs
11279    // Prevents: Pattern 4 issues #746, #418, #777, #848
11280    // ─────────────────────────────────────────────────────────────────────
11281
11282    #[test]
11283    fn parse_tool_calls_empty_input_returns_empty() {
11284        let (text, calls) = parse_tool_calls("");
11285        assert!(calls.is_empty(), "empty input should produce no tool calls");
11286        assert!(text.is_empty(), "empty input should produce no text");
11287    }
11288
11289    #[test]
11290    fn parse_tool_calls_whitespace_only_returns_empty_calls() {
11291        let (text, calls) = parse_tool_calls("   \n\t  ");
11292        assert!(calls.is_empty());
11293        assert!(text.is_empty() || text.trim().is_empty());
11294    }
11295
11296    #[test]
11297    fn parse_tool_calls_nested_xml_tags_handled() {
11298        // Double-wrapped tool call should still parse the inner call
11299        let response = r#"<tool_call><tool_call>{"name":"echo","arguments":{"msg":"hi"}}</tool_call></tool_call>"#;
11300        let (_text, calls) = parse_tool_calls(response);
11301        // Should find at least one tool call
11302        assert!(
11303            !calls.is_empty(),
11304            "nested XML tags should still yield at least one tool call"
11305        );
11306    }
11307
11308    #[test]
11309    fn parse_tool_calls_truncated_json_no_panic() {
11310        // Incomplete JSON inside tool_call tags
11311        let response = r#"<tool_call>{"name":"shell","arguments":{"command":"ls"</tool_call>"#;
11312        let (_text, _calls) = parse_tool_calls(response);
11313        // Should not panic — graceful handling of truncated JSON
11314    }
11315
11316    #[test]
11317    fn parse_tool_calls_empty_json_object_in_tag() {
11318        let response = "<tool_call>{}</tool_call>";
11319        let (_text, calls) = parse_tool_calls(response);
11320        // Empty JSON object has no name field — should not produce valid tool call
11321        assert!(
11322            calls.is_empty(),
11323            "empty JSON object should not produce a tool call"
11324        );
11325    }
11326
11327    #[test]
11328    fn parse_tool_calls_closing_tag_only_returns_text() {
11329        let response = "Some text </tool_call> more text";
11330        let (text, calls) = parse_tool_calls(response);
11331        assert!(
11332            calls.is_empty(),
11333            "closing tag only should not produce calls"
11334        );
11335        assert!(
11336            !text.is_empty(),
11337            "text around orphaned closing tag should be preserved"
11338        );
11339    }
11340
11341    #[test]
11342    fn parse_tool_calls_very_large_arguments_no_panic() {
11343        let large_arg = "x".repeat(100_000);
11344        let response = format!(
11345            r#"<tool_call>{{"name":"echo","arguments":{{"message":"{}"}}}}</tool_call>"#,
11346            large_arg
11347        );
11348        let (_text, calls) = parse_tool_calls(&response);
11349        assert_eq!(calls.len(), 1, "large arguments should still parse");
11350        assert_eq!(calls[0].name, "echo");
11351    }
11352
11353    #[test]
11354    fn parse_tool_calls_special_characters_in_arguments() {
11355        let response = r#"<tool_call>{"name":"echo","arguments":{"message":"hello \"world\" <>&'\n\t"}}</tool_call>"#;
11356        let (_text, calls) = parse_tool_calls(response);
11357        assert_eq!(calls.len(), 1);
11358        assert_eq!(calls[0].name, "echo");
11359    }
11360
11361    #[test]
11362    fn parse_tool_calls_text_with_embedded_json_not_extracted() {
11363        // Raw JSON without any tags should NOT be extracted as a tool call
11364        let response = r#"Here is some data: {"name":"echo","arguments":{"message":"hi"}} end."#;
11365        let (_text, calls) = parse_tool_calls(response);
11366        assert!(
11367            calls.is_empty(),
11368            "raw JSON in text without tags should not be extracted"
11369        );
11370    }
11371
11372    #[test]
11373    fn parse_tool_calls_multiple_formats_mixed() {
11374        // Mix of text and properly tagged tool call
11375        let response = r#"I'll help you with that.
11376
11377<tool_call>
11378{"name":"shell","arguments":{"command":"echo hello"}}
11379</tool_call>
11380
11381Let me check the result."#;
11382        let (text, calls) = parse_tool_calls(response);
11383        assert_eq!(
11384            calls.len(),
11385            1,
11386            "should extract one tool call from mixed content"
11387        );
11388        assert_eq!(calls[0].name, "shell");
11389        assert!(
11390            text.contains("help you"),
11391            "text before tool call should be preserved"
11392        );
11393    }
11394
11395    // ─────────────────────────────────────────────────────────────────────
11396    // TG4 (inline): scrub_credentials edge cases
11397    // ─────────────────────────────────────────────────────────────────────
11398
11399    #[test]
11400    fn scrub_credentials_empty_input() {
11401        let result = scrub_credentials("");
11402        assert_eq!(result, "");
11403    }
11404
11405    #[test]
11406    fn scrub_credentials_no_sensitive_data() {
11407        let input = "normal text without any secrets";
11408        let result = scrub_credentials(input);
11409        assert_eq!(
11410            result, input,
11411            "non-sensitive text should pass through unchanged"
11412        );
11413    }
11414
11415    #[test]
11416    fn scrub_credentials_multibyte_chars_no_panic() {
11417        // Regression test for #3024: byte index 4 is not a char boundary
11418        // when the captured value contains multi-byte UTF-8 characters.
11419        // The regex only matches quoted values for non-ASCII content, since
11420        // capture group 4 is restricted to [a-zA-Z0-9_\-\.].
11421        let input = "password=\"\u{4f60}\u{7684}WiFi\u{5bc6}\u{7801}ab\"";
11422        let result = scrub_credentials(input);
11423        assert!(
11424            result.contains("[REDACTED]"),
11425            "multi-byte quoted value should be redacted without panic, got: {result}"
11426        );
11427    }
11428
11429    #[test]
11430    fn scrub_credentials_short_values_not_redacted() {
11431        // Values shorter than 8 chars should not be redacted
11432        let input = r#"api_key="short""#;
11433        let result = scrub_credentials(input);
11434        assert_eq!(result, input, "short values should not be redacted");
11435    }
11436
11437    // ─────────────────────────────────────────────────────────────────────
11438    // TG4 (inline): trim_history edge cases
11439    // ─────────────────────────────────────────────────────────────────────
11440
11441    #[test]
11442    fn trim_history_empty_history() {
11443        let mut history: Vec<ChatMessage> = vec![];
11444        trim_history(&mut history, 10);
11445        assert!(history.is_empty());
11446    }
11447
11448    #[test]
11449    fn trim_history_system_only() {
11450        let mut history = vec![ChatMessage::system("system prompt")];
11451        trim_history(&mut history, 10);
11452        assert_eq!(history.len(), 1);
11453        assert_eq!(history[0].role, "system");
11454    }
11455
11456    #[test]
11457    fn trim_history_exactly_at_limit() {
11458        let mut history = vec![
11459            ChatMessage::system("system"),
11460            ChatMessage::user("msg 1"),
11461            ChatMessage::assistant("reply 1"),
11462        ];
11463        trim_history(&mut history, 2); // 2 non-system messages = exactly at limit
11464        assert_eq!(history.len(), 3, "should not trim when exactly at limit");
11465    }
11466
11467    #[test]
11468    fn trim_history_keeps_first_user_anchor_and_recent_tail() {
11469        // The framing anchor (first user message) must survive trim so the
11470        // model doesn't start a turn thinking "Continue" is the first thing
11471        // it ever saw. Middle messages are the ones that get dropped.
11472        let mut history = vec![
11473            ChatMessage::system("system"),
11474            ChatMessage::user("anchor: what's the task"),
11475            ChatMessage::assistant("middle reply 1"),
11476            ChatMessage::user("middle user 1"),
11477            ChatMessage::assistant("middle reply 2"),
11478            ChatMessage::user("recent user"),
11479            ChatMessage::assistant("recent reply"),
11480        ];
11481        // max_history = 3 → keep anchor + 2 most recent (=3 non-system).
11482        trim_history(&mut history, 3);
11483        assert_eq!(history[0].role, "system");
11484        assert_eq!(
11485            history[1].content, "anchor: what's the task",
11486            "first user message (framing anchor) must survive"
11487        );
11488        let last = history.last().expect("history not empty");
11489        assert_eq!(last.content, "recent reply", "tail must be preserved");
11490    }
11491
11492    #[test]
11493    fn trim_history_falls_back_to_tail_when_max_history_is_one() {
11494        // With max_history=1 there's no room for both anchor and tail; fall
11495        // back to plain head-drop so we don't produce a degenerate window.
11496        let mut history = vec![
11497            ChatMessage::system("system"),
11498            ChatMessage::user("anchor"),
11499            ChatMessage::assistant("middle"),
11500            ChatMessage::user("recent"),
11501        ];
11502        trim_history(&mut history, 1);
11503        assert_eq!(history.len(), 2);
11504        assert_eq!(history[0].role, "system");
11505        assert_eq!(history[1].content, "recent");
11506    }
11507
11508    /// When `build_system_prompt_with_mode` is called with `native_tools = true`,
11509    /// the output must contain ZERO XML protocol artifacts and must not inject
11510    /// the duplicate non-native tools summary.
11511    #[test]
11512    fn native_tools_system_prompt_contains_zero_xml() {
11513        use crate::agent::system_prompt::build_system_prompt_with_mode;
11514
11515        let workspace = tempdir().unwrap();
11516        let tool_summaries: Vec<(&str, &str)> = vec![
11517            ("shell", "Execute shell commands"),
11518            ("file_read", "Read files"),
11519        ];
11520
11521        let system_prompt = build_system_prompt_with_mode(
11522            workspace.path(),
11523            "test-model",
11524            &tool_summaries,
11525            &[],  // no skills
11526            None, // no identity config
11527            None, // no bootstrap_max_chars
11528            true, // native_tools
11529            zeroclaw_config::schema::SkillsPromptInjectionMode::Full,
11530            crate::security::AutonomyLevel::default(),
11531        );
11532
11533        // Must contain zero XML protocol artifacts
11534        assert!(
11535            !system_prompt.contains("<tool_call>"),
11536            "Native prompt must not contain <tool_call>"
11537        );
11538        assert!(
11539            !system_prompt.contains("</tool_call>"),
11540            "Native prompt must not contain </tool_call>"
11541        );
11542        assert!(
11543            !system_prompt.contains("<tool_result>"),
11544            "Native prompt must not contain <tool_result>"
11545        );
11546        assert!(
11547            !system_prompt.contains("</tool_result>"),
11548            "Native prompt must not contain </tool_result>"
11549        );
11550        assert!(
11551            !system_prompt.contains("## Tool Use Protocol"),
11552            "Native prompt must not contain XML protocol header"
11553        );
11554
11555        // Positive: native prompt should still contain native-task framing.
11556        assert!(
11557            !system_prompt.contains("## Tools"),
11558            "Native prompt should skip the duplicate tools summary"
11559        );
11560        assert!(
11561            system_prompt.contains("## Your Task"),
11562            "Native prompt should contain task instructions"
11563        );
11564    }
11565
11566    #[test]
11567    fn non_native_system_prompt_with_no_tools_contains_zero_tool_protocol() {
11568        use crate::agent::system_prompt::build_system_prompt_with_mode;
11569
11570        let tool_summaries: Vec<(&str, &str)> = vec![];
11571
11572        let system_prompt = build_system_prompt_with_mode(
11573            std::path::Path::new("/tmp"),
11574            "test-model",
11575            &tool_summaries,
11576            &[],
11577            None,
11578            None,
11579            false,
11580            zeroclaw_config::schema::SkillsPromptInjectionMode::Full,
11581            crate::security::AutonomyLevel::default(),
11582        );
11583
11584        assert!(
11585            !system_prompt.contains("## Tools"),
11586            "No-tools prompt must not include a Tools section"
11587        );
11588        assert!(
11589            !system_prompt.contains("## Tool Use Protocol"),
11590            "No-tools prompt must not include tool protocol"
11591        );
11592        assert!(
11593            !system_prompt.contains("<tool_call>"),
11594            "No-tools prompt must not mention XML tool calls"
11595        );
11596        assert!(
11597            !system_prompt.contains("<tool_result>"),
11598            "No-tools prompt must not mention XML tool results"
11599        );
11600        assert!(
11601            !system_prompt.contains("Use the tools"),
11602            "No-tools prompt must not instruct the model to use unavailable tools"
11603        );
11604        assert!(
11605            system_prompt.contains("No tools are available for this turn"),
11606            "No-tools prompt should explicitly describe the current capability boundary"
11607        );
11608    }
11609
11610    #[test]
11611    fn strict_non_native_prompt_policy_hides_text_tool_protocol_inputs() {
11612        let mut tool_descs = vec![("shell", "Run commands")];
11613        let mut deferred_section = "## Deferred MCP Tools\n\n- mcp__example".to_string();
11614
11615        let expose_text_protocol =
11616            apply_text_tool_prompt_policy(false, true, &mut tool_descs, &mut deferred_section);
11617
11618        assert!(!expose_text_protocol);
11619        assert!(
11620            tool_descs.is_empty(),
11621            "strict non-native prompt paths must not advertise text tools"
11622        );
11623        assert!(
11624            deferred_section.is_empty(),
11625            "strict non-native prompt paths must not advertise deferred text tools"
11626        );
11627    }
11628
11629    // ── Cross-Alias & GLM Shortened Body Tests ──────────────────────────
11630
11631    #[test]
11632    fn parse_tool_calls_cross_alias_close_tag_with_json() {
11633        // <tool_call> opened but closed with </invoke> — JSON body
11634        let input = r#"<tool_call>{"name": "shell", "arguments": {"command": "ls"}}</invoke>"#;
11635        let (text, calls) = parse_tool_calls(input);
11636        assert_eq!(calls.len(), 1);
11637        assert_eq!(calls[0].name, "shell");
11638        assert_eq!(calls[0].arguments["command"], "ls");
11639        assert!(text.is_empty());
11640    }
11641
11642    #[test]
11643    fn parse_tool_calls_cross_alias_close_tag_with_glm_shortened() {
11644        // <tool_call>shell>uname -a</invoke> — GLM shortened inside cross-alias tags
11645        let input = "<tool_call>shell>uname -a</invoke>";
11646        let (text, calls) = parse_tool_calls(input);
11647        assert_eq!(calls.len(), 1);
11648        assert_eq!(calls[0].name, "shell");
11649        assert_eq!(calls[0].arguments["command"], "uname -a");
11650        assert!(text.is_empty());
11651    }
11652
11653    #[test]
11654    fn parse_tool_calls_glm_shortened_body_in_matched_tags() {
11655        // <tool_call>shell>pwd</tool_call> — GLM shortened in matched tags
11656        let input = "<tool_call>shell>pwd</tool_call>";
11657        let (text, calls) = parse_tool_calls(input);
11658        assert_eq!(calls.len(), 1);
11659        assert_eq!(calls[0].name, "shell");
11660        assert_eq!(calls[0].arguments["command"], "pwd");
11661        assert!(text.is_empty());
11662    }
11663
11664    #[test]
11665    fn parse_tool_calls_glm_yaml_style_in_tags() {
11666        // <tool_call>shell>\ncommand: date\napproved: true</invoke>
11667        let input = "<tool_call>shell>\ncommand: date\napproved: true</invoke>";
11668        let (text, calls) = parse_tool_calls(input);
11669        assert_eq!(calls.len(), 1);
11670        assert_eq!(calls[0].name, "shell");
11671        assert_eq!(calls[0].arguments["command"], "date");
11672        assert_eq!(calls[0].arguments["approved"], true);
11673        assert!(text.is_empty());
11674    }
11675
11676    #[test]
11677    fn parse_tool_calls_attribute_style_in_tags() {
11678        // <tool_call>shell command="date" /></tool_call>
11679        let input = r#"<tool_call>shell command="date" /></tool_call>"#;
11680        let (text, calls) = parse_tool_calls(input);
11681        assert_eq!(calls.len(), 1);
11682        assert_eq!(calls[0].name, "shell");
11683        assert_eq!(calls[0].arguments["command"], "date");
11684        assert!(text.is_empty());
11685    }
11686
11687    #[test]
11688    fn parse_tool_calls_file_read_shortened_in_cross_alias() {
11689        // <tool_call>file_read path=".env" /></invoke>
11690        let input = r#"<tool_call>file_read path=".env" /></invoke>"#;
11691        let (text, calls) = parse_tool_calls(input);
11692        assert_eq!(calls.len(), 1);
11693        assert_eq!(calls[0].name, "file_read");
11694        assert_eq!(calls[0].arguments["path"], ".env");
11695        assert!(text.is_empty());
11696    }
11697
11698    #[test]
11699    fn parse_tool_calls_unclosed_glm_shortened_no_close_tag() {
11700        // <tool_call>shell>ls -la (no close tag at all)
11701        let input = "<tool_call>shell>ls -la";
11702        let (text, calls) = parse_tool_calls(input);
11703        assert_eq!(calls.len(), 1);
11704        assert_eq!(calls[0].name, "shell");
11705        assert_eq!(calls[0].arguments["command"], "ls -la");
11706        assert!(text.is_empty());
11707    }
11708
11709    #[test]
11710    fn parse_tool_calls_text_before_cross_alias() {
11711        // Text before and after cross-alias tool call
11712        let input = "Let me check that.\n<tool_call>shell>uname -a</invoke>\nDone.";
11713        let (text, calls) = parse_tool_calls(input);
11714        assert_eq!(calls.len(), 1);
11715        assert_eq!(calls[0].name, "shell");
11716        assert_eq!(calls[0].arguments["command"], "uname -a");
11717        assert!(text.contains("Let me check that."));
11718        assert!(text.contains("Done."));
11719    }
11720
11721    // ═══════════════════════════════════════════════════════════════════════
11722    // reasoning_content pass-through tests for history builders
11723    // ═══════════════════════════════════════════════════════════════════════
11724
11725    #[test]
11726    fn build_native_assistant_history_includes_reasoning_content() {
11727        let calls = vec![ToolCall {
11728            id: "call_1".into(),
11729            name: "shell".into(),
11730            arguments: "{}".into(),
11731            extra_content: None,
11732        }];
11733        let result = build_native_assistant_history("answer", &calls, Some("thinking step"));
11734        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
11735        assert_eq!(parsed["content"].as_str(), Some("answer"));
11736        assert_eq!(parsed["reasoning_content"].as_str(), Some("thinking step"));
11737        assert!(parsed["tool_calls"].is_array());
11738    }
11739
11740    #[test]
11741    fn build_native_assistant_history_omits_reasoning_content_when_none() {
11742        let calls = vec![ToolCall {
11743            id: "call_1".into(),
11744            name: "shell".into(),
11745            arguments: "{}".into(),
11746            extra_content: None,
11747        }];
11748        let result = build_native_assistant_history("answer", &calls, None);
11749        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
11750        assert_eq!(parsed["content"].as_str(), Some("answer"));
11751        assert!(parsed.get("reasoning_content").is_none());
11752    }
11753
11754    #[test]
11755    fn build_native_assistant_history_from_parsed_calls_includes_reasoning_content() {
11756        let calls = vec![ParsedToolCall {
11757            name: "shell".into(),
11758            arguments: serde_json::json!({"command": "pwd"}),
11759            tool_call_id: Some("call_2".into()),
11760        }];
11761        let result = build_native_assistant_history_from_parsed_calls(
11762            "answer",
11763            &calls,
11764            Some("deep thought"),
11765        );
11766        assert!(result.is_some());
11767        let parsed: serde_json::Value = serde_json::from_str(result.as_deref().unwrap()).unwrap();
11768        assert_eq!(parsed["content"].as_str(), Some("answer"));
11769        assert_eq!(parsed["reasoning_content"].as_str(), Some("deep thought"));
11770        assert!(parsed["tool_calls"].is_array());
11771    }
11772
11773    #[test]
11774    fn build_native_assistant_history_from_parsed_calls_omits_reasoning_content_when_none() {
11775        let calls = vec![ParsedToolCall {
11776            name: "shell".into(),
11777            arguments: serde_json::json!({"command": "pwd"}),
11778            tool_call_id: Some("call_2".into()),
11779        }];
11780        let result = build_native_assistant_history_from_parsed_calls("answer", &calls, None);
11781        assert!(result.is_some());
11782        let parsed: serde_json::Value = serde_json::from_str(result.as_deref().unwrap()).unwrap();
11783        assert_eq!(parsed["content"].as_str(), Some("answer"));
11784        assert!(parsed.get("reasoning_content").is_none());
11785    }
11786
11787    /// Regression test for issue #6059 — DeepSeek V4 thinking-mode tool-call
11788    /// replay rejected with `400` because the assistant's prior
11789    /// `reasoning_content` was missing from the next request.
11790    ///
11791    /// Before the fix, the streaming consumer dropped reasoning chunks on the
11792    /// floor (`chunk.delta.is_empty()` short-circuit + hardcoded
11793    /// `reasoning_content: None` on the synthesized `ChatResponse`). After
11794    /// the fix, reasoning deltas accumulate into `StreamedChatOutcome` and
11795    /// surface on the response so the agent's history layer can persist them
11796    /// and replay them on subsequent turns.
11797    #[tokio::test]
11798    async fn consume_provider_streaming_response_captures_reasoning_content() {
11799        let model_provider = StreamingNativeToolEventModelProvider::with_turns(vec![
11800            NativeStreamTurn::TextWithReasoning {
11801                text: "Listing the directory now.".to_string(),
11802                reasoning: "I need to call the shell tool to list files.".to_string(),
11803            },
11804        ]);
11805        let messages = vec![ChatMessage::user(
11806            "List the folders in the current directory",
11807        )];
11808
11809        let outcome = consume_provider_streaming_response(
11810            &model_provider,
11811            &messages,
11812            None,
11813            "deepseek-v4-pro",
11814            Some(0.2),
11815            None,
11816            None,
11817            false,
11818        )
11819        .await
11820        .expect("streaming should succeed");
11821
11822        assert_eq!(outcome.response_text, "Listing the directory now.");
11823        assert_eq!(
11824            outcome.reasoning_content,
11825            "I need to call the shell tool to list files."
11826        );
11827        assert!(
11828            outcome.tool_calls.is_empty(),
11829            "this turn does not emit native tool calls"
11830        );
11831    }
11832
11833    #[tokio::test]
11834    async fn consume_provider_streaming_response_accumulates_split_reasoning_chunks() {
11835        // Scripted multi-event stream: two reasoning chunks straddling a text
11836        // delta. The outcome should concatenate the reasoning chunks in order
11837        // and keep them out of the visible response text.
11838        struct MultiChunkModelProvider;
11839
11840        #[async_trait]
11841        impl ModelProvider for MultiChunkModelProvider {
11842            async fn chat_with_system(
11843                &self,
11844                _system_prompt: Option<&str>,
11845                _message: &str,
11846                _model: &str,
11847                _temperature: Option<f64>,
11848            ) -> anyhow::Result<String> {
11849                anyhow::bail!("not used in this test")
11850            }
11851
11852            async fn chat(
11853                &self,
11854                _request: ChatRequest<'_>,
11855                _model: &str,
11856                _temperature: Option<f64>,
11857            ) -> anyhow::Result<ChatResponse> {
11858                anyhow::bail!("not used in this test")
11859            }
11860
11861            fn supports_streaming(&self) -> bool {
11862                true
11863            }
11864
11865            fn stream_chat(
11866                &self,
11867                _request: ChatRequest<'_>,
11868                _model: &str,
11869                _temperature: Option<f64>,
11870                _options: StreamOptions,
11871            ) -> futures_util::stream::BoxStream<
11872                'static,
11873                zeroclaw_providers::traits::StreamResult<StreamEvent>,
11874            > {
11875                Box::pin(futures_util::stream::iter(vec![
11876                    Ok(StreamEvent::TextDelta(StreamChunk::reasoning("Step 1: "))),
11877                    Ok(StreamEvent::TextDelta(StreamChunk::delta("Hello "))),
11878                    Ok(StreamEvent::TextDelta(StreamChunk::reasoning(
11879                        "consider options.",
11880                    ))),
11881                    Ok(StreamEvent::TextDelta(StreamChunk::delta("there."))),
11882                    Ok(StreamEvent::Final),
11883                ]))
11884            }
11885        }
11886        impl ::zeroclaw_api::attribution::Attributable for MultiChunkModelProvider {
11887            fn role(&self) -> ::zeroclaw_api::attribution::Role {
11888                ::zeroclaw_api::attribution::Role::Provider(
11889                    ::zeroclaw_api::attribution::ProviderKind::Model(
11890                        ::zeroclaw_api::attribution::ModelProviderKind::Custom,
11891                    ),
11892                )
11893            }
11894            fn alias(&self) -> &str {
11895                "MultiChunkModelProvider"
11896            }
11897        }
11898
11899        let model_provider = MultiChunkModelProvider;
11900        let messages = vec![ChatMessage::user("hi")];
11901
11902        let outcome = consume_provider_streaming_response(
11903            &model_provider,
11904            &messages,
11905            None,
11906            "deepseek-v4-flash",
11907            Some(0.2),
11908            None,
11909            None,
11910            false,
11911        )
11912        .await
11913        .expect("streaming should succeed");
11914
11915        assert_eq!(outcome.response_text, "Hello there.");
11916        assert_eq!(outcome.reasoning_content, "Step 1: consider options.");
11917    }
11918
11919    // ── glob_match tests ──────────────────────────────────────────────────────
11920
11921    #[test]
11922    fn glob_match_exact_no_wildcard() {
11923        assert!(glob_match("mcp_browser_navigate", "mcp_browser_navigate"));
11924        assert!(!glob_match("mcp_browser_navigate", "mcp_browser_click"));
11925    }
11926
11927    #[test]
11928    fn glob_match_prefix_wildcard() {
11929        // Suffix pattern: mcp_browser_*
11930        assert!(glob_match("mcp_browser_*", "mcp_browser_navigate"));
11931        assert!(glob_match("mcp_browser_*", "mcp_browser_click"));
11932        assert!(!glob_match("mcp_browser_*", "mcp_filesystem_read"));
11933
11934        // Prefix pattern: *_read
11935        assert!(glob_match("*_read", "mcp_filesystem_read"));
11936        assert!(!glob_match("*_read", "mcp_filesystem_write"));
11937
11938        // Infix: mcp_*_navigate
11939        assert!(glob_match("mcp_*_navigate", "mcp_browser_navigate"));
11940        assert!(!glob_match("mcp_*_navigate", "mcp_browser_click"));
11941    }
11942
11943    #[test]
11944    fn glob_match_star_matches_everything() {
11945        assert!(glob_match("*", "anything_at_all"));
11946        assert!(glob_match("*", ""));
11947    }
11948
11949    // ── filter_tool_specs_for_turn tests ──────────────────────────────────────
11950
11951    fn make_spec(name: &str) -> crate::tools::ToolSpec {
11952        crate::tools::ToolSpec {
11953            name: name.to_string(),
11954            description: String::new(),
11955            parameters: serde_json::json!({}),
11956        }
11957    }
11958
11959    #[test]
11960    fn filter_tool_specs_no_groups_returns_all() {
11961        let specs = vec![
11962            make_spec("shell_exec"),
11963            make_spec("mcp_browser_navigate"),
11964            make_spec("mcp_filesystem_read"),
11965        ];
11966        let result = filter_tool_specs_for_turn(specs, &[], "hello");
11967        assert_eq!(result.len(), 3);
11968    }
11969
11970    #[test]
11971    fn filter_tool_specs_always_group_includes_matching_mcp_tool() {
11972        use zeroclaw_config::schema::{ToolFilterGroup, ToolFilterGroupMode};
11973
11974        let specs = vec![
11975            make_spec("shell_exec"),
11976            make_spec("mcp_browser_navigate"),
11977            make_spec("mcp_filesystem_read"),
11978        ];
11979        let groups = vec![ToolFilterGroup {
11980            mode: ToolFilterGroupMode::Always,
11981            tools: vec!["mcp_filesystem_*".into()],
11982            keywords: vec![],
11983            filter_builtins: false,
11984        }];
11985        let result = filter_tool_specs_for_turn(specs, &groups, "anything");
11986        let names: Vec<&str> = result.iter().map(|s| s.name.as_str()).collect();
11987        // Built-in passes through, matched MCP passes, unmatched MCP excluded.
11988        assert!(names.contains(&"shell_exec"));
11989        assert!(names.contains(&"mcp_filesystem_read"));
11990        assert!(!names.contains(&"mcp_browser_navigate"));
11991    }
11992
11993    #[test]
11994    fn filter_tool_specs_dynamic_group_included_on_keyword_match() {
11995        use zeroclaw_config::schema::{ToolFilterGroup, ToolFilterGroupMode};
11996
11997        let specs = vec![make_spec("shell_exec"), make_spec("mcp_browser_navigate")];
11998        let groups = vec![ToolFilterGroup {
11999            mode: ToolFilterGroupMode::Dynamic,
12000            tools: vec!["mcp_browser_*".into()],
12001            keywords: vec!["browse".into(), "website".into()],
12002            filter_builtins: false,
12003        }];
12004        let result = filter_tool_specs_for_turn(specs, &groups, "please browse this page");
12005        let names: Vec<&str> = result.iter().map(|s| s.name.as_str()).collect();
12006        assert!(names.contains(&"shell_exec"));
12007        assert!(names.contains(&"mcp_browser_navigate"));
12008    }
12009
12010    #[test]
12011    fn filter_tool_specs_dynamic_group_excluded_on_no_keyword_match() {
12012        use zeroclaw_config::schema::{ToolFilterGroup, ToolFilterGroupMode};
12013
12014        let specs = vec![make_spec("shell_exec"), make_spec("mcp_browser_navigate")];
12015        let groups = vec![ToolFilterGroup {
12016            mode: ToolFilterGroupMode::Dynamic,
12017            tools: vec!["mcp_browser_*".into()],
12018            keywords: vec!["browse".into(), "website".into()],
12019            filter_builtins: false,
12020        }];
12021        let result = filter_tool_specs_for_turn(specs, &groups, "read the file /etc/hosts");
12022        let names: Vec<&str> = result.iter().map(|s| s.name.as_str()).collect();
12023        assert!(names.contains(&"shell_exec"));
12024        assert!(!names.contains(&"mcp_browser_navigate"));
12025    }
12026
12027    #[test]
12028    fn filter_tool_specs_dynamic_keyword_match_is_case_insensitive() {
12029        use zeroclaw_config::schema::{ToolFilterGroup, ToolFilterGroupMode};
12030
12031        let specs = vec![make_spec("mcp_browser_navigate")];
12032        let groups = vec![ToolFilterGroup {
12033            mode: ToolFilterGroupMode::Dynamic,
12034            tools: vec!["mcp_browser_*".into()],
12035            keywords: vec!["Browse".into()],
12036            filter_builtins: false,
12037        }];
12038        let result = filter_tool_specs_for_turn(specs, &groups, "BROWSE the site");
12039        assert_eq!(result.len(), 1);
12040    }
12041
12042    // ── Token-based compaction tests ──────────────────────────
12043
12044    #[test]
12045    fn estimate_history_tokens_empty() {
12046        assert_eq!(super::estimate_history_tokens(&[]), 0);
12047    }
12048
12049    #[test]
12050    fn estimate_history_tokens_single_message() {
12051        let history = vec![ChatMessage::user("hello world")]; // 11 chars
12052        let tokens = super::estimate_history_tokens(&history);
12053        // 11.div_ceil(4) + 4 = 3 + 4 = 7
12054        assert_eq!(tokens, 7);
12055    }
12056
12057    #[test]
12058    fn estimate_history_tokens_multiple_messages() {
12059        let history = vec![
12060            ChatMessage::system("You are helpful."), // 16 chars → 4 + 4 = 8
12061            ChatMessage::user("What is Rust?"),      // 13 chars → 4 + 4 = 8
12062            ChatMessage::assistant("A language."),   // 11 chars → 3 + 4 = 7
12063        ];
12064        let tokens = super::estimate_history_tokens(&history);
12065        assert_eq!(tokens, 23);
12066    }
12067
12068    #[tokio::test]
12069    async fn run_tool_call_loop_surfaces_tool_failure_reason_in_on_delta() {
12070        let model_provider = ScriptedModelProvider::from_text_responses(vec![
12071            r#"<tool_call>
12072{"name":"failing_shell","arguments":{"command":"rm -rf /"}}
12073</tool_call>"#,
12074            "I could not execute that command.",
12075        ]);
12076
12077        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(FailingTool::new(
12078            "failing_shell",
12079            "Command not allowed by security policy: rm -rf /",
12080        ))];
12081
12082        let mut history = vec![
12083            ChatMessage::system("test-system"),
12084            ChatMessage::user("delete everything"),
12085        ];
12086        let observer = NoopObserver;
12087
12088        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(64);
12089
12090        let result = run_tool_call_loop(
12091            &model_provider,
12092            &mut history,
12093            &tools_registry,
12094            &observer,
12095            "mock-provider",
12096            "mock-model",
12097            Some(0.0),
12098            true,
12099            None,
12100            "telegram",
12101            None,
12102            &zeroclaw_config::schema::MultimodalConfig::default(),
12103            4,
12104            None,
12105            Some(tx),
12106            None,
12107            &[],
12108            &[],
12109            None,
12110            None,
12111            &zeroclaw_config::schema::PacingConfig::default(),
12112            false,
12113            false, // parallel_tools
12114            0,
12115            0,
12116            None,
12117            None, // channel
12118            None, // receipt_generator
12119            None, // collected_receipts
12120        )
12121        .await
12122        .expect("tool loop should complete");
12123
12124        // Collect all messages sent to the on_delta channel.
12125        let mut deltas = Vec::new();
12126        while let Ok(msg) = rx.try_recv() {
12127            deltas.push(msg);
12128        }
12129
12130        let all_deltas: String = deltas
12131            .iter()
12132            .map(|d| match d {
12133                StreamDelta::Status(t) | StreamDelta::Text(t) => t.as_str(),
12134            })
12135            .collect();
12136
12137        // The failure reason should appear in the progress messages.
12138        assert!(
12139            all_deltas.contains("Command not allowed by security policy"),
12140            "on_delta messages should include the tool failure reason, got: {all_deltas}"
12141        );
12142
12143        // Should also contain the cross mark (❌) icon to indicate failure.
12144        assert!(
12145            all_deltas.contains('\u{274c}'),
12146            "on_delta messages should include ❌ for failed tool calls, got: {all_deltas}"
12147        );
12148
12149        assert!(
12150            result.ends_with("I could not execute that command."),
12151            "result should end with error message, got: {result}"
12152        );
12153    }
12154
12155    // ── filter_by_allowed_tools tests ─────────────────────────────────────
12156
12157    #[test]
12158    fn filter_by_allowed_tools_none_passes_all() {
12159        let specs = vec![
12160            make_spec("shell"),
12161            make_spec("memory_store"),
12162            make_spec("file_read"),
12163        ];
12164        let result = filter_by_allowed_tools(specs, None);
12165        assert_eq!(result.len(), 3);
12166    }
12167
12168    #[test]
12169    fn filter_by_allowed_tools_some_restricts_to_listed() {
12170        let specs = vec![
12171            make_spec("shell"),
12172            make_spec("memory_store"),
12173            make_spec("file_read"),
12174        ];
12175        let allowed = vec!["shell".to_string(), "memory_store".to_string()];
12176        let result = filter_by_allowed_tools(specs, Some(&allowed));
12177        let names: Vec<&str> = result.iter().map(|s| s.name.as_str()).collect();
12178        assert_eq!(names.len(), 2);
12179        assert!(names.contains(&"shell"));
12180        assert!(names.contains(&"memory_store"));
12181        assert!(!names.contains(&"file_read"));
12182    }
12183
12184    #[test]
12185    fn filter_by_allowed_tools_unknown_names_silently_ignored() {
12186        let specs = vec![make_spec("shell"), make_spec("file_read")];
12187        let allowed = vec![
12188            "shell".to_string(),
12189            "nonexistent_tool".to_string(),
12190            "another_missing".to_string(),
12191        ];
12192        let result = filter_by_allowed_tools(specs, Some(&allowed));
12193        let names: Vec<&str> = result.iter().map(|s| s.name.as_str()).collect();
12194        assert_eq!(names.len(), 1);
12195        assert!(names.contains(&"shell"));
12196    }
12197
12198    #[test]
12199    fn filter_by_allowed_tools_empty_list_excludes_all() {
12200        let specs = vec![make_spec("shell"), make_spec("file_read")];
12201        let allowed: Vec<String> = vec![];
12202        let result = filter_by_allowed_tools(specs, Some(&allowed));
12203        assert!(result.is_empty());
12204    }
12205
12206    // ── Cost tracking tests ──
12207
12208    #[tokio::test]
12209    async fn cost_tracking_records_usage_when_scoped() {
12210        use super::{
12211            TOOL_LOOP_COST_TRACKING_CONTEXT, ToolLoopCostTrackingContext, run_tool_call_loop,
12212        };
12213        use crate::cost::CostTracker;
12214        use crate::observability::noop::NoopObserver;
12215        use std::collections::HashMap;
12216
12217        let model_provider = ScriptedModelProvider {
12218            responses: Arc::new(Mutex::new(VecDeque::from([ChatResponse {
12219                text: Some("done".to_string()),
12220                tool_calls: Vec::new(),
12221                usage: Some(zeroclaw_providers::traits::TokenUsage {
12222                    input_tokens: Some(1_000),
12223                    output_tokens: Some(200),
12224                    cached_input_tokens: None,
12225                }),
12226                reasoning_content: None,
12227            }]))),
12228            capabilities: ProviderCapabilities::default(),
12229        };
12230        let observer = NoopObserver;
12231        let workspace = tempfile::TempDir::new().unwrap();
12232        let cost_config = zeroclaw_config::schema::CostConfig {
12233            enabled: true,
12234            ..zeroclaw_config::schema::CostConfig::default()
12235        };
12236        let tracker = Arc::new(CostTracker::new(cost_config.clone(), workspace.path()).unwrap());
12237        let mut model_pricing: HashMap<String, f64> = HashMap::new();
12238        model_pricing.insert("mock-model.input".to_string(), 3.0);
12239        model_pricing.insert("mock-model.output".to_string(), 15.0);
12240        let mut pricing: crate::agent::cost::ModelProviderPricing = HashMap::new();
12241        pricing.insert("mock-provider".to_string(), model_pricing);
12242        let ctx = ToolLoopCostTrackingContext::new(Arc::clone(&tracker), Arc::new(pricing));
12243        let mut history = vec![ChatMessage::system("test"), ChatMessage::user("hello")];
12244
12245        let result = TOOL_LOOP_COST_TRACKING_CONTEXT
12246            .scope(
12247                Some(ctx),
12248                run_tool_call_loop(
12249                    &model_provider,
12250                    &mut history,
12251                    &[],
12252                    &observer,
12253                    "mock-provider",
12254                    "mock-model",
12255                    Some(0.0),
12256                    true,
12257                    None,
12258                    "test",
12259                    None,
12260                    &zeroclaw_config::schema::MultimodalConfig::default(),
12261                    2,
12262                    None,
12263                    None,
12264                    None,
12265                    &[],
12266                    &[],
12267                    None,
12268                    None,
12269                    &zeroclaw_config::schema::PacingConfig::default(),
12270                    false,
12271                    false, // parallel_tools
12272                    0,
12273                    0,
12274                    None,
12275                    None, // channel
12276                    None, // receipt_generator
12277                    None, // collected_receipts
12278                ),
12279            )
12280            .await
12281            .expect("tool loop should succeed");
12282
12283        assert!(
12284            result.ends_with("done"),
12285            "result should end with 'done', got: {result}"
12286        );
12287        let summary = tracker.get_summary().unwrap();
12288        assert_eq!(summary.request_count, 1);
12289        assert_eq!(summary.total_tokens, 1_200);
12290        assert!(summary.session_cost_usd > 0.0);
12291    }
12292
12293    #[tokio::test]
12294    async fn tool_loop_normalizes_non_leading_system_messages_before_provider_request() {
12295        let provider = RecordingModelProvider::new();
12296        let requests = Arc::clone(&provider.requests);
12297        let observer = NoopObserver;
12298        let mut history = vec![
12299            ChatMessage::system("base system"),
12300            ChatMessage::user("first question"),
12301            ChatMessage::assistant("first answer"),
12302            ChatMessage::system("late loop-detection guidance"),
12303            ChatMessage::user("follow-up"),
12304        ];
12305
12306        let result = run_tool_call_loop(
12307            &provider,
12308            &mut history,
12309            &[],
12310            &observer,
12311            "recording-provider",
12312            "mock-model",
12313            Some(0.0),
12314            true,
12315            None,
12316            "test",
12317            None,
12318            &zeroclaw_config::schema::MultimodalConfig::default(),
12319            2,
12320            None,
12321            None,
12322            None,
12323            &[],
12324            &[],
12325            None,
12326            None,
12327            &zeroclaw_config::schema::PacingConfig::default(),
12328            false,
12329            false, // parallel_tools
12330            0,
12331            0,
12332            None,
12333            None,
12334            None,
12335            None,
12336        )
12337        .await
12338        .expect("tool loop should complete");
12339
12340        assert_eq!(result, "done");
12341        let requests = requests.lock().expect("requests lock should be valid");
12342        assert_eq!(requests.len(), 1);
12343        let sent = &requests[0];
12344        assert_eq!(sent[0].role, "system");
12345        assert_eq!(
12346            sent.iter().filter(|msg| msg.role == "system").count(),
12347            1,
12348            "provider request must not contain non-leading system messages: {:?}",
12349            sent.iter().map(|msg| msg.role.as_str()).collect::<Vec<_>>()
12350        );
12351        assert!(sent[0].content.contains("base system"));
12352        assert!(sent[0].content.contains("late loop-detection guidance"));
12353        assert_eq!(
12354            sent.iter().map(|msg| msg.role.as_str()).collect::<Vec<_>>(),
12355            vec!["system", "user", "assistant", "user"]
12356        );
12357    }
12358
12359    #[tokio::test]
12360    async fn cost_tracking_enforces_budget() {
12361        use super::{
12362            TOOL_LOOP_COST_TRACKING_CONTEXT, ToolLoopCostTrackingContext, run_tool_call_loop,
12363        };
12364        use crate::cost::CostTracker;
12365        use crate::observability::noop::NoopObserver;
12366        use std::collections::HashMap;
12367
12368        let model_provider =
12369            ScriptedModelProvider::from_text_responses(vec!["should not reach this"]);
12370        let observer = NoopObserver;
12371        let workspace = tempfile::TempDir::new().unwrap();
12372        let cost_config = zeroclaw_config::schema::CostConfig {
12373            enabled: true,
12374            daily_limit_usd: 0.001, // very low limit
12375            ..zeroclaw_config::schema::CostConfig::default()
12376        };
12377        let tracker = Arc::new(CostTracker::new(cost_config.clone(), workspace.path()).unwrap());
12378        // Record a usage that already exceeds the limit
12379        tracker
12380            .record_usage(crate::cost::types::TokenUsage::new(
12381                "mock-model",
12382                100_000,
12383                50_000,
12384                0,
12385                1.0,
12386                1.0,
12387                0.0,
12388            ))
12389            .unwrap();
12390
12391        let mut model_pricing: HashMap<String, f64> = HashMap::new();
12392        model_pricing.insert("mock-model.input".to_string(), 1.0);
12393        model_pricing.insert("mock-model.output".to_string(), 1.0);
12394        let mut pricing: crate::agent::cost::ModelProviderPricing = HashMap::new();
12395        pricing.insert("mock-provider".to_string(), model_pricing);
12396        let ctx = ToolLoopCostTrackingContext::new(Arc::clone(&tracker), Arc::new(pricing));
12397        let mut history = vec![ChatMessage::system("test"), ChatMessage::user("hello")];
12398
12399        let err = TOOL_LOOP_COST_TRACKING_CONTEXT
12400            .scope(
12401                Some(ctx),
12402                run_tool_call_loop(
12403                    &model_provider,
12404                    &mut history,
12405                    &[],
12406                    &observer,
12407                    "mock-provider",
12408                    "mock-model",
12409                    Some(0.0),
12410                    true,
12411                    None,
12412                    "test",
12413                    None,
12414                    &zeroclaw_config::schema::MultimodalConfig::default(),
12415                    2,
12416                    None,
12417                    None,
12418                    None,
12419                    &[],
12420                    &[],
12421                    None,
12422                    None,
12423                    &zeroclaw_config::schema::PacingConfig::default(),
12424                    false,
12425                    false, // parallel_tools
12426                    0,
12427                    0,
12428                    None,
12429                    None, // channel
12430                    None, // receipt_generator
12431                    None, // collected_receipts
12432                ),
12433            )
12434            .await
12435            .expect_err("should fail with budget exceeded");
12436
12437        assert!(
12438            err.to_string().contains("Budget exceeded"),
12439            "error should mention budget: {err}"
12440        );
12441    }
12442
12443    #[tokio::test]
12444    async fn cost_tracking_is_noop_without_scope() {
12445        use super::run_tool_call_loop;
12446        use crate::observability::noop::NoopObserver;
12447
12448        // No TOOL_LOOP_COST_TRACKING_CONTEXT scoped — should run fine
12449        let model_provider = ScriptedModelProvider {
12450            responses: Arc::new(Mutex::new(VecDeque::from([ChatResponse {
12451                text: Some("ok".to_string()),
12452                tool_calls: Vec::new(),
12453                usage: Some(zeroclaw_providers::traits::TokenUsage {
12454                    input_tokens: Some(500),
12455                    output_tokens: Some(100),
12456                    cached_input_tokens: None,
12457                }),
12458                reasoning_content: None,
12459            }]))),
12460            capabilities: ProviderCapabilities::default(),
12461        };
12462        let observer = NoopObserver;
12463        let mut history = vec![ChatMessage::system("test"), ChatMessage::user("hello")];
12464
12465        let result = run_tool_call_loop(
12466            &model_provider,
12467            &mut history,
12468            &[],
12469            &observer,
12470            "mock-provider",
12471            "mock-model",
12472            Some(0.0),
12473            true,
12474            None,
12475            "test",
12476            None,
12477            &zeroclaw_config::schema::MultimodalConfig::default(),
12478            2,
12479            None,
12480            None,
12481            None,
12482            &[],
12483            &[],
12484            None,
12485            None,
12486            &zeroclaw_config::schema::PacingConfig::default(),
12487            false,
12488            false, // parallel_tools
12489            0,
12490            0,
12491            None,
12492            None, // channel
12493            None, // receipt_generator
12494            None, // collected_receipts
12495        )
12496        .await
12497        .expect("should succeed without cost scope");
12498
12499        assert_eq!(result, "ok");
12500    }
12501
12502    // ── apply_policy_tool_filter coverage ─────────────────────
12503    //
12504    // The dispatch-site filter must consult both the parent agent's
12505    // SecurityPolicy.allowed_tools / .excluded_tools AND the
12506    // caller-supplied allowed_tools list, with both gates composing
12507    // by intersection. A tool name absent from either falls out.
12508
12509    use zeroclaw_api::tool::Tool as TestTool;
12510    use zeroclaw_config::policy::SecurityPolicy as TestPolicy;
12511
12512    struct NamedMockTool {
12513        the_name: &'static str,
12514    }
12515
12516    #[async_trait]
12517    impl TestTool for NamedMockTool {
12518        fn name(&self) -> &str {
12519            self.the_name
12520        }
12521        fn description(&self) -> &str {
12522            ""
12523        }
12524        fn parameters_schema(&self) -> serde_json::Value {
12525            serde_json::json!({})
12526        }
12527        async fn execute(
12528            &self,
12529            _args: serde_json::Value,
12530        ) -> anyhow::Result<crate::tools::ToolResult> {
12531            Ok(crate::tools::ToolResult {
12532                success: true,
12533                output: String::new(),
12534                error: None,
12535            })
12536        }
12537    }
12538
12539    fn mock_tool(name: &'static str) -> Box<dyn TestTool> {
12540        Box::new(NamedMockTool { the_name: name })
12541    }
12542
12543    fn mock_tool_arc(name: &'static str) -> std::sync::Arc<dyn TestTool> {
12544        std::sync::Arc::new(NamedMockTool { the_name: name })
12545    }
12546
12547    fn tool_names(tools: &[Box<dyn TestTool>]) -> Vec<&str> {
12548        tools.iter().map(|t| t.name()).collect()
12549    }
12550
12551    #[test]
12552    fn apply_policy_tool_filter_no_gates_keeps_everything() {
12553        let mut tools = vec![
12554            mock_tool("shell"),
12555            mock_tool("spawn_subagent"),
12556            mock_tool("memory_recall"),
12557        ];
12558        super::apply_policy_tool_filter(&mut tools, None, None);
12559        assert_eq!(
12560            tool_names(&tools),
12561            vec!["shell", "spawn_subagent", "memory_recall"]
12562        );
12563    }
12564
12565    #[test]
12566    fn apply_policy_tool_filter_policy_allowlist_restricts() {
12567        let mut tools = vec![
12568            mock_tool("shell"),
12569            mock_tool("spawn_subagent"),
12570            mock_tool("memory_recall"),
12571        ];
12572        let policy = TestPolicy {
12573            allowed_tools: Some(vec!["shell".into(), "memory_recall".into()]),
12574            ..TestPolicy::default()
12575        };
12576
12577        super::apply_policy_tool_filter(&mut tools, Some(&policy), None);
12578        assert_eq!(tool_names(&tools), vec!["shell", "memory_recall"]);
12579    }
12580
12581    #[test]
12582    fn apply_policy_tool_filter_policy_excluded_subtracts_from_unrestricted() {
12583        let mut tools = vec![mock_tool("shell"), mock_tool("spawn_subagent")];
12584        let policy = TestPolicy {
12585            excluded_tools: Some(vec!["spawn_subagent".into()]),
12586            ..TestPolicy::default()
12587        };
12588
12589        super::apply_policy_tool_filter(&mut tools, Some(&policy), None);
12590        assert_eq!(tool_names(&tools), vec!["shell"]);
12591    }
12592
12593    #[test]
12594    fn apply_policy_tool_filter_caller_filter_alone_restricts() {
12595        let mut tools = vec![
12596            mock_tool("shell"),
12597            mock_tool("spawn_subagent"),
12598            mock_tool("memory_recall"),
12599        ];
12600        let caller = vec!["memory_recall".to_string()];
12601
12602        super::apply_policy_tool_filter(&mut tools, None, Some(&caller));
12603        assert_eq!(tool_names(&tools), vec!["memory_recall"]);
12604    }
12605
12606    #[test]
12607    fn apply_policy_tool_filter_policy_and_caller_intersect() {
12608        let mut tools = vec![
12609            mock_tool("shell"),
12610            mock_tool("spawn_subagent"),
12611            mock_tool("memory_recall"),
12612        ];
12613        let policy = TestPolicy {
12614            allowed_tools: Some(vec!["shell".into(), "memory_recall".into()]),
12615            ..TestPolicy::default()
12616        };
12617        let caller = vec!["shell".to_string(), "spawn_subagent".to_string()];
12618
12619        super::apply_policy_tool_filter(&mut tools, Some(&policy), Some(&caller));
12620        // Only `shell` survives — it's the intersection of the policy
12621        // allowlist {shell, memory_recall} and the caller filter
12622        // {shell, spawn_subagent}.
12623        assert_eq!(tool_names(&tools), vec!["shell"]);
12624    }
12625
12626    #[test]
12627    fn apply_policy_tool_filter_policy_deny_all_drops_everything() {
12628        let mut tools = vec![mock_tool("shell"), mock_tool("spawn_subagent")];
12629        let policy = TestPolicy {
12630            allowed_tools: Some(vec![]),
12631            ..TestPolicy::default()
12632        };
12633
12634        super::apply_policy_tool_filter(&mut tools, Some(&policy), None);
12635        assert!(
12636            tools.is_empty(),
12637            "Some(vec![]) on policy must deny every tool"
12638        );
12639    }
12640
12641    #[test]
12642    fn eager_mcp_policy_allows_only_names_that_pass_policy_and_caller_gates() {
12643        let policy = TestPolicy {
12644            allowed_tools: Some(vec!["fs__read_file".into(), "slack__post".into()]),
12645            excluded_tools: Some(vec!["slack__post".into()]),
12646            ..TestPolicy::default()
12647        };
12648        let caller = vec!["fs__read_file".to_string(), "github__search".to_string()];
12649        let access_policy = super::mcp_tool_access_policy(&policy, Some(&caller));
12650
12651        assert!(
12652            super::eager_mcp_tool_allowed("fs__read_file", access_policy.as_ref()),
12653            "name admitted by both policy and caller gates must be registered eagerly"
12654        );
12655        assert!(
12656            !super::eager_mcp_tool_allowed("slack__post", access_policy.as_ref()),
12657            "policy excluded_tools must block eager MCP registration"
12658        );
12659        assert!(
12660            !super::eager_mcp_tool_allowed("github__search", access_policy.as_ref()),
12661            "caller allowlist must compose with SecurityPolicy for run() eager MCP"
12662        );
12663    }
12664
12665    #[test]
12666    fn eager_mcp_policy_uses_security_policy_without_caller_gate_on_process_message() {
12667        let policy = TestPolicy {
12668            allowed_tools: Some(vec!["fs__read_file".into()]),
12669            ..TestPolicy::default()
12670        };
12671        let access_policy = super::mcp_tool_access_policy(&policy, None);
12672
12673        assert!(
12674            super::eager_mcp_tool_allowed("fs__read_file", access_policy.as_ref()),
12675            "process_message eager MCP should use the agent SecurityPolicy allowlist"
12676        );
12677        assert!(
12678            !super::eager_mcp_tool_allowed("github__search", access_policy.as_ref()),
12679            "non-allowlisted eager MCP names must not be registered on process_message"
12680        );
12681    }
12682
12683    #[test]
12684    fn deferred_mcp_allowed_count_honors_deny_all_policy() {
12685        let policy = TestPolicy {
12686            allowed_tools: Some(vec![]),
12687            ..TestPolicy::default()
12688        };
12689        let access_policy = super::mcp_tool_access_policy(&policy, None);
12690
12691        assert_eq!(
12692            super::mcp_allowed_tool_count(
12693                ["fs__read_file", "github__search"],
12694                access_policy.as_ref()
12695            ),
12696            0,
12697            "deferred MCP must not register tool_search when policy admits no MCP stubs"
12698        );
12699    }
12700
12701    #[test]
12702    fn register_eager_mcp_tool_filters_tools_and_delegate_handle_together() {
12703        let policy = TestPolicy {
12704            allowed_tools: Some(vec!["fs__read_file".into()]),
12705            excluded_tools: Some(vec!["slack__post".into()]),
12706            ..TestPolicy::default()
12707        };
12708        let access_policy = super::mcp_tool_access_policy(&policy, None);
12709        let delegate_handle: crate::tools::DelegateParentToolsHandle =
12710            std::sync::Arc::new(parking_lot::RwLock::new(Vec::new()));
12711        let mut tools: Vec<Box<dyn TestTool>> = Vec::new();
12712
12713        assert!(super::register_eager_mcp_tool_if_allowed(
12714            mock_tool_arc("fs__read_file"),
12715            &mut tools,
12716            Some(&delegate_handle),
12717            access_policy.as_ref(),
12718        ));
12719        assert!(!super::register_eager_mcp_tool_if_allowed(
12720            mock_tool_arc("github__search"),
12721            &mut tools,
12722            Some(&delegate_handle),
12723            access_policy.as_ref(),
12724        ));
12725        assert!(!super::register_eager_mcp_tool_if_allowed(
12726            mock_tool_arc("slack__post"),
12727            &mut tools,
12728            Some(&delegate_handle),
12729            access_policy.as_ref(),
12730        ));
12731
12732        assert_eq!(tool_names(&tools), vec!["fs__read_file"]);
12733        let delegate_names: Vec<String> = delegate_handle
12734            .read()
12735            .iter()
12736            .map(|tool| tool.name().to_string())
12737            .collect();
12738        assert_eq!(delegate_names, vec!["fs__read_file"]);
12739    }
12740
12741    // ── agent_provider_composite regression ───────────────────────────────
12742
12743    #[test]
12744    fn agent_provider_composite_returns_dotted_ref_not_bare_family() {
12745        use zeroclaw_config::providers::ModelProviderRef;
12746        use zeroclaw_config::schema::{
12747            AliasedAgentConfig, ModelProviderConfig, OpenAIModelProviderConfig,
12748        };
12749
12750        let alias = "qwertfoozp";
12751
12752        let mut config = zeroclaw_config::schema::Config::default();
12753        config.providers.models.openai.insert(
12754            alias.to_string(),
12755            OpenAIModelProviderConfig {
12756                base: ModelProviderConfig {
12757                    requires_openai_auth: true,
12758                    ..Default::default()
12759                },
12760            },
12761        );
12762        config.agents.insert(
12763            "my_agent".to_string(),
12764            AliasedAgentConfig {
12765                model_provider: ModelProviderRef::new(format!("openai.{alias}")),
12766                ..Default::default()
12767            },
12768        );
12769
12770        let result = super::agent_provider_composite(&config, "my_agent");
12771
12772        // Must be the full dotted ref so the alias-aware factory path is taken.
12773        assert_eq!(
12774            result.as_deref(),
12775            Some("openai.qwertfoozp"),
12776            "agent_provider_composite must return the dotted composite ref"
12777        );
12778        // Explicitly assert it is NOT the bare family — this is the regression
12779        // this test protects against.
12780        assert_ne!(
12781            result.as_deref(),
12782            Some("openai"),
12783            "bare family name would bypass the alias-aware factory path and drop \
12784             requires_openai_auth from the config, routing to the wrong provider"
12785        );
12786    }
12787
12788    // ── process_message() path regression (#6959) ─────────────────
12789    //
12790    // The bug was not that `apply_policy_tool_filter` filtered wrong; it
12791    // was that the daemon/channel `process_message` path never called it,
12792    // so a restrictive SecurityPolicy did not apply when the same agent was
12793    // reached through a channel. This drives the exact seam that path now
12794    // calls (`filter_channel_builtin_tools`) against the *real* eager
12795    // built-in registry produced by `all_tools`, and proves an agent
12796    // allowlisted to `file_read` does not get raw `shell` / `file_write`.
12797    #[test]
12798    fn process_message_policy_filters_eager_builtins() {
12799        use std::sync::Arc;
12800
12801        let config = zeroclaw_config::schema::Config::default();
12802        let security = Arc::new(TestPolicy {
12803            workspace_dir: std::env::temp_dir(),
12804            ..TestPolicy::default()
12805        });
12806        let risk = zeroclaw_config::schema::RiskProfileConfig::default();
12807        let mem: Arc<dyn zeroclaw_memory::Memory> =
12808            Arc::new(zeroclaw_memory::NoneMemory::new("test"));
12809
12810        let mut registry = crate::tools::all_tools(
12811            Arc::new(config.clone()),
12812            &security,
12813            &risk,
12814            "test",
12815            mem,
12816            None,
12817            None,
12818            &config.browser,
12819            &config.http_request,
12820            &config.web_fetch,
12821            &security.workspace_dir,
12822            &config.agents,
12823            None,
12824            &config,
12825            None,
12826            false,
12827            None,
12828        )
12829        .tools;
12830
12831        // Sanity: the unrestricted channel registry exposes the dangerous
12832        // eager built-ins a restrictive policy is expected to remove.
12833        let unrestricted = tool_names(&registry);
12834        assert!(
12835            unrestricted.contains(&"file_read"),
12836            "expected file_read in unrestricted registry, got {unrestricted:?}"
12837        );
12838        assert!(
12839            unrestricted.contains(&"shell"),
12840            "expected shell in unrestricted registry, got {unrestricted:?}"
12841        );
12842        assert!(
12843            unrestricted.contains(&"file_write"),
12844            "expected file_write in unrestricted registry, got {unrestricted:?}"
12845        );
12846
12847        // Allowlist the agent to `file_read` only, then run the exact filter
12848        // `process_message` applies.
12849        let policy = TestPolicy {
12850            allowed_tools: Some(vec!["file_read".into()]),
12851            ..TestPolicy::default()
12852        };
12853        super::filter_channel_builtin_tools(&mut registry, &policy);
12854
12855        let filtered = tool_names(&registry);
12856        assert!(
12857            filtered.contains(&"file_read"),
12858            "allowlisted tool must survive on process_message path, got {filtered:?}"
12859        );
12860        assert!(
12861            !filtered.contains(&"shell"),
12862            "shell must be filtered out on process_message path, got {filtered:?}"
12863        );
12864        assert!(
12865            !filtered.contains(&"file_write"),
12866            "file_write must be filtered out on process_message path, got {filtered:?}"
12867        );
12868
12869        // Denylist variant: an exclusion drops only the named tool.
12870        let mut registry2 = crate::tools::all_tools(
12871            Arc::new(config.clone()),
12872            &security,
12873            &risk,
12874            "test",
12875            Arc::new(zeroclaw_memory::NoneMemory::new("test")),
12876            None,
12877            None,
12878            &config.browser,
12879            &config.http_request,
12880            &config.web_fetch,
12881            &security.workspace_dir,
12882            &config.agents,
12883            None,
12884            &config,
12885            None,
12886            false,
12887            None,
12888        )
12889        .tools;
12890        let deny = TestPolicy {
12891            excluded_tools: Some(vec!["shell".into()]),
12892            ..TestPolicy::default()
12893        };
12894        super::filter_channel_builtin_tools(&mut registry2, &deny);
12895        let after_deny = tool_names(&registry2);
12896        assert!(
12897            !after_deny.contains(&"shell"),
12898            "excluded shell must be removed on process_message path, got {after_deny:?}"
12899        );
12900        assert!(
12901            after_deny.contains(&"file_read"),
12902            "non-excluded file_read must remain, got {after_deny:?}"
12903        );
12904    }
12905}