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/// Channel map factory type — builds `channel_key → Arc<dyn Channel>` map.
34/// Injected by the binary so `zeroclaw-runtime` doesn't depend on
35/// `zeroclaw-channels`.
36type ChannelMapFn = Box<
37    dyn Fn()
38            -> std::collections::HashMap<String, std::sync::Arc<dyn zeroclaw_api::channel::Channel>>
39        + Send
40        + Sync,
41>;
42
43/// Channel map factory, injected by the binary.
44static CHANNEL_MAP_FN: std::sync::OnceLock<ChannelMapFn> = std::sync::OnceLock::new();
45
46/// Register the channel map factory. Called once at startup by the binary.
47pub fn register_channel_map_fn(f: ChannelMapFn) {
48    let _ = CHANNEL_MAP_FN.set(f);
49}
50
51/// Populate all channel-driven tool handles from the registered factory.
52/// Returns the number of channels seeded.
53///
54/// Parameter order matches the return tuple of `all_tools_with_runtime`:
55/// Seed all channel-driven tool handles from the registered channel map factory.
56/// Returns the number of channels seeded. Parameters match the return order of
57/// `all_tools_with_runtime`:
58///   ask_user_handle = `Option<PerToolChannelHandle>`
59///   reaction_handle = `PerToolChannelHandle` (NOT Option)
60///   poll_handle = `Option<PerToolChannelHandle>`
61///   escalate_handle = `Option<PerToolChannelHandle>`
62///   channel_send_handle = `Option<PerToolChannelHandle>`
63pub(crate) fn seed_channel_handles(
64    ask_user_handle: &Option<tools::PerToolChannelHandle>,
65    reaction_handle: &tools::PerToolChannelHandle,
66    poll_handle: &Option<tools::PerToolChannelHandle>,
67    escalate_handle: &Option<tools::PerToolChannelHandle>,
68    channel_send_handle: &Option<tools::PerToolChannelHandle>,
69) -> usize {
70    let Some(factory) = CHANNEL_MAP_FN.get() else {
71        return 0;
72    };
73    let map = factory();
74    if map.is_empty() {
75        return 0;
76    }
77
78    let handles = [
79        ask_user_handle.as_ref(),
80        Some(reaction_handle),
81        poll_handle.as_ref(),
82        escalate_handle.as_ref(),
83        channel_send_handle.as_ref(),
84    ];
85
86    let mut count = 0;
87    for (name, ch) in &map {
88        for handle in handles.iter().flatten() {
89            handle
90                .write()
91                .insert(name.clone(), std::sync::Arc::clone(ch));
92        }
93        count += 1;
94    }
95    count
96}
97use crate::cost::types::BudgetCheck;
98use crate::observability::{self, Observer, ObserverEvent};
99use crate::platform;
100use crate::security::{AutonomyLevel, SecurityPolicy};
101use crate::tools::{self, Tool};
102use crate::util::truncate_with_ellipsis;
103use anyhow::{Context, Result};
104use futures_util::StreamExt;
105use regex::Regex;
106use std::collections::HashSet;
107use std::fmt::Write;
108use std::io::Write as _;
109use std::path::PathBuf;
110use std::sync::{Arc, LazyLock, Mutex};
111use std::time::{Duration, Instant};
112use tokio_util::sync::CancellationToken;
113use uuid::Uuid;
114use zeroclaw_api::channel::Channel;
115use zeroclaw_api::model_provider::StreamEvent;
116use zeroclaw_config::schema::Config;
117use zeroclaw_memory::{
118    self, MEMORY_CONTEXT_CLOSE, MEMORY_CONTEXT_OPEN, Memory, MemoryCategory, decay,
119};
120use zeroclaw_providers::multimodal;
121use zeroclaw_providers::{
122    self, ChatMessage, ChatRequest, ModelProvider, ProviderCapabilityError, ToolCall,
123};
124
125// Cost tracking moved to `super::cost`.
126pub use super::cost::{
127    TOOL_LOOP_COST_TRACKING_CONTEXT, ToolLoopCostTrackingContext, TurnUsage,
128    check_tool_loop_budget, record_tool_loop_cost_usage,
129};
130
131/// Minimum characters per chunk when relaying LLM text to a streaming draft.
132const STREAM_CHUNK_MIN_CHARS: usize = 80;
133/// Maximum malformed internal tool-protocol retries before returning a safe fallback.
134const MAX_MALFORMED_TOOL_PROTOCOL_RETRIES: usize = 2;
135
136/// Default maximum agentic tool-use iterations per user message to prevent runaway loops.
137/// Used as a safe fallback when `max_tool_iterations` is unset or configured as zero.
138const DEFAULT_MAX_TOOL_ITERATIONS: usize = 10;
139
140// History management moved to `super::history`.
141pub use super::history::{
142    append_or_merge_system_message, canonicalize_tool_result_media_markers, emergency_history_trim,
143    estimate_history_tokens, fast_trim_tool_results, load_interactive_session_history,
144    normalize_system_messages, save_interactive_session_history, trim_history,
145    truncate_tool_result,
146};
147
148/// Minimum user-message length (in chars) for auto-save to memory.
149/// Matches the channel-side constant in `channels/mod.rs`.
150const AUTOSAVE_MIN_MESSAGE_CHARS: usize = 20;
151
152/// Callback type for checking if model has been switched during tool execution.
153/// Returns Some((model_provider, model)) if a switch was requested, None otherwise.
154pub type ModelSwitchCallback = Arc<Mutex<Option<(String, String)>>>;
155
156/// Global model switch request state - used for runtime model switching via model_switch tool.
157/// This is set by the model_switch tool and checked by the agent loop.
158#[allow(clippy::type_complexity)]
159static MODEL_SWITCH_REQUEST: LazyLock<Arc<Mutex<Option<(String, String)>>>> =
160    LazyLock::new(|| Arc::new(Mutex::new(None)));
161
162/// Get the global model switch request state
163pub fn get_model_switch_state() -> ModelSwitchCallback {
164    Arc::clone(&MODEL_SWITCH_REQUEST)
165}
166
167/// Clear any pending model switch request
168pub fn clear_model_switch_request() {
169    if let Ok(guard) = MODEL_SWITCH_REQUEST.lock() {
170        let mut guard = guard;
171        *guard = None;
172    }
173}
174
175fn glob_match(pattern: &str, name: &str) -> bool {
176    match pattern.find('*') {
177        None => pattern == name,
178        Some(star) => {
179            let prefix = &pattern[..star];
180            let suffix = &pattern[star + 1..];
181            name.starts_with(prefix)
182                && name.ends_with(suffix)
183                && name.len() >= prefix.len() + suffix.len()
184        }
185    }
186}
187
188/// Drop tools from `tools` that fail either gate.
189///
190/// 1. The parent agent's `SecurityPolicy.allowed_tools` allowlist plus
191///    `SecurityPolicy.excluded_tools` denylist, evaluated via
192///    `SecurityPolicy::is_tool_allowed`.
193/// 2. The caller-supplied `caller_allowed` filter (the existing
194///    `agent::run`-level `allowed_tools` parameter).
195///
196/// A tool survives only when BOTH gates admit its name. `None` on
197/// either gate is unrestricted for that gate alone. Built-in tools,
198/// MCP tools, and skill tools all flow through the same filter; the
199/// helper does not know or care about category.
200pub fn apply_policy_tool_filter(
201    tools: &mut Vec<Box<dyn Tool>>,
202    policy: Option<&zeroclaw_config::policy::SecurityPolicy>,
203    caller_allowed: Option<&[String]>,
204) {
205    tools.retain(|t| {
206        let name = t.name();
207        let policy_ok = policy.is_none_or(|p| p.is_tool_allowed(name));
208        let caller_ok = caller_allowed.is_none_or(|list| list.iter().any(|n| n == name));
209        policy_ok && caller_ok
210    });
211}
212
213/// Apply the SecurityPolicy built-in tool filter on the channel/daemon
214/// (`process_message`) path.
215///
216/// Extracted as a named seam so the production filtering of the eager
217/// built-in registry is regression-testable without driving the full agent
218/// loop (see `process_message_policy_filters_eager_builtins`). The channel
219/// path has no caller-supplied allowlist, so only the agent's own
220/// `SecurityPolicy` (`allowed_tools` + `excluded_tools`) gates here; the
221/// `run()` path additionally composes a caller-supplied `allowed_tools` gate.
222pub(crate) fn filter_channel_builtin_tools(
223    tools_registry: &mut Vec<Box<dyn Tool>>,
224    security: &zeroclaw_config::policy::SecurityPolicy,
225) {
226    let before_filter = tools_registry.len();
227    apply_policy_tool_filter(tools_registry, Some(security), None);
228    if tools_registry.len() != before_filter {
229        ::zeroclaw_log::record!(
230            INFO,
231            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
232                ::serde_json::json!({
233                    "before": before_filter,
234                    "retained": tools_registry.len(),
235                    "policy_allowed": security.allowed_tools.as_ref().map(|v| v.len()),
236                    "policy_excluded": security.excluded_tools.as_ref().map(|v| v.len()),
237                })
238            ),
239            "Applied capability-based tool access filter (process_message)"
240        );
241    }
242}
243
244/// Returns the subset of `tool_specs` that should be sent to the LLM for this turn.
245///
246/// Rules (mirrors NullClaw `filterToolSpecsForTurn`):
247/// - Built-in tools (names that do not start with `"mcp_"`) always pass through.
248/// - When `groups` is empty, all tools pass through (backward compatible default).
249/// - An MCP tool is included if at least one group matches it:
250///   - `always` group: included unconditionally if any pattern matches the tool name.
251///   - `dynamic` group: included if any pattern matches AND the user message contains
252///     at least one keyword (case-insensitive substring).
253pub fn filter_tool_specs_for_turn(
254    tool_specs: Vec<crate::tools::ToolSpec>,
255    groups: &[zeroclaw_config::schema::ToolFilterGroup],
256    user_message: &str,
257) -> Vec<crate::tools::ToolSpec> {
258    use zeroclaw_config::schema::ToolFilterGroupMode;
259
260    if groups.is_empty() {
261        return tool_specs;
262    }
263
264    let msg_lower = user_message.to_ascii_lowercase();
265
266    tool_specs
267        .into_iter()
268        .filter(|spec| {
269            // Built-in tools always pass through.
270            if !spec.name.starts_with("mcp_") {
271                return true;
272            }
273            // MCP tool: include if any active group matches.
274            groups.iter().any(|group| {
275                let pattern_matches = group.tools.iter().any(|pat| glob_match(pat, &spec.name));
276                if !pattern_matches {
277                    return false;
278                }
279                match group.mode {
280                    ToolFilterGroupMode::Always => true,
281                    ToolFilterGroupMode::Dynamic => group
282                        .keywords
283                        .iter()
284                        .any(|kw| msg_lower.contains(&kw.to_ascii_lowercase())),
285                }
286            })
287        })
288        .collect()
289}
290
291/// Filters a tool spec list by an optional capability allowlist.
292///
293/// When `allowed` is `None`, all specs pass through unchanged.
294/// When `allowed` is `Some(list)`, only specs whose name appears in the list
295/// are retained. Unknown names in the allowlist are silently ignored.
296pub fn filter_by_allowed_tools(
297    specs: Vec<crate::tools::ToolSpec>,
298    allowed: Option<&[String]>,
299) -> Vec<crate::tools::ToolSpec> {
300    match allowed {
301        None => specs,
302        Some(list) => specs
303            .into_iter()
304            .filter(|spec| list.iter().any(|name| name == &spec.name))
305            .collect(),
306    }
307}
308
309// Re-export from zeroclaw-types for backwards compatibility.
310pub use zeroclaw_api::TOOL_LOOP_SESSION_KEY;
311pub use zeroclaw_api::TOOL_LOOP_THREAD_ID;
312
313// Re-export tool call parsing from the standalone parser crate.
314pub use zeroclaw_tool_call_parser::{
315    ParsedToolCall, ToolProtocolEnvelopeKind, build_native_assistant_history_from_parsed_calls,
316    canonicalize_json_for_tool_signature, classify_tool_protocol_envelope,
317    contains_tool_protocol_tag_call, detect_tool_call_parse_issue,
318    looks_like_malformed_tool_protocol_envelope,
319    looks_like_malformed_tool_protocol_envelope_for_known_tools, looks_like_tool_protocol_envelope,
320    looks_like_tool_protocol_example, parse_tool_calls, strip_think_tags, strip_tool_result_blocks,
321    tool_protocol_envelope_mentions_known_tool,
322};
323
324/// Run a future with the thread ID set in task-local storage.
325/// Rate-limiting reads this to assign per-sender buckets.
326pub async fn scope_thread_id<F>(thread_id: Option<String>, future: F) -> F::Output
327where
328    F: std::future::Future,
329{
330    TOOL_LOOP_THREAD_ID.scope(thread_id, future).await
331}
332
333/// Run a future with the session key set in task-local storage.
334/// The scope wraps the entire agent turn, so all tools invoked during
335/// the turn (including nested calls) see the same session key.
336/// SessionsCurrentTool reads this to identify the active session.
337pub async fn scope_session_key<F>(session_key: Option<String>, future: F) -> F::Output
338where
339    F: std::future::Future,
340{
341    TOOL_LOOP_SESSION_KEY.scope(session_key, future).await
342}
343
344/// Computes the list of MCP tool names that should be excluded for a given turn
345/// based on `tool_filter_groups` and the user message.
346///
347/// Returns an empty `Vec` when `groups` is empty (no filtering).
348fn compute_excluded_mcp_tools(
349    tools_registry: &[Box<dyn Tool>],
350    groups: &[zeroclaw_config::schema::ToolFilterGroup],
351    user_message: &str,
352) -> Vec<String> {
353    if groups.is_empty() {
354        return Vec::new();
355    }
356    let filtered_specs = filter_tool_specs_for_turn(
357        tools_registry.iter().map(|t| t.spec()).collect(),
358        groups,
359        user_message,
360    );
361    let included: HashSet<&str> = filtered_specs.iter().map(|s| s.name.as_str()).collect();
362    tools_registry
363        .iter()
364        .filter(|t| t.name().starts_with("mcp_") && !included.contains(t.name()))
365        .map(|t| t.name().to_string())
366        .collect()
367}
368
369static SENSITIVE_KV_REGEX: LazyLock<Regex> = LazyLock::new(|| {
370    Regex::new(r#"(?i)(token|api[_-]?key|password|secret|user[_-]?key|bearer|credential)["']?\s*[:=]\s*(?:"([^"]{8,})"|'([^']{8,})'|([a-zA-Z0-9_\-\.]{8,}))"#).unwrap()
371});
372
373/// Scrub credentials from tool output to prevent accidental exfiltration.
374/// Replaces known credential patterns with a redacted placeholder while preserving
375/// a small prefix for context.
376pub fn scrub_credentials(input: &str) -> String {
377    SENSITIVE_KV_REGEX
378        .replace_all(input, |caps: &regex::Captures| {
379            let full_match = &caps[0];
380            let key = &caps[1];
381            let val = caps
382                .get(2)
383                .or(caps.get(3))
384                .or(caps.get(4))
385                .map(|m| m.as_str())
386                .unwrap_or("");
387
388            // Preserve first 4 chars for context, then redact.
389            // Use char_indices to find the byte offset of the 4th character
390            // so we never slice in the middle of a multi-byte UTF-8 sequence.
391            let prefix = if val.len() > 4 {
392                val.char_indices()
393                    .nth(4)
394                    .map(|(byte_idx, _)| &val[..byte_idx])
395                    .unwrap_or(val)
396            } else {
397                ""
398            };
399
400            if full_match.contains(':') {
401                if full_match.contains('"') {
402                    format!("\"{}\": \"{}*[REDACTED]\"", key, prefix)
403                } else {
404                    format!("{}: {}*[REDACTED]", key, prefix)
405                }
406            } else if full_match.contains('=') {
407                if full_match.contains('"') {
408                    format!("{}=\"{}*[REDACTED]\"", key, prefix)
409                } else {
410                    format!("{}={}*[REDACTED]", key, prefix)
411                }
412            } else {
413                format!("{}: {}*[REDACTED]", key, prefix)
414            }
415        })
416        .to_string()
417}
418
419/// Default trigger for auto-compaction when non-system message count exceeds this threshold.
420/// Prefer passing the config-driven value via `run_tool_call_loop`; this constant is only
421/// used when callers omit the parameter.
422/// Minimum interval between progress sends to avoid flooding the draft channel.
423pub const PROGRESS_MIN_INTERVAL_MS: u64 = 500;
424
425/// Delta sent from the agent loop to the channel's draft updater.
426/// Append-only — no clear/reset variant exists by design.
427#[derive(Debug, Clone)]
428pub enum StreamDelta {
429    /// Response text to append to the message buffer.
430    Text(String),
431    /// Ephemeral tool progress (not part of the response body).
432    Status(String),
433}
434
435/// Backwards-compatible alias while callers are migrated.
436pub type DraftEvent = StreamDelta;
437
438pub use zeroclaw_api::TOOL_CHOICE_OVERRIDE;
439
440/// Convert a tool registry to OpenAI function-calling format for native tool support.
441#[cfg(test)]
442fn tools_to_openai_format(tools_registry: &[Box<dyn Tool>]) -> Vec<serde_json::Value> {
443    tools_registry
444        .iter()
445        .map(|tool| {
446            serde_json::json!({
447                "type": "function",
448                "function": {
449                    "name": tool.name(),
450                    "description": tool.description(),
451                    "parameters": tool.parameters_schema()
452                }
453            })
454        })
455        .collect()
456}
457
458fn autosave_memory_key(prefix: &str) -> String {
459    format!("{prefix}_{}", Uuid::new_v4())
460}
461
462/// Build context preamble by searching memory for relevant entries.
463/// Entries with a hybrid score below `min_relevance_score` are dropped to
464/// prevent unrelated memories from bleeding into the conversation.
465/// Core memories are exempt from time decay (evergreen).
466///
467/// `exclude_conversation` skips `MemoryCategory::Conversation` entries
468/// regardless of their key shape. Set to `true` for autonomous/scheduled
469/// runs (cron, daemon heartbeat) so chat memory cannot leak into prompts
470/// the user did not initiate. / #5456.
471async fn build_context(
472    mem: &dyn Memory,
473    user_msg: &str,
474    min_relevance_score: f64,
475    session_id: Option<&str>,
476    exclude_conversation: bool,
477) -> String {
478    let mut context = String::new();
479
480    // Pull relevant memories for this message
481    if let Ok(mut entries) = mem.recall(user_msg, 5, session_id, None, None).await {
482        // Apply time decay: older non-Core memories score lower
483        decay::apply_time_decay(&mut entries, decay::DEFAULT_HALF_LIFE_DAYS);
484
485        let relevant: Vec<_> = entries
486            .iter()
487            .filter(|e| match e.score {
488                Some(score) => score >= min_relevance_score,
489                None => true,
490            })
491            .collect();
492
493        if !relevant.is_empty() {
494            let mut included = false;
495            for entry in &relevant {
496                // Scheduled (cron / heartbeat) runs must not see chat-origin
497                // memories. The autosave-key checks below catch the agent's
498                // own autosaves but miss Conversation entries written by
499                // channel handlers (Discord, gateway, WhatsApp, …) under
500                // their own keys. / #5456.
501                if exclude_conversation && matches!(entry.category, MemoryCategory::Conversation) {
502                    continue;
503                }
504                if zeroclaw_memory::is_assistant_autosave_key(&entry.key) {
505                    continue;
506                }
507                // Skip raw per-turn user messages: re-injecting them causes each
508                // recalled entry to embed all prior generations, growing exponentially.
509                // Consolidated knowledge is already promoted to Core/Daily entries.
510                if zeroclaw_memory::is_user_autosave_key(&entry.key) {
511                    continue;
512                }
513                if zeroclaw_memory::should_skip_autosave_content(&entry.content) {
514                    continue;
515                }
516                // Skip entries containing tool_result blocks — they can leak
517                // stale tool output from previous heartbeat ticks into new
518                // sessions, presenting the LLM with orphan tool_result data.
519                if entry.content.contains("<tool_result") {
520                    continue;
521                }
522                if !included {
523                    context.push_str(MEMORY_CONTEXT_OPEN);
524                    context.push('\n');
525                    included = true;
526                }
527                let _ = writeln!(context, "- {}: {}", entry.key, entry.content);
528            }
529            if included {
530                context.push_str(MEMORY_CONTEXT_CLOSE);
531                context.push_str("\n\n");
532            }
533        }
534    }
535
536    context
537}
538
539/// Build hardware datasheet context from RAG when peripherals are enabled.
540/// Includes pin-alias lookup (e.g. "red_led" → 13) when query matches, plus retrieved chunks.
541fn build_hardware_context(
542    rag: &crate::rag::HardwareRag,
543    user_msg: &str,
544    boards: &[String],
545    chunk_limit: usize,
546) -> String {
547    if rag.is_empty() || boards.is_empty() {
548        return String::new();
549    }
550
551    let mut context = String::new();
552
553    // Pin aliases: when user says "red led", inject "red_led: 13" for matching boards
554    let pin_ctx = rag.pin_alias_context(user_msg, boards);
555    if !pin_ctx.is_empty() {
556        context.push_str(&pin_ctx);
557    }
558
559    let chunks = rag.retrieve(user_msg, boards, chunk_limit);
560    if chunks.is_empty() && pin_ctx.is_empty() {
561        return String::new();
562    }
563
564    if !chunks.is_empty() {
565        context.push_str("[Hardware documentation]\n");
566    }
567    for chunk in chunks {
568        let board_tag = chunk.board.as_deref().unwrap_or("generic");
569        let _ = writeln!(
570            context,
571            "--- {} ({}) ---\n{}\n",
572            chunk.source, board_tag, chunk.content
573        );
574    }
575    context.push('\n');
576    context
577}
578
579// Tool execution moved to `super::tool_execution`.
580pub use super::tool_execution::{
581    ToolExecutionOutcome, execute_tools_parallel, execute_tools_sequential,
582    should_execute_tools_in_parallel,
583};
584
585/// Build assistant history entry in JSON format for native tool-call APIs.
586/// `convert_messages` in the OpenRouter model_provider parses this JSON to reconstruct
587/// the proper `NativeMessage` with structured `tool_calls`.
588fn build_native_assistant_history(
589    text: &str,
590    tool_calls: &[ToolCall],
591    reasoning_content: Option<&str>,
592) -> String {
593    let calls_json: Vec<serde_json::Value> = tool_calls
594        .iter()
595        .map(|tc| {
596            serde_json::json!({
597                "id": tc.id,
598                "name": tc.name,
599                "arguments": tc.arguments,
600            })
601        })
602        .collect();
603
604    let content = if text.trim().is_empty() {
605        serde_json::Value::Null
606    } else {
607        serde_json::Value::String(text.trim().to_string())
608    };
609
610    let mut obj = serde_json::json!({
611        "content": content,
612        "tool_calls": calls_json,
613    });
614
615    if let Some(rc) = reasoning_content {
616        obj.as_object_mut().unwrap().insert(
617            "reasoning_content".to_string(),
618            serde_json::Value::String(rc.to_string()),
619        );
620    }
621
622    obj.to_string()
623}
624
625fn resolve_display_text(
626    response_text: &str,
627    parsed_text: &str,
628    has_tool_calls: bool,
629    has_native_tool_calls: bool,
630) -> String {
631    if has_tool_calls {
632        if !parsed_text.is_empty() {
633            return parsed_text.to_string();
634        }
635        if has_native_tool_calls {
636            return response_text.to_string();
637        }
638        return String::new();
639    }
640
641    if parsed_text.is_empty() {
642        response_text.to_string()
643    } else {
644        parsed_text.to_string()
645    }
646}
647
648#[derive(Debug)]
649pub struct ToolLoopCancelled;
650
651impl std::fmt::Display for ToolLoopCancelled {
652    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
653        f.write_str("tool loop cancelled")
654    }
655}
656
657impl std::error::Error for ToolLoopCancelled {}
658
659pub fn is_tool_loop_cancelled(err: &anyhow::Error) -> bool {
660    err.chain().any(|source| source.is::<ToolLoopCancelled>())
661}
662
663#[derive(Debug)]
664pub struct ModelSwitchRequested {
665    pub model_provider: String,
666    pub model: String,
667}
668
669impl std::fmt::Display for ModelSwitchRequested {
670    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
671        write!(
672            f,
673            "model switch requested to {} {}",
674            self.model_provider, self.model
675        )
676    }
677}
678
679impl std::error::Error for ModelSwitchRequested {}
680
681pub fn is_model_switch_requested(err: &anyhow::Error) -> Option<(String, String)> {
682    err.chain()
683        .filter_map(|source| source.downcast_ref::<ModelSwitchRequested>())
684        .map(|e| (e.model_provider.clone(), e.model.clone()))
685        .next()
686}
687
688#[derive(Debug, Default)]
689struct StreamedChatOutcome {
690    response_text: String,
691    /// Accumulated reasoning/thinking content from streaming deltas.
692    ///
693    /// Captured separately from `response_text` so it can be threaded into
694    /// `ChatResponse.reasoning_content` and ultimately persisted on the
695    /// `AssistantToolCalls` history entry. Required for model_providers like
696    /// DeepSeek V4 that reject follow-up requests when the assistant's
697    /// prior `reasoning_content` is missing from replayed tool-call turns
698    ///.
699    reasoning_content: String,
700    tool_calls: Vec<ToolCall>,
701    forwarded_live_deltas: bool,
702    suppressed_protocol: bool,
703    usage: Option<zeroclaw_providers::traits::TokenUsage>,
704}
705
706#[derive(Debug, Default)]
707struct StreamTextGuard {
708    // Suspicious leading chunks can split `"toolcalls"` / `<tool_call>` across
709    // deltas. Buffer just that prefix until it is clearly protocol or normal JSON.
710    pending: String,
711    pending_candidate_start: Option<usize>,
712    known_tool_names: HashSet<String>,
713    has_active_tools: bool,
714    suppress_forwarding: bool,
715    suppressed_protocol: bool,
716}
717
718impl StreamTextGuard {
719    fn new(available_tools: Option<&[crate::tools::ToolSpec]>) -> Self {
720        let available_tools = available_tools.unwrap_or(&[]);
721        let known_tool_names = available_tools
722            .iter()
723            .map(|tool| tool.name.to_ascii_lowercase())
724            .collect();
725        Self {
726            known_tool_names,
727            has_active_tools: !available_tools.is_empty(),
728            ..Self::default()
729        }
730    }
731
732    fn push(&mut self, chunk: &str) -> Option<String> {
733        if self.suppress_forwarding || chunk.is_empty() {
734            return None;
735        }
736
737        if self.pending.is_empty() && !starts_suspicious_protocol_prefix(chunk) {
738            if let Some(start) = find_embedded_protocol_candidate_start(chunk) {
739                self.pending_candidate_start = Some(start);
740                self.pending.push_str(&chunk[start..]);
741                return if self.should_suppress_protocol_candidate(&self.pending) {
742                    self.suppress_protocol();
743                    None
744                } else {
745                    self.pending.insert_str(0, &chunk[..start]);
746                    self.evaluate_pending(false)
747                };
748            }
749            if let Some(start) = find_incomplete_protocol_candidate_start(chunk) {
750                self.pending_candidate_start = Some(start);
751                self.pending.push_str(chunk);
752                return None;
753            }
754            return Some(chunk.to_string());
755        }
756
757        self.pending.push_str(chunk);
758        self.evaluate_pending(false)
759    }
760
761    fn finish(&mut self) -> Option<String> {
762        if self.suppress_forwarding || self.pending.is_empty() {
763            return None;
764        }
765        if let Some(release) = self.evaluate_pending(true) {
766            return Some(release);
767        }
768        if self.suppressed_protocol || self.pending.is_empty() {
769            return None;
770        }
771        if looks_like_malformed_tool_protocol_envelope_for_known_tools(
772            &self.pending,
773            &self.known_tool_names,
774        ) {
775            self.suppress_protocol();
776            return None;
777        }
778        Some(std::mem::take(&mut self.pending))
779    }
780
781    fn evaluate_pending(&mut self, finalizing: bool) -> Option<String> {
782        let candidate = self
783            .pending_candidate_start
784            .and_then(|start| self.pending.get(start..))
785            .unwrap_or(&self.pending);
786
787        if !finalizing && starts_suspicious_tag_or_fence_prefix(candidate) {
788            return None;
789        }
790
791        if self.should_suppress_protocol_candidate(candidate) {
792            self.suppress_protocol();
793            return None;
794        }
795
796        if let Some(is_protocol) =
797            complete_json_fence_protocol_state(candidate, &self.known_tool_names)
798        {
799            if is_protocol && self.has_active_tools {
800                self.suppress_protocol();
801                return None;
802            }
803            self.pending_candidate_start = None;
804            return Some(std::mem::take(&mut self.pending));
805        }
806
807        if complete_non_protocol_json(candidate, &self.known_tool_names) {
808            self.pending_candidate_start = None;
809            return Some(std::mem::take(&mut self.pending));
810        }
811
812        None
813    }
814
815    fn suppress_protocol(&mut self) {
816        self.pending.clear();
817        self.pending_candidate_start = None;
818        self.suppress_forwarding = true;
819        self.suppressed_protocol = true;
820    }
821
822    fn looks_like_active_tool_json(&self, text: &str) -> bool {
823        if self.known_tool_names.is_empty() {
824            return false;
825        }
826
827        let Ok(value) = serde_json::from_str::<serde_json::Value>(text.trim()) else {
828            return false;
829        };
830
831        match value {
832            serde_json::Value::Array(items) => {
833                !items.is_empty() && items.iter().all(|item| self.is_known_tool_payload(item))
834            }
835            serde_json::Value::Object(_) => self.is_known_tool_payload(&value),
836            _ => false,
837        }
838    }
839
840    fn is_known_tool_payload(&self, value: &serde_json::Value) -> bool {
841        let Some(object) = value.as_object() else {
842            return false;
843        };
844
845        let (name, has_args) =
846            if let Some(function) = object.get("function").and_then(|value| value.as_object()) {
847                (
848                    function
849                        .get("name")
850                        .and_then(serde_json::Value::as_str)
851                        .or_else(|| object.get("name").and_then(serde_json::Value::as_str)),
852                    function.contains_key("arguments")
853                        || function.contains_key("parameters")
854                        || object.contains_key("arguments")
855                        || object.contains_key("parameters"),
856                )
857            } else {
858                (
859                    object.get("name").and_then(serde_json::Value::as_str),
860                    object.contains_key("arguments") || object.contains_key("parameters"),
861                )
862            };
863
864        let Some(name) = name.map(str::trim).filter(|name| !name.is_empty()) else {
865            return false;
866        };
867
868        has_args && self.known_tool_names.contains(&name.to_ascii_lowercase())
869    }
870
871    fn should_suppress_protocol_candidate(&self, text: &str) -> bool {
872        if looks_like_tool_protocol_example(text) {
873            return false;
874        }
875
876        if looks_like_malformed_tool_protocol_envelope_for_known_tools(text, &self.known_tool_names)
877            || contains_tool_protocol_tag_call(text)
878        {
879            return true;
880        }
881
882        if let Some(kind) = classify_tool_protocol_envelope(text) {
883            return matches!(kind, ToolProtocolEnvelopeKind::TaggedToolCall)
884                || (self.has_active_tools
885                    && (matches!(kind, ToolProtocolEnvelopeKind::ToolResult)
886                        || tool_protocol_envelope_mentions_known_tool(
887                            text,
888                            &self.known_tool_names,
889                        )));
890        }
891
892        // Parsed JSON that carries protocol-only fields but cannot yield a valid
893        // tool call is an internal protocol failure, not user-facing text.
894        if looks_like_tool_protocol_envelope(text) {
895            return true;
896        }
897
898        self.looks_like_active_tool_json(text)
899    }
900}
901
902fn find_embedded_protocol_candidate_start(text: &str) -> Option<usize> {
903    let lower = text.to_ascii_lowercase();
904    let mut earliest: Option<usize> = None;
905
906    for pattern in [
907        "<tool_call",
908        "<toolcall",
909        "<tool-call",
910        "<invoke",
911        "<function",
912        "```tool",
913        "```invoke",
914        "```json",
915    ] {
916        if let Some(idx) = lower.find(pattern) {
917            earliest = Some(earliest.map_or(idx, |current| current.min(idx)));
918        }
919    }
920
921    for key in ["\"tool_calls\"", "\"toolcalls\"", "\"function_call\""] {
922        if let Some(key_idx) = lower.find(key)
923            && let Some(json_start) = text[..key_idx].rfind(['{', '['])
924        {
925            earliest = Some(earliest.map_or(json_start, |current| current.min(json_start)));
926        }
927    }
928
929    earliest
930}
931
932fn find_incomplete_protocol_candidate_start(text: &str) -> Option<usize> {
933    let lower = text.to_ascii_lowercase();
934    let mut earliest: Option<usize> = None;
935
936    for pattern in [
937        "<tool",
938        "<invoke",
939        "<function",
940        "```tool",
941        "```invoke",
942        "```json",
943    ] {
944        if let Some(idx) = lower.rfind(pattern) {
945            earliest = Some(earliest.map_or(idx, |current| current.min(idx)));
946        }
947    }
948
949    for delimiter in ['{', '['] {
950        if let Some(idx) = text.rfind(delimiter) {
951            let tail = &lower[idx..];
952            if tail.contains("\"tool")
953                || tail.contains("\"function")
954                || tail.contains("\"call")
955                || tail.len() <= 16
956            {
957                earliest = Some(earliest.map_or(idx, |current| current.min(idx)));
958            }
959        }
960    }
961
962    earliest
963}
964
965fn starts_suspicious_protocol_prefix(text: &str) -> bool {
966    let trimmed = text.trim_start();
967    if trimmed.is_empty() {
968        return false;
969    }
970    let lower = trimmed.to_ascii_lowercase();
971    lower.starts_with('{')
972        || lower.starts_with('[')
973        || lower.starts_with("<tool")
974        || lower.starts_with("<invoke")
975        || lower.starts_with("<function")
976        || lower.starts_with("```tool")
977        || lower.starts_with("```invoke")
978        || lower.starts_with("```json")
979}
980
981fn starts_suspicious_tag_or_fence_prefix(text: &str) -> bool {
982    let lower = text.trim_start().to_ascii_lowercase();
983    lower.starts_with("<tool")
984        || lower.starts_with("<invoke")
985        || lower.starts_with("<function")
986        || lower.starts_with("```tool")
987        || lower.starts_with("```invoke")
988        || lower.starts_with("```json")
989        || lower.starts_with("[tool_call]")
990}
991
992fn complete_non_protocol_json(text: &str, known_tool_names: &HashSet<String>) -> bool {
993    let trimmed = text.trim();
994    (trimmed.starts_with('{') || trimmed.starts_with('['))
995        && serde_json::from_str::<serde_json::Value>(trimmed).is_ok()
996        && (!looks_like_tool_protocol_envelope(trimmed)
997            || !tool_protocol_envelope_mentions_known_tool(trimmed, known_tool_names))
998}
999
1000fn complete_json_fence_protocol_state(
1001    text: &str,
1002    known_tool_names: &HashSet<String>,
1003) -> Option<bool> {
1004    let trimmed = text.trim();
1005    let body = json_fence_body(trimmed)?;
1006    Some(
1007        looks_like_tool_protocol_envelope(body)
1008            && tool_protocol_envelope_mentions_known_tool(body, known_tool_names),
1009    )
1010}
1011
1012fn detect_internal_protocol_without_tools(response: &str) -> Option<String> {
1013    let trimmed = response.trim();
1014    if trimmed.is_empty() {
1015        return None;
1016    }
1017    if looks_like_tool_protocol_example(trimmed) {
1018        return None;
1019    }
1020
1021    (looks_like_malformed_tool_protocol_envelope(trimmed)
1022        || contains_tool_protocol_tag_call(trimmed)
1023        || classify_tool_protocol_envelope(trimmed)
1024            .is_some_and(|kind| matches!(kind, ToolProtocolEnvelopeKind::TaggedToolCall))
1025        || (classify_tool_protocol_envelope(trimmed).is_none()
1026            && looks_like_tool_protocol_envelope(trimmed)))
1027    .then(|| {
1028        "response resembled an internal tool protocol envelope but no tools were enabled".into()
1029    })
1030}
1031
1032fn detect_tool_call_parse_issue_for_known_tools(
1033    response: &str,
1034    parsed_calls: &[ParsedToolCall],
1035    known_tool_names: &HashSet<String>,
1036) -> Option<String> {
1037    if !parsed_calls.is_empty() {
1038        return None;
1039    }
1040
1041    let trimmed = response.trim();
1042    if trimmed.is_empty() || looks_like_tool_protocol_example(trimmed) {
1043        return None;
1044    }
1045
1046    let message = "response resembled an internal tool protocol envelope but no valid tool call could be parsed";
1047
1048    if looks_like_malformed_tool_protocol_envelope_for_known_tools(trimmed, known_tool_names)
1049        || contains_tool_protocol_tag_call(trimmed)
1050    {
1051        return Some(message.into());
1052    }
1053
1054    if let Some(kind) = classify_tool_protocol_envelope(trimmed) {
1055        return (matches!(
1056            kind,
1057            ToolProtocolEnvelopeKind::TaggedToolCall | ToolProtocolEnvelopeKind::ToolResult
1058        ) || tool_protocol_envelope_mentions_known_tool(trimmed, known_tool_names))
1059        .then(|| message.into());
1060    }
1061
1062    looks_like_tool_protocol_envelope(trimmed).then(|| message.into())
1063}
1064
1065fn json_fence_body(trimmed: &str) -> Option<&str> {
1066    let rest = trimmed.strip_prefix("```")?;
1067    let first_newline = rest.find('\n')?;
1068    let language = rest[..first_newline].trim().trim_end_matches('\r');
1069    if !language.eq_ignore_ascii_case("json") {
1070        return None;
1071    }
1072
1073    let body_with_close = &rest[first_newline + 1..];
1074    let close_start = body_with_close.rfind("```")?;
1075    if !body_with_close[close_start + 3..].trim().is_empty() {
1076        return None;
1077    }
1078    Some(body_with_close[..close_start].trim())
1079}
1080
1081async fn consume_provider_streaming_response(
1082    model_provider: &dyn ModelProvider,
1083    messages: &[ChatMessage],
1084    request_tools: Option<&[crate::tools::ToolSpec]>,
1085    model: &str,
1086    temperature: Option<f64>,
1087    cancellation_token: Option<&CancellationToken>,
1088    on_delta: Option<&tokio::sync::mpsc::Sender<DraftEvent>>,
1089    strict_tool_parsing: bool,
1090) -> Result<StreamedChatOutcome> {
1091    let mut provider_stream = model_provider.stream_chat(
1092        ChatRequest {
1093            messages,
1094            tools: request_tools,
1095            thinking: zeroclaw_api::NATIVE_THINKING_OVERRIDE
1096                .try_with(Clone::clone)
1097                .ok()
1098                .flatten(),
1099        },
1100        model,
1101        temperature,
1102        zeroclaw_providers::traits::StreamOptions::new(true),
1103    );
1104    let mut outcome = StreamedChatOutcome::default();
1105    let mut delta_sender = on_delta;
1106    let mut suppress_forwarding = false;
1107    let mut text_guard = StreamTextGuard::new(request_tools);
1108
1109    loop {
1110        let next_chunk = if let Some(token) = cancellation_token {
1111            tokio::select! {
1112                () = token.cancelled() => return Err(ToolLoopCancelled.into()),
1113                chunk = provider_stream.next() => chunk,
1114            }
1115        } else {
1116            provider_stream.next().await
1117        };
1118
1119        let Some(event_result) = next_chunk else {
1120            break;
1121        };
1122
1123        let event = event_result.map_err(|err| {
1124            ::zeroclaw_log::record!(
1125                WARN,
1126                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
1127                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1128                    .with_attrs(::serde_json::json!({"error": format!("{}", err)})),
1129                "model_provider stream emitted an error event"
1130            );
1131            anyhow::Error::msg(format!("model_provider stream error: {err}"))
1132        })?;
1133        match event {
1134            StreamEvent::Final => break,
1135            StreamEvent::Usage(usage) => {
1136                outcome.usage = Some(usage);
1137            }
1138            StreamEvent::ToolCall(tool_call) => {
1139                outcome.tool_calls.push(tool_call);
1140                suppress_forwarding = true;
1141                text_guard.suppress_forwarding = true;
1142            }
1143            StreamEvent::PreExecutedToolCall { .. } | StreamEvent::PreExecutedToolResult { .. } => {
1144                // Pre-executed tool events are for observability only.
1145                // They are forwarded to the gateway via turn_streamed but
1146                // do not affect the agent's tool dispatch loop.
1147            }
1148            StreamEvent::TextDelta(chunk) => {
1149                // Reasoning/thinking deltas arrive on the same `TextDelta`
1150                // event as plain text but populate `chunk.reasoning` instead
1151                // of `chunk.delta`. They must be captured into the outcome
1152                // even when `chunk.delta` is empty — otherwise model_providers
1153                // that require reasoning to round-trip on subsequent turns
1154                // (DeepSeek V4 thinking mode; see #6059) reject the next
1155                // request with a 400. Reasoning is never forwarded as a
1156                // visible response delta — it is the model's internal
1157                // monologue, kept for replay only.
1158                if let Some(reasoning) = chunk.reasoning.as_deref()
1159                    && !reasoning.is_empty()
1160                {
1161                    outcome.reasoning_content.push_str(reasoning);
1162                }
1163
1164                if chunk.delta.is_empty() {
1165                    continue;
1166                }
1167
1168                outcome.response_text.push_str(&chunk.delta);
1169
1170                if suppress_forwarding {
1171                    continue;
1172                }
1173
1174                if strict_tool_parsing {
1175                    if let Some(tx) = delta_sender {
1176                        outcome.forwarded_live_deltas = true;
1177                        if tx.send(StreamDelta::Text(chunk.delta)).await.is_err() {
1178                            delta_sender = None;
1179                        }
1180                    }
1181                    continue;
1182                }
1183
1184                let Some(forward_text) = text_guard.push(&chunk.delta) else {
1185                    continue;
1186                };
1187
1188                if let Some(tx) = delta_sender {
1189                    outcome.forwarded_live_deltas = true;
1190                    if tx.send(StreamDelta::Text(forward_text)).await.is_err() {
1191                        delta_sender = None;
1192                    }
1193                }
1194            }
1195        }
1196    }
1197
1198    if let Some(forward_text) = text_guard.finish()
1199        && let Some(tx) = delta_sender
1200    {
1201        outcome.forwarded_live_deltas = true;
1202        let _ = tx.send(StreamDelta::Text(forward_text)).await;
1203    }
1204    outcome.suppressed_protocol = text_guard.suppressed_protocol;
1205
1206    Ok(outcome)
1207}
1208
1209/// Execute a single turn of the agent loop: send messages, parse tool calls,
1210/// execute tools, and loop until the LLM produces a final text response.
1211/// When `silent` is true, suppresses stdout (for channel use).
1212#[allow(clippy::too_many_arguments)]
1213pub async fn agent_turn(
1214    model_provider: &dyn ModelProvider,
1215    history: &mut Vec<ChatMessage>,
1216    tools_registry: &[Box<dyn Tool>],
1217    observer: &dyn Observer,
1218    provider_name: &str,
1219    model: &str,
1220    temperature: Option<f64>,
1221    silent: bool,
1222    channel_name: &str,
1223    channel_reply_target: Option<&str>,
1224    multimodal_config: &zeroclaw_config::schema::MultimodalConfig,
1225    max_tool_iterations: usize,
1226    approval: Option<&ApprovalManager>,
1227    excluded_tools: &[String],
1228    dedup_exempt_tools: &[String],
1229    activated_tools: Option<&std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>>,
1230    model_switch_callback: Option<ModelSwitchCallback>,
1231    strict_tool_parsing: bool,
1232    channel: Option<&dyn Channel>,
1233) -> Result<String> {
1234    run_tool_call_loop(
1235        model_provider,
1236        history,
1237        tools_registry,
1238        observer,
1239        provider_name,
1240        model,
1241        temperature,
1242        silent,
1243        approval,
1244        channel_name,
1245        channel_reply_target,
1246        multimodal_config,
1247        max_tool_iterations,
1248        None,
1249        None,
1250        None,
1251        excluded_tools,
1252        dedup_exempt_tools,
1253        activated_tools,
1254        model_switch_callback,
1255        &zeroclaw_config::schema::PacingConfig::default(),
1256        strict_tool_parsing,
1257        0,    // max_tool_result_chars: 0 = disabled (legacy callers)
1258        0,    // context_token_budget: 0 = disabled (legacy callers)
1259        None, // shared_budget: no shared budget for legacy callers
1260        channel,
1261        None, // receipt_generator
1262        None, // collected_receipts
1263    )
1264    .await
1265}
1266
1267fn maybe_inject_channel_delivery_defaults(
1268    tool_name: &str,
1269    tool_args: &mut serde_json::Value,
1270    channel_name: &str,
1271    channel_reply_target: Option<&str>,
1272) {
1273    if tool_name != "cron_add" {
1274        return;
1275    }
1276
1277    if !matches!(
1278        channel_name,
1279        "telegram" | "discord" | "slack" | "mattermost" | "matrix"
1280    ) {
1281        return;
1282    }
1283
1284    let Some(reply_target) = channel_reply_target
1285        .map(str::trim)
1286        .filter(|value| !value.is_empty())
1287    else {
1288        return;
1289    };
1290
1291    let Some(args) = tool_args.as_object_mut() else {
1292        return;
1293    };
1294
1295    let is_agent_job = args
1296        .get("job_type")
1297        .and_then(serde_json::Value::as_str)
1298        .is_some_and(|job_type| job_type.eq_ignore_ascii_case("agent"))
1299        || args
1300            .get("prompt")
1301            .and_then(serde_json::Value::as_str)
1302            .is_some_and(|prompt| !prompt.trim().is_empty());
1303    if !is_agent_job {
1304        return;
1305    }
1306
1307    let default_delivery = || {
1308        serde_json::json!({
1309            "mode": "announce",
1310            "channel": channel_name,
1311            "to": reply_target,
1312        })
1313    };
1314
1315    match args.get_mut("delivery") {
1316        None => {
1317            args.insert("delivery".to_string(), default_delivery());
1318        }
1319        Some(serde_json::Value::Null) => {
1320            *args.get_mut("delivery").expect("delivery key exists") = default_delivery();
1321        }
1322        Some(serde_json::Value::Object(delivery)) => {
1323            if delivery
1324                .get("mode")
1325                .and_then(serde_json::Value::as_str)
1326                .is_some_and(|mode| mode.eq_ignore_ascii_case("none"))
1327            {
1328                return;
1329            }
1330
1331            delivery
1332                .entry("mode".to_string())
1333                .or_insert_with(|| serde_json::Value::String("announce".to_string()));
1334
1335            let needs_channel = delivery
1336                .get("channel")
1337                .and_then(serde_json::Value::as_str)
1338                .is_none_or(|value| value.trim().is_empty());
1339            if needs_channel {
1340                delivery.insert(
1341                    "channel".to_string(),
1342                    serde_json::Value::String(channel_name.to_string()),
1343                );
1344            }
1345
1346            let needs_target = delivery
1347                .get("to")
1348                .and_then(serde_json::Value::as_str)
1349                .is_none_or(|value| value.trim().is_empty());
1350            if needs_target {
1351                delivery.insert(
1352                    "to".to_string(),
1353                    serde_json::Value::String(reply_target.to_string()),
1354                );
1355            }
1356        }
1357        Some(_) => {}
1358    }
1359}
1360
1361// ── Agent Tool-Call Loop ──────────────────────────────────────────────────
1362// Core agentic iteration: send conversation to the LLM, parse any tool
1363// calls from the response, execute them, append results to history, and
1364// repeat until the LLM produces a final text-only answer.
1365//
1366// Loop invariant: at the start of each iteration, `history` contains the
1367// full conversation so far (system prompt + user messages + prior tool
1368// results). The loop exits when:
1369//   • the LLM returns no tool calls (final answer), or
1370//   • max_iterations is reached (runaway safety), or
1371//   • the cancellation token fires (external abort).
1372
1373/// Append a receipt footer to the response text if any receipts were collected.
1374/// Execute a single turn of the agent loop: send messages, parse tool calls,
1375/// execute tools, and loop until the LLM produces a final text response.
1376#[allow(clippy::too_many_arguments)]
1377pub async fn run_tool_call_loop(
1378    model_provider: &dyn ModelProvider,
1379    history: &mut Vec<ChatMessage>,
1380    tools_registry: &[Box<dyn Tool>],
1381    observer: &dyn Observer,
1382    provider_name: &str,
1383    model: &str,
1384    temperature: Option<f64>,
1385    silent: bool,
1386    approval: Option<&ApprovalManager>,
1387    channel_name: &str,
1388    channel_reply_target: Option<&str>,
1389    multimodal_config: &zeroclaw_config::schema::MultimodalConfig,
1390    max_tool_iterations: usize,
1391    cancellation_token: Option<CancellationToken>,
1392    on_delta: Option<tokio::sync::mpsc::Sender<DraftEvent>>,
1393    hooks: Option<&crate::hooks::HookRunner>,
1394    excluded_tools: &[String],
1395    dedup_exempt_tools: &[String],
1396    activated_tools: Option<&std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>>,
1397    model_switch_callback: Option<ModelSwitchCallback>,
1398    pacing: &zeroclaw_config::schema::PacingConfig,
1399    strict_tool_parsing: bool,
1400    max_tool_result_chars: usize,
1401    context_token_budget: usize,
1402    shared_budget: Option<Arc<std::sync::atomic::AtomicUsize>>,
1403    channel: Option<&dyn Channel>,
1404    receipt_generator: Option<&crate::agent::tool_receipts::ReceiptGenerator>,
1405    collected_receipts: Option<&std::sync::Mutex<Vec<String>>>,
1406) -> Result<String> {
1407    let max_iterations = if max_tool_iterations == 0 {
1408        DEFAULT_MAX_TOOL_ITERATIONS
1409    } else {
1410        max_tool_iterations
1411    };
1412
1413    let turn_id = Uuid::new_v4().to_string();
1414    let loop_started_at = Instant::now();
1415    let loop_ignore_tools: HashSet<&str> = pacing
1416        .loop_ignore_tools
1417        .iter()
1418        .map(String::as_str)
1419        .collect();
1420    let mut consecutive_identical_outputs: usize = 0;
1421    let mut last_tool_output_hash: Option<u64> = None;
1422
1423    let mut loop_detector = crate::agent::loop_detector::LoopDetector::new(
1424        crate::agent::loop_detector::LoopDetectorConfig {
1425            enabled: pacing.loop_detection_enabled,
1426            window_size: pacing.loop_detection_window_size,
1427            max_repeats: pacing.loop_detection_max_repeats,
1428        },
1429    );
1430
1431    // Accumulated display text across all tool-loop calls.
1432    let mut accumulated_display_text = String::new();
1433    let mut malformed_tool_protocol_retries: usize = 0;
1434
1435    for iteration in 0..max_iterations {
1436        let mut seen_tool_signatures: HashSet<(String, String)> = HashSet::new();
1437
1438        if cancellation_token
1439            .as_ref()
1440            .is_some_and(CancellationToken::is_cancelled)
1441        {
1442            return Err(ToolLoopCancelled.into());
1443        }
1444
1445        // Shared iteration budget: parent + subagents share a global counter
1446        if let Some(ref budget) = shared_budget {
1447            let remaining = budget.load(std::sync::atomic::Ordering::Relaxed);
1448            if remaining == 0 {
1449                ::zeroclaw_log::record!(
1450                    WARN,
1451                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1452                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1453                        .with_attrs(::serde_json::json!({"iteration": iteration})),
1454                    "Shared iteration budget exhausted at iteration "
1455                );
1456                break;
1457            }
1458            budget.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
1459        }
1460
1461        // Preemptive context management: trim history before it overflows
1462        if context_token_budget > 0 {
1463            let estimated = estimate_history_tokens(history);
1464            if estimated > context_token_budget {
1465                ::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");
1466                let chars_saved = fast_trim_tool_results(history, 4);
1467                if chars_saved > 0 {
1468                    ::zeroclaw_log::record!(
1469                        INFO,
1470                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1471                            .with_attrs(::serde_json::json!({"chars_saved": chars_saved})),
1472                        "Preemptive fast-trim applied"
1473                    );
1474                }
1475                // If still over budget, use the history pruner for deeper cleanup
1476                let recheck = estimate_history_tokens(history);
1477                if recheck > context_token_budget {
1478                    let stats = crate::agent::history_pruner::prune_history(
1479                        history,
1480                        &crate::agent::history_pruner::HistoryPrunerConfig {
1481                            enabled: true,
1482                            max_tokens: context_token_budget,
1483                            keep_recent: 4,
1484                            collapse_tool_results: true,
1485                        },
1486                    );
1487                    if stats.dropped_messages > 0 || stats.collapsed_pairs > 0 {
1488                        ::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");
1489                    }
1490                }
1491            }
1492        }
1493
1494        // Remove orphaned tool-role messages whose assistant (tool_calls)
1495        // counterpart was dropped by proactive trimming, context compression,
1496        // or session history reloading.  Without this, model_providers like MiniMax
1497        // reject the request with "tool result's tool id not found" (bug #5743).
1498        crate::agent::history_pruner::remove_orphaned_tool_messages(history);
1499        normalize_system_messages(history);
1500
1501        // Check if model switch was requested via model_switch tool
1502        if let Some(ref callback) = model_switch_callback
1503            && let Ok(guard) = callback.lock()
1504            && let Some((new_model_provider, new_model)) = guard.as_ref()
1505            && (new_model_provider != provider_name || new_model != model)
1506        {
1507            ::zeroclaw_log::record!(
1508                INFO,
1509                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1510                &format!(
1511                    "Model switch detected: {} {} -> {} {}",
1512                    provider_name, model, new_model_provider, new_model
1513                )
1514            );
1515            return Err(ModelSwitchRequested {
1516                model_provider: new_model_provider.clone(),
1517                model: new_model.clone(),
1518            }
1519            .into());
1520        }
1521
1522        // Rebuild tool_specs each iteration so newly activated deferred tools appear.
1523        let mut tool_specs: Vec<crate::tools::ToolSpec> = tools_registry
1524            .iter()
1525            .filter(|tool| !excluded_tools.iter().any(|ex| ex == tool.name()))
1526            .map(|tool| tool.spec())
1527            .collect();
1528        if let Some(at) = activated_tools {
1529            for spec in at.lock().unwrap().tool_specs() {
1530                if !excluded_tools.iter().any(|ex| ex == &spec.name) {
1531                    tool_specs.push(spec);
1532                }
1533            }
1534        }
1535        let known_tool_names: HashSet<String> = tool_specs
1536            .iter()
1537            .map(|tool| tool.name.to_ascii_lowercase())
1538            .collect();
1539        let use_native_tools = model_provider.supports_native_tools() && !tool_specs.is_empty();
1540
1541        let image_marker_count = multimodal::count_image_markers(history);
1542
1543        // ── Vision model_provider routing ──────────────────────────
1544        // When the default model_provider lacks vision support but a dedicated
1545        // vision_model_provider is configured, create it on demand and use it
1546        // for this iteration.  Otherwise, preserve the original error.
1547        let vision_model_provider_box: Option<Box<dyn ModelProvider>> = if image_marker_count > 0
1548            && !model_provider.supports_vision()
1549        {
1550            if let Some(ref vp) = multimodal_config.vision_model_provider {
1551                let vp_instance =
1552                    zeroclaw_providers::create_model_provider(vp, None).map_err(|e| {
1553                        ::zeroclaw_log::record!(
1554                            ERROR,
1555                            ::zeroclaw_log::Event::new(
1556                                module_path!(),
1557                                ::zeroclaw_log::Action::Fail
1558                            )
1559                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1560                            .with_attrs(::serde_json::json!({
1561                                "vision_provider": vp,
1562                                "error": format!("{}", e),
1563                            })),
1564                            "vision model_provider construction failed"
1565                        );
1566                        anyhow::Error::msg(format!(
1567                            "failed to create vision model_provider '{vp}': {e}"
1568                        ))
1569                    })?;
1570                if !vp_instance.supports_vision() {
1571                    return Err(ProviderCapabilityError {
1572                        model_provider: vp.clone(),
1573                        capability: "vision".to_string(),
1574                        message: format!(
1575                            "configured vision_model_provider '{vp}' does not support vision input"
1576                        ),
1577                    }
1578                    .into());
1579                }
1580                Some(vp_instance)
1581            } else {
1582                return Err(ProviderCapabilityError {
1583                        model_provider: provider_name.to_string(),
1584                        capability: "vision".to_string(),
1585                        message: format!(
1586                            "received {image_marker_count} image marker(s), but this model_provider does not support vision input"
1587                        ),
1588                    }
1589                    .into());
1590            }
1591        } else {
1592            None
1593        };
1594
1595        let (active_model_provider, active_model_provider_name, active_model): (
1596            &dyn ModelProvider,
1597            &str,
1598            &str,
1599        ) = if let Some(ref vp_box) = vision_model_provider_box {
1600            let vp_name = multimodal_config
1601                .vision_model_provider
1602                .as_deref()
1603                .unwrap_or(provider_name);
1604            let vm = multimodal_config.vision_model.as_deref().unwrap_or(model);
1605            (vp_box.as_ref(), vp_name, vm)
1606        } else {
1607            (model_provider, provider_name, model)
1608        };
1609
1610        let prepared_messages =
1611            multimodal::prepare_messages_for_provider(history, multimodal_config).await?;
1612
1613        // ── Progress: LLM thinking ────────────────────────────
1614        if let Some(ref tx) = on_delta {
1615            let phase = if iteration == 0 {
1616                "\u{1f914} Thinking...\n".to_string()
1617            } else {
1618                format!("\u{1f914} Thinking (round {})...\n", iteration + 1)
1619            };
1620            let _ = tx.send(StreamDelta::Status(phase)).await;
1621        }
1622
1623        observer.record_event(&ObserverEvent::LlmRequest {
1624            model_provider: active_model_provider_name.to_string(),
1625            model: active_model.to_string(),
1626            messages_count: history.len(),
1627        });
1628        {
1629            let _provider_guard =
1630                ::zeroclaw_log::attribution_span!(active_model_provider).entered();
1631            ::zeroclaw_log::record!(
1632                INFO,
1633                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Send)
1634                    .with_attrs(::serde_json::json!({
1635                        "iteration": iteration + 1,
1636                        "messages_count": history.len(),
1637                        "model": active_model,
1638                        "trace_id": turn_id,
1639                    })),
1640                "llm_request"
1641            );
1642        }
1643
1644        let llm_started_at = Instant::now();
1645
1646        // Fire void hook before LLM call
1647        if let Some(hooks) = hooks {
1648            hooks.fire_llm_input(history, model).await;
1649        }
1650
1651        // Budget enforcement — block if limit exceeded (no-op when not scoped)
1652        if let Some(BudgetCheck::Exceeded {
1653            current_usd,
1654            limit_usd,
1655            period,
1656        }) = check_tool_loop_budget()
1657        {
1658            ::zeroclaw_log::record!(
1659                WARN,
1660                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
1661                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1662                    .with_attrs(::serde_json::json!({
1663                        "current_usd": current_usd,
1664                        "limit_usd": limit_usd,
1665                        "period": format!("{period:?}"),
1666                    })),
1667                "tool-call loop budget exceeded"
1668            );
1669            anyhow::bail!(
1670                "Budget exceeded: ${:.4} of ${:.2} {:?} limit. Cannot make further API calls until the budget resets.",
1671                current_usd,
1672                limit_usd,
1673                period
1674            );
1675        }
1676
1677        // Unified path via ModelProvider::chat so provider-specific native tool logic
1678        // (OpenAI/Anthropic/OpenRouter/compatible adapters) is honored.
1679        let request_tools = if use_native_tools {
1680            Some(tool_specs.as_slice())
1681        } else {
1682            None
1683        };
1684        let should_consume_provider_stream = on_delta.is_some()
1685            && model_provider.supports_streaming()
1686            && (request_tools.is_none() || model_provider.supports_streaming_tool_events());
1687        ::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));
1688        let mut streamed_live_deltas = false;
1689        let mut streamed_protocol_suppressed = false;
1690
1691        let chat_result = if should_consume_provider_stream {
1692            match consume_provider_streaming_response(
1693                active_model_provider,
1694                &prepared_messages.messages,
1695                request_tools,
1696                active_model,
1697                temperature,
1698                cancellation_token.as_ref(),
1699                on_delta.as_ref(),
1700                strict_tool_parsing,
1701            )
1702            .await
1703            {
1704                Ok(streamed) => {
1705                    streamed_live_deltas = streamed.forwarded_live_deltas;
1706                    streamed_protocol_suppressed = streamed.suppressed_protocol;
1707                    let reasoning_content = if streamed.reasoning_content.is_empty() {
1708                        None
1709                    } else {
1710                        Some(streamed.reasoning_content)
1711                    };
1712                    Ok(zeroclaw_providers::ChatResponse {
1713                        text: Some(streamed.response_text),
1714                        tool_calls: streamed.tool_calls,
1715                        usage: streamed.usage,
1716                        reasoning_content,
1717                    })
1718                }
1719                Err(stream_err) => {
1720                    ::zeroclaw_log::record!(
1721                        WARN,
1722                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
1723                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1724                            .with_attrs(::serde_json::json!({
1725                                "model": active_model,
1726                                "iteration": iteration + 1,
1727                                "error": scrub_credentials(&stream_err.to_string()),
1728                                "trace_id": turn_id,
1729                            })),
1730                        "llm_stream_fallback: provider stream failed, falling back to non-streaming chat"
1731                    );
1732                    {
1733                        use ::zeroclaw_log::Instrument;
1734                        let provider_span =
1735                            ::zeroclaw_log::attribution_span!(active_model_provider);
1736                        let chat_future = ::zeroclaw_log::scope!(
1737                            model: active_model,
1738                            =>
1739                            active_model_provider.chat(
1740                                ChatRequest {
1741                                    messages: &prepared_messages.messages,
1742                                    tools: request_tools,
1743                                    thinking: zeroclaw_api::NATIVE_THINKING_OVERRIDE
1744                                        .try_with(Clone::clone)
1745                                        .ok()
1746                                        .flatten(),
1747                                },
1748                                active_model,
1749                                temperature,
1750                            )
1751                        )
1752                        .instrument(provider_span);
1753                        if let Some(token) = cancellation_token.as_ref() {
1754                            tokio::select! {
1755                                () = token.cancelled() => Err(ToolLoopCancelled.into()),
1756                                result = chat_future => result,
1757                            }
1758                        } else {
1759                            chat_future.await
1760                        }
1761                    }
1762                }
1763            }
1764        } else {
1765            // Non-streaming path: wrap with optional per-step timeout from
1766            // pacing config to catch hung model responses.
1767            use ::zeroclaw_log::Instrument;
1768            let provider_span = ::zeroclaw_log::attribution_span!(active_model_provider);
1769            let chat_future = ::zeroclaw_log::scope!(
1770                model: active_model,
1771                =>
1772                active_model_provider.chat(
1773                    ChatRequest {
1774                        messages: &prepared_messages.messages,
1775                        tools: request_tools,
1776                        thinking: zeroclaw_api::NATIVE_THINKING_OVERRIDE
1777                            .try_with(Clone::clone)
1778                            .ok()
1779                            .flatten(),
1780                    },
1781                    active_model,
1782                    temperature,
1783                )
1784            )
1785            .instrument(provider_span);
1786
1787            match pacing.step_timeout_secs {
1788                Some(step_secs) if step_secs > 0 => {
1789                    let step_timeout = Duration::from_secs(step_secs);
1790                    if let Some(token) = cancellation_token.as_ref() {
1791                        tokio::select! {
1792                            () = token.cancelled() => return Err(ToolLoopCancelled.into()),
1793                            result = tokio::time::timeout(step_timeout, chat_future) => {
1794                                match result {
1795                                    Ok(inner) => inner,
1796                                    Err(_) => anyhow::bail!(
1797                                        "LLM inference step timed out after {step_secs}s (step_timeout_secs)"
1798                                    ),
1799                                }
1800                            },
1801                        }
1802                    } else {
1803                        match tokio::time::timeout(step_timeout, chat_future).await {
1804                            Ok(inner) => inner,
1805                            Err(_) => anyhow::bail!(
1806                                "LLM inference step timed out after {step_secs}s (step_timeout_secs)"
1807                            ),
1808                        }
1809                    }
1810                }
1811                _ => {
1812                    if let Some(token) = cancellation_token.as_ref() {
1813                        tokio::select! {
1814                            () = token.cancelled() => return Err(ToolLoopCancelled.into()),
1815                            result = chat_future => result,
1816                        }
1817                    } else {
1818                        chat_future.await
1819                    }
1820                }
1821            }
1822        };
1823
1824        let (
1825            response_text,
1826            parsed_text,
1827            tool_calls,
1828            assistant_history_content,
1829            native_tool_calls,
1830            parse_issue_detected,
1831            protocol_suppressed,
1832            response_streamed_live,
1833        ) = match chat_result {
1834            Ok(resp) => {
1835                let (resp_input_tokens, resp_output_tokens) = resp
1836                    .usage
1837                    .as_ref()
1838                    .map(|u| (u.input_tokens, u.output_tokens))
1839                    .unwrap_or((None, None));
1840
1841                observer.record_event(&ObserverEvent::LlmResponse {
1842                    model_provider: provider_name.to_string(),
1843                    model: model.to_string(),
1844                    duration: llm_started_at.elapsed(),
1845                    success: true,
1846                    error_message: None,
1847                    input_tokens: resp_input_tokens,
1848                    output_tokens: resp_output_tokens,
1849                });
1850
1851                // Record cost via task-local tracker (no-op when not scoped)
1852                let _ = resp
1853                    .usage
1854                    .as_ref()
1855                    .and_then(|usage| record_tool_loop_cost_usage(provider_name, model, usage));
1856
1857                let mut response_text = if tool_specs.is_empty() {
1858                    strip_think_tags(resp.text_or_empty())
1859                } else {
1860                    resp.text_or_empty().to_string()
1861                };
1862                // First try native structured tool calls (OpenAI-format).
1863                // Fall back to text-based parsing (XML tags, markdown blocks,
1864                // GLM format) only if the model_provider returned no native calls —
1865                // this ensures we support both native and prompt-guided models.
1866                let mut calls: Vec<ParsedToolCall> = if tool_specs.is_empty() {
1867                    Vec::new()
1868                } else {
1869                    resp.tool_calls
1870                        .iter()
1871                        .map(|call| ParsedToolCall {
1872                            name: call.name.clone(),
1873                            arguments: serde_json::from_str::<serde_json::Value>(&call.arguments)
1874                                .unwrap_or_else(|_| {
1875                                    serde_json::Value::Object(serde_json::Map::new())
1876                                }),
1877                            tool_call_id: Some(call.id.clone()),
1878                        })
1879                        .collect()
1880                };
1881                let mut parsed_text = String::new();
1882
1883                if strict_tool_parsing && calls.is_empty() {
1884                    response_text = strip_think_tags(&response_text);
1885                }
1886
1887                if calls.is_empty()
1888                    && !tool_specs.is_empty()
1889                    && !strict_tool_parsing
1890                    && !looks_like_tool_protocol_example(&response_text)
1891                {
1892                    let (fallback_text, fallback_calls) = parse_tool_calls(&response_text);
1893                    let filtered_calls: Vec<ParsedToolCall> = fallback_calls
1894                        .into_iter()
1895                        .filter(|call| known_tool_names.contains(&call.name.to_ascii_lowercase()))
1896                        .collect();
1897                    if !fallback_text.is_empty() && !filtered_calls.is_empty() {
1898                        parsed_text = fallback_text;
1899                    }
1900                    calls = filtered_calls;
1901                }
1902
1903                let parse_issue = if strict_tool_parsing {
1904                    None
1905                } else if tool_specs.is_empty() {
1906                    detect_internal_protocol_without_tools(&response_text).or_else(|| {
1907                        streamed_protocol_suppressed.then(|| {
1908                            "streaming text guard suppressed an internal tool protocol envelope"
1909                                .to_string()
1910                        })
1911                    })
1912                } else {
1913                    detect_tool_call_parse_issue_for_known_tools(
1914                        &response_text,
1915                        &calls,
1916                        &known_tool_names,
1917                    )
1918                    .or_else(|| {
1919                        streamed_protocol_suppressed.then(|| {
1920                            "streaming text guard suppressed an internal tool protocol envelope"
1921                                .to_string()
1922                        })
1923                    })
1924                };
1925                if let Some(ref issue) = parse_issue {
1926                    ::zeroclaw_log::record!(
1927                        WARN,
1928                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
1929                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1930                            .with_attrs(::serde_json::json!({
1931                                "model": model,
1932                                "iteration": iteration + 1,
1933                                "issue": issue.as_str(),
1934                                "response": scrub_credentials(&response_text),
1935                                "trace_id": turn_id,
1936                            })),
1937                        "tool_call_parse_issue"
1938                    );
1939                }
1940
1941                ::zeroclaw_log::record!(
1942                    INFO,
1943                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Receive)
1944                        .with_outcome(::zeroclaw_log::EventOutcome::Success)
1945                        .with_duration(
1946                            u64::try_from(llm_started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
1947                        )
1948                        .with_attrs(::serde_json::json!({
1949                            "model": model,
1950                            "iteration": iteration + 1,
1951                            "input_tokens": resp_input_tokens,
1952                            "output_tokens": resp_output_tokens,
1953                            "raw_response": scrub_credentials(&response_text),
1954                            "native_tool_calls": resp.tool_calls.len(),
1955                            "parsed_tool_calls": calls.len(),
1956                            "trace_id": turn_id,
1957                        })),
1958                    "llm_response"
1959                );
1960
1961                // Preserve native tool call IDs in assistant history so role=tool
1962                // follow-up messages can reference the exact call id.
1963                let reasoning_content = resp.reasoning_content.clone();
1964                let assistant_history_content = if resp.tool_calls.is_empty() {
1965                    if use_native_tools {
1966                        build_native_assistant_history_from_parsed_calls(
1967                            &response_text,
1968                            &calls,
1969                            reasoning_content.as_deref(),
1970                        )
1971                        .unwrap_or_else(|| response_text.clone())
1972                    } else {
1973                        response_text.clone()
1974                    }
1975                } else {
1976                    build_native_assistant_history(
1977                        &response_text,
1978                        &resp.tool_calls,
1979                        reasoning_content.as_deref(),
1980                    )
1981                };
1982
1983                let native_calls = resp.tool_calls;
1984                (
1985                    response_text,
1986                    parsed_text,
1987                    calls,
1988                    assistant_history_content,
1989                    native_calls,
1990                    parse_issue.is_some(),
1991                    streamed_protocol_suppressed,
1992                    streamed_live_deltas,
1993                )
1994            }
1995            Err(e) => {
1996                let safe_error = zeroclaw_providers::sanitize_api_error(&e.to_string());
1997                observer.record_event(&ObserverEvent::LlmResponse {
1998                    model_provider: provider_name.to_string(),
1999                    model: model.to_string(),
2000                    duration: llm_started_at.elapsed(),
2001                    success: false,
2002                    error_message: Some(safe_error.clone()),
2003                    input_tokens: None,
2004                    output_tokens: None,
2005                });
2006                ::zeroclaw_log::record!(
2007                    WARN,
2008                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
2009                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
2010                        .with_duration(
2011                            u64::try_from(llm_started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
2012                        )
2013                        .with_attrs(::serde_json::json!({
2014                            "model": model,
2015                            "iteration": iteration + 1,
2016                            "error": safe_error,
2017                            "trace_id": turn_id,
2018                        })),
2019                    "llm_response"
2020                );
2021
2022                // Context overflow recovery: trim history and retry
2023                if zeroclaw_providers::reliable::is_context_window_exceeded(&e) {
2024                    ::zeroclaw_log::record!(
2025                        WARN,
2026                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2027                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2028                            .with_attrs(::serde_json::json!({"iteration": iteration + 1})),
2029                        "Context window exceeded, attempting in-loop recovery"
2030                    );
2031
2032                    // Step 1: fast-trim old tool results (cheap)
2033                    let chars_saved = fast_trim_tool_results(history, 4);
2034                    if chars_saved > 0 {
2035                        ::zeroclaw_log::record!(
2036                            INFO,
2037                            ::zeroclaw_log::Event::new(
2038                                module_path!(),
2039                                ::zeroclaw_log::Action::Note
2040                            )
2041                            .with_attrs(::serde_json::json!({"chars_saved": chars_saved})),
2042                            "Context recovery: trimmed old tool results, retrying"
2043                        );
2044                        continue;
2045                    }
2046
2047                    // Step 2: emergency drop oldest non-system messages
2048                    let dropped = emergency_history_trim(history, 4);
2049                    if dropped > 0 {
2050                        ::zeroclaw_log::record!(
2051                            INFO,
2052                            ::zeroclaw_log::Event::new(
2053                                module_path!(),
2054                                ::zeroclaw_log::Action::Note
2055                            )
2056                            .with_attrs(::serde_json::json!({"dropped": dropped})),
2057                            "Context recovery: dropped old messages, retrying"
2058                        );
2059                        continue;
2060                    }
2061
2062                    // Nothing left to trim — truly unrecoverable
2063                    ::zeroclaw_log::record!(
2064                        ERROR,
2065                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
2066                            .with_outcome(::zeroclaw_log::EventOutcome::Failure),
2067                        "Context overflow unrecoverable: no trimmable messages"
2068                    );
2069                }
2070
2071                return Err(e);
2072            }
2073        };
2074
2075        let display_text = resolve_display_text(
2076            &response_text,
2077            &parsed_text,
2078            !tool_calls.is_empty(),
2079            !native_tool_calls.is_empty(),
2080        );
2081
2082        // Native provider tool_calls are converted into parsed `tool_calls`
2083        // above; if this branch is reached there is no valid native call to run.
2084        if tool_calls.is_empty() && parse_issue_detected {
2085            malformed_tool_protocol_retries += 1;
2086            ::zeroclaw_log::record!(
2087                WARN,
2088                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
2089                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
2090                    .with_attrs(serde_json::json!({
2091                        "channel": channel_name,
2092                        "model_provider": provider_name,
2093                        "model": model,
2094                        "trace_id": turn_id,
2095                        "error": "malformed internal tool protocol omitted from channel output",
2096                    })),
2097                "tool_call_parse_feedback"
2098            );
2099            ::zeroclaw_log::record!(
2100                DEBUG,
2101                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2102                    .with_attrs(serde_json::json!({
2103                    "iteration": iteration + 1,
2104                    "retry": malformed_tool_protocol_retries,
2105                    "max_retries": MAX_MALFORMED_TOOL_PROTOCOL_RETRIES,
2106                    "response_excerpt": truncate_with_ellipsis(
2107                        &scrub_credentials(&response_text),
2108                        600
2109                    ),
2110                    })),
2111                "tool_call_parse_feedback_details"
2112            );
2113
2114            if malformed_tool_protocol_retries <= MAX_MALFORMED_TOOL_PROTOCOL_RETRIES {
2115                // This is model feedback, not a tool result: malformed protocol
2116                // output has no valid tool_call_id to attach a role=tool message to.
2117                history.push(ChatMessage::user(
2118                    "[Tool call parse error]\n\
2119                     Your previous response looked like an internal tool-call protocol payload, \
2120                     but ZeroClaw could not parse it into a valid tool call. Use the supported \
2121                     tool-call schema, or answer in natural language if no tool is needed."
2122                        .to_string(),
2123                ));
2124                continue;
2125            }
2126
2127            let fallback =
2128                crate::i18n::get_required_cli_string("channel-runtime-malformed-tool-output");
2129            accumulated_display_text.push_str(&fallback);
2130            if let Some(ref tx) = on_delta {
2131                let _ = tx.send(StreamDelta::Text(fallback.to_string())).await;
2132            }
2133            history.push(ChatMessage::assistant(fallback.to_string()));
2134            return Ok(accumulated_display_text);
2135        }
2136
2137        // ── Progress: LLM responded ─────────────────────────────
2138        if let Some(ref tx) = on_delta {
2139            let llm_secs = llm_started_at.elapsed().as_secs();
2140            if !tool_calls.is_empty() {
2141                let _ = tx
2142                    .send(StreamDelta::Status(format!(
2143                        "\u{1f4ac} Got {} tool call(s) ({llm_secs}s)\n",
2144                        tool_calls.len()
2145                    )))
2146                    .await;
2147            }
2148        }
2149
2150        if tool_calls.is_empty() {
2151            ::zeroclaw_log::record!(
2152                INFO,
2153                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Complete)
2154                    .with_outcome(::zeroclaw_log::EventOutcome::Success)
2155                    .with_attrs(::serde_json::json!({
2156                        "model": model,
2157                        "iteration": iteration + 1,
2158                        "text": scrub_credentials(&display_text),
2159                        "trace_id": turn_id,
2160                    })),
2161                "turn_final_response"
2162            );
2163            // No tool calls — this is the final response.
2164            accumulated_display_text.push_str(&display_text);
2165
2166            // If text wasn't streamed live, send it now via post-hoc chunking.
2167            // When streamed live, the channel already received the deltas.
2168            if let Some(ref tx) = on_delta
2169                && !response_streamed_live
2170                && !protocol_suppressed
2171            {
2172                let mut chunk = String::new();
2173                for word in display_text.split_inclusive(char::is_whitespace) {
2174                    if cancellation_token
2175                        .as_ref()
2176                        .is_some_and(CancellationToken::is_cancelled)
2177                    {
2178                        return Err(ToolLoopCancelled.into());
2179                    }
2180                    chunk.push_str(word);
2181                    if chunk.len() >= STREAM_CHUNK_MIN_CHARS
2182                        && tx
2183                            .send(StreamDelta::Text(std::mem::take(&mut chunk)))
2184                            .await
2185                            .is_err()
2186                    {
2187                        break;
2188                    }
2189                }
2190                if !chunk.is_empty() {
2191                    let _ = tx.send(StreamDelta::Text(chunk)).await;
2192                }
2193            }
2194
2195            history.push(ChatMessage::assistant(response_text.clone()));
2196            return Ok(accumulated_display_text);
2197        }
2198
2199        // Accumulate text from this iteration (tool calls present, loop continues).
2200        accumulated_display_text.push_str(&display_text);
2201
2202        // Native tool-call model_providers can return assistant text separately from
2203        // the structured call payload; relay it to draft-capable channels.
2204        if !display_text.is_empty() {
2205            if !native_tool_calls.is_empty()
2206                && let Some(ref tx) = on_delta
2207            {
2208                let mut narration = display_text.clone();
2209                if !narration.ends_with('\n') {
2210                    narration.push('\n');
2211                }
2212                let _ = tx.send(StreamDelta::Text(narration)).await;
2213            }
2214            if !silent {
2215                print!("{display_text}");
2216                let _ = std::io::stdout().flush();
2217            }
2218        }
2219
2220        // Execute tool calls and build results. `individual_results` tracks per-call output so
2221        // native-mode history can emit one role=tool message per tool call with the correct ID.
2222        //
2223        // When multiple tool calls are present and interactive CLI approval is not needed, run
2224        // tool executions concurrently for lower wall-clock latency.
2225        let mut tool_results = String::new();
2226        let mut individual_results: Vec<(Option<String>, String)> = Vec::new();
2227        let mut ordered_results: Vec<Option<(String, Option<String>, ToolExecutionOutcome)>> =
2228            (0..tool_calls.len()).map(|_| None).collect();
2229        let allow_parallel_execution = should_execute_tools_in_parallel(&tool_calls, approval);
2230        let mut executable_indices: Vec<usize> = Vec::new();
2231        let mut executable_calls: Vec<ParsedToolCall> = Vec::new();
2232
2233        for (idx, call) in tool_calls.iter().enumerate() {
2234            // ── Hook: before_tool_call (modifying) ──────────
2235            let mut tool_name = call.name.clone();
2236            let mut tool_args = call.arguments.clone();
2237            if let Some(hooks) = hooks {
2238                match hooks
2239                    .run_before_tool_call(tool_name.clone(), tool_args.clone())
2240                    .await
2241                {
2242                    crate::hooks::HookResult::Cancel(reason) => {
2243                        ::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");
2244                        let cancelled = format!("Cancelled by hook: {reason}");
2245                        ::zeroclaw_log::record!(
2246                            WARN,
2247                            ::zeroclaw_log::Event::new(
2248                                module_path!(),
2249                                ::zeroclaw_log::Action::Cancel
2250                            )
2251                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
2252                            .with_attrs(::serde_json::json!({
2253                                "model": model,
2254                                "iteration": iteration + 1,
2255                                "tool": call.name,
2256                                "arguments": scrub_credentials(&tool_args.to_string()),
2257                                "result": cancelled,
2258                                "trace_id": turn_id,
2259                            })),
2260                            "tool_call_result"
2261                        );
2262                        if let Some(ref tx) = on_delta {
2263                            let _ = tx
2264                                .send(StreamDelta::Status(format!(
2265                                    "\u{274c} {}: {}\n",
2266                                    call.name,
2267                                    truncate_with_ellipsis(&scrub_credentials(&cancelled), 200)
2268                                )))
2269                                .await;
2270                        }
2271                        ordered_results[idx] = Some((
2272                            call.name.clone(),
2273                            call.tool_call_id.clone(),
2274                            ToolExecutionOutcome {
2275                                output: cancelled,
2276                                success: false,
2277                                error_reason: Some(scrub_credentials(&reason)),
2278                                duration: Duration::ZERO,
2279                                receipt: None,
2280                            },
2281                        ));
2282                        continue;
2283                    }
2284                    crate::hooks::HookResult::Continue((name, args)) => {
2285                        tool_name = name;
2286                        tool_args = args;
2287                    }
2288                }
2289            }
2290
2291            maybe_inject_channel_delivery_defaults(
2292                &tool_name,
2293                &mut tool_args,
2294                channel_name,
2295                channel_reply_target,
2296            );
2297
2298            super::set_runtime_approved_arg(&tool_name, &mut tool_args, false);
2299
2300            // ── Approval hook ────────────────────────────────
2301            let mut approval_requirement = approval
2302                .map(|mgr| mgr.approval_requirement(&tool_name))
2303                .unwrap_or(ApprovalRequirement::NotRequired);
2304            if let Some(mgr) = approval
2305                && approval_requirement == ApprovalRequirement::Prompt
2306            {
2307                let request = ApprovalRequest {
2308                    tool_name: tool_name.clone(),
2309                    arguments: tool_args.clone(),
2310                };
2311
2312                // Interactive CLI: prompt the operator.
2313                // Non-interactive (channels): try the channel's inline
2314                // approval (e.g. Telegram inline keyboard) before falling
2315                // back to auto-deny.
2316                let decision = if mgr.is_non_interactive() {
2317                    let channel_decision = if let Some(ch) = channel {
2318                        let ch_request = zeroclaw_api::channel::ChannelApprovalRequest {
2319                            tool_name: request.tool_name.clone(),
2320                            arguments_summary: crate::approval::summarize_args(&request.arguments),
2321                            raw_arguments: Some(request.arguments.clone()),
2322                        };
2323                        let recipient = channel_reply_target.unwrap_or_default();
2324                        match ch.request_approval(recipient, &ch_request).await {
2325                            Ok(Some(r)) => Some(r),
2326                            Ok(None) => None,
2327                            Err(e) => {
2328                                ::zeroclaw_log::record!(
2329                                    WARN,
2330                                    ::zeroclaw_log::Event::new(
2331                                        module_path!(),
2332                                        ::zeroclaw_log::Action::Note
2333                                    )
2334                                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2335                                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
2336                                    "Channel approval request failed"
2337                                );
2338                                None
2339                            }
2340                        }
2341                    } else {
2342                        None
2343                    };
2344                    match channel_decision {
2345                        Some(zeroclaw_api::channel::ChannelApprovalResponse::Approve) => {
2346                            ApprovalResponse::Yes
2347                        }
2348                        Some(zeroclaw_api::channel::ChannelApprovalResponse::AlwaysApprove) => {
2349                            ApprovalResponse::Always
2350                        }
2351                        Some(zeroclaw_api::channel::ChannelApprovalResponse::Deny) => {
2352                            ApprovalResponse::No
2353                        }
2354                        // Channel doesn't support approval — auto-deny.
2355                        None => ApprovalResponse::No,
2356                    }
2357                } else {
2358                    mgr.prompt_cli(&request)
2359                };
2360
2361                mgr.record_decision(&tool_name, &tool_args, decision, channel_name);
2362
2363                if decision == ApprovalResponse::No {
2364                    let denied = "Denied by user.".to_string();
2365                    ::zeroclaw_log::record!(
2366                        WARN,
2367                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
2368                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
2369                            .with_attrs(::serde_json::json!({
2370                                "model": model,
2371                                "iteration": iteration + 1,
2372                                "tool": tool_name.clone(),
2373                                "arguments": scrub_credentials(&tool_args.to_string()),
2374                                "result": denied,
2375                                "trace_id": turn_id,
2376                            })),
2377                        "tool_call_result"
2378                    );
2379                    if let Some(ref tx) = on_delta {
2380                        let _ = tx
2381                            .send(StreamDelta::Status(format!(
2382                                "\u{274c} {}: {}\n",
2383                                tool_name, denied
2384                            )))
2385                            .await;
2386                    }
2387                    ordered_results[idx] = Some((
2388                        tool_name.clone(),
2389                        call.tool_call_id.clone(),
2390                        ToolExecutionOutcome {
2391                            output: denied.clone(),
2392                            success: false,
2393                            error_reason: Some(denied),
2394                            duration: Duration::ZERO,
2395                            receipt: None,
2396                        },
2397                    ));
2398                    continue;
2399                }
2400
2401                if matches!(decision, ApprovalResponse::Yes | ApprovalResponse::Always) {
2402                    approval_requirement = ApprovalRequirement::Approved;
2403                }
2404            }
2405            super::set_runtime_approved_arg(
2406                &tool_name,
2407                &mut tool_args,
2408                approval_requirement == ApprovalRequirement::Approved,
2409            );
2410
2411            let signature = {
2412                let canonical_args = canonicalize_json_for_tool_signature(&tool_args);
2413                let args_json =
2414                    serde_json::to_string(&canonical_args).unwrap_or_else(|_| "{}".to_string());
2415                (tool_name.trim().to_ascii_lowercase(), args_json)
2416            };
2417            let dedup_exempt = dedup_exempt_tools.iter().any(|e| e == &tool_name);
2418            if !dedup_exempt && !seen_tool_signatures.insert(signature) {
2419                let duplicate = format!(
2420                    "Skipped duplicate tool call '{tool_name}' with identical arguments in this turn."
2421                );
2422                ::zeroclaw_log::record!(
2423                    INFO,
2424                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Skip)
2425                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
2426                        .with_attrs(::serde_json::json!({
2427                            "model": model,
2428                            "iteration": iteration + 1,
2429                            "tool": tool_name.clone(),
2430                            "arguments": scrub_credentials(&tool_args.to_string()),
2431                            "result": duplicate,
2432                            "deduplicated": true,
2433                            "trace_id": turn_id,
2434                        })),
2435                    "tool_call_result"
2436                );
2437                if let Some(ref tx) = on_delta {
2438                    let _ = tx
2439                        .send(StreamDelta::Status(format!(
2440                            "\u{274c} {}: {}\n",
2441                            tool_name, duplicate
2442                        )))
2443                        .await;
2444                }
2445                ordered_results[idx] = Some((
2446                    tool_name.clone(),
2447                    call.tool_call_id.clone(),
2448                    ToolExecutionOutcome {
2449                        output: duplicate.clone(),
2450                        success: false,
2451                        error_reason: Some(duplicate),
2452                        duration: Duration::ZERO,
2453                        receipt: None,
2454                    },
2455                ));
2456                continue;
2457            }
2458
2459            ::zeroclaw_log::record!(
2460                INFO,
2461                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Start)
2462                    .with_attrs(::serde_json::json!({
2463                        "model": model,
2464                        "iteration": iteration + 1,
2465                        "tool": tool_name.clone(),
2466                        "arguments": scrub_credentials(&tool_args.to_string()),
2467                        "trace_id": turn_id,
2468                    })),
2469                "tool_call_start"
2470            );
2471
2472            // ── Progress: tool start ────────────────────────────
2473            if let Some(ref tx) = on_delta {
2474                let hint = {
2475                    let raw = match tool_name.as_str() {
2476                        "shell" => tool_args.get("command").and_then(|v| v.as_str()),
2477                        "file_read" | "file_write" => {
2478                            tool_args.get("path").and_then(|v| v.as_str())
2479                        }
2480                        _ => tool_args
2481                            .get("action")
2482                            .and_then(|v| v.as_str())
2483                            .or_else(|| tool_args.get("query").and_then(|v| v.as_str())),
2484                    };
2485                    match raw {
2486                        Some(s) => truncate_with_ellipsis(s, 60),
2487                        None => String::new(),
2488                    }
2489                };
2490                let progress = if hint.is_empty() {
2491                    format!("\u{23f3} {}\n", tool_name)
2492                } else {
2493                    format!("\u{23f3} {}: {hint}\n", tool_name)
2494                };
2495                ::zeroclaw_log::record!(
2496                    DEBUG,
2497                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2498                        .with_attrs(::serde_json::json!({"tool": tool_name})),
2499                    "Sending progress start to draft"
2500                );
2501                let _ = tx.send(StreamDelta::Status(progress)).await;
2502            }
2503
2504            executable_indices.push(idx);
2505            executable_calls.push(ParsedToolCall {
2506                name: tool_name,
2507                arguments: tool_args,
2508                tool_call_id: call.tool_call_id.clone(),
2509            });
2510        }
2511
2512        let executed_outcomes = if allow_parallel_execution && executable_calls.len() > 1 {
2513            execute_tools_parallel(
2514                &executable_calls,
2515                tools_registry,
2516                activated_tools,
2517                observer,
2518                cancellation_token.as_ref(),
2519                receipt_generator,
2520            )
2521            .await?
2522        } else {
2523            execute_tools_sequential(
2524                &executable_calls,
2525                tools_registry,
2526                activated_tools,
2527                observer,
2528                cancellation_token.as_ref(),
2529                receipt_generator,
2530            )
2531            .await?
2532        };
2533
2534        for ((idx, call), outcome) in executable_indices
2535            .iter()
2536            .zip(executable_calls.iter())
2537            .zip(executed_outcomes)
2538        {
2539            ::zeroclaw_log::record!(
2540                INFO,
2541                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Complete)
2542                    .with_outcome(if outcome.success {
2543                        ::zeroclaw_log::EventOutcome::Success
2544                    } else {
2545                        ::zeroclaw_log::EventOutcome::Failure
2546                    })
2547                    .with_duration(u64::try_from(outcome.duration.as_millis()).unwrap_or(u64::MAX),)
2548                    .with_attrs(::serde_json::json!({
2549                        "model": model,
2550                        "iteration": iteration + 1,
2551                        "tool": call.name.clone(),
2552                        "error_reason": outcome.error_reason,
2553                        "output": scrub_credentials(&outcome.output),
2554                        "trace_id": turn_id,
2555                    })),
2556                "tool_call_result"
2557            );
2558
2559            // ── Hook: after_tool_call (void) ─────────────────
2560            if let Some(hooks) = hooks {
2561                let tool_result_obj = crate::tools::ToolResult {
2562                    success: outcome.success,
2563                    output: outcome.output.clone(),
2564                    error: None,
2565                };
2566                hooks
2567                    .fire_after_tool_call(&call.name, &tool_result_obj, outcome.duration)
2568                    .await;
2569            }
2570
2571            // ── Progress: tool completion ───────────────────────
2572            if let Some(ref tx) = on_delta {
2573                let secs = outcome.duration.as_secs();
2574                let progress_msg = if outcome.success {
2575                    format!("\u{2705} {} ({secs}s)\n", call.name)
2576                } else if let Some(ref reason) = outcome.error_reason {
2577                    format!(
2578                        "\u{274c} {} ({secs}s): {}\n",
2579                        call.name,
2580                        truncate_with_ellipsis(reason, 200)
2581                    )
2582                } else {
2583                    format!("\u{274c} {} ({secs}s)\n", call.name)
2584                };
2585                ::zeroclaw_log::record!(
2586                    DEBUG,
2587                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2588                        .with_attrs(::serde_json::json!({"tool": call.name, "secs": secs})),
2589                    "Sending progress complete to draft"
2590                );
2591                let _ = tx.send(StreamDelta::Status(progress_msg)).await;
2592            }
2593
2594            ordered_results[*idx] = Some((call.name.clone(), call.tool_call_id.clone(), outcome));
2595        }
2596
2597        // Collect tool results and build per-tool output for loop detection.
2598        // Only non-ignored tool outputs contribute to the identical-output hash.
2599        let mut detection_relevant_output = String::new();
2600        // Use enumerate *before* filter_map so result_index stays aligned with
2601        // tool_calls even when some ordered_results entries are None.
2602        for (result_index, (tool_name, tool_call_id, outcome)) in ordered_results
2603            .into_iter()
2604            .enumerate()
2605            .filter_map(|(i, opt)| opt.map(|v| (i, v)))
2606        {
2607            if !loop_ignore_tools.contains(tool_name.as_str()) {
2608                detection_relevant_output.push_str(&outcome.output);
2609
2610                // Feed the pattern-based loop detector with name + args + result.
2611                let args = tool_calls
2612                    .get(result_index)
2613                    .map(|c| &c.arguments)
2614                    .unwrap_or(&serde_json::Value::Null);
2615                let det_result = loop_detector.record(&tool_name, args, &outcome.output);
2616                match det_result {
2617                    crate::agent::loop_detector::LoopDetectionResult::Ok => {}
2618                    crate::agent::loop_detector::LoopDetectionResult::Warning(ref msg) => {
2619                        ::zeroclaw_log::record!(
2620                            WARN,
2621                            ::zeroclaw_log::Event::new(
2622                                module_path!(),
2623                                ::zeroclaw_log::Action::Note
2624                            )
2625                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2626                            .with_attrs(
2627                                ::serde_json::json!({"tool": tool_name, "msg": msg.to_string()})
2628                            ),
2629                            "loop detector warning"
2630                        );
2631                        append_or_merge_system_message(history, format!("[Loop Detection] {msg}"));
2632                    }
2633                    crate::agent::loop_detector::LoopDetectionResult::Block(ref msg) => {
2634                        ::zeroclaw_log::record!(
2635                            WARN,
2636                            ::zeroclaw_log::Event::new(
2637                                module_path!(),
2638                                ::zeroclaw_log::Action::Note
2639                            )
2640                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2641                            .with_attrs(
2642                                ::serde_json::json!({"tool": tool_name, "msg": msg.to_string()})
2643                            ),
2644                            "loop detector blocked tool call"
2645                        );
2646                        // Replace the tool output with the block message.
2647                        // We still continue the loop so the LLM sees the block feedback.
2648                        append_or_merge_system_message(
2649                            history,
2650                            format!("[Loop Detection — BLOCKED] {msg}"),
2651                        );
2652                    }
2653                    crate::agent::loop_detector::LoopDetectionResult::Break(msg) => {
2654                        ::zeroclaw_log::record!(
2655                            WARN,
2656                            ::zeroclaw_log::Event::new(
2657                                module_path!(),
2658                                ::zeroclaw_log::Action::Fail
2659                            )
2660                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
2661                            .with_attrs(::serde_json::json!({
2662                                "model": model,
2663                                "iteration": iteration + 1,
2664                                "tool": tool_name,
2665                                "message": msg,
2666                                "trace_id": turn_id,
2667                            })),
2668                            "loop_detector_circuit_breaker"
2669                        );
2670                        anyhow::bail!("Agent loop aborted by loop detector: {msg}");
2671                    }
2672                }
2673            }
2674            let canonical_output = canonicalize_tool_result_media_markers(&outcome.output);
2675            let mut result_output = truncate_tool_result(&canonical_output, max_tool_result_chars);
2676            // Append HMAC receipt to tool result when receipts are enabled
2677            if let Some(ref receipt) = outcome.receipt {
2678                ::zeroclaw_log::record!(
2679                    DEBUG,
2680                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2681                        .with_attrs(::serde_json::json!({"tool": tool_name, "receipt": receipt})),
2682                    "Tool receipt generated"
2683                );
2684                result_output = format!("{result_output}\n\n[receipt: {receipt}]");
2685                if let Some(store) = collected_receipts
2686                    && let Ok(mut v) = store.lock()
2687                {
2688                    v.push(format!("{tool_name}: {receipt}"));
2689                }
2690            }
2691            individual_results.push((tool_call_id, result_output.clone()));
2692            let _ = writeln!(
2693                tool_results,
2694                "<tool_result name=\"{}\">\n{}\n</tool_result>",
2695                tool_name, result_output
2696            );
2697        }
2698
2699        // ── Time-gated loop detection ──────────────────────────
2700        // When pacing.loop_detection_min_elapsed_secs is set, identical-output
2701        // loop detection activates after the task has been running that long.
2702        // This avoids false-positive aborts on long-running browser/research
2703        // workflows while keeping aggressive protection for quick tasks.
2704        // When not configured, identical-output detection is disabled (preserving
2705        // existing behavior where only max_iterations prevents runaway loops).
2706        let loop_detection_active = match pacing.loop_detection_min_elapsed_secs {
2707            Some(min_secs) => loop_started_at.elapsed() >= Duration::from_secs(min_secs),
2708            None => false, // disabled when not configured (backwards compatible)
2709        };
2710
2711        if loop_detection_active && !detection_relevant_output.is_empty() {
2712            use std::hash::{Hash, Hasher};
2713            let mut hasher = std::collections::hash_map::DefaultHasher::new();
2714            detection_relevant_output.hash(&mut hasher);
2715            let current_hash = hasher.finish();
2716
2717            if last_tool_output_hash == Some(current_hash) {
2718                consecutive_identical_outputs += 1;
2719            } else {
2720                consecutive_identical_outputs = 0;
2721                last_tool_output_hash = Some(current_hash);
2722            }
2723
2724            // Bail if we see 3+ consecutive identical tool outputs (clear runaway).
2725            if consecutive_identical_outputs >= 3 {
2726                ::zeroclaw_log::record!(
2727                    WARN,
2728                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
2729                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
2730                        .with_attrs(::serde_json::json!({
2731                            "model": model,
2732                            "iteration": iteration + 1,
2733                            "consecutive_identical": consecutive_identical_outputs,
2734                            "trace_id": turn_id,
2735                        })),
2736                    "tool_loop_identical_output_abort"
2737                );
2738                anyhow::bail!(
2739                    "Agent loop aborted: identical tool output detected {} consecutive times",
2740                    consecutive_identical_outputs
2741                );
2742            }
2743        }
2744
2745        // Add assistant message with tool calls + tool results to history.
2746        // Native mode: use JSON-structured messages so convert_messages() can
2747        // reconstruct proper OpenAI-format tool_calls and tool result messages.
2748        // Prompt mode: use XML-based text format as before.
2749        history.push(ChatMessage::assistant(assistant_history_content));
2750        if native_tool_calls.is_empty() {
2751            let all_results_have_ids = use_native_tools
2752                && !individual_results.is_empty()
2753                && individual_results
2754                    .iter()
2755                    .all(|(tool_call_id, _)| tool_call_id.is_some());
2756            if all_results_have_ids {
2757                for (tool_call_id, result) in &individual_results {
2758                    let tool_msg = serde_json::json!({
2759                        "tool_call_id": tool_call_id,
2760                        "content": result,
2761                    });
2762                    history.push(ChatMessage::tool(tool_msg.to_string()));
2763                }
2764            } else {
2765                history.push(ChatMessage::user(format!("[Tool results]\n{tool_results}")));
2766            }
2767        } else {
2768            for (native_call, (_, result)) in
2769                native_tool_calls.iter().zip(individual_results.iter())
2770            {
2771                let tool_msg = serde_json::json!({
2772                    "tool_call_id": native_call.id,
2773                    "content": result,
2774                });
2775                history.push(ChatMessage::tool(tool_msg.to_string()));
2776            }
2777        }
2778    }
2779
2780    ::zeroclaw_log::record!(
2781        WARN,
2782        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
2783            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
2784            .with_attrs(::serde_json::json!({
2785                "model": model,
2786                "max_iterations": max_iterations,
2787                "trace_id": turn_id,
2788            })),
2789        "tool_loop_exhausted"
2790    );
2791
2792    // Graceful shutdown: ask the LLM for a final summary without tools
2793    ::zeroclaw_log::record!(
2794        WARN,
2795        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2796            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2797            .with_attrs(::serde_json::json!({"max_iterations": max_iterations})),
2798        "Max iterations reached, requesting final summary"
2799    );
2800    history.push(ChatMessage::user(
2801        "You have reached the maximum number of tool iterations. \
2802         Please provide your best answer based on the work completed so far. \
2803         Summarize what you accomplished and what remains to be done."
2804            .to_string(),
2805    ));
2806
2807    let summary_request = zeroclaw_providers::ChatRequest {
2808        messages: history,
2809        tools: None, // No tools — force a text response
2810        thinking: zeroclaw_api::NATIVE_THINKING_OVERRIDE
2811            .try_with(Clone::clone)
2812            .ok()
2813            .flatten(),
2814    };
2815    match model_provider
2816        .chat(summary_request, model, temperature)
2817        .await
2818    {
2819        Ok(resp) => {
2820            let text = resp.text.unwrap_or_default();
2821            if text.is_empty() {
2822                anyhow::bail!("Agent exceeded maximum tool iterations ({max_iterations})")
2823            }
2824            accumulated_display_text.push_str(&text);
2825            Ok(accumulated_display_text)
2826        }
2827        Err(e) => {
2828            ::zeroclaw_log::record!(
2829                WARN,
2830                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2831                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2832                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
2833                "Final summary LLM call failed, bailing"
2834            );
2835            anyhow::bail!("Agent exceeded maximum tool iterations ({max_iterations})")
2836        }
2837    }
2838}
2839
2840/// Build the tool instruction block for the system prompt so the LLM knows
2841/// how to invoke tools.
2842pub fn build_tool_instructions(tools_registry: &[Box<dyn Tool>]) -> String {
2843    build_tool_instructions_for_tools(tools_registry.iter().map(|tool| tool.as_ref()))
2844}
2845
2846/// Build tool instructions for the subset of registered tools that are
2847/// effective for the current prompt.
2848pub fn build_tool_instructions_for_names(
2849    tools_registry: &[Box<dyn Tool>],
2850    effective_tool_names: &HashSet<&str>,
2851) -> String {
2852    build_tool_instructions_for_tools(
2853        tools_registry
2854            .iter()
2855            .map(|tool| tool.as_ref())
2856            .filter(|tool| effective_tool_names.contains(tool.name())),
2857    )
2858}
2859
2860fn build_tool_instructions_for_tools<'a>(tools: impl IntoIterator<Item = &'a dyn Tool>) -> String {
2861    let tools: Vec<&dyn Tool> = tools.into_iter().collect();
2862    if tools.is_empty() {
2863        return String::new();
2864    }
2865
2866    let mut instructions = String::new();
2867    instructions.push_str("\n## Tool Use Protocol\n\n");
2868    instructions.push_str("To use a tool, wrap a JSON object in <tool_call></tool_call> tags:\n\n");
2869    instructions.push_str("```\n<tool_call>\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n```\n\n");
2870    instructions.push_str(
2871        "CRITICAL: Output actual <tool_call> tags—never describe steps or give examples.\n\n",
2872    );
2873    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");
2874    instructions.push_str("You may use multiple tool calls in a single response. ");
2875    instructions.push_str("After tool execution, results appear in <tool_result> tags. ");
2876    instructions
2877        .push_str("Continue reasoning with the results until you can give a final answer.\n\n");
2878    instructions.push_str("### Available Tools\n\n");
2879
2880    for tool in tools {
2881        let desc = tool.description();
2882        let _ = writeln!(
2883            instructions,
2884            "**{}**: {}\nParameters: `{}`\n",
2885            tool.name(),
2886            desc,
2887            tool.parameters_schema()
2888        );
2889    }
2890
2891    instructions
2892}
2893
2894fn retain_registered_tool_descriptions(
2895    tool_descs: &mut Vec<(&str, &str)>,
2896    tools_registry: &[Box<dyn Tool>],
2897) {
2898    let registered_tool_names: HashSet<&str> =
2899        tools_registry.iter().map(|tool| tool.name()).collect();
2900    tool_descs.retain(|(name, _)| registered_tool_names.contains(name));
2901}
2902
2903pub fn apply_text_tool_prompt_policy(
2904    native_tools: bool,
2905    strict_tool_parsing: bool,
2906    tool_descs: &mut Vec<(&str, &str)>,
2907    deferred_section: &mut String,
2908) -> bool {
2909    let expose_text_tool_protocol = !native_tools && !strict_tool_parsing;
2910    if !native_tools && strict_tool_parsing {
2911        tool_descs.clear();
2912        deferred_section.clear();
2913    }
2914    expose_text_tool_protocol
2915}
2916
2917// ── CLI Entrypoint ───────────────────────────────────────────────────────
2918// Wires up all subsystems (observer, runtime, security, memory, tools,
2919// model_provider, hardware RAG, peripherals) and enters either single-shot or
2920// interactive REPL mode. The interactive loop manages history compaction
2921// and hard trimming to keep the context window bounded.
2922
2923/// Optional per-call overrides for [`run`].
2924///
2925/// SubAgent spawn paths use this to inject the validated child policy
2926/// returned from [`SecurityPolicy::ensure_no_escalation_beyond`] (and,
2927/// once v0.8.1 plumbs caller-supplied allowlist narrowing, the
2928/// validated agent-scoped memory wrapper). Without this hook the run
2929/// path rebuilds both surfaces from config, so the validator's
2930/// guarantees never reach the agent loop. `None` on either field
2931/// preserves the from-config behavior — the same shape as a fresh
2932/// interactive launch.
2933#[derive(Default)]
2934pub struct AgentRunOverrides {
2935    pub security: Option<Arc<SecurityPolicy>>,
2936    pub memory: Option<Arc<dyn Memory>>,
2937    /// `true` when the run is a SubAgent invocation. SubAgents must not
2938    /// spawn further subagents (depth-1 cap). The agent loop reads this
2939    /// when constructing the `spawn_subagent` tool so the depth-cap
2940    /// refusal fires at the tool, not after a child run is already
2941    /// underway. Default `false` keeps top-level / cron-launched /
2942    /// CLI-launched agents at depth 0.
2943    pub is_subagent: bool,
2944}
2945
2946/// Build the dotted provider ref (`"openai.qwertfoozp"`) from the agent's
2947/// configured `model_provider` field. Returns `None` when the agent has no
2948/// `model_provider` set or when the ref does not resolve to a known alias.
2949///
2950/// Using the full dotted ref (rather than just the family type) ensures the
2951/// alias-aware factory path is taken, so config fields such as
2952/// `requires_openai_auth` reach `dispatch_family_factory` instead of being
2953/// silently dropped.
2954fn agent_provider_composite(
2955    config: &zeroclaw_config::schema::Config,
2956    agent_alias: &str,
2957) -> Option<String> {
2958    config
2959        .resolved_model_provider_for_agent(agent_alias)
2960        .map(|(ty, alias, _)| format!("{ty}.{alias}"))
2961}
2962
2963/// Resolve (api_key, uri) for `provider_name`, preferring the alias-specific
2964/// config when `provider_name` is a dotted `<family>.<alias>` reference.
2965/// Falls back to `fallback` (the agent's configured provider) for bare family
2966/// names or when the alias isn't found.
2967///
2968/// This prevents `-p openai.shartgpt` (OAuth, no key) from inheriting the
2969/// agent's current provider key (e.g. an xai key), which would trigger the
2970/// API key prefix-mismatch preflight and block providers that authenticate
2971/// via OAuth rather than an explicit API key.
2972fn api_key_and_uri_for_provider(
2973    config: &zeroclaw_config::schema::Config,
2974    provider_name: &str,
2975    fallback: Option<&zeroclaw_config::schema::ModelProviderConfig>,
2976) -> (Option<String>, Option<String>) {
2977    if let Some((fam, al)) = provider_name.split_once('.')
2978        && let Some(entry) = config.providers.models.find(fam, al)
2979    {
2980        return (entry.api_key.clone(), entry.uri.clone());
2981    }
2982    (
2983        fallback.and_then(|e| e.api_key.clone()),
2984        fallback.and_then(|e| e.uri.clone()),
2985    )
2986}
2987
2988#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
2989pub async fn run(
2990    config: Config,
2991    agent_alias: &str,
2992    message: Option<String>,
2993    provider_override: Option<String>,
2994    model_override: Option<String>,
2995    temperature: Option<f64>,
2996    peripheral_overrides: Vec<String>,
2997    interactive: bool,
2998    session_state_file: Option<PathBuf>,
2999    allowed_tools: Option<Vec<String>>,
3000    overrides: AgentRunOverrides,
3001) -> Result<String> {
3002    use ::zeroclaw_log::Instrument;
3003    let agent = config
3004        .agent(agent_alias)
3005        .with_context(|| format!("agents.{agent_alias} is not configured"))?
3006        .clone();
3007    crate::agent::thinking::validate_thinking_config(&agent.thinking);
3008    let risk_profile = config
3009        .risk_profile_for_agent(agent_alias)
3010        .with_context(|| {
3011            format!(
3012                "agents.{agent_alias}.risk_profile does not name a configured risk_profiles entry"
3013            )
3014        })?
3015        .clone();
3016    let memory_composite = {
3017        use zeroclaw_config::multi_agent::MemoryBackendKind;
3018        match agent.memory.backend {
3019            MemoryBackendKind::Markdown => format!("markdown.{agent_alias}"),
3020            MemoryBackendKind::None => "none".to_string(),
3021            _ => {
3022                let raw = config.memory.backend.trim();
3023                if raw.is_empty() || raw.eq_ignore_ascii_case("none") {
3024                    "none".to_string()
3025                } else {
3026                    let (kind, alias) = raw.split_once('.').unwrap_or((raw, "default"));
3027                    format!("{kind}.{alias}")
3028                }
3029            }
3030        }
3031    };
3032    let __zc_alias = agent_alias.to_string();
3033    let __zc_attribution_span =
3034        ::zeroclaw_log::attribution_span!(&crate::agent::AgentAttribution(__zc_alias.as_str()));
3035    let __zc_scope_span = ::zeroclaw_log::info_span!(
3036        target: "zeroclaw_log_internal_scope",
3037        "zeroclaw_scope",
3038        risk_profile = %agent.risk_profile,
3039        runtime_profile = %agent.runtime_profile,
3040        memory_namespace = %memory_composite,
3041    );
3042    let __zc_body = async move {
3043        let agent_alias: &str = __zc_alias.as_str();
3044        // ── Wire up agnostic subsystems ──────────────────────────────
3045        let base_observer = observability::create_observer(&config.observability);
3046        let observer: Arc<dyn Observer> = Arc::from(base_observer);
3047        let runtime: Arc<dyn platform::RuntimeAdapter> =
3048            Arc::from(platform::create_runtime(&config.runtime)?);
3049        let is_subagent_caller = overrides.is_subagent;
3050        let security = match overrides.security {
3051            Some(sec) => sec,
3052            None => Arc::new(SecurityPolicy::for_agent(&config, agent_alias)?),
3053        };
3054
3055        let agent_provider_resolved = config
3056            .resolved_model_provider_for_agent(agent_alias)
3057            .map(|(ty, alias, cfg)| (ty, alias.to_string(), cfg.clone()));
3058        let agent_model_provider = agent_provider_resolved.as_ref().map(|(_, _, cfg)| cfg);
3059
3060        // ── Memory (the brain) ────────────────────────────────────────
3061        // Per-agent memory: the inner backend is the install-wide store
3062        // (or, for Markdown agents, the agent's own dir composed with
3063        // peer dirs); the wrapper stamps every store with the bound
3064        // agent's UUID and filters every recall by the resolved
3065        // `read_memory_from` allowlist. When the caller supplies a
3066        // pre-built memory handle (SubAgent narrowing path), use that
3067        // instead so the validator's allowlist subset reaches the loop.
3068        let mem: Arc<dyn Memory> = match overrides.memory {
3069            Some(m) => m,
3070            None => {
3071                zeroclaw_memory::create_memory_for_agent(
3072                    &config,
3073                    agent_alias,
3074                    agent_model_provider.and_then(|e| e.api_key.as_deref()),
3075                )
3076                .await?
3077            }
3078        };
3079        ::zeroclaw_log::record!(
3080            INFO,
3081            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3082                .with_attrs(::serde_json::json!({"backend": mem.name()})),
3083            "Memory initialized"
3084        );
3085
3086        // ── Peripherals (merge peripheral tools into registry) ─
3087        if !peripheral_overrides.is_empty() {
3088            ::zeroclaw_log::record!(
3089                INFO,
3090                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3091                    .with_attrs(::serde_json::json!({"peripherals": peripheral_overrides})),
3092                "Peripheral overrides from CLI (config boards take precedence)"
3093            );
3094        }
3095
3096        // ── Tools (including memory tools and peripherals) ────────────
3097        let (composio_key, composio_entity_id) = if config.composio.enabled {
3098            (
3099                config.composio.api_key.as_deref(),
3100                Some(config.composio.entity_id.as_str()),
3101            )
3102        } else {
3103            (None, None)
3104        };
3105        let all_tools_result = tools::all_tools_with_runtime(
3106            Arc::new(config.clone()),
3107            &security,
3108            &risk_profile,
3109            agent_alias,
3110            runtime,
3111            mem.clone(),
3112            composio_key,
3113            composio_entity_id,
3114            &config.browser,
3115            &config.http_request,
3116            &config.web_fetch,
3117            &config.data_dir,
3118            &config.agents,
3119            agent_model_provider.and_then(|e| e.api_key.as_deref()),
3120            &config,
3121            None,
3122            is_subagent_caller,
3123        );
3124        let mut tools_registry = all_tools_result.tools;
3125        let delegate_handle = all_tools_result.delegate_handle;
3126        let unfiltered_tool_arcs = all_tools_result.unfiltered_tool_arcs;
3127        let ask_user_handle = all_tools_result.ask_user_handle;
3128        let reaction_handle = all_tools_result.reaction_handle;
3129        let poll_handle = all_tools_result.poll_handle;
3130        let escalate_handle = all_tools_result.escalate_handle;
3131        let channel_send_handle = all_tools_result.channel_send_handle;
3132
3133        // Populate all channel-driven tool handles from the registered factory.
3134        let count = seed_channel_handles(
3135            &ask_user_handle,
3136            &reaction_handle,
3137            &poll_handle,
3138            &escalate_handle,
3139            &channel_send_handle,
3140        );
3141        if count > 0 {
3142            ::zeroclaw_log::record!(
3143                INFO,
3144                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3145                    .with_attrs(::serde_json::json!({"count": count})),
3146                &format!("Registered {} channel(s) for CLI agent", count),
3147            );
3148        }
3149
3150        let peripheral_tools: Vec<Box<dyn Tool>> = if let Some(f) = PERIPHERAL_TOOLS_FN.get() {
3151            f(config.peripherals.clone()).await.unwrap_or_default()
3152        } else {
3153            vec![]
3154        };
3155        if !peripheral_tools.is_empty() {
3156            ::zeroclaw_log::record!(
3157                INFO,
3158                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3159                    .with_attrs(::serde_json::json!({"count": peripheral_tools.len()})),
3160                "Peripheral tools added"
3161            );
3162            tools_registry.extend(peripheral_tools);
3163        }
3164
3165        // ── Capability-based tool access control ─────────────────────
3166        // Two-gate filter: parent agent's SecurityPolicy
3167        // (`allowed_tools` + `excluded_tools`) AND the caller-supplied
3168        // `allowed_tools` parameter. Both must admit a tool name for
3169        // the tool to survive. `None` on either gate is unrestricted
3170        // for that gate alone.
3171        let before_filter = tools_registry.len();
3172        apply_policy_tool_filter(
3173            &mut tools_registry,
3174            Some(security.as_ref()),
3175            allowed_tools.as_deref(),
3176        );
3177        if tools_registry.len() != before_filter {
3178            ::zeroclaw_log::record!(
3179                INFO,
3180                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3181                    .with_attrs(::serde_json::json!({
3182                        "before": before_filter,
3183                        "retained": tools_registry.len(),
3184                        "policy_allowed": security.allowed_tools.as_ref().map(|v| v.len()),
3185                        "policy_excluded": security.excluded_tools.as_ref().map(|v| v.len()),
3186                        "caller_allowed": allowed_tools.as_ref().map(|v| v.len()),
3187                    })),
3188                "Applied capability-based tool access filter"
3189            );
3190        }
3191
3192        // ── Wire MCP tools (non-fatal) — CLI path ────────────────────
3193        // NOTE: MCP tools are injected after built-in tool filtering
3194        // (filter_primary_agent_tools_or_fail / agent.allowed_tools / agent.denied_tools).
3195        // MCP servers are user-declared external integrations; the built-in allow/deny
3196        // filter is not appropriate for them and would silently drop all MCP tools when
3197        // a restrictive allowlist is configured. Keep this block after any such filter call.
3198        //
3199        // When `deferred_loading` is enabled, MCP tools are NOT added to the registry
3200        // eagerly. Instead, a `tool_search` built-in is registered so the LLM can
3201        // fetch schemas on demand. This reduces context window waste.
3202        let mut deferred_section = String::new();
3203        let mut activated_handle: Option<
3204            std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>,
3205        > = None;
3206        // Resolution-only MCP wrappers for skill MCP elevation (kind = "mcp").
3207        let mut mcp_elevation_arcs: Vec<std::sync::Arc<dyn Tool>> = Vec::new();
3208        if config.mcp.enabled && !config.mcp.servers.is_empty() {
3209            ::zeroclaw_log::record!(
3210                INFO,
3211                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
3212                &format!(
3213                    "Initializing MCP client — {} server(s) configured",
3214                    config.mcp.servers.len()
3215                )
3216            );
3217            match crate::tools::McpRegistry::connect_all(&config.mcp.servers).await {
3218                Ok(registry) => {
3219                    let registry = std::sync::Arc::new(registry);
3220                    mcp_elevation_arcs = crate::tools::collect_mcp_elevation_arcs(&registry).await;
3221                    if config.mcp.deferred_loading {
3222                        // Deferred path: build stubs and register tool_search
3223                        let deferred_set = crate::tools::DeferredMcpToolSet::from_registry(
3224                            std::sync::Arc::clone(&registry),
3225                        )
3226                        .await;
3227                        ::zeroclaw_log::record!(
3228                            INFO,
3229                            ::zeroclaw_log::Event::new(
3230                                module_path!(),
3231                                ::zeroclaw_log::Action::Note
3232                            ),
3233                            &format!(
3234                                "MCP deferred: {} tool stub(s) from {} server(s)",
3235                                deferred_set.len(),
3236                                registry.server_count()
3237                            )
3238                        );
3239                        // Build access policy from SecurityPolicy so blocked
3240                        // MCP tools never surface anywhere in context.
3241                        let mcp_policy =
3242                            zeroclaw_tools::tool_search::ToolAccessPolicy::from_security(
3243                                security.allowed_tools.as_deref(),
3244                                security.excluded_tools.as_deref(),
3245                                allowed_tools.as_deref(),
3246                            );
3247                        deferred_section = crate::tools::build_deferred_tools_section_filtered(
3248                            &deferred_set,
3249                            mcp_policy.as_ref(),
3250                        );
3251                        let activated = std::sync::Arc::new(std::sync::Mutex::new(
3252                            crate::tools::ActivatedToolSet::new(),
3253                        ));
3254                        activated_handle = Some(std::sync::Arc::clone(&activated));
3255                        let mut tool_search =
3256                            crate::tools::ToolSearchTool::new(deferred_set, activated);
3257                        if let Some(policy) = mcp_policy {
3258                            tool_search = tool_search.with_access_policy(policy);
3259                        }
3260                        tools_registry.push(Box::new(tool_search));
3261                    } else {
3262                        // Eager path: register all MCP tools directly
3263                        let names = registry.tool_names();
3264                        let mut registered = 0usize;
3265                        for name in names {
3266                            if let Some(def) = registry.get_tool_def(&name).await {
3267                                let wrapper: std::sync::Arc<dyn Tool> =
3268                                    std::sync::Arc::new(crate::tools::McpToolWrapper::new(
3269                                        name,
3270                                        def,
3271                                        std::sync::Arc::clone(&registry),
3272                                    ));
3273                                if let Some(ref handle) = delegate_handle {
3274                                    handle.write().push(std::sync::Arc::clone(&wrapper));
3275                                }
3276                                tools_registry.push(Box::new(crate::tools::ArcToolRef(wrapper)));
3277                                registered += 1;
3278                            }
3279                        }
3280                        ::zeroclaw_log::record!(
3281                            INFO,
3282                            ::zeroclaw_log::Event::new(
3283                                module_path!(),
3284                                ::zeroclaw_log::Action::Note
3285                            ),
3286                            &format!(
3287                                "MCP: {} tool(s) registered from {} server(s)",
3288                                registered,
3289                                registry.server_count()
3290                            )
3291                        );
3292                    }
3293                }
3294                Err(e) => {
3295                    ::zeroclaw_log::record!(
3296                        ERROR,
3297                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
3298                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
3299                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
3300                        "MCP registry failed to initialize"
3301                    );
3302                }
3303            }
3304        }
3305
3306        // ── Resolve model_provider ─────────────────────────────────────────
3307        let agent_provider_ref = agent_provider_composite(&config, agent_alias);
3308        let mut provider_name = provider_override
3309            .as_deref()
3310            .or(agent_provider_ref.as_deref())
3311            .ok_or_else(|| {
3312                ::zeroclaw_log::record!(
3313                    ERROR,
3314                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
3315                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
3316                        .with_attrs(::serde_json::json!({"agent_alias": agent_alias})),
3317                    "agent loop refused: agent.model_provider unresolved and no --provider override"
3318                );
3319                anyhow::Error::msg(format!(
3320                    "agents.{agent_alias}.model_provider does not resolve and no provider override \
3321                     was passed on the CLI. Either set `[agents.{agent_alias}] model_provider` or \
3322                     pass --provider."
3323                ))
3324            })?
3325            .to_string();
3326
3327        let mut model_name = match model_override
3328            .as_deref()
3329            .or(agent_model_provider.and_then(|e| e.model.as_deref()))
3330        {
3331            Some(m) => m.to_string(),
3332            None => anyhow::bail!(
3333                "no model configured for agent {agent_alias}: \
3334             [model_providers.{provider_name}.<alias>].model is unset and --model was not passed"
3335            ),
3336        };
3337
3338        {
3339            let span = zeroclaw_log::Span::current();
3340            let mp_composite = match agent_provider_resolved.as_ref() {
3341                Some((ty, alias, _)) => format!("{ty}.{alias}"),
3342                None => provider_name.clone(),
3343            };
3344            span.record("model_provider", mp_composite.as_str());
3345            span.record("model", model_name.as_str());
3346        }
3347
3348        let provider_runtime_options_base =
3349            zeroclaw_providers::provider_runtime_options_from_config(&config);
3350        let provider_runtime_options = zeroclaw_providers::options_for_provider_ref(
3351            &config,
3352            &provider_name,
3353            &provider_runtime_options_base,
3354        );
3355
3356        // Resolve api_key and uri from the actual provider being constructed.
3357        // For dotted aliases (e.g. "openai.shartgpt"), look up the alias-specific
3358        // config so a -p override does not leak the agent's current provider key
3359        // (e.g. an xai key) to a different provider family that doesn't expect it.
3360        let (initial_api_key, initial_uri) =
3361            api_key_and_uri_for_provider(&config, &provider_name, agent_model_provider);
3362        let mut model_provider: Box<dyn ModelProvider> =
3363            zeroclaw_providers::create_routed_model_provider_with_options(
3364                &config,
3365                &provider_name,
3366                initial_api_key.as_deref(),
3367                initial_uri.as_deref(),
3368                &config.reliability,
3369                &config.model_routes,
3370                &model_name,
3371                &provider_runtime_options,
3372            )?;
3373
3374        let model_switch_callback = get_model_switch_state();
3375
3376        observer.record_event(&ObserverEvent::AgentStart {
3377            model_provider: provider_name.to_string(),
3378            model: model_name.to_string(),
3379        });
3380
3381        // ── Hardware RAG (datasheet retrieval when peripherals + datasheet_dir) ──
3382        let hardware_rag: Option<crate::rag::HardwareRag> = config
3383            .peripherals
3384            .datasheet_dir
3385            .as_ref()
3386            .filter(|d| !d.trim().is_empty())
3387            .map(|dir| crate::rag::HardwareRag::load(&config.data_dir, dir.trim()))
3388            .and_then(Result::ok)
3389            .filter(|r: &crate::rag::HardwareRag| !r.is_empty());
3390        if let Some(ref rag) = hardware_rag {
3391            ::zeroclaw_log::record!(
3392                INFO,
3393                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3394                    .with_attrs(::serde_json::json!({"chunks": rag.len()})),
3395                "Hardware RAG loaded"
3396            );
3397        }
3398
3399        let board_names: Vec<String> = config
3400            .peripherals
3401            .boards
3402            .iter()
3403            .map(|b| b.board.clone())
3404            .collect();
3405
3406        // ── Initialize locale-aware tool descriptions ──────────────────
3407        let i18n_locale = config
3408            .locale
3409            .as_deref()
3410            .filter(|s| !s.is_empty())
3411            .map(ToString::to_string)
3412            .unwrap_or_else(crate::i18n::detect_locale);
3413        crate::i18n::init(&i18n_locale);
3414
3415        // ── Build system prompt from workspace MD files (OpenClaw framework) ──
3416        let skills = crate::skills::load_skills_for_agent(&config.data_dir, &config, agent_alias);
3417
3418        // Register skill-defined tools as callable tool specs in the tool registry
3419        // so the LLM can invoke them via native function calling, not just XML prompts.
3420        // Resolution registry = built-in arcs + resolution-only MCP wrappers, so
3421        // skill elevation (kind = "builtin" / "mcp") can resolve either target.
3422        let skill_resolution_registry: Vec<std::sync::Arc<dyn Tool>> = unfiltered_tool_arcs
3423            .iter()
3424            .cloned()
3425            .chain(mcp_elevation_arcs.iter().cloned())
3426            .collect();
3427        tools::register_skill_tools_with_context(
3428            &mut tools_registry,
3429            &skills,
3430            security.clone(),
3431            &skill_resolution_registry,
3432        );
3433
3434        let mut tool_descs: Vec<(&str, &str)> = vec![
3435            (
3436                "shell",
3437                "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.",
3438            ),
3439            (
3440                "file_read",
3441                "Read file contents. Use when: inspecting project files, configs, logs. Don't use when: a targeted search is enough.",
3442            ),
3443            (
3444                "file_write",
3445                "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.",
3446            ),
3447            (
3448                "memory_store",
3449                "Save to memory. Use when: preserving durable preferences, decisions, key context. Don't use when: information is transient/noisy/sensitive without need.",
3450            ),
3451            (
3452                "memory_recall",
3453                "Search memory. Use when: retrieving prior decisions, user preferences, historical context. Don't use when: answer is already in current context.",
3454            ),
3455            (
3456                "memory_forget",
3457                "Delete a memory entry. Use when: memory is incorrect/stale or explicitly requested for removal. Don't use when: impact is uncertain.",
3458            ),
3459        ];
3460        if matches!(
3461            config.skills.prompt_injection_mode,
3462            zeroclaw_config::schema::SkillsPromptInjectionMode::Compact
3463        ) {
3464            tool_descs.push((
3465            "read_skill",
3466            "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.",
3467        ));
3468        }
3469        tool_descs.push((
3470        "cron_add",
3471        "Create a cron job. Supports schedule kinds: cron, at, every; and job types: shell or agent.",
3472    ));
3473        tool_descs.push((
3474            "cron_list",
3475            "List all cron jobs with schedule, status, and metadata.",
3476        ));
3477        tool_descs.push(("cron_remove", "Remove a cron job by job_id."));
3478        tool_descs.push((
3479        "cron_update",
3480        "Patch a cron job (schedule, enabled, command/prompt, model, delivery, session_target).",
3481    ));
3482        tool_descs.push((
3483            "cron_run",
3484            "Force-run a cron job immediately and record a run history entry.",
3485        ));
3486        tool_descs.push(("cron_runs", "Show recent run history for a cron job."));
3487        tool_descs.push((
3488        "screenshot",
3489        "Capture a screenshot of the current screen. Returns file path and base64-encoded PNG. Use when: visual verification, UI inspection, debugging displays.",
3490    ));
3491        tool_descs.push((
3492        "image_info",
3493        "Read image file metadata (format, dimensions, size) and optionally base64-encode it. Use when: inspecting images, preparing visual data for analysis.",
3494    ));
3495        if config.browser.enabled {
3496            tool_descs.push((
3497                "browser_open",
3498                "Open approved HTTPS URLs in system browser (allowlist-only, no scraping)",
3499            ));
3500        }
3501        if config.composio.enabled {
3502            tool_descs.push((
3503            "composio",
3504            "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.",
3505        ));
3506        }
3507        tool_descs.push((
3508        "schedule",
3509        "Manage scheduled tasks (create/list/get/cancel/pause/resume). Supports recurring cron and one-shot delays.",
3510    ));
3511        tool_descs.push((
3512        "model_routing_config",
3513        "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'.",
3514    ));
3515        if !config.agents.is_empty() {
3516            tool_descs.push((
3517            "delegate",
3518            "Delegate a sub-task to a specialized agent. Use when: task needs different model/capability, or to parallelize work.",
3519        ));
3520        }
3521        if config.peripherals.enabled && !config.peripherals.boards.is_empty() {
3522            tool_descs.push((
3523            "gpio_read",
3524            "Read GPIO pin value (0 or 1) on connected hardware (STM32, Arduino). Use when: checking sensor/button state, LED status.",
3525        ));
3526            tool_descs.push((
3527            "gpio_write",
3528            "Set GPIO pin high (1) or low (0) on connected hardware. Use when: turning LED on/off, controlling actuators.",
3529        ));
3530            tool_descs.push((
3531            "arduino_upload",
3532            "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.",
3533        ));
3534            tool_descs.push((
3535            "hardware_memory_map",
3536            "Return flash and RAM address ranges for connected hardware. Use when: user asks for 'upper and lower memory addresses', 'memory map', or 'readable addresses'.",
3537        ));
3538            tool_descs.push((
3539            "hardware_board_info",
3540            "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'.",
3541        ));
3542            tool_descs.push((
3543            "hardware_memory_read",
3544            "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).",
3545        ));
3546            tool_descs.push((
3547            "hardware_capabilities",
3548            "Query connected hardware for reported GPIO pins and LED pin. Use when: user asks what pins are available.",
3549        ));
3550        }
3551        retain_registered_tool_descriptions(&mut tool_descs, &tools_registry);
3552        let bootstrap_max_chars = if agent.compact_context {
3553            Some(6000)
3554        } else {
3555            None
3556        };
3557        let native_tools = model_provider.supports_native_tools();
3558        let expose_text_tool_protocol = apply_text_tool_prompt_policy(
3559            native_tools,
3560            agent.strict_tool_parsing,
3561            &mut tool_descs,
3562            &mut deferred_section,
3563        );
3564        let agent_workspace = config.agent_workspace_dir(agent_alias);
3565        let mut system_prompt =
3566            crate::agent::system_prompt::build_system_prompt_with_mode_and_autonomy(
3567                &agent_workspace,
3568                &model_name,
3569                &tool_descs,
3570                &skills,
3571                Some(&agent.identity),
3572                bootstrap_max_chars,
3573                Some(&risk_profile),
3574                native_tools,
3575                config.skills.prompt_injection_mode,
3576                agent.compact_context,
3577                agent.max_system_prompt_chars,
3578            );
3579
3580        // Append structured tool-use instructions with schemas (only for non-native model_providers)
3581        if expose_text_tool_protocol {
3582            system_prompt.push_str(&build_tool_instructions(&tools_registry));
3583        }
3584
3585        // Append deferred MCP tool names so the LLM knows what is available
3586        if !deferred_section.is_empty() {
3587            system_prompt.push('\n');
3588            system_prompt.push_str(&deferred_section);
3589        }
3590
3591        // Inject configured channel targets so the agent knows where to deliver outbound messages.
3592        // Only when channel_send is in the effective tool set — otherwise the prompt would
3593        // advertise a disabled tool's targets.
3594        if tools_registry.iter().any(|t| t.name() == "channel_send")
3595            && let Some(channel_targets) = crate::channel_targets::build_channel_targets(&config)
3596        {
3597            system_prompt.push('\n');
3598            system_prompt.push_str(&channel_targets);
3599        }
3600
3601        // ── Approval manager (supervised mode) ───────────────────────
3602        let approval_manager = if interactive {
3603            Some(ApprovalManager::from_risk_profile(&risk_profile))
3604        } else {
3605            None
3606        };
3607        let channel_name = if interactive { "cli" } else { "daemon" };
3608        let memory_session_id = session_state_file.as_deref().and_then(|path| {
3609            let raw = path.to_string_lossy().trim().to_string();
3610            if raw.is_empty() {
3611                None
3612            } else {
3613                // Match the sanitized form persisted by memory backend migrations.
3614                Some(zeroclaw_api::session_keys::sanitize_session_key(&format!(
3615                    "cli:{raw}"
3616                )))
3617            }
3618        });
3619
3620        // ── Cost tracking context (scoped for CLI / cron / web agents) ──
3621        let cost_tracking_context: Option<ToolLoopCostTrackingContext> =
3622            crate::cost::CostTracker::get_or_init_global(config.cost.clone(), &config.data_dir)
3623                .map(|tracker| {
3624                    let pricing: crate::agent::cost::ModelProviderPricing = config
3625                        .providers
3626                        .models
3627                        .iter_entries()
3628                        .map(|(type_k, alias_k, profile)| {
3629                            (format!("{type_k}.{alias_k}"), profile.pricing.clone())
3630                        })
3631                        .filter(|(_, p)| !p.is_empty())
3632                        .collect();
3633                    ToolLoopCostTrackingContext::new(tracker, Arc::new(pricing))
3634                        .with_agent_alias(agent_alias)
3635                });
3636
3637        // ── Execute ──────────────────────────────────────────────────
3638        let start = Instant::now();
3639
3640        let mut final_output = String::new();
3641
3642        // Save the base system prompt before any thinking modifications so
3643        // the interactive loop can restore it between turns.
3644        let base_system_prompt = system_prompt.clone();
3645
3646        if let Some(msg) = message {
3647            // ── Parse thinking directive from user message ─────────
3648            let (thinking_directive, effective_msg) =
3649                match crate::agent::thinking::parse_thinking_directive(&msg) {
3650                    Some((level, remaining)) => {
3651                        ::zeroclaw_log::record!(
3652                            INFO,
3653                            ::zeroclaw_log::Event::new(
3654                                module_path!(),
3655                                ::zeroclaw_log::Action::Note
3656                            )
3657                            .with_attrs(::serde_json::json!({"thinking_level": level})),
3658                            "Thinking directive parsed from message"
3659                        );
3660                        (Some(level), remaining)
3661                    }
3662                    None => (None, msg.clone()),
3663                };
3664            let thinking_level = crate::agent::thinking::resolve_thinking_level(
3665                thinking_directive,
3666                None,
3667                &agent.thinking,
3668            );
3669            let thinking_params = crate::agent::thinking::apply_thinking_level_with_config(
3670                thinking_level,
3671                &agent.thinking,
3672            );
3673            let effective_temperature: Option<f64> = temperature.map(|t| {
3674                crate::agent::thinking::clamp_temperature(
3675                    t + thinking_params.temperature_adjustment,
3676                )
3677            });
3678
3679            // Prepend thinking system prompt prefix when present.
3680            if let Some(ref prefix) = thinking_params.system_prompt_prefix {
3681                system_prompt = format!("{prefix}\n\n{system_prompt}");
3682            }
3683
3684            if let Some(suggestion) = crate::skills::render_missing_skill_install_suggestion(
3685                &effective_msg,
3686                &skills,
3687                &config.data_dir,
3688                config.skills.install_suggestions.enabled,
3689            ) {
3690                final_output = suggestion.clone();
3691                println!("{suggestion}");
3692                observer.record_event(&ObserverEvent::TurnComplete);
3693                return Ok(final_output);
3694            }
3695
3696            // Auto-save user message to memory (skip short/trivial messages)
3697            if config.memory.auto_save
3698                && effective_msg.chars().count() >= AUTOSAVE_MIN_MESSAGE_CHARS
3699                && !zeroclaw_memory::should_skip_autosave_content(&effective_msg)
3700            {
3701                let user_key = autosave_memory_key("user_msg");
3702                let _ = mem
3703                    .store(
3704                        &user_key,
3705                        &effective_msg,
3706                        MemoryCategory::Conversation,
3707                        memory_session_id.as_deref(),
3708                    )
3709                    .await;
3710            }
3711
3712            // Inject memory + hardware RAG context into user message.
3713            // For non-interactive runs (cron, daemon heartbeat), exclude
3714            // Conversation-category memories so chat history does not leak
3715            // into autonomous executions. / #5456.
3716            let mem_context = build_context(
3717                mem.as_ref(),
3718                &effective_msg,
3719                config.memory.min_relevance_score,
3720                memory_session_id.as_deref(),
3721                !interactive,
3722            )
3723            .await;
3724            let rag_limit = if agent.compact_context { 2 } else { 5 };
3725            let hw_context = hardware_rag
3726                .as_ref()
3727                .map(|r| build_hardware_context(r, &effective_msg, &board_names, rag_limit))
3728                .unwrap_or_default();
3729            let context = format!("{mem_context}{hw_context}");
3730            let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z");
3731            let enriched = if context.is_empty() {
3732                format!("[{now}] {effective_msg}")
3733            } else {
3734                format!("{context}[{now}] {effective_msg}")
3735            };
3736
3737            let mut history = vec![
3738                ChatMessage::system(&system_prompt),
3739                ChatMessage::user(&enriched),
3740            ];
3741
3742            // Prune history for token efficiency (when enabled).
3743            if agent.history_pruning.enabled {
3744                let _stats = crate::agent::history_pruner::prune_history(
3745                    &mut history,
3746                    &agent.history_pruning,
3747                );
3748            }
3749
3750            // Compute per-turn excluded MCP tools from tool_filter_groups.
3751            let excluded_tools = compute_excluded_mcp_tools(
3752                &tools_registry,
3753                &agent.tool_filter_groups,
3754                &effective_msg,
3755            );
3756
3757            #[allow(unused_assignments)]
3758            let mut response = String::new();
3759            loop {
3760                match zeroclaw_api::NATIVE_THINKING_OVERRIDE
3761                    .scope(
3762                        thinking_params.native_thinking,
3763                        TOOL_LOOP_COST_TRACKING_CONTEXT.scope(
3764                            cost_tracking_context.clone(),
3765                            run_tool_call_loop(
3766                                model_provider.as_ref(),
3767                                &mut history,
3768                                &tools_registry,
3769                                observer.as_ref(),
3770                                &provider_name,
3771                                &model_name,
3772                                effective_temperature,
3773                                false,
3774                                approval_manager.as_ref(),
3775                                channel_name,
3776                                None,
3777                                &config.multimodal,
3778                                agent.max_tool_iterations,
3779                                None,
3780                                None,
3781                                None,
3782                                &excluded_tools,
3783                                &agent.tool_call_dedup_exempt,
3784                                activated_handle.as_ref(),
3785                                Some(model_switch_callback.clone()),
3786                                &config.pacing,
3787                                agent.strict_tool_parsing,
3788                                agent.max_tool_result_chars,
3789                                agent.max_context_tokens,
3790                                None, // shared_budget
3791                                None, // channel: CLI mode — uses prompt_cli
3792                                None, // receipt_generator
3793                                None, // collected_receipts
3794                            ),
3795                        ),
3796                    )
3797                    .await
3798                {
3799                    Ok(resp) => {
3800                        response = resp;
3801                        break;
3802                    }
3803                    Err(e) => {
3804                        if let Some((new_model_provider, new_model)) = is_model_switch_requested(&e)
3805                        {
3806                            ::zeroclaw_log::record!(
3807                                INFO,
3808                                ::zeroclaw_log::Event::new(
3809                                    module_path!(),
3810                                    ::zeroclaw_log::Action::Note
3811                                ),
3812                                &format!(
3813                                    "Model switch requested, switching from {} {} to {} {}",
3814                                    provider_name, model_name, new_model_provider, new_model
3815                                )
3816                            );
3817
3818                            let (switch_api_key, switch_uri) = api_key_and_uri_for_provider(
3819                                &config,
3820                                &new_model_provider,
3821                                agent_model_provider,
3822                            );
3823                            model_provider =
3824                                zeroclaw_providers::create_routed_model_provider_with_options(
3825                                    &config,
3826                                    &new_model_provider,
3827                                    switch_api_key.as_deref(),
3828                                    switch_uri.as_deref(),
3829                                    &config.reliability,
3830                                    &config.model_routes,
3831                                    &new_model,
3832                                    &zeroclaw_providers::options_for_provider_ref(
3833                                        &config,
3834                                        &new_model_provider,
3835                                        &provider_runtime_options_base,
3836                                    ),
3837                                )?;
3838
3839                            provider_name = new_model_provider;
3840                            model_name = new_model;
3841
3842                            clear_model_switch_request();
3843
3844                            observer.record_event(&ObserverEvent::AgentStart {
3845                                model_provider: provider_name.to_string(),
3846                                model: model_name.to_string(),
3847                            });
3848
3849                            continue;
3850                        }
3851                        return Err(e);
3852                    }
3853                }
3854            }
3855
3856            // After successful multi-step execution, attempt autonomous skill creation.
3857            if config.skills.skill_creation.enabled {
3858                let tool_calls = crate::skills::creator::extract_tool_calls_from_history(&history);
3859                if tool_calls.len() >= 2 {
3860                    let creator = crate::skills::creator::SkillCreator::new(
3861                        config.data_dir.clone(),
3862                        config.skills.skill_creation.clone(),
3863                    );
3864                    match creator.create_from_execution(&msg, &tool_calls, None).await {
3865                        Ok(Some(slug)) => {
3866                            ::zeroclaw_log::record!(
3867                                INFO,
3868                                ::zeroclaw_log::Event::new(
3869                                    module_path!(),
3870                                    ::zeroclaw_log::Action::Note
3871                                )
3872                                .with_attrs(::serde_json::json!({"slug": slug})),
3873                                "Auto-created skill from execution"
3874                            );
3875                        }
3876                        Ok(None) => {
3877                            ::zeroclaw_log::record!(
3878                                DEBUG,
3879                                ::zeroclaw_log::Event::new(
3880                                    module_path!(),
3881                                    ::zeroclaw_log::Action::Note
3882                                ),
3883                                "Skill creation skipped (duplicate or disabled)"
3884                            );
3885                        }
3886                        Err(e) => ::zeroclaw_log::record!(
3887                            WARN,
3888                            ::zeroclaw_log::Event::new(
3889                                module_path!(),
3890                                ::zeroclaw_log::Action::Note
3891                            )
3892                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
3893                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
3894                            "Skill creation failed"
3895                        ),
3896                    }
3897                }
3898            }
3899            final_output = response.clone();
3900            println!("{response}");
3901            observer.record_event(&ObserverEvent::TurnComplete);
3902        } else {
3903            println!("🦀 ZeroClaw Interactive Mode");
3904            println!("Type /help for commands.\n");
3905            let cli = CLI_CHANNEL_FN.get().expect(
3906                "CLI channel factory not registered — call register_cli_channel_fn at startup",
3907            )();
3908
3909            // Persistent conversation history across turns
3910            let mut history = if let Some(path) = session_state_file.as_deref() {
3911                load_interactive_session_history(path, &system_prompt)?
3912            } else {
3913                vec![ChatMessage::system(&system_prompt)]
3914            };
3915
3916            loop {
3917                print!("> ");
3918                let _ = std::io::stdout().flush();
3919
3920                // Read raw bytes to avoid UTF-8 validation errors when PTY
3921                // transport splits multi-byte characters at frame boundaries
3922                // (e.g. CJK input with spaces over kubectl exec / SSH).
3923                let mut raw = Vec::new();
3924                match std::io::BufRead::read_until(&mut std::io::stdin().lock(), b'\n', &mut raw) {
3925                    Ok(0) => break,
3926                    Ok(_) => {}
3927                    Err(e) => {
3928                        eprintln!("\nError reading input: {e}\n");
3929                        break;
3930                    }
3931                }
3932                let input = String::from_utf8_lossy(&raw).into_owned();
3933
3934                let user_input = input.trim().to_string();
3935                if user_input.is_empty() {
3936                    continue;
3937                }
3938                match user_input.as_str() {
3939                    "/quit" | "/exit" => break,
3940                    "/help" => {
3941                        println!("Available commands:");
3942                        println!("  /help             Show this help message");
3943                        println!("  /clear /new       Clear conversation history");
3944                        println!("  /quit /exit       Exit interactive mode");
3945                        println!(
3946                            "  /think:<level>    Set reasoning depth (off|minimal|low|medium|high|max)\n"
3947                        );
3948                        continue;
3949                    }
3950                    "/clear" | "/new" => {
3951                        println!(
3952                            "This will clear the current conversation and delete all session memory."
3953                        );
3954                        println!("Core memories (long-term facts/preferences) will be preserved.");
3955                        print!("Continue? [y/N] ");
3956                        let _ = std::io::stdout().flush();
3957
3958                        let mut confirm_raw = Vec::new();
3959                        if std::io::BufRead::read_until(
3960                            &mut std::io::stdin().lock(),
3961                            b'\n',
3962                            &mut confirm_raw,
3963                        )
3964                        .is_err()
3965                        {
3966                            continue;
3967                        }
3968                        let confirm = String::from_utf8_lossy(&confirm_raw);
3969                        if !matches!(confirm.trim().to_lowercase().as_str(), "y" | "yes") {
3970                            println!("Cancelled.\n");
3971                            continue;
3972                        }
3973
3974                        history.clear();
3975                        history.push(ChatMessage::system(&system_prompt));
3976                        // Clear conversation and daily memory
3977                        let mut cleared = 0;
3978                        for category in [MemoryCategory::Conversation, MemoryCategory::Daily] {
3979                            let entries = mem.list(Some(&category), None).await.unwrap_or_default();
3980                            for entry in entries {
3981                                if mem.forget(&entry.key).await.unwrap_or(false) {
3982                                    cleared += 1;
3983                                }
3984                            }
3985                        }
3986                        if cleared > 0 {
3987                            println!("Conversation cleared ({cleared} memory entries removed).\n");
3988                        } else {
3989                            println!("Conversation cleared.\n");
3990                        }
3991                        if let Some(path) = session_state_file.as_deref() {
3992                            save_interactive_session_history(path, &history)?;
3993                        }
3994                        continue;
3995                    }
3996                    _ => {}
3997                }
3998
3999                // ── Parse thinking directive from interactive input ───
4000                let (thinking_directive, effective_input) =
4001                    match crate::agent::thinking::parse_thinking_directive(&user_input) {
4002                        Some((level, remaining)) => {
4003                            ::zeroclaw_log::record!(
4004                                INFO,
4005                                ::zeroclaw_log::Event::new(
4006                                    module_path!(),
4007                                    ::zeroclaw_log::Action::Note
4008                                )
4009                                .with_attrs(::serde_json::json!({"thinking_level": level})),
4010                                "Thinking directive parsed"
4011                            );
4012                            (Some(level), remaining)
4013                        }
4014                        None => (None, user_input.clone()),
4015                    };
4016                let thinking_level = crate::agent::thinking::resolve_thinking_level(
4017                    thinking_directive,
4018                    None,
4019                    &agent.thinking,
4020                );
4021                let thinking_params = crate::agent::thinking::apply_thinking_level_with_config(
4022                    thinking_level,
4023                    &agent.thinking,
4024                );
4025                let turn_temperature: Option<f64> = temperature.map(|t| {
4026                    crate::agent::thinking::clamp_temperature(
4027                        t + thinking_params.temperature_adjustment,
4028                    )
4029                });
4030
4031                // For non-Medium levels, temporarily patch the system prompt with prefix.
4032                let turn_system_prompt;
4033                if let Some(ref prefix) = thinking_params.system_prompt_prefix {
4034                    turn_system_prompt = format!("{prefix}\n\n{system_prompt}");
4035                    // Update the system message in history for this turn.
4036                    if let Some(sys_msg) = history.first_mut()
4037                        && sys_msg.role == "system"
4038                    {
4039                        sys_msg.content = turn_system_prompt.clone();
4040                    }
4041                }
4042
4043                if let Some(suggestion) = crate::skills::render_missing_skill_install_suggestion(
4044                    &effective_input,
4045                    &skills,
4046                    &config.data_dir,
4047                    config.skills.install_suggestions.enabled,
4048                ) {
4049                    final_output = suggestion.clone();
4050                    if let Err(e) = zeroclaw_api::channel::Channel::send(
4051                        &*cli,
4052                        &zeroclaw_api::channel::SendMessage::new(
4053                            format!("\n{suggestion}\n"),
4054                            "user",
4055                        ),
4056                    )
4057                    .await
4058                    {
4059                        eprintln!("\nError sending CLI response: {e}\n");
4060                    }
4061                    observer.record_event(&ObserverEvent::TurnComplete);
4062                    if thinking_params.system_prompt_prefix.is_some()
4063                        && let Some(sys_msg) = history.first_mut()
4064                        && sys_msg.role == "system"
4065                    {
4066                        sys_msg.content.clone_from(&base_system_prompt);
4067                    }
4068                    continue;
4069                }
4070
4071                // Auto-save conversation turns (skip short/trivial messages)
4072                if config.memory.auto_save
4073                    && effective_input.chars().count() >= AUTOSAVE_MIN_MESSAGE_CHARS
4074                    && !zeroclaw_memory::should_skip_autosave_content(&effective_input)
4075                {
4076                    let user_key = autosave_memory_key("user_msg");
4077                    let _ = mem
4078                        .store(
4079                            &user_key,
4080                            &effective_input,
4081                            MemoryCategory::Conversation,
4082                            memory_session_id.as_deref(),
4083                        )
4084                        .await;
4085                }
4086
4087                // Inject memory + hardware RAG context into user message.
4088                // Interactive REPL: keep Conversation memories (user is actively
4089                // chatting in this session and may want their own history recalled).
4090                let mem_context = build_context(
4091                    mem.as_ref(),
4092                    &effective_input,
4093                    config.memory.min_relevance_score,
4094                    memory_session_id.as_deref(),
4095                    false,
4096                )
4097                .await;
4098                let rag_limit = if agent.compact_context { 2 } else { 5 };
4099                let hw_context = hardware_rag
4100                    .as_ref()
4101                    .map(|r| build_hardware_context(r, &effective_input, &board_names, rag_limit))
4102                    .unwrap_or_default();
4103                let context = format!("{mem_context}{hw_context}");
4104                let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z");
4105                let enriched = if context.is_empty() {
4106                    format!("[{now}] {effective_input}")
4107                } else {
4108                    format!("{context}[{now}] {effective_input}")
4109                };
4110
4111                history.push(ChatMessage::user(&enriched));
4112
4113                // Compute per-turn excluded MCP tools from tool_filter_groups.
4114                let excluded_tools = compute_excluded_mcp_tools(
4115                    &tools_registry,
4116                    &agent.tool_filter_groups,
4117                    &effective_input,
4118                );
4119
4120                // Set up streaming channel so tool progress and response
4121                // content are printed progressively instead of buffered.
4122                let (delta_tx, mut delta_rx) = tokio::sync::mpsc::channel::<DraftEvent>(64);
4123                let content_was_streamed =
4124                    std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
4125                let content_streamed_flag = content_was_streamed.clone();
4126                let is_tty = std::io::IsTerminal::is_terminal(&std::io::stderr());
4127
4128                let consumer_handle = tokio::spawn(async move {
4129                    use std::io::Write;
4130                    while let Some(event) = delta_rx.recv().await {
4131                        match event {
4132                            StreamDelta::Status(text) => {
4133                                if is_tty {
4134                                    let _ = write!(std::io::stderr(), "\x1b[2m{text}\x1b[0m");
4135                                } else {
4136                                    let _ = write!(std::io::stderr(), "{text}");
4137                                }
4138                                let _ = std::io::stderr().flush();
4139                            }
4140                            StreamDelta::Text(text) => {
4141                                content_streamed_flag
4142                                    .store(true, std::sync::atomic::Ordering::Relaxed);
4143                                print!("{text}");
4144                                let _ = std::io::stdout().flush();
4145                            }
4146                        }
4147                    }
4148                });
4149
4150                // Ctrl+C cancels the in-flight turn instead of killing the process.
4151                let cancel_token = CancellationToken::new();
4152                let cancel_token_clone = cancel_token.clone();
4153                let ctrlc_handle = tokio::spawn(async move {
4154                    if tokio::signal::ctrl_c().await.is_ok() {
4155                        cancel_token_clone.cancel();
4156                    }
4157                });
4158
4159                let response = loop {
4160                    match zeroclaw_api::NATIVE_THINKING_OVERRIDE
4161                        .scope(
4162                            thinking_params.native_thinking,
4163                            TOOL_LOOP_COST_TRACKING_CONTEXT.scope(
4164                                cost_tracking_context.clone(),
4165                                run_tool_call_loop(
4166                                    model_provider.as_ref(),
4167                                    &mut history,
4168                                    &tools_registry,
4169                                    observer.as_ref(),
4170                                    &provider_name,
4171                                    &model_name,
4172                                    turn_temperature,
4173                                    true,
4174                                    approval_manager.as_ref(),
4175                                    channel_name,
4176                                    None,
4177                                    &config.multimodal,
4178                                    agent.max_tool_iterations,
4179                                    Some(cancel_token.clone()),
4180                                    Some(delta_tx.clone()),
4181                                    None,
4182                                    &excluded_tools,
4183                                    &agent.tool_call_dedup_exempt,
4184                                    activated_handle.as_ref(),
4185                                    Some(model_switch_callback.clone()),
4186                                    &config.pacing,
4187                                    agent.strict_tool_parsing,
4188                                    agent.max_tool_result_chars,
4189                                    agent.max_context_tokens,
4190                                    None, // shared_budget
4191                                    None, // channel: interactive CLI — uses prompt_cli
4192                                    None, // receipt_generator
4193                                    None, // collected_receipts
4194                                ),
4195                            ),
4196                        )
4197                        .await
4198                    {
4199                        Ok(resp) => break resp,
4200                        Err(e) => {
4201                            if is_tool_loop_cancelled(&e) {
4202                                eprintln!("\n\x1b[2m(cancelled)\x1b[0m");
4203                                break String::new();
4204                            }
4205                            if let Some((new_model_provider, new_model)) =
4206                                is_model_switch_requested(&e)
4207                            {
4208                                ::zeroclaw_log::record!(
4209                                    INFO,
4210                                    ::zeroclaw_log::Event::new(
4211                                        module_path!(),
4212                                        ::zeroclaw_log::Action::Note
4213                                    ),
4214                                    &format!(
4215                                        "Model switch requested, switching from {} {} to {} {}",
4216                                        provider_name, model_name, new_model_provider, new_model
4217                                    )
4218                                );
4219
4220                                let (switch_api_key2, switch_uri2) = api_key_and_uri_for_provider(
4221                                    &config,
4222                                    &new_model_provider,
4223                                    agent_model_provider,
4224                                );
4225                                model_provider =
4226                                    zeroclaw_providers::create_routed_model_provider_with_options(
4227                                        &config,
4228                                        &new_model_provider,
4229                                        switch_api_key2.as_deref(),
4230                                        switch_uri2.as_deref(),
4231                                        &config.reliability,
4232                                        &config.model_routes,
4233                                        &new_model,
4234                                        &zeroclaw_providers::options_for_provider_ref(
4235                                            &config,
4236                                            &new_model_provider,
4237                                            &provider_runtime_options_base,
4238                                        ),
4239                                    )?;
4240
4241                                provider_name = new_model_provider;
4242                                model_name = new_model;
4243
4244                                clear_model_switch_request();
4245
4246                                observer.record_event(&ObserverEvent::AgentStart {
4247                                    model_provider: provider_name.to_string(),
4248                                    model: model_name.to_string(),
4249                                });
4250
4251                                continue;
4252                            }
4253                            // Context overflow recovery: compress and retry
4254                            if zeroclaw_providers::reliable::is_context_window_exceeded(&e) {
4255                                ::zeroclaw_log::record!(
4256                                    WARN,
4257                                    ::zeroclaw_log::Event::new(
4258                                        module_path!(),
4259                                        ::zeroclaw_log::Action::Note
4260                                    )
4261                                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
4262                                    "Context overflow in interactive loop, attempting recovery"
4263                                );
4264                                let mut compressor =
4265                                    crate::agent::context_compressor::ContextCompressor::new(
4266                                        agent.context_compression.clone(),
4267                                        agent.max_context_tokens,
4268                                    )
4269                                    .with_memory(mem.clone());
4270                                let error_msg = format!("{e}");
4271                                match compressor
4272                                    .compress_on_error(
4273                                        &mut history,
4274                                        model_provider.as_ref(),
4275                                        &model_name,
4276                                        temperature,
4277                                        &error_msg,
4278                                    )
4279                                    .await
4280                                {
4281                                    Ok(true) => {
4282                                        ::zeroclaw_log::record!(
4283                                            INFO,
4284                                            ::zeroclaw_log::Event::new(
4285                                                module_path!(),
4286                                                ::zeroclaw_log::Action::Note
4287                                            ),
4288                                            "Context recovered via compression, retrying turn"
4289                                        );
4290                                        continue;
4291                                    }
4292                                    Ok(false) => {
4293                                        ::zeroclaw_log::record!(
4294                                            WARN,
4295                                            ::zeroclaw_log::Event::new(
4296                                                module_path!(),
4297                                                ::zeroclaw_log::Action::Note
4298                                            )
4299                                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
4300                                            "Compression ran but couldn't reduce enough"
4301                                        );
4302                                    }
4303                                    Err(compress_err) => {
4304                                        ::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");
4305                                    }
4306                                }
4307                            }
4308
4309                            eprintln!("\nError: {e}\n");
4310                            break String::new();
4311                        }
4312                    }
4313                };
4314
4315                // Clean up: stop the Ctrl+C listener and flush streaming events.
4316                ctrlc_handle.abort();
4317                drop(delta_tx);
4318                let _ = consumer_handle.await;
4319
4320                final_output = response.clone();
4321                if content_was_streamed.load(std::sync::atomic::Ordering::Relaxed) {
4322                    println!();
4323                } else if let Err(e) = zeroclaw_api::channel::Channel::send(
4324                    &*cli,
4325                    &zeroclaw_api::channel::SendMessage::new(format!("\n{response}\n"), "user"),
4326                )
4327                .await
4328                {
4329                    eprintln!("\nError sending CLI response: {e}\n");
4330                }
4331                observer.record_event(&ObserverEvent::TurnComplete);
4332
4333                // Context compression before hard trimming to preserve long-context signal.
4334                {
4335                    let compressor = crate::agent::context_compressor::ContextCompressor::new(
4336                        agent.context_compression.clone(),
4337                        agent.max_context_tokens,
4338                    )
4339                    .with_memory(mem.clone());
4340                    match compressor
4341                        .compress_if_needed(
4342                            &mut history,
4343                            model_provider.as_ref(),
4344                            &model_name,
4345                            temperature,
4346                        )
4347                        .await
4348                    {
4349                        Ok(result) if result.compressed => {
4350                            ::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");
4351                        }
4352                        Ok(_) => {} // No compression needed
4353                        Err(e) => {
4354                            ::zeroclaw_log::record!(
4355                                WARN,
4356                                ::zeroclaw_log::Event::new(
4357                                    module_path!(),
4358                                    ::zeroclaw_log::Action::Note
4359                                )
4360                                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
4361                                .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
4362                                "Context compression failed, falling back to history trim"
4363                            );
4364                            trim_history(&mut history, agent.max_history_messages / 2);
4365                        }
4366                    }
4367                }
4368
4369                // Hard cap as a safety net.
4370                trim_history(&mut history, agent.max_history_messages);
4371
4372                // Restore base system prompt (remove per-turn thinking prefix).
4373                if thinking_params.system_prompt_prefix.is_some()
4374                    && let Some(sys_msg) = history.first_mut()
4375                    && sys_msg.role == "system"
4376                {
4377                    sys_msg.content.clone_from(&base_system_prompt);
4378                }
4379
4380                if let Some(path) = session_state_file.as_deref() {
4381                    save_interactive_session_history(path, &history)?;
4382                }
4383            }
4384        }
4385
4386        let duration = start.elapsed();
4387        observer.record_event(&ObserverEvent::AgentEnd {
4388            model_provider: provider_name.to_string(),
4389            model: model_name.to_string(),
4390            duration,
4391            tokens_used: None,
4392            cost_usd: None,
4393        });
4394
4395        Ok(final_output)
4396    };
4397    __zc_body
4398        .instrument(__zc_scope_span)
4399        .instrument(__zc_attribution_span)
4400        .await
4401}
4402
4403/// Process a single message through the full agent (with tools, peripherals, memory).
4404/// Used by channels (Telegram, Discord, etc.) to enable hardware and tool use.
4405pub async fn process_message(
4406    config: Config,
4407    agent_alias: &str,
4408    message: &str,
4409    session_id: Option<&str>,
4410) -> Result<String> {
4411    use ::zeroclaw_log::Instrument;
4412    let agent = config
4413        .agent(agent_alias)
4414        .with_context(|| format!("agents.{agent_alias} is not configured"))?
4415        .clone();
4416    crate::agent::thinking::validate_thinking_config(&agent.thinking);
4417    let risk_profile = config
4418        .risk_profile_for_agent(agent_alias)
4419        .with_context(|| {
4420            format!(
4421                "agents.{agent_alias}.risk_profile does not name a configured risk_profiles entry"
4422            )
4423        })?
4424        .clone();
4425    let memory_composite = {
4426        use zeroclaw_config::multi_agent::MemoryBackendKind;
4427        match agent.memory.backend {
4428            MemoryBackendKind::Markdown => format!("markdown.{agent_alias}"),
4429            MemoryBackendKind::None => "none".to_string(),
4430            _ => {
4431                let raw = config.memory.backend.trim();
4432                if raw.is_empty() || raw.eq_ignore_ascii_case("none") {
4433                    "none".to_string()
4434                } else {
4435                    let (kind, alias) = raw.split_once('.').unwrap_or((raw, "default"));
4436                    format!("{kind}.{alias}")
4437                }
4438            }
4439        }
4440    };
4441    let __zc_alias = agent_alias.to_string();
4442    let __zc_message = message.to_string();
4443    let __zc_session_id = session_id.map(str::to_string);
4444    let __zc_attribution_span =
4445        ::zeroclaw_log::attribution_span!(&crate::agent::AgentAttribution(__zc_alias.as_str()));
4446    let __zc_scope_span = ::zeroclaw_log::info_span!(
4447        target: "zeroclaw_log_internal_scope",
4448        "zeroclaw_scope",
4449        risk_profile = %agent.risk_profile,
4450        runtime_profile = %agent.runtime_profile,
4451        memory_namespace = %memory_composite,
4452    );
4453    let __zc_body = async move {
4454        let agent_alias: &str = __zc_alias.as_str();
4455        let message: &str = __zc_message.as_str();
4456        let session_id: Option<&str> = __zc_session_id.as_deref();
4457
4458        let observer: Arc<dyn Observer> =
4459            Arc::from(observability::create_observer(&config.observability));
4460        let runtime: Arc<dyn platform::RuntimeAdapter> =
4461            Arc::from(platform::create_runtime(&config.runtime)?);
4462        let security = Arc::new(SecurityPolicy::for_agent(&config, agent_alias)?);
4463        let (provider_name, provider_alias, agent_model_provider) = match config
4464            .resolved_model_provider_for_agent(agent_alias)
4465        {
4466            Some(resolved) => (resolved.0, resolved.1.to_string(), Some(resolved.2.clone())),
4467            None => {
4468                let agent_ref = agent.model_provider.as_str();
4469                if !agent_ref.is_empty() {
4470                    anyhow::bail!(
4471                        "agents.{agent_alias}.model_provider = \"{agent_ref}\" does not resolve to \
4472                     a configured [model_providers.<type>.<alias>] entry"
4473                    );
4474                }
4475                anyhow::bail!(
4476                    "agents.{agent_alias}.model_provider is empty \u{2014} set it to a configured \
4477                 \"<type>.<alias>\" (e.g. \"anthropic.{agent_alias}\")"
4478                );
4479            }
4480        };
4481        let approval_manager = ApprovalManager::for_non_interactive(&risk_profile);
4482        let mem: Arc<dyn Memory> = zeroclaw_memory::create_memory_for_agent(
4483            &config,
4484            agent_alias,
4485            agent_model_provider
4486                .as_ref()
4487                .and_then(|e| e.api_key.as_deref()),
4488        )
4489        .await?;
4490
4491        let (composio_key, composio_entity_id) = if config.composio.enabled {
4492            (
4493                config.composio.api_key.as_deref(),
4494                Some(config.composio.entity_id.as_str()),
4495            )
4496        } else {
4497            (None, None)
4498        };
4499        let all_tools_result_pm = tools::all_tools_with_runtime(
4500            Arc::new(config.clone()),
4501            &security,
4502            &risk_profile,
4503            agent_alias,
4504            runtime,
4505            mem.clone(),
4506            composio_key,
4507            composio_entity_id,
4508            &config.browser,
4509            &config.http_request,
4510            &config.web_fetch,
4511            &config.data_dir,
4512            &config.agents,
4513            agent_model_provider
4514                .as_ref()
4515                .and_then(|e| e.api_key.as_deref()),
4516            &config,
4517            None,
4518            false,
4519        );
4520        let mut tools_registry = all_tools_result_pm.tools;
4521        let delegate_handle_pm = all_tools_result_pm.delegate_handle;
4522        let unfiltered_tool_arcs_pm = all_tools_result_pm.unfiltered_tool_arcs;
4523        let ask_user_handle_pm = all_tools_result_pm.ask_user_handle;
4524        let reaction_handle_pm = all_tools_result_pm.reaction_handle;
4525        let poll_handle_pm = all_tools_result_pm.poll_handle;
4526        let escalate_handle_pm = all_tools_result_pm.escalate_handle;
4527        let channel_send_handle_pm = all_tools_result_pm.channel_send_handle;
4528
4529        // Populate all channel-driven tool handles from the registered factory.
4530        let count = seed_channel_handles(
4531            &ask_user_handle_pm,
4532            &reaction_handle_pm,
4533            &poll_handle_pm,
4534            &escalate_handle_pm,
4535            &channel_send_handle_pm,
4536        );
4537        if count > 0 {
4538            ::zeroclaw_log::record!(
4539                INFO,
4540                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
4541                    .with_attrs(::serde_json::json!({"count": count})),
4542                &format!("Registered {} channel(s) for process_message agent", count),
4543            );
4544        }
4545        let peripheral_tools: Vec<Box<dyn Tool>> = if let Some(f) = PERIPHERAL_TOOLS_FN.get() {
4546            f(config.peripherals.clone()).await.unwrap_or_default()
4547        } else {
4548            vec![]
4549        };
4550        tools_registry.extend(peripheral_tools);
4551
4552        // ── Capability-based tool access control ─────────────────────
4553        // Mirror the `run()` path: apply the SecurityPolicy filter
4554        // (allowed_tools + excluded_tools) so daemon-provisioned agents get
4555        // the same restriction as CLI-invoked agents. Extracted into
4556        // `filter_channel_builtin_tools` so the production path is
4557        // regression-tested (see process_message_policy_filters_eager_builtins).
4558        filter_channel_builtin_tools(&mut tools_registry, security.as_ref());
4559
4560        // ── Wire MCP tools (non-fatal) — process_message path ────────
4561        // NOTE: Same ordering contract as the CLI path above — MCP tools must be
4562        // injected after the policy tool filter to avoid MCP tools being
4563        // silently dropped by a restrictive allowlist.
4564        let mut deferred_section = String::new();
4565        let mut activated_handle_pm: Option<
4566            std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>,
4567        > = None;
4568        // Resolution-only MCP wrappers for skill MCP elevation (kind = "mcp").
4569        let mut mcp_elevation_arcs: Vec<std::sync::Arc<dyn Tool>> = Vec::new();
4570        if config.mcp.enabled && !config.mcp.servers.is_empty() {
4571            ::zeroclaw_log::record!(
4572                INFO,
4573                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
4574                &format!(
4575                    "Initializing MCP client — {} server(s) configured",
4576                    config.mcp.servers.len()
4577                )
4578            );
4579            match crate::tools::McpRegistry::connect_all(&config.mcp.servers).await {
4580                Ok(registry) => {
4581                    let registry = std::sync::Arc::new(registry);
4582                    mcp_elevation_arcs = crate::tools::collect_mcp_elevation_arcs(&registry).await;
4583                    if config.mcp.deferred_loading {
4584                        let deferred_set = crate::tools::DeferredMcpToolSet::from_registry(
4585                            std::sync::Arc::clone(&registry),
4586                        )
4587                        .await;
4588                        ::zeroclaw_log::record!(
4589                            INFO,
4590                            ::zeroclaw_log::Event::new(
4591                                module_path!(),
4592                                ::zeroclaw_log::Action::Note
4593                            ),
4594                            &format!(
4595                                "MCP deferred: {} tool stub(s) from {} server(s)",
4596                                deferred_set.len(),
4597                                registry.server_count()
4598                            )
4599                        );
4600                        let mcp_policy_pm =
4601                            zeroclaw_tools::tool_search::ToolAccessPolicy::from_security(
4602                                security.allowed_tools.as_deref(),
4603                                security.excluded_tools.as_deref(),
4604                                None, // no caller-supplied allowlist in channel path
4605                            );
4606                        deferred_section = crate::tools::build_deferred_tools_section_filtered(
4607                            &deferred_set,
4608                            mcp_policy_pm.as_ref(),
4609                        );
4610                        let activated = std::sync::Arc::new(std::sync::Mutex::new(
4611                            crate::tools::ActivatedToolSet::new(),
4612                        ));
4613                        activated_handle_pm = Some(std::sync::Arc::clone(&activated));
4614                        let mut tool_search_pm =
4615                            crate::tools::ToolSearchTool::new(deferred_set, activated);
4616                        if let Some(policy) = mcp_policy_pm {
4617                            tool_search_pm = tool_search_pm.with_access_policy(policy);
4618                        }
4619                        tools_registry.push(Box::new(tool_search_pm));
4620                    } else {
4621                        let names = registry.tool_names();
4622                        let mut registered = 0usize;
4623                        for name in names {
4624                            if let Some(def) = registry.get_tool_def(&name).await {
4625                                let wrapper: std::sync::Arc<dyn Tool> =
4626                                    std::sync::Arc::new(crate::tools::McpToolWrapper::new(
4627                                        name,
4628                                        def,
4629                                        std::sync::Arc::clone(&registry),
4630                                    ));
4631                                if let Some(ref handle) = delegate_handle_pm {
4632                                    handle.write().push(std::sync::Arc::clone(&wrapper));
4633                                }
4634                                tools_registry.push(Box::new(crate::tools::ArcToolRef(wrapper)));
4635                                registered += 1;
4636                            }
4637                        }
4638                        ::zeroclaw_log::record!(
4639                            INFO,
4640                            ::zeroclaw_log::Event::new(
4641                                module_path!(),
4642                                ::zeroclaw_log::Action::Note
4643                            ),
4644                            &format!(
4645                                "MCP: {} tool(s) registered from {} server(s)",
4646                                registered,
4647                                registry.server_count()
4648                            )
4649                        );
4650                    }
4651                }
4652                Err(e) => {
4653                    ::zeroclaw_log::record!(
4654                        ERROR,
4655                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
4656                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
4657                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
4658                        "MCP registry failed to initialize"
4659                    );
4660                }
4661            }
4662        }
4663
4664        let model_name = match agent_model_provider
4665            .as_ref()
4666            .and_then(|e| e.model.as_deref())
4667            .map(str::trim)
4668            .filter(|m| !m.is_empty())
4669        {
4670            Some(m) => m.to_string(),
4671            None => anyhow::bail!(
4672                "agents.{agent_alias}.model_provider resolves to a model_provider entry with no \
4673             `model` set. Configure [model_providers.{provider_name}.<alias>] model = \"...\"."
4674            ),
4675        };
4676        let provider_runtime_options = zeroclaw_providers::provider_runtime_options_for_alias(
4677            &config,
4678            provider_name,
4679            provider_alias.as_str(),
4680        );
4681        let model_provider: Box<dyn ModelProvider> =
4682            zeroclaw_providers::create_routed_model_provider_with_options(
4683                &config,
4684                &format!("{provider_name}.{provider_alias}"),
4685                agent_model_provider
4686                    .as_ref()
4687                    .and_then(|e| e.api_key.as_deref()),
4688                agent_model_provider.as_ref().and_then(|e| e.uri.as_deref()),
4689                &config.reliability,
4690                &config.model_routes,
4691                &model_name,
4692                &provider_runtime_options,
4693            )?;
4694
4695        let hardware_rag: Option<crate::rag::HardwareRag> = config
4696            .peripherals
4697            .datasheet_dir
4698            .as_ref()
4699            .filter(|d| !d.trim().is_empty())
4700            .map(|dir| crate::rag::HardwareRag::load(&config.data_dir, dir.trim()))
4701            .and_then(Result::ok)
4702            .filter(|r: &crate::rag::HardwareRag| !r.is_empty());
4703        let board_names: Vec<String> = config
4704            .peripherals
4705            .boards
4706            .iter()
4707            .map(|b| b.board.clone())
4708            .collect();
4709
4710        // ── Initialize locale-aware tool descriptions ──────────────────
4711        let i18n_locale = config
4712            .locale
4713            .as_deref()
4714            .filter(|s| !s.is_empty())
4715            .map(ToString::to_string)
4716            .unwrap_or_else(crate::i18n::detect_locale);
4717        crate::i18n::init(&i18n_locale);
4718
4719        let skills = crate::skills::load_skills_for_agent(&config.data_dir, &config, agent_alias);
4720
4721        // Register skill-defined tools as callable tool specs (process_message path).
4722        // Resolution registry = built-in arcs + resolution-only MCP wrappers.
4723        let skill_resolution_registry: Vec<std::sync::Arc<dyn Tool>> = unfiltered_tool_arcs_pm
4724            .iter()
4725            .cloned()
4726            .chain(mcp_elevation_arcs.iter().cloned())
4727            .collect();
4728        tools::register_skill_tools_with_context(
4729            &mut tools_registry,
4730            &skills,
4731            security.clone(),
4732            &skill_resolution_registry,
4733        );
4734
4735        let mut tool_descs: Vec<(&str, &str)> = vec![
4736            ("shell", "Execute terminal commands."),
4737            ("file_read", "Read file contents."),
4738            ("file_write", "Write file contents."),
4739            ("memory_store", "Save to memory."),
4740            ("memory_recall", "Search memory."),
4741            ("memory_forget", "Delete a memory entry."),
4742            (
4743                "model_routing_config",
4744                "Configure default model, scenario routing, and delegate agents.",
4745            ),
4746            ("screenshot", "Capture a screenshot."),
4747            ("image_info", "Read image metadata."),
4748        ];
4749        if matches!(
4750            config.skills.prompt_injection_mode,
4751            zeroclaw_config::schema::SkillsPromptInjectionMode::Compact
4752        ) {
4753            tool_descs.push((
4754                "read_skill",
4755                "Load the full source for an available skill by name.",
4756            ));
4757        }
4758        if config.browser.enabled {
4759            tool_descs.push(("browser_open", "Open approved URLs in browser."));
4760        }
4761        if config.composio.enabled {
4762            tool_descs.push(("composio", "Execute actions on 1000+ apps via Composio."));
4763        }
4764        if config.peripherals.enabled && !config.peripherals.boards.is_empty() {
4765            tool_descs.push(("gpio_read", "Read GPIO pin value on connected hardware."));
4766            tool_descs.push((
4767                "gpio_write",
4768                "Set GPIO pin high or low on connected hardware.",
4769            ));
4770            tool_descs.push((
4771            "arduino_upload",
4772            "Upload Arduino sketch. Use for 'make a heart', custom patterns. You write full .ino code; ZeroClaw uploads it.",
4773        ));
4774            tool_descs.push((
4775            "hardware_memory_map",
4776            "Return flash and RAM address ranges. Use when user asks for memory addresses or memory map.",
4777        ));
4778            tool_descs.push((
4779            "hardware_board_info",
4780            "Return full board info (chip, architecture, memory map). Use when user asks for board info, what board, connected hardware, or chip info.",
4781        ));
4782            tool_descs.push((
4783            "hardware_memory_read",
4784            "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.",
4785        ));
4786            tool_descs.push((
4787            "hardware_capabilities",
4788            "Query connected hardware for reported GPIO pins and LED pin. Use when user asks what pins are available.",
4789        ));
4790        }
4791
4792        // ── Compute final effective tool set BEFORE prompt construction ──
4793        // This ensures the system prompt, tool instructions, and channel target
4794        // injection all reflect the same policy-filtered tool set that will be
4795        // used at execution time. Without this, the prompt could advertise
4796        // tools (and their target identifiers) that the execution denylist
4797        // would block — a control boundary violation.
4798        //
4799        // Note: compute_excluded_mcp_tools uses the raw message here (before
4800        // thinking directive stripping). This is safe — dynamic tool filter
4801        // keyword matching works the same, and risk-profile excluded_tools
4802        // are message-independent.
4803        let mut excluded_tools =
4804            compute_excluded_mcp_tools(&tools_registry, &agent.tool_filter_groups, message);
4805        {
4806            let active_profile = &risk_profile;
4807            if active_profile.level != AutonomyLevel::Full {
4808                excluded_tools.extend(active_profile.excluded_tools.iter().cloned());
4809            }
4810        }
4811
4812        // Filter tool descriptions to match the effective set.
4813        tool_descs.retain(|(name, _)| !excluded_tools.iter().any(|ex| ex == name));
4814
4815        // Derive effective tool names from the filtered set so prompt builders
4816        // and channel target guards see the correct state.
4817        let effective_tool_names: HashSet<&str> = tools_registry
4818            .iter()
4819            .map(|tool| tool.name())
4820            .filter(|name| !excluded_tools.iter().any(|ex| ex == *name))
4821            .collect();
4822        tool_descs.retain(|(name, _)| effective_tool_names.contains(name));
4823
4824        let bootstrap_max_chars = if agent.compact_context {
4825            Some(6000)
4826        } else {
4827            None
4828        };
4829        let native_tools = model_provider.supports_native_tools();
4830        let expose_text_tool_protocol = apply_text_tool_prompt_policy(
4831            native_tools,
4832            agent.strict_tool_parsing,
4833            &mut tool_descs,
4834            &mut deferred_section,
4835        );
4836        let agent_workspace = config.agent_workspace_dir(agent_alias);
4837        let mut system_prompt =
4838            crate::agent::system_prompt::build_system_prompt_with_mode_and_autonomy(
4839                &agent_workspace,
4840                &model_name,
4841                &tool_descs,
4842                &skills,
4843                Some(&agent.identity),
4844                bootstrap_max_chars,
4845                Some(&risk_profile),
4846                native_tools,
4847                config.skills.prompt_injection_mode,
4848                agent.compact_context,
4849                agent.max_system_prompt_chars,
4850            );
4851        if expose_text_tool_protocol {
4852            system_prompt.push_str(&build_tool_instructions_for_names(
4853                &tools_registry,
4854                &effective_tool_names,
4855            ));
4856        }
4857        if !deferred_section.is_empty() {
4858            system_prompt.push('\n');
4859            system_prompt.push_str(&deferred_section);
4860        }
4861
4862        // Inject configured channel targets so the agent knows where to deliver outbound messages.
4863        // Only when channel_send is in the effective tool set — otherwise the prompt would
4864        // advertise a disabled tool's targets.
4865        if effective_tool_names.contains("channel_send")
4866            && let Some(channel_targets) = crate::channel_targets::build_channel_targets(&config)
4867        {
4868            system_prompt.push('\n');
4869            system_prompt.push_str(&channel_targets);
4870        }
4871
4872        // ── Parse thinking directive from user message ─────────────
4873        let (thinking_directive, effective_message) =
4874            match crate::agent::thinking::parse_thinking_directive(message) {
4875                Some((level, remaining)) => {
4876                    ::zeroclaw_log::record!(
4877                        INFO,
4878                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
4879                            .with_attrs(::serde_json::json!({"thinking_level": level})),
4880                        "Thinking directive parsed from message"
4881                    );
4882                    (Some(level), remaining)
4883                }
4884                None => (None, message.to_string()),
4885            };
4886        let thinking_level = crate::agent::thinking::resolve_thinking_level(
4887            thinking_directive,
4888            None,
4889            &agent.thinking,
4890        );
4891        let thinking_params = crate::agent::thinking::apply_thinking_level_with_config(
4892            thinking_level,
4893            &agent.thinking,
4894        );
4895        let effective_temperature: Option<f64> = config
4896            .first_model_provider()
4897            .and_then(|e| e.temperature)
4898            .map(|t| {
4899                crate::agent::thinking::clamp_temperature(
4900                    t + thinking_params.temperature_adjustment,
4901                )
4902            });
4903
4904        // Prepend thinking system prompt prefix when present.
4905        if let Some(ref prefix) = thinking_params.system_prompt_prefix {
4906            system_prompt = format!("{prefix}\n\n{system_prompt}");
4907        }
4908
4909        let effective_msg_ref = effective_message.as_str();
4910        if let Some(suggestion) = crate::skills::render_missing_skill_install_suggestion(
4911            effective_msg_ref,
4912            &skills,
4913            &config.data_dir,
4914            config.skills.install_suggestions.enabled,
4915        ) {
4916            return Ok(suggestion);
4917        }
4918
4919        // process_message is the channel entrypoint (Discord, Telegram, gateway,
4920        // etc.) — recall is scoped to the channel's session_id, so retrieving the
4921        // user's own Conversation history within their session is intended.
4922        let mem_context = build_context(
4923            mem.as_ref(),
4924            effective_msg_ref,
4925            config.memory.min_relevance_score,
4926            session_id,
4927            false,
4928        )
4929        .await;
4930        let rag_limit = if agent.compact_context { 2 } else { 5 };
4931        let hw_context = hardware_rag
4932            .as_ref()
4933            .map(|r| build_hardware_context(r, effective_msg_ref, &board_names, rag_limit))
4934            .unwrap_or_default();
4935        let context = format!("{mem_context}{hw_context}");
4936        let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z");
4937        let enriched = if context.is_empty() {
4938            format!("[{now}] {effective_message}")
4939        } else {
4940            format!("{context}[{now}] {effective_message}")
4941        };
4942
4943        let mut history = vec![
4944            ChatMessage::system(&system_prompt),
4945            ChatMessage::user(&enriched),
4946        ];
4947        // excluded_tools was already computed above for prompt construction.
4948        // Re-use it here for execution — no need to recompute.
4949
4950        zeroclaw_api::NATIVE_THINKING_OVERRIDE
4951            .scope(
4952                thinking_params.native_thinking,
4953                agent_turn(
4954                    model_provider.as_ref(),
4955                    &mut history,
4956                    &tools_registry,
4957                    observer.as_ref(),
4958                    provider_name,
4959                    &model_name,
4960                    effective_temperature,
4961                    true,
4962                    "daemon",
4963                    None,
4964                    &config.multimodal,
4965                    agent.max_tool_iterations,
4966                    Some(&approval_manager),
4967                    &excluded_tools,
4968                    &agent.tool_call_dedup_exempt,
4969                    activated_handle_pm.as_ref(),
4970                    None,
4971                    agent.strict_tool_parsing,
4972                    None, // channel: process_message path has no channel ref
4973                ),
4974            )
4975            .await
4976    };
4977    __zc_body
4978        .instrument(__zc_scope_span)
4979        .instrument(__zc_attribution_span)
4980        .await
4981}
4982
4983#[cfg(test)]
4984mod tests {
4985    use super::{
4986        apply_text_tool_prompt_policy, emergency_history_trim, estimate_history_tokens,
4987        fast_trim_tool_results, load_interactive_session_history, save_interactive_session_history,
4988        truncate_tool_result,
4989    };
4990    use crate::agent::history::{DEFAULT_MAX_HISTORY_MESSAGES, InteractiveSessionState};
4991    use crate::agent::tool_execution::execute_one_tool;
4992    use tempfile::tempdir;
4993    use zeroclaw_providers::ChatMessage;
4994    use zeroclaw_tool_call_parser::parse_tool_calls;
4995
4996    zeroclaw_api::mock_tool_attribution!(
4997        CountingTool,
4998        EmptySuccessTool,
4999        RecordingArgsTool,
5000        DelayTool,
5001        FailingTool,
5002        NamedMockTool,
5003    );
5004
5005    // ── truncate_tool_result tests ────────────────────────────────
5006
5007    #[test]
5008    fn truncate_tool_result_short_passthrough() {
5009        let output = "short output";
5010        assert_eq!(truncate_tool_result(output, 100), output);
5011    }
5012
5013    #[test]
5014    fn truncate_tool_result_exact_boundary() {
5015        let output = "a".repeat(100);
5016        assert_eq!(truncate_tool_result(&output, 100), output);
5017    }
5018
5019    #[test]
5020    fn truncate_tool_result_zero_disables() {
5021        let output = "a".repeat(200_000);
5022        assert_eq!(truncate_tool_result(&output, 0), output);
5023    }
5024
5025    #[test]
5026    fn truncate_tool_result_truncates_with_marker() {
5027        let output = "a".repeat(200);
5028        let result = truncate_tool_result(&output, 100);
5029        assert!(result.contains("[... "));
5030        assert!(result.contains("characters truncated ...]\n\n"));
5031        // Head should be ~2/3 of 100 = 66, tail ~1/3 = 34
5032        assert!(result.starts_with("aaa"));
5033        assert!(result.ends_with("aaa"));
5034        // Result should be shorter than original
5035        assert!(result.len() < output.len());
5036    }
5037
5038    #[test]
5039    fn truncate_tool_result_preserves_head_tail_ratio() {
5040        let output: String = (0u32..1000)
5041            .map(|i| char::from(b'a' + (i % 26) as u8))
5042            .collect();
5043        let result = truncate_tool_result(&output, 300);
5044        // Head = 2/3 of 300 = 200 chars, tail = 100 chars
5045        // Find the marker
5046        let marker_start = result.find("[... ").unwrap();
5047        let marker_end = result.find("characters truncated ...]\n\n").unwrap()
5048            + "characters truncated ...]\n\n".len();
5049        let head = &result[..marker_start - 2]; // subtract \n\n
5050        let tail = &result[marker_end..];
5051        assert!(
5052            head.len() >= 190 && head.len() <= 210,
5053            "head len={}",
5054            head.len()
5055        );
5056        assert!(
5057            tail.len() >= 90 && tail.len() <= 110,
5058            "tail len={}",
5059            tail.len()
5060        );
5061    }
5062
5063    #[test]
5064    fn truncate_tool_result_utf8_boundary_safety() {
5065        // Create string with multi-byte chars: each emoji is 4 bytes
5066        let output = "🦀".repeat(100); // 400 bytes
5067        // This should not panic even with a limit that falls mid-char
5068        let result = truncate_tool_result(&output, 50);
5069        assert!(result.contains("[... "));
5070        // Verify the result is valid UTF-8 (would panic otherwise)
5071        let _ = result.len();
5072    }
5073
5074    #[test]
5075    fn truncate_tool_result_very_small_max() {
5076        let output = "abcdefghijklmnopqrstuvwxyz";
5077        // With max=5, head=3 tail=2 — result includes marker overhead
5078        // but should not panic and should contain truncation marker
5079        let result = truncate_tool_result(output, 5);
5080        assert!(result.contains("[... "));
5081        // Head (3 chars) + tail (2 chars) from original should be preserved
5082        assert!(result.starts_with("abc"));
5083        assert!(result.ends_with("yz"));
5084    }
5085
5086    // ── truncate_tool_message tests ─────────────────────────────
5087
5088    #[test]
5089    fn truncate_tool_message_preserves_json_structure() {
5090        use crate::agent::history::truncate_tool_message;
5091        let big_content = "x".repeat(5000);
5092        let msg = serde_json::json!({
5093            "tool_call_id": "call_abc123",
5094            "content": big_content,
5095        })
5096        .to_string();
5097        let result = truncate_tool_message(&msg, 2000);
5098        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
5099        assert_eq!(parsed["tool_call_id"], "call_abc123");
5100        assert!(parsed["content"].as_str().unwrap().contains("[... "));
5101    }
5102
5103    #[test]
5104    fn truncate_tool_message_plain_text_fallback() {
5105        use crate::agent::history::truncate_tool_message;
5106        let plain = "a".repeat(5000);
5107        let result = truncate_tool_message(&plain, 2000);
5108        assert!(result.contains("[... "));
5109        assert!(result.len() < 5000);
5110    }
5111
5112    #[test]
5113    fn truncate_tool_message_short_passthrough() {
5114        use crate::agent::history::truncate_tool_message;
5115        let msg = r#"{"tool_call_id":"call_1","content":"ok"}"#;
5116        assert_eq!(truncate_tool_message(msg, 2000), msg);
5117    }
5118
5119    // ── fast_trim_tool_results tests ────────────────────────────
5120
5121    #[test]
5122    fn fast_trim_protects_recent_messages() {
5123        let mut history = vec![
5124            ChatMessage::system("sys"),
5125            ChatMessage::tool("a".repeat(5000)),
5126            ChatMessage::tool("b".repeat(5000)),
5127            ChatMessage::user("recent user msg"),
5128            ChatMessage::tool("c".repeat(5000)), // recent, should be protected
5129        ];
5130        // protect_last_n = 2 → last 2 messages protected
5131        let saved = fast_trim_tool_results(&mut history, 2);
5132        assert!(saved > 0);
5133        // First two tool messages should be trimmed
5134        assert!(history[1].content.len() <= 2100);
5135        assert!(history[2].content.len() <= 2100);
5136        // Last tool message (protected) should be unchanged
5137        assert_eq!(history[4].content.len(), 5000);
5138    }
5139
5140    #[test]
5141    fn fast_trim_skips_non_tool_messages() {
5142        let mut history = vec![
5143            ChatMessage::system("sys"),
5144            ChatMessage::user("a".repeat(5000)),
5145            ChatMessage::assistant("b".repeat(5000)),
5146        ];
5147        let saved = fast_trim_tool_results(&mut history, 0);
5148        assert_eq!(saved, 0);
5149        assert_eq!(history[1].content.len(), 5000);
5150        assert_eq!(history[2].content.len(), 5000);
5151    }
5152
5153    #[test]
5154    fn fast_trim_small_tool_results_unchanged() {
5155        let mut history = vec![
5156            ChatMessage::system("sys"),
5157            ChatMessage::tool("short result"),
5158        ];
5159        let saved = fast_trim_tool_results(&mut history, 0);
5160        assert_eq!(saved, 0);
5161        assert_eq!(history[1].content, "short result");
5162    }
5163
5164    // ── emergency_history_trim tests ──────────────────────────────
5165
5166    #[test]
5167    fn emergency_trim_preserves_system() {
5168        let mut history = vec![
5169            ChatMessage::system("sys"),
5170            ChatMessage::user("msg1"),
5171            ChatMessage::assistant("resp1"),
5172            ChatMessage::user("msg2"),
5173            ChatMessage::assistant("resp2"),
5174            ChatMessage::user("msg3"),
5175        ];
5176        let dropped = emergency_history_trim(&mut history, 2);
5177        assert!(dropped > 0);
5178        // System message should always be preserved
5179        assert_eq!(history[0].role, "system");
5180        assert_eq!(history[0].content, "sys");
5181        // Last 2 messages should be preserved
5182        let len = history.len();
5183        assert_eq!(history[len - 1].content, "msg3");
5184    }
5185
5186    #[test]
5187    fn emergency_trim_preserves_recent() {
5188        let mut history = vec![
5189            ChatMessage::system("sys"),
5190            ChatMessage::user("old1"),
5191            ChatMessage::user("old2"),
5192            ChatMessage::user("recent1"),
5193            ChatMessage::user("recent2"),
5194        ];
5195        let dropped = emergency_history_trim(&mut history, 2);
5196        assert!(dropped > 0);
5197        // Last 2 should be preserved
5198        let len = history.len();
5199        assert_eq!(history[len - 1].content, "recent2");
5200        assert_eq!(history[len - 2].content, "recent1");
5201    }
5202
5203    #[test]
5204    fn emergency_trim_nothing_to_drop() {
5205        let mut history = vec![
5206            ChatMessage::system("sys"),
5207            ChatMessage::user("only user msg"),
5208        ];
5209        // protect_last = 1, system is protected → only 1 droppable
5210        // target_drop = 2/3 = 0 → nothing dropped
5211        let dropped = emergency_history_trim(&mut history, 1);
5212        assert_eq!(dropped, 0);
5213    }
5214
5215    // ── estimate_history_tokens tests ─────────────────────────────
5216
5217    #[test]
5218    fn estimate_tokens_empty_history() {
5219        let history: Vec<ChatMessage> = vec![];
5220        assert_eq!(estimate_history_tokens(&history), 0);
5221    }
5222
5223    #[test]
5224    fn estimate_tokens_single_message() {
5225        // 40 chars → 40.div_ceil(4) + 4 = 10 + 4 = 14 tokens
5226        let msg = "a".repeat(40);
5227        let history = vec![ChatMessage::user(&msg)];
5228        let est = estimate_history_tokens(&history);
5229        assert_eq!(est, 14);
5230    }
5231
5232    #[test]
5233    fn estimate_tokens_multiple_messages() {
5234        let history = vec![
5235            ChatMessage::system("system prompt here"), // 18 chars → 18/4=4 +4=8 (div_ceil: 5+4=9)
5236            ChatMessage::user("hello"),                // 5 chars → 5/4=1 +4=5 (div_ceil: 2+4=6)
5237            ChatMessage::assistant("world"),           // 5 chars → 5/4=1 +4=5 (div_ceil: 2+4=6)
5238        ];
5239        let est = estimate_history_tokens(&history);
5240        // Each message: content_len.div_ceil(4) + 4
5241        // 18.div_ceil(4)=5, 5.div_ceil(4)=2, 5.div_ceil(4)=2 → 5+4 + 2+4 + 2+4 = 21
5242        assert_eq!(est, 21);
5243    }
5244
5245    #[test]
5246    fn estimate_tokens_large_tool_result() {
5247        let big = "x".repeat(40_000);
5248        let history = vec![ChatMessage::tool(&big)];
5249        let est = estimate_history_tokens(&history);
5250        // 40000.div_ceil(4) + 4 = 10000 + 4 = 10004
5251        assert_eq!(est, 10_004);
5252    }
5253
5254    // ── shared_budget tests ───────────────────────────────────────
5255
5256    #[test]
5257    fn shared_budget_decrement_logic() {
5258        use std::sync::Arc;
5259        use std::sync::atomic::{AtomicUsize, Ordering};
5260
5261        let budget = Arc::new(AtomicUsize::new(3));
5262
5263        // Simulate 3 iterations decrementing
5264        for i in 0..3 {
5265            let remaining = budget.load(Ordering::Relaxed);
5266            assert!(remaining > 0, "Budget should be >0 at iteration {i}");
5267            budget.fetch_sub(1, Ordering::Relaxed);
5268        }
5269
5270        // Budget should now be 0
5271        assert_eq!(budget.load(Ordering::Relaxed), 0);
5272    }
5273
5274    #[test]
5275    fn shared_budget_none_has_no_effect() {
5276        // When shared_budget is None, the check is simply skipped
5277        let budget: Option<Arc<std::sync::atomic::AtomicUsize>> = None;
5278        assert!(budget.is_none());
5279    }
5280
5281    // ── existing tests ────────────────────────────────────────────
5282
5283    #[test]
5284    fn interactive_session_state_round_trips_history() {
5285        let dir = tempdir().unwrap();
5286        let path = dir.path().join("session.json");
5287        let history = vec![
5288            ChatMessage::system("system"),
5289            ChatMessage::user("hello"),
5290            ChatMessage::assistant("hi"),
5291        ];
5292
5293        save_interactive_session_history(&path, &history).unwrap();
5294        let restored = load_interactive_session_history(&path, "fallback").unwrap();
5295
5296        assert_eq!(restored.len(), 3);
5297        assert_eq!(restored[0].role, "system");
5298        assert_eq!(restored[1].content, "hello");
5299        assert_eq!(restored[2].content, "hi");
5300    }
5301
5302    #[test]
5303    fn interactive_session_state_adds_missing_system_prompt() {
5304        let dir = tempdir().unwrap();
5305        let path = dir.path().join("session.json");
5306        let payload = serde_json::to_string_pretty(&InteractiveSessionState {
5307            version: 1,
5308            history: vec![ChatMessage::user("orphan")],
5309        })
5310        .unwrap();
5311        std::fs::write(&path, payload).unwrap();
5312
5313        let restored = load_interactive_session_history(&path, "fallback system").unwrap();
5314
5315        assert_eq!(restored[0].role, "system");
5316        assert_eq!(restored[0].content, "fallback system");
5317        assert_eq!(restored[1].content, "orphan");
5318    }
5319
5320    #[test]
5321    fn load_interactive_session_merges_non_leading_system_messages() {
5322        let dir = tempdir().unwrap();
5323        let path = dir.path().join("session.json");
5324        let payload = serde_json::to_string_pretty(&InteractiveSessionState {
5325            version: 1,
5326            history: vec![
5327                ChatMessage::system("base system"),
5328                ChatMessage::user("first question"),
5329                ChatMessage::assistant("first answer"),
5330                ChatMessage::system("late loop-detection guidance"),
5331                ChatMessage::user("follow-up"),
5332            ],
5333        })
5334        .unwrap();
5335        std::fs::write(&path, payload).unwrap();
5336
5337        let restored = load_interactive_session_history(&path, "fallback").unwrap();
5338
5339        assert_eq!(
5340            restored
5341                .iter()
5342                .filter(|message| message.role == "system")
5343                .count(),
5344            1,
5345            "loaded session must not contain non-leading system messages: {:?}",
5346            restored
5347                .iter()
5348                .map(|message| message.role.as_str())
5349                .collect::<Vec<_>>()
5350        );
5351        assert_eq!(restored[0].role, "system");
5352        assert!(restored[0].content.contains("base system"));
5353        assert!(restored[0].content.contains("late loop-detection guidance"));
5354        assert_eq!(
5355            restored
5356                .iter()
5357                .map(|message| message.role.as_str())
5358                .collect::<Vec<_>>(),
5359            vec!["system", "user", "assistant", "user"]
5360        );
5361    }
5362
5363    #[test]
5364    fn load_interactive_session_replaces_empty_system_messages_with_fallback() {
5365        let dir = tempdir().unwrap();
5366        let path = dir.path().join("session.json");
5367        let payload = serde_json::to_string_pretty(&InteractiveSessionState {
5368            version: 1,
5369            history: vec![
5370                ChatMessage::system(""),
5371                ChatMessage::user("follow-up"),
5372                ChatMessage::system(""),
5373            ],
5374        })
5375        .unwrap();
5376        std::fs::write(&path, payload).unwrap();
5377
5378        let restored = load_interactive_session_history(&path, "fallback system").unwrap();
5379
5380        assert_eq!(
5381            restored
5382                .iter()
5383                .map(|message| (message.role.as_str(), message.content.as_str()))
5384                .collect::<Vec<_>>(),
5385            vec![("system", "fallback system"), ("user", "follow-up")]
5386        );
5387    }
5388
5389    /// Regression test for issue #5813: a persisted session whose assistant
5390    /// (tool_use) was lost to compaction must self-heal on load so the next
5391    /// API call doesn't fail with "unexpected tool_use_id found in tool_result
5392    /// blocks".
5393    #[test]
5394    fn load_interactive_session_heals_orphaned_tool_result() {
5395        let dir = tempdir().unwrap();
5396        let path = dir.path().join("session.json");
5397        let orphan_tool = ChatMessage::tool(
5398            r#"{"tool_call_id":"toolu_01OrphanFromCompaction","content":"stale result"}"#,
5399        );
5400        let payload = serde_json::to_string_pretty(&InteractiveSessionState {
5401            version: 1,
5402            history: vec![
5403                ChatMessage::system("sys"),
5404                orphan_tool,
5405                ChatMessage::user("next question"),
5406            ],
5407        })
5408        .unwrap();
5409        std::fs::write(&path, payload).unwrap();
5410
5411        let restored = load_interactive_session_history(&path, "fallback").unwrap();
5412
5413        assert!(
5414            !restored.iter().any(|m| m.role == "tool"),
5415            "orphaned tool_result should be removed on load; got roles {:?}",
5416            restored.iter().map(|m| &m.role).collect::<Vec<_>>()
5417        );
5418    }
5419
5420    use super::*;
5421    use async_trait::async_trait;
5422    use base64::{Engine as _, engine::general_purpose::STANDARD};
5423    use std::collections::VecDeque;
5424    use std::sync::atomic::{AtomicUsize, Ordering};
5425    use std::sync::{Arc, Mutex};
5426    use std::time::Duration;
5427
5428    #[test]
5429    fn scrub_credentials_redacts_bearer_token() {
5430        let input = "API_KEY=sk-1234567890abcdef; token: 1234567890; password=\"secret123456\"";
5431        let scrubbed = scrub_credentials(input);
5432        assert!(scrubbed.contains("API_KEY=sk-1*[REDACTED]"));
5433        assert!(scrubbed.contains("token: 1234*[REDACTED]"));
5434        assert!(scrubbed.contains("password=\"secr*[REDACTED]\""));
5435        assert!(!scrubbed.contains("abcdef"));
5436        assert!(!scrubbed.contains("secret123456"));
5437    }
5438
5439    #[test]
5440    fn scrub_credentials_redacts_json_api_key() {
5441        let input = r#"{"api_key": "sk-1234567890", "other": "public"}"#;
5442        let scrubbed = scrub_credentials(input);
5443        assert!(scrubbed.contains("\"api_key\": \"sk-1*[REDACTED]\""));
5444        assert!(scrubbed.contains("public"));
5445    }
5446
5447    #[tokio::test]
5448    async fn execute_one_tool_does_not_panic_on_utf8_boundary() {
5449        let call_arguments = (0..600)
5450            .map(|n| serde_json::json!({ "content": format!("{}:tail", "a".repeat(n)) }))
5451            .find(|args| {
5452                let raw = args.to_string();
5453                raw.len() > 300 && !raw.is_char_boundary(300)
5454            })
5455            .expect("should produce a sample whose byte index 300 is not a char boundary");
5456
5457        let observer = NoopObserver;
5458        let result = execute_one_tool(
5459            "unknown_tool",
5460            call_arguments,
5461            None,
5462            &[],
5463            None,
5464            &observer,
5465            None,
5466            None,
5467        )
5468        .await;
5469        assert!(result.is_ok(), "execute_one_tool should not panic or error");
5470
5471        let outcome = result.unwrap();
5472        assert!(!outcome.success);
5473        assert!(outcome.output.contains("Unknown tool: unknown_tool"));
5474    }
5475
5476    #[tokio::test]
5477    async fn execute_one_tool_resolves_unique_activated_tool_suffix() {
5478        let observer = NoopObserver;
5479        let invocations = Arc::new(AtomicUsize::new(0));
5480        let activated = Arc::new(std::sync::Mutex::new(crate::tools::ActivatedToolSet::new()));
5481        let activated_tool: Arc<dyn Tool> = Arc::new(CountingTool::new(
5482            "docker-mcp__extract_text",
5483            Arc::clone(&invocations),
5484        ));
5485        activated
5486            .lock()
5487            .unwrap()
5488            .activate("docker-mcp__extract_text".into(), activated_tool);
5489
5490        let outcome = execute_one_tool(
5491            "extract_text",
5492            serde_json::json!({ "value": "ok" }),
5493            None,
5494            &[],
5495            Some(&activated),
5496            &observer,
5497            None,
5498            None, // receipt_generator
5499        )
5500        .await
5501        .expect("suffix alias should execute the unique activated tool");
5502
5503        assert!(outcome.success);
5504        assert_eq!(outcome.output, "counted:ok");
5505        assert_eq!(invocations.load(Ordering::SeqCst), 1);
5506    }
5507
5508    #[tokio::test]
5509    async fn execute_one_tool_normalizes_empty_success_output() {
5510        let observer = NoopObserver;
5511        let tools: Vec<Box<dyn Tool>> = vec![Box::new(EmptySuccessTool)];
5512
5513        let outcome = execute_one_tool(
5514            "empty_success",
5515            serde_json::json!({}),
5516            None,
5517            &tools,
5518            None,
5519            &observer,
5520            None,
5521            None, // receipt_generator
5522        )
5523        .await
5524        .expect("empty successful tool output should still execute");
5525
5526        assert!(outcome.success);
5527        assert_eq!(outcome.output, "(no output)");
5528        assert!(outcome.error_reason.is_none());
5529    }
5530    use crate::observability::NoopObserver;
5531    use tempfile::TempDir;
5532    use zeroclaw_api::model_provider::{
5533        ProviderCapabilities, StreamChunk, StreamEvent, StreamOptions,
5534    };
5535    use zeroclaw_memory::{Memory, MemoryCategory, SqliteMemory};
5536    use zeroclaw_providers::ChatResponse;
5537    use zeroclaw_providers::router::{Route, RouterModelProvider};
5538
5539    macro_rules! impl_test_model_provider_attribution {
5540        ($ty:ty) => {
5541            impl ::zeroclaw_api::attribution::Attributable for $ty {
5542                fn role(&self) -> ::zeroclaw_api::attribution::Role {
5543                    ::zeroclaw_api::attribution::Role::Provider(
5544                        ::zeroclaw_api::attribution::ProviderKind::Model(
5545                            ::zeroclaw_api::attribution::ModelProviderKind::Custom,
5546                        ),
5547                    )
5548                }
5549
5550                fn alias(&self) -> &str {
5551                    stringify!($ty)
5552                }
5553            }
5554        };
5555    }
5556
5557    struct NonVisionModelProvider {
5558        calls: Arc<AtomicUsize>,
5559    }
5560
5561    #[async_trait]
5562    impl ModelProvider for NonVisionModelProvider {
5563        async fn chat_with_system(
5564            &self,
5565            _system_prompt: Option<&str>,
5566            _message: &str,
5567            _model: &str,
5568            _temperature: Option<f64>,
5569        ) -> anyhow::Result<String> {
5570            self.calls.fetch_add(1, Ordering::SeqCst);
5571            Ok("ok".to_string())
5572        }
5573    }
5574    impl ::zeroclaw_api::attribution::Attributable for NonVisionModelProvider {
5575        fn role(&self) -> ::zeroclaw_api::attribution::Role {
5576            ::zeroclaw_api::attribution::Role::Provider(
5577                ::zeroclaw_api::attribution::ProviderKind::Model(
5578                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
5579                ),
5580            )
5581        }
5582        fn alias(&self) -> &str {
5583            "NonVisionModelProvider"
5584        }
5585    }
5586
5587    struct VisionModelProvider {
5588        calls: Arc<AtomicUsize>,
5589    }
5590
5591    #[async_trait]
5592    impl ModelProvider for VisionModelProvider {
5593        fn capabilities(&self) -> ProviderCapabilities {
5594            ProviderCapabilities {
5595                native_tool_calling: false,
5596                vision: true,
5597                prompt_caching: false,
5598                extended_thinking: false,
5599            }
5600        }
5601
5602        async fn chat_with_system(
5603            &self,
5604            _system_prompt: Option<&str>,
5605            _message: &str,
5606            _model: &str,
5607            _temperature: Option<f64>,
5608        ) -> anyhow::Result<String> {
5609            self.calls.fetch_add(1, Ordering::SeqCst);
5610            Ok("ok".to_string())
5611        }
5612
5613        async fn chat(
5614            &self,
5615            request: ChatRequest<'_>,
5616            _model: &str,
5617            _temperature: Option<f64>,
5618        ) -> anyhow::Result<ChatResponse> {
5619            self.calls.fetch_add(1, Ordering::SeqCst);
5620            let marker_count =
5621                zeroclaw_providers::multimodal::count_image_markers(request.messages);
5622            if marker_count == 0 {
5623                anyhow::bail!("expected image markers in request messages");
5624            }
5625
5626            if request.tools.is_some() {
5627                anyhow::bail!("no tools should be attached for this test");
5628            }
5629
5630            Ok(ChatResponse {
5631                text: Some("vision-ok".to_string()),
5632                tool_calls: Vec::new(),
5633                usage: None,
5634                reasoning_content: None,
5635            })
5636        }
5637    }
5638    impl ::zeroclaw_api::attribution::Attributable for VisionModelProvider {
5639        fn role(&self) -> ::zeroclaw_api::attribution::Role {
5640            ::zeroclaw_api::attribution::Role::Provider(
5641                ::zeroclaw_api::attribution::ProviderKind::Model(
5642                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
5643                ),
5644            )
5645        }
5646        fn alias(&self) -> &str {
5647            "VisionModelProvider"
5648        }
5649    }
5650
5651    struct ScriptedModelProvider {
5652        responses: Arc<Mutex<VecDeque<ChatResponse>>>,
5653        capabilities: ProviderCapabilities,
5654    }
5655
5656    impl ScriptedModelProvider {
5657        fn from_text_responses(responses: Vec<&str>) -> Self {
5658            let scripted = responses
5659                .into_iter()
5660                .map(|text| ChatResponse {
5661                    text: Some(text.to_string()),
5662                    tool_calls: Vec::new(),
5663                    usage: None,
5664                    reasoning_content: None,
5665                })
5666                .collect();
5667            Self {
5668                responses: Arc::new(Mutex::new(scripted)),
5669                capabilities: ProviderCapabilities::default(),
5670            }
5671        }
5672
5673        fn with_native_tool_support(mut self) -> Self {
5674            self.capabilities.native_tool_calling = true;
5675            self
5676        }
5677    }
5678
5679    #[async_trait]
5680    impl ModelProvider for ScriptedModelProvider {
5681        fn capabilities(&self) -> ProviderCapabilities {
5682            self.capabilities.clone()
5683        }
5684
5685        async fn chat_with_system(
5686            &self,
5687            _system_prompt: Option<&str>,
5688            _message: &str,
5689            _model: &str,
5690            _temperature: Option<f64>,
5691        ) -> anyhow::Result<String> {
5692            anyhow::bail!("chat_with_system should not be used in scripted model_provider tests");
5693        }
5694
5695        async fn chat(
5696            &self,
5697            _request: ChatRequest<'_>,
5698            _model: &str,
5699            _temperature: Option<f64>,
5700        ) -> anyhow::Result<ChatResponse> {
5701            let mut responses = self
5702                .responses
5703                .lock()
5704                .expect("responses lock should be valid");
5705            responses
5706                .pop_front()
5707                .ok_or_else(|| anyhow::Error::msg("scripted model_provider exhausted responses"))
5708        }
5709    }
5710    impl ::zeroclaw_api::attribution::Attributable for ScriptedModelProvider {
5711        fn role(&self) -> ::zeroclaw_api::attribution::Role {
5712            ::zeroclaw_api::attribution::Role::Provider(
5713                ::zeroclaw_api::attribution::ProviderKind::Model(
5714                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
5715                ),
5716            )
5717        }
5718        fn alias(&self) -> &str {
5719            "ScriptedModelProvider"
5720        }
5721    }
5722
5723    struct RecordingModelProvider {
5724        requests: Arc<Mutex<Vec<Vec<ChatMessage>>>>,
5725        capabilities: ProviderCapabilities,
5726    }
5727
5728    impl RecordingModelProvider {
5729        fn new() -> Self {
5730            Self {
5731                requests: Arc::new(Mutex::new(Vec::new())),
5732                capabilities: ProviderCapabilities::default(),
5733            }
5734        }
5735
5736        fn with_vision_support(mut self) -> Self {
5737            self.capabilities.vision = true;
5738            self
5739        }
5740    }
5741
5742    #[async_trait]
5743    impl ModelProvider for RecordingModelProvider {
5744        fn capabilities(&self) -> ProviderCapabilities {
5745            self.capabilities.clone()
5746        }
5747
5748        async fn chat_with_system(
5749            &self,
5750            _system_prompt: Option<&str>,
5751            _message: &str,
5752            _model: &str,
5753            _temperature: Option<f64>,
5754        ) -> anyhow::Result<String> {
5755            anyhow::bail!("chat_with_system should not be used in recording provider tests");
5756        }
5757
5758        async fn chat(
5759            &self,
5760            request: ChatRequest<'_>,
5761            _model: &str,
5762            _temperature: Option<f64>,
5763        ) -> anyhow::Result<ChatResponse> {
5764            self.requests
5765                .lock()
5766                .expect("requests lock should be valid")
5767                .push(request.messages.to_vec());
5768            Ok(ChatResponse {
5769                text: Some("done".to_string()),
5770                tool_calls: Vec::new(),
5771                usage: None,
5772                reasoning_content: None,
5773            })
5774        }
5775    }
5776    impl ::zeroclaw_api::attribution::Attributable for RecordingModelProvider {
5777        fn role(&self) -> ::zeroclaw_api::attribution::Role {
5778            ::zeroclaw_api::attribution::Role::Provider(
5779                ::zeroclaw_api::attribution::ProviderKind::Model(
5780                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
5781                ),
5782            )
5783        }
5784        fn alias(&self) -> &str {
5785            "RecordingModelProvider"
5786        }
5787    }
5788
5789    struct StreamingScriptedModelProvider {
5790        responses: Arc<Mutex<VecDeque<String>>>,
5791        stream_calls: Arc<AtomicUsize>,
5792        chat_calls: Arc<AtomicUsize>,
5793    }
5794
5795    impl StreamingScriptedModelProvider {
5796        fn from_text_responses(responses: Vec<&str>) -> Self {
5797            Self {
5798                responses: Arc::new(Mutex::new(
5799                    responses.into_iter().map(ToString::to_string).collect(),
5800                )),
5801                stream_calls: Arc::new(AtomicUsize::new(0)),
5802                chat_calls: Arc::new(AtomicUsize::new(0)),
5803            }
5804        }
5805    }
5806
5807    #[async_trait]
5808    impl ModelProvider for StreamingScriptedModelProvider {
5809        async fn chat_with_system(
5810            &self,
5811            _system_prompt: Option<&str>,
5812            _message: &str,
5813            _model: &str,
5814            _temperature: Option<f64>,
5815        ) -> anyhow::Result<String> {
5816            anyhow::bail!(
5817                "chat_with_system should not be used in streaming scripted model_provider tests"
5818            );
5819        }
5820
5821        async fn chat(
5822            &self,
5823            _request: ChatRequest<'_>,
5824            _model: &str,
5825            _temperature: Option<f64>,
5826        ) -> anyhow::Result<ChatResponse> {
5827            self.chat_calls.fetch_add(1, Ordering::SeqCst);
5828            anyhow::bail!("chat should not be called when streaming succeeds")
5829        }
5830
5831        fn supports_streaming(&self) -> bool {
5832            true
5833        }
5834
5835        fn stream_chat_with_history(
5836            &self,
5837            _messages: &[ChatMessage],
5838            _model: &str,
5839            _temperature: Option<f64>,
5840            options: StreamOptions,
5841        ) -> futures_util::stream::BoxStream<
5842            'static,
5843            zeroclaw_providers::traits::StreamResult<StreamChunk>,
5844        > {
5845            self.stream_calls.fetch_add(1, Ordering::SeqCst);
5846            if !options.enabled {
5847                return Box::pin(futures_util::stream::empty());
5848            }
5849
5850            let response = self
5851                .responses
5852                .lock()
5853                .expect("responses lock should be valid")
5854                .pop_front()
5855                .unwrap_or_default();
5856
5857            Box::pin(futures_util::stream::iter(vec![
5858                Ok(StreamChunk::delta(response)),
5859                Ok(StreamChunk::final_chunk()),
5860            ]))
5861        }
5862    }
5863    impl ::zeroclaw_api::attribution::Attributable for StreamingScriptedModelProvider {
5864        fn role(&self) -> ::zeroclaw_api::attribution::Role {
5865            ::zeroclaw_api::attribution::Role::Provider(
5866                ::zeroclaw_api::attribution::ProviderKind::Model(
5867                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
5868                ),
5869            )
5870        }
5871        fn alias(&self) -> &str {
5872            "StreamingScriptedModelProvider"
5873        }
5874    }
5875
5876    enum NativeStreamTurn {
5877        ToolCall(ToolCall),
5878        Text(String),
5879        /// Emit a single text delta with associated reasoning content. Used by
5880        /// regression tests for issue #6059 (DeepSeek V4 thinking-mode replay).
5881        TextWithReasoning {
5882            text: String,
5883            reasoning: String,
5884        },
5885    }
5886
5887    struct StreamingNativeToolEventModelProvider {
5888        turns: Arc<Mutex<VecDeque<NativeStreamTurn>>>,
5889        stream_calls: Arc<AtomicUsize>,
5890        stream_tool_requests: Arc<AtomicUsize>,
5891        chat_calls: Arc<AtomicUsize>,
5892    }
5893
5894    impl StreamingNativeToolEventModelProvider {
5895        fn with_turns(turns: Vec<NativeStreamTurn>) -> Self {
5896            Self {
5897                turns: Arc::new(Mutex::new(turns.into())),
5898                stream_calls: Arc::new(AtomicUsize::new(0)),
5899                stream_tool_requests: Arc::new(AtomicUsize::new(0)),
5900                chat_calls: Arc::new(AtomicUsize::new(0)),
5901            }
5902        }
5903    }
5904
5905    #[async_trait]
5906    impl ModelProvider for StreamingNativeToolEventModelProvider {
5907        fn capabilities(&self) -> ProviderCapabilities {
5908            ProviderCapabilities {
5909                native_tool_calling: true,
5910                vision: false,
5911                prompt_caching: false,
5912                extended_thinking: false,
5913            }
5914        }
5915
5916        async fn chat_with_system(
5917            &self,
5918            _system_prompt: Option<&str>,
5919            _message: &str,
5920            _model: &str,
5921            _temperature: Option<f64>,
5922        ) -> anyhow::Result<String> {
5923            anyhow::bail!(
5924                "chat_with_system should not be used in streaming native tool event model_provider tests"
5925            );
5926        }
5927
5928        async fn chat(
5929            &self,
5930            _request: ChatRequest<'_>,
5931            _model: &str,
5932            _temperature: Option<f64>,
5933        ) -> anyhow::Result<ChatResponse> {
5934            self.chat_calls.fetch_add(1, Ordering::SeqCst);
5935            anyhow::bail!("chat should not be called when native streaming events succeed")
5936        }
5937
5938        fn supports_streaming(&self) -> bool {
5939            true
5940        }
5941
5942        fn supports_streaming_tool_events(&self) -> bool {
5943            true
5944        }
5945
5946        fn stream_chat(
5947            &self,
5948            request: ChatRequest<'_>,
5949            _model: &str,
5950            _temperature: Option<f64>,
5951            options: StreamOptions,
5952        ) -> futures_util::stream::BoxStream<
5953            'static,
5954            zeroclaw_providers::traits::StreamResult<StreamEvent>,
5955        > {
5956            self.stream_calls.fetch_add(1, Ordering::SeqCst);
5957            if request.tools.is_some_and(|tools| !tools.is_empty()) {
5958                self.stream_tool_requests.fetch_add(1, Ordering::SeqCst);
5959            }
5960            if !options.enabled {
5961                return Box::pin(futures_util::stream::empty());
5962            }
5963
5964            let turn = self
5965                .turns
5966                .lock()
5967                .expect("turns lock should be valid")
5968                .pop_front()
5969                .expect("streaming turns should have scripted output");
5970            match turn {
5971                NativeStreamTurn::ToolCall(tool_call) => {
5972                    Box::pin(futures_util::stream::iter(vec![
5973                        Ok(StreamEvent::ToolCall(tool_call)),
5974                        Ok(StreamEvent::Final),
5975                    ]))
5976                }
5977                NativeStreamTurn::Text(text) => Box::pin(futures_util::stream::iter(vec![
5978                    Ok(StreamEvent::TextDelta(StreamChunk::delta(text))),
5979                    Ok(StreamEvent::Final),
5980                ])),
5981                NativeStreamTurn::TextWithReasoning { text, reasoning } => {
5982                    Box::pin(futures_util::stream::iter(vec![
5983                        Ok(StreamEvent::TextDelta(StreamChunk::reasoning(reasoning))),
5984                        Ok(StreamEvent::TextDelta(StreamChunk::delta(text))),
5985                        Ok(StreamEvent::Final),
5986                    ]))
5987                }
5988            }
5989        }
5990    }
5991    impl ::zeroclaw_api::attribution::Attributable for StreamingNativeToolEventModelProvider {
5992        fn role(&self) -> ::zeroclaw_api::attribution::Role {
5993            ::zeroclaw_api::attribution::Role::Provider(
5994                ::zeroclaw_api::attribution::ProviderKind::Model(
5995                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
5996                ),
5997            )
5998        }
5999        fn alias(&self) -> &str {
6000            "StreamingNativeToolEventModelProvider"
6001        }
6002    }
6003
6004    struct RouteAwareStreamingModelProvider {
6005        response: String,
6006        stream_calls: Arc<AtomicUsize>,
6007        chat_calls: Arc<AtomicUsize>,
6008        last_model: Arc<Mutex<String>>,
6009    }
6010
6011    impl RouteAwareStreamingModelProvider {
6012        fn new(response: &str) -> Self {
6013            Self {
6014                response: response.to_string(),
6015                stream_calls: Arc::new(AtomicUsize::new(0)),
6016                chat_calls: Arc::new(AtomicUsize::new(0)),
6017                last_model: Arc::new(Mutex::new(String::new())),
6018            }
6019        }
6020    }
6021
6022    #[async_trait]
6023    impl ModelProvider for RouteAwareStreamingModelProvider {
6024        async fn chat_with_system(
6025            &self,
6026            _system_prompt: Option<&str>,
6027            _message: &str,
6028            _model: &str,
6029            _temperature: Option<f64>,
6030        ) -> anyhow::Result<String> {
6031            anyhow::bail!("chat_with_system should not be used in route-aware stream tests");
6032        }
6033
6034        async fn chat(
6035            &self,
6036            _request: ChatRequest<'_>,
6037            _model: &str,
6038            _temperature: Option<f64>,
6039        ) -> anyhow::Result<ChatResponse> {
6040            self.chat_calls.fetch_add(1, Ordering::SeqCst);
6041            anyhow::bail!("chat should not be called when routed streaming succeeds")
6042        }
6043
6044        fn supports_streaming(&self) -> bool {
6045            true
6046        }
6047
6048        fn stream_chat_with_history(
6049            &self,
6050            _messages: &[ChatMessage],
6051            model: &str,
6052            _temperature: Option<f64>,
6053            options: StreamOptions,
6054        ) -> futures_util::stream::BoxStream<
6055            'static,
6056            zeroclaw_providers::traits::StreamResult<StreamChunk>,
6057        > {
6058            self.stream_calls.fetch_add(1, Ordering::SeqCst);
6059            *self
6060                .last_model
6061                .lock()
6062                .expect("last_model lock should be valid") = model.to_string();
6063            if !options.enabled {
6064                return Box::pin(futures_util::stream::empty());
6065            }
6066
6067            Box::pin(futures_util::stream::iter(vec![
6068                Ok(StreamChunk::delta(self.response.clone())),
6069                Ok(StreamChunk::final_chunk()),
6070            ]))
6071        }
6072    }
6073    impl ::zeroclaw_api::attribution::Attributable for RouteAwareStreamingModelProvider {
6074        fn role(&self) -> ::zeroclaw_api::attribution::Role {
6075            ::zeroclaw_api::attribution::Role::Provider(
6076                ::zeroclaw_api::attribution::ProviderKind::Model(
6077                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
6078                ),
6079            )
6080        }
6081        fn alias(&self) -> &str {
6082            "RouteAwareStreamingModelProvider"
6083        }
6084    }
6085
6086    struct CountingTool {
6087        name: String,
6088        invocations: Arc<AtomicUsize>,
6089    }
6090
6091    impl CountingTool {
6092        fn new(name: &str, invocations: Arc<AtomicUsize>) -> Self {
6093            Self {
6094                name: name.to_string(),
6095                invocations,
6096            }
6097        }
6098    }
6099
6100    #[async_trait]
6101    impl Tool for CountingTool {
6102        fn name(&self) -> &str {
6103            &self.name
6104        }
6105
6106        fn description(&self) -> &str {
6107            "Counts executions for loop-stability tests"
6108        }
6109
6110        fn parameters_schema(&self) -> serde_json::Value {
6111            serde_json::json!({
6112                "type": "object",
6113                "properties": {
6114                    "value": { "type": "string" }
6115                }
6116            })
6117        }
6118
6119        async fn execute(
6120            &self,
6121            args: serde_json::Value,
6122        ) -> anyhow::Result<crate::tools::ToolResult> {
6123            self.invocations.fetch_add(1, Ordering::SeqCst);
6124            let value = args
6125                .get("value")
6126                .and_then(serde_json::Value::as_str)
6127                .unwrap_or_default();
6128            Ok(crate::tools::ToolResult {
6129                success: true,
6130                output: format!("counted:{value}"),
6131                error: None,
6132            })
6133        }
6134    }
6135
6136    struct EmptySuccessTool;
6137
6138    #[async_trait]
6139    impl Tool for EmptySuccessTool {
6140        fn name(&self) -> &str {
6141            "empty_success"
6142        }
6143
6144        fn description(&self) -> &str {
6145            "Returns success with no stdout"
6146        }
6147
6148        fn parameters_schema(&self) -> serde_json::Value {
6149            serde_json::json!({
6150                "type": "object",
6151                "properties": {}
6152            })
6153        }
6154
6155        async fn execute(
6156            &self,
6157            _args: serde_json::Value,
6158        ) -> anyhow::Result<crate::tools::ToolResult> {
6159            Ok(crate::tools::ToolResult {
6160                success: true,
6161                output: String::new(),
6162                error: None,
6163            })
6164        }
6165    }
6166
6167    struct RecordingArgsTool {
6168        name: String,
6169        recorded_args: Arc<Mutex<Vec<serde_json::Value>>>,
6170    }
6171
6172    impl RecordingArgsTool {
6173        fn new(name: &str, recorded_args: Arc<Mutex<Vec<serde_json::Value>>>) -> Self {
6174            Self {
6175                name: name.to_string(),
6176                recorded_args,
6177            }
6178        }
6179    }
6180
6181    #[async_trait]
6182    impl Tool for RecordingArgsTool {
6183        fn name(&self) -> &str {
6184            &self.name
6185        }
6186
6187        fn description(&self) -> &str {
6188            "Records tool arguments for regression tests"
6189        }
6190
6191        fn parameters_schema(&self) -> serde_json::Value {
6192            serde_json::json!({
6193                "type": "object",
6194                "properties": {
6195                    "prompt": { "type": "string" },
6196                    "schedule": { "type": "object" },
6197                    "delivery": { "type": "object" }
6198                }
6199            })
6200        }
6201
6202        async fn execute(
6203            &self,
6204            args: serde_json::Value,
6205        ) -> anyhow::Result<crate::tools::ToolResult> {
6206            self.recorded_args
6207                .lock()
6208                .expect("recorded args lock should be valid")
6209                .push(args.clone());
6210            Ok(crate::tools::ToolResult {
6211                success: true,
6212                output: args.to_string(),
6213                error: None,
6214            })
6215        }
6216    }
6217
6218    struct DelayTool {
6219        name: String,
6220        delay_ms: u64,
6221        active: Arc<AtomicUsize>,
6222        max_active: Arc<AtomicUsize>,
6223    }
6224
6225    impl DelayTool {
6226        fn new(
6227            name: &str,
6228            delay_ms: u64,
6229            active: Arc<AtomicUsize>,
6230            max_active: Arc<AtomicUsize>,
6231        ) -> Self {
6232            Self {
6233                name: name.to_string(),
6234                delay_ms,
6235                active,
6236                max_active,
6237            }
6238        }
6239    }
6240
6241    #[async_trait]
6242    impl Tool for DelayTool {
6243        fn name(&self) -> &str {
6244            &self.name
6245        }
6246
6247        fn description(&self) -> &str {
6248            "Delay tool for testing parallel tool execution"
6249        }
6250
6251        fn parameters_schema(&self) -> serde_json::Value {
6252            serde_json::json!({
6253                "type": "object",
6254                "properties": {
6255                    "value": { "type": "string" }
6256                },
6257                "required": ["value"]
6258            })
6259        }
6260
6261        async fn execute(
6262            &self,
6263            args: serde_json::Value,
6264        ) -> anyhow::Result<crate::tools::ToolResult> {
6265            let now_active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
6266            self.max_active.fetch_max(now_active, Ordering::SeqCst);
6267
6268            tokio::time::sleep(Duration::from_millis(self.delay_ms)).await;
6269
6270            self.active.fetch_sub(1, Ordering::SeqCst);
6271
6272            let value = args
6273                .get("value")
6274                .and_then(serde_json::Value::as_str)
6275                .unwrap_or_default()
6276                .to_string();
6277
6278            Ok(crate::tools::ToolResult {
6279                success: true,
6280                output: format!("ok:{value}"),
6281                error: None,
6282            })
6283        }
6284    }
6285
6286    /// A tool that always returns a failure with a given error reason.
6287    struct FailingTool {
6288        tool_name: String,
6289        error_reason: String,
6290    }
6291
6292    impl FailingTool {
6293        #[allow(dead_code)]
6294        fn new(name: &str, error_reason: &str) -> Self {
6295            Self {
6296                tool_name: name.to_string(),
6297                error_reason: error_reason.to_string(),
6298            }
6299        }
6300    }
6301
6302    #[async_trait]
6303    impl Tool for FailingTool {
6304        fn name(&self) -> &str {
6305            &self.tool_name
6306        }
6307
6308        fn description(&self) -> &str {
6309            "A tool that always fails for testing failure surfacing"
6310        }
6311
6312        fn parameters_schema(&self) -> serde_json::Value {
6313            serde_json::json!({
6314                "type": "object",
6315                "properties": {
6316                    "command": { "type": "string" }
6317                }
6318            })
6319        }
6320
6321        async fn execute(
6322            &self,
6323            _args: serde_json::Value,
6324        ) -> anyhow::Result<crate::tools::ToolResult> {
6325            Ok(crate::tools::ToolResult {
6326                success: false,
6327                output: String::new(),
6328                error: Some(self.error_reason.clone()),
6329            })
6330        }
6331    }
6332
6333    #[tokio::test]
6334    async fn run_tool_call_loop_returns_structured_error_for_non_vision_provider() {
6335        let calls = Arc::new(AtomicUsize::new(0));
6336        let model_provider = NonVisionModelProvider {
6337            calls: Arc::clone(&calls),
6338        };
6339
6340        let mut history = vec![ChatMessage::user(
6341            "please inspect [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
6342        )];
6343        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6344        let observer = NoopObserver;
6345
6346        let err = run_tool_call_loop(
6347            &model_provider,
6348            &mut history,
6349            &tools_registry,
6350            &observer,
6351            "mock-provider",
6352            "mock-model",
6353            Some(0.0),
6354            true,
6355            None,
6356            "cli",
6357            None,
6358            &zeroclaw_config::schema::MultimodalConfig::default(),
6359            3,
6360            None,
6361            None,
6362            None,
6363            &[],
6364            &[],
6365            None,
6366            None,
6367            &zeroclaw_config::schema::PacingConfig::default(),
6368            false,
6369            0,
6370            0,
6371            None,
6372            None, // channel
6373            None, // receipt_generator
6374            None, // collected_receipts
6375        )
6376        .await
6377        .expect_err("model_provider without vision support should fail");
6378
6379        assert!(err.to_string().contains("provider_capability_error"));
6380        assert!(err.to_string().contains("capability=vision"));
6381        assert_eq!(calls.load(Ordering::SeqCst), 0);
6382    }
6383
6384    #[tokio::test]
6385    async fn run_tool_call_loop_skips_oversized_image_payload() {
6386        let model_provider = RecordingModelProvider::new().with_vision_support();
6387        let recorded_requests = Arc::clone(&model_provider.requests);
6388
6389        let oversized_payload = STANDARD.encode(vec![0_u8; (1024 * 1024) + 1]);
6390        let mut history = vec![ChatMessage::user(format!(
6391            "[IMAGE:data:image/png;base64,{oversized_payload}]"
6392        ))];
6393
6394        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6395        let observer = NoopObserver;
6396        let multimodal = zeroclaw_config::schema::MultimodalConfig {
6397            max_images: 4,
6398            max_image_size_mb: 1,
6399            allow_remote_fetch: false,
6400            ..Default::default()
6401        };
6402
6403        let result = run_tool_call_loop(
6404            &model_provider,
6405            &mut history,
6406            &tools_registry,
6407            &observer,
6408            "mock-provider",
6409            "mock-model",
6410            Some(0.0),
6411            true,
6412            None,
6413            "cli",
6414            None,
6415            &multimodal,
6416            3,
6417            None,
6418            None,
6419            None,
6420            &[],
6421            &[],
6422            None,
6423            None,
6424            &zeroclaw_config::schema::PacingConfig::default(),
6425            false,
6426            0,
6427            0,
6428            None,
6429            None, // channel
6430            None, // receipt_generator
6431            None, // collected_receipts
6432        )
6433        .await
6434        .expect("oversized payload should be skipped and continue as text-only");
6435
6436        assert_eq!(result, "done");
6437        let requests = recorded_requests
6438            .lock()
6439            .expect("recorded requests lock should be valid");
6440        assert_eq!(requests.len(), 1);
6441        assert_eq!(requests[0].len(), 1);
6442        assert!(
6443            requests[0][0]
6444                .content
6445                .contains("1 attached image(s) could not be loaded")
6446        );
6447        assert!(!requests[0][0].content.contains("[IMAGE:"));
6448        assert!(!requests[0][0].content.contains(&oversized_payload));
6449    }
6450
6451    #[tokio::test]
6452    async fn run_tool_call_loop_accepts_valid_multimodal_request_flow() {
6453        let calls = Arc::new(AtomicUsize::new(0));
6454        let model_provider = VisionModelProvider {
6455            calls: Arc::clone(&calls),
6456        };
6457
6458        let mut history = vec![ChatMessage::user(
6459            "Analyze this [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
6460        )];
6461        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6462        let observer = NoopObserver;
6463
6464        let result = run_tool_call_loop(
6465            &model_provider,
6466            &mut history,
6467            &tools_registry,
6468            &observer,
6469            "mock-provider",
6470            "mock-model",
6471            Some(0.0),
6472            true,
6473            None,
6474            "cli",
6475            None,
6476            &zeroclaw_config::schema::MultimodalConfig::default(),
6477            3,
6478            None,
6479            None,
6480            None,
6481            &[],
6482            &[],
6483            None,
6484            None,
6485            &zeroclaw_config::schema::PacingConfig::default(),
6486            false,
6487            0,
6488            0,
6489            None,
6490            None, // channel
6491            None, // receipt_generator
6492            None, // collected_receipts
6493        )
6494        .await
6495        .expect("valid multimodal payload should pass");
6496
6497        assert_eq!(result, "vision-ok");
6498        assert_eq!(calls.load(Ordering::SeqCst), 1);
6499    }
6500
6501    /// When `vision_model_provider` is not set and the default model_provider lacks vision
6502    /// support, the original `ProviderCapabilityError` should be returned.
6503    #[tokio::test]
6504    async fn run_tool_call_loop_no_vision_provider_config_preserves_error() {
6505        let calls = Arc::new(AtomicUsize::new(0));
6506        let model_provider = NonVisionModelProvider {
6507            calls: Arc::clone(&calls),
6508        };
6509
6510        let mut history = vec![ChatMessage::user(
6511            "check [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
6512        )];
6513        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6514        let observer = NoopObserver;
6515
6516        let err = run_tool_call_loop(
6517            &model_provider,
6518            &mut history,
6519            &tools_registry,
6520            &observer,
6521            "mock-provider",
6522            "mock-model",
6523            Some(0.0),
6524            true,
6525            None,
6526            "cli",
6527            None,
6528            &zeroclaw_config::schema::MultimodalConfig::default(),
6529            3,
6530            None,
6531            None,
6532            None,
6533            &[],
6534            &[],
6535            None,
6536            None,
6537            &zeroclaw_config::schema::PacingConfig::default(),
6538            false,
6539            0,
6540            0,
6541            None,
6542            None, // channel
6543            None, // receipt_generator
6544            None, // collected_receipts
6545        )
6546        .await
6547        .expect_err("should fail without vision_model_provider config");
6548
6549        assert!(err.to_string().contains("capability=vision"));
6550        assert_eq!(calls.load(Ordering::SeqCst), 0);
6551    }
6552
6553    /// When `vision_model_provider` is set but the model_provider factory cannot resolve
6554    /// the name, a descriptive error should be returned (not the generic
6555    /// capability error).
6556    #[tokio::test]
6557    async fn run_tool_call_loop_vision_provider_creation_failure() {
6558        let calls = Arc::new(AtomicUsize::new(0));
6559        let model_provider = NonVisionModelProvider {
6560            calls: Arc::clone(&calls),
6561        };
6562
6563        let mut history = vec![ChatMessage::user(
6564            "inspect [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
6565        )];
6566        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6567        let observer = NoopObserver;
6568
6569        let multimodal = zeroclaw_config::schema::MultimodalConfig {
6570            vision_model_provider: Some("nonexistent-provider-xyz".to_string()),
6571            vision_model: Some("some-model".to_string()),
6572            ..Default::default()
6573        };
6574
6575        let err = run_tool_call_loop(
6576            &model_provider,
6577            &mut history,
6578            &tools_registry,
6579            &observer,
6580            "mock-provider",
6581            "mock-model",
6582            Some(0.0),
6583            true,
6584            None,
6585            "cli",
6586            None,
6587            &multimodal,
6588            3,
6589            None,
6590            None,
6591            None,
6592            &[],
6593            &[],
6594            None,
6595            None,
6596            &zeroclaw_config::schema::PacingConfig::default(),
6597            false,
6598            0,
6599            0,
6600            None,
6601            None, // channel
6602            None, // receipt_generator
6603            None, // collected_receipts
6604        )
6605        .await
6606        .expect_err("should fail when vision model_provider cannot be created");
6607
6608        assert!(
6609            err.to_string()
6610                .contains("failed to create vision model_provider"),
6611            "expected creation failure error, got: {}",
6612            err
6613        );
6614        assert_eq!(calls.load(Ordering::SeqCst), 0);
6615    }
6616
6617    /// Messages without image markers should use the default model_provider even
6618    /// when `vision_model_provider` is configured.
6619    #[tokio::test]
6620    async fn run_tool_call_loop_no_images_uses_default_provider() {
6621        let model_provider = ScriptedModelProvider::from_text_responses(vec!["hello world"]);
6622
6623        let mut history = vec![ChatMessage::user("just text, no images".to_string())];
6624        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6625        let observer = NoopObserver;
6626
6627        let multimodal = zeroclaw_config::schema::MultimodalConfig {
6628            vision_model_provider: Some("nonexistent-provider-xyz".to_string()),
6629            vision_model: Some("some-model".to_string()),
6630            ..Default::default()
6631        };
6632
6633        // Even though vision_model_provider points to a nonexistent model_provider, this
6634        // should succeed because there are no image markers to trigger routing.
6635        let result = run_tool_call_loop(
6636            &model_provider,
6637            &mut history,
6638            &tools_registry,
6639            &observer,
6640            "scripted",
6641            "scripted-model",
6642            Some(0.0),
6643            true,
6644            None,
6645            "cli",
6646            None,
6647            &multimodal,
6648            3,
6649            None,
6650            None,
6651            None,
6652            &[],
6653            &[],
6654            None,
6655            None,
6656            &zeroclaw_config::schema::PacingConfig::default(),
6657            false,
6658            0,
6659            0,
6660            None,
6661            None, // channel
6662            None, // receipt_generator
6663            None, // collected_receipts
6664        )
6665        .await
6666        .expect("text-only messages should succeed with default model_provider");
6667
6668        assert_eq!(result, "hello world");
6669    }
6670
6671    /// When `vision_model_provider` is set but `vision_model` is not, the default
6672    /// model should be used as fallback for the vision model_provider.
6673    #[tokio::test]
6674    async fn run_tool_call_loop_vision_provider_without_model_falls_back() {
6675        let calls = Arc::new(AtomicUsize::new(0));
6676        let model_provider = NonVisionModelProvider {
6677            calls: Arc::clone(&calls),
6678        };
6679
6680        let mut history = vec![ChatMessage::user(
6681            "look [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
6682        )];
6683        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6684        let observer = NoopObserver;
6685
6686        // vision_model_provider set but vision_model is None — the code should
6687        // fall back to the default model. Since the model_provider name is invalid,
6688        // we just verify the error path references the correct model_provider.
6689        let multimodal = zeroclaw_config::schema::MultimodalConfig {
6690            vision_model_provider: Some("nonexistent-provider-xyz".to_string()),
6691            vision_model: None,
6692            ..Default::default()
6693        };
6694
6695        let err = run_tool_call_loop(
6696            &model_provider,
6697            &mut history,
6698            &tools_registry,
6699            &observer,
6700            "mock-provider",
6701            "mock-model",
6702            Some(0.0),
6703            true,
6704            None,
6705            "cli",
6706            None,
6707            &multimodal,
6708            3,
6709            None,
6710            None,
6711            None,
6712            &[],
6713            &[],
6714            None,
6715            None,
6716            &zeroclaw_config::schema::PacingConfig::default(),
6717            false,
6718            0,
6719            0,
6720            None,
6721            None, // channel
6722            None, // receipt_generator
6723            None, // collected_receipts
6724        )
6725        .await
6726        .expect_err("should fail due to nonexistent vision model_provider");
6727
6728        // Verify the routing was attempted (not the generic capability error).
6729        assert!(
6730            err.to_string()
6731                .contains("failed to create vision model_provider"),
6732            "expected creation failure, got: {}",
6733            err
6734        );
6735    }
6736
6737    /// Empty `[IMAGE:]` markers (which are preserved as literal text by the
6738    /// parser) should not trigger vision model_provider routing.
6739    #[tokio::test]
6740    async fn run_tool_call_loop_empty_image_markers_use_default_provider() {
6741        let model_provider = ScriptedModelProvider::from_text_responses(vec!["handled"]);
6742
6743        let mut history = vec![ChatMessage::user(
6744            "empty marker [IMAGE:] should be ignored".to_string(),
6745        )];
6746        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6747        let observer = NoopObserver;
6748
6749        let multimodal = zeroclaw_config::schema::MultimodalConfig {
6750            vision_model_provider: Some("nonexistent-provider-xyz".to_string()),
6751            ..Default::default()
6752        };
6753
6754        let result = run_tool_call_loop(
6755            &model_provider,
6756            &mut history,
6757            &tools_registry,
6758            &observer,
6759            "scripted",
6760            "scripted-model",
6761            Some(0.0),
6762            true,
6763            None,
6764            "cli",
6765            None,
6766            &multimodal,
6767            3,
6768            None,
6769            None,
6770            None,
6771            &[],
6772            &[],
6773            None,
6774            None,
6775            &zeroclaw_config::schema::PacingConfig::default(),
6776            false,
6777            0,
6778            0,
6779            None,
6780            None, // channel
6781            None, // receipt_generator
6782            None, // collected_receipts
6783        )
6784        .await
6785        .expect("empty image markers should not trigger vision routing");
6786
6787        assert_eq!(result, "handled");
6788    }
6789
6790    /// Multiple image markers should still trigger vision routing when
6791    /// vision_model_provider is configured.
6792    #[tokio::test]
6793    async fn run_tool_call_loop_multiple_images_trigger_vision_routing() {
6794        let calls = Arc::new(AtomicUsize::new(0));
6795        let model_provider = NonVisionModelProvider {
6796            calls: Arc::clone(&calls),
6797        };
6798
6799        let mut history = vec![ChatMessage::user(
6800            "two images [IMAGE:data:image/png;base64,aQ==] and [IMAGE:data:image/png;base64,bQ==]"
6801                .to_string(),
6802        )];
6803        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6804        let observer = NoopObserver;
6805
6806        let multimodal = zeroclaw_config::schema::MultimodalConfig {
6807            vision_model_provider: Some("nonexistent-provider-xyz".to_string()),
6808            vision_model: Some("llava:7b".to_string()),
6809            ..Default::default()
6810        };
6811
6812        let err = run_tool_call_loop(
6813            &model_provider,
6814            &mut history,
6815            &tools_registry,
6816            &observer,
6817            "mock-provider",
6818            "mock-model",
6819            Some(0.0),
6820            true,
6821            None,
6822            "cli",
6823            None,
6824            &multimodal,
6825            3,
6826            None,
6827            None,
6828            None,
6829            &[],
6830            &[],
6831            None,
6832            None,
6833            &zeroclaw_config::schema::PacingConfig::default(),
6834            false,
6835            0,
6836            0,
6837            None,
6838            None, // channel
6839            None, // receipt_generator
6840            None, // collected_receipts
6841        )
6842        .await
6843        .expect_err("should attempt vision model_provider creation for multiple images");
6844
6845        assert!(
6846            err.to_string()
6847                .contains("failed to create vision model_provider"),
6848            "expected creation failure for multiple images, got: {}",
6849            err
6850        );
6851    }
6852
6853    #[test]
6854    fn should_execute_tools_in_parallel_returns_false_for_single_call() {
6855        let calls = vec![ParsedToolCall {
6856            name: "file_read".to_string(),
6857            arguments: serde_json::json!({"path": "a.txt"}),
6858            tool_call_id: None,
6859        }];
6860
6861        assert!(!should_execute_tools_in_parallel(&calls, None));
6862    }
6863
6864    #[test]
6865    fn should_execute_tools_in_parallel_returns_false_when_approval_is_required() {
6866        let calls = vec![
6867            ParsedToolCall {
6868                name: "shell".to_string(),
6869                arguments: serde_json::json!({"command": "pwd"}),
6870                tool_call_id: None,
6871            },
6872            ParsedToolCall {
6873                name: "http_request".to_string(),
6874                arguments: serde_json::json!({"url": "https://example.com"}),
6875                tool_call_id: None,
6876            },
6877        ];
6878        let approval_cfg = zeroclaw_config::schema::RiskProfileConfig::default();
6879        let approval_mgr = ApprovalManager::from_risk_profile(&approval_cfg);
6880
6881        assert!(!should_execute_tools_in_parallel(
6882            &calls,
6883            Some(&approval_mgr)
6884        ));
6885    }
6886
6887    #[test]
6888    fn should_execute_tools_in_parallel_returns_true_when_cli_has_no_interactive_approvals() {
6889        let calls = vec![
6890            ParsedToolCall {
6891                name: "shell".to_string(),
6892                arguments: serde_json::json!({"command": "pwd"}),
6893                tool_call_id: None,
6894            },
6895            ParsedToolCall {
6896                name: "http_request".to_string(),
6897                arguments: serde_json::json!({"url": "https://example.com"}),
6898                tool_call_id: None,
6899            },
6900        ];
6901        let approval_cfg = zeroclaw_config::schema::RiskProfileConfig {
6902            level: crate::security::AutonomyLevel::Full,
6903            ..zeroclaw_config::schema::RiskProfileConfig::default()
6904        };
6905        let approval_mgr = ApprovalManager::from_risk_profile(&approval_cfg);
6906
6907        assert!(should_execute_tools_in_parallel(
6908            &calls,
6909            Some(&approval_mgr)
6910        ));
6911    }
6912
6913    #[tokio::test]
6914    async fn run_tool_call_loop_executes_multiple_tools_with_ordered_results() {
6915        let model_provider = ScriptedModelProvider::from_text_responses(vec![
6916            r#"<tool_call>
6917{"name":"delay_a","arguments":{"value":"A"}}
6918</tool_call>
6919<tool_call>
6920{"name":"delay_b","arguments":{"value":"B"}}
6921</tool_call>"#,
6922            "done",
6923        ]);
6924
6925        let active = Arc::new(AtomicUsize::new(0));
6926        let max_active = Arc::new(AtomicUsize::new(0));
6927        let tools_registry: Vec<Box<dyn Tool>> = vec![
6928            Box::new(DelayTool::new(
6929                "delay_a",
6930                200,
6931                Arc::clone(&active),
6932                Arc::clone(&max_active),
6933            )),
6934            Box::new(DelayTool::new(
6935                "delay_b",
6936                200,
6937                Arc::clone(&active),
6938                Arc::clone(&max_active),
6939            )),
6940        ];
6941
6942        let approval_cfg = zeroclaw_config::schema::RiskProfileConfig {
6943            level: crate::security::AutonomyLevel::Full,
6944            ..zeroclaw_config::schema::RiskProfileConfig::default()
6945        };
6946        let approval_mgr = ApprovalManager::from_risk_profile(&approval_cfg);
6947
6948        let mut history = vec![
6949            ChatMessage::system("test-system"),
6950            ChatMessage::user("run tool calls"),
6951        ];
6952        let observer = NoopObserver;
6953
6954        let result = run_tool_call_loop(
6955            &model_provider,
6956            &mut history,
6957            &tools_registry,
6958            &observer,
6959            "mock-provider",
6960            "mock-model",
6961            Some(0.0),
6962            true,
6963            Some(&approval_mgr),
6964            "telegram",
6965            None,
6966            &zeroclaw_config::schema::MultimodalConfig::default(),
6967            4,
6968            None,
6969            None,
6970            None,
6971            &[],
6972            &[],
6973            None,
6974            None,
6975            &zeroclaw_config::schema::PacingConfig::default(),
6976            false,
6977            0,
6978            0,
6979            None,
6980            None, // channel
6981            None, // receipt_generator
6982            None, // collected_receipts
6983        )
6984        .await
6985        .expect("parallel execution should complete");
6986
6987        assert!(
6988            result.ends_with("done"),
6989            "result should end with 'done', got: {result}"
6990        );
6991        assert!(
6992            max_active.load(Ordering::SeqCst) >= 1,
6993            "tools should execute successfully"
6994        );
6995
6996        let tool_results_message = history
6997            .iter()
6998            .find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]"))
6999            .expect("tool results message should be present");
7000        let idx_a = tool_results_message
7001            .content
7002            .find("name=\"delay_a\"")
7003            .expect("delay_a result should be present");
7004        let idx_b = tool_results_message
7005            .content
7006            .find("name=\"delay_b\"")
7007            .expect("delay_b result should be present");
7008        assert!(
7009            idx_a < idx_b,
7010            "tool results should preserve input order for tool call mapping"
7011        );
7012    }
7013
7014    #[tokio::test]
7015    async fn run_tool_call_loop_injects_channel_delivery_defaults_for_cron_add() {
7016        let model_provider = ScriptedModelProvider::from_text_responses(vec![
7017            r#"<tool_call>
7018{"name":"cron_add","arguments":{"job_type":"agent","prompt":"remind me later","schedule":{"kind":"every","every_ms":60000}}}
7019</tool_call>"#,
7020            "done",
7021        ]);
7022
7023        let recorded_args = Arc::new(Mutex::new(Vec::new()));
7024        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(RecordingArgsTool::new(
7025            "cron_add",
7026            Arc::clone(&recorded_args),
7027        ))];
7028
7029        let mut history = vec![
7030            ChatMessage::system("test-system"),
7031            ChatMessage::user("schedule a reminder"),
7032        ];
7033        let observer = NoopObserver;
7034
7035        let result = run_tool_call_loop(
7036            &model_provider,
7037            &mut history,
7038            &tools_registry,
7039            &observer,
7040            "mock-provider",
7041            "mock-model",
7042            Some(0.0),
7043            true,
7044            None,
7045            "telegram",
7046            Some("chat-42"),
7047            &zeroclaw_config::schema::MultimodalConfig::default(),
7048            4,
7049            None,
7050            None,
7051            None,
7052            &[],
7053            &[],
7054            None,
7055            None,
7056            &zeroclaw_config::schema::PacingConfig::default(),
7057            false,
7058            0,
7059            0,
7060            None,
7061            None, // channel
7062            None, // receipt_generator
7063            None, // collected_receipts
7064        )
7065        .await
7066        .expect("cron_add delivery defaults should be injected");
7067
7068        assert!(
7069            result.ends_with("done"),
7070            "result should end with 'done', got: {result}"
7071        );
7072
7073        let recorded = recorded_args
7074            .lock()
7075            .expect("recorded args lock should be valid");
7076        let delivery = recorded[0]["delivery"].clone();
7077        assert_eq!(
7078            delivery,
7079            serde_json::json!({
7080                "mode": "announce",
7081                "channel": "telegram",
7082                "to": "chat-42",
7083            })
7084        );
7085    }
7086
7087    #[tokio::test]
7088    async fn run_tool_call_loop_preserves_explicit_cron_delivery_none() {
7089        let model_provider = ScriptedModelProvider::from_text_responses(vec![
7090            r#"<tool_call>
7091{"name":"cron_add","arguments":{"job_type":"agent","prompt":"run silently","schedule":{"kind":"every","every_ms":60000},"delivery":{"mode":"none"}}}
7092</tool_call>"#,
7093            "done",
7094        ]);
7095
7096        let recorded_args = Arc::new(Mutex::new(Vec::new()));
7097        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(RecordingArgsTool::new(
7098            "cron_add",
7099            Arc::clone(&recorded_args),
7100        ))];
7101
7102        let mut history = vec![
7103            ChatMessage::system("test-system"),
7104            ChatMessage::user("schedule a quiet cron job"),
7105        ];
7106        let observer = NoopObserver;
7107
7108        let result = run_tool_call_loop(
7109            &model_provider,
7110            &mut history,
7111            &tools_registry,
7112            &observer,
7113            "mock-provider",
7114            "mock-model",
7115            Some(0.0),
7116            true,
7117            None,
7118            "telegram",
7119            Some("chat-42"),
7120            &zeroclaw_config::schema::MultimodalConfig::default(),
7121            4,
7122            None,
7123            None,
7124            None,
7125            &[],
7126            &[],
7127            None,
7128            None,
7129            &zeroclaw_config::schema::PacingConfig::default(),
7130            false,
7131            0,
7132            0,
7133            None,
7134            None, // channel
7135            None, // receipt_generator
7136            None, // collected_receipts
7137        )
7138        .await
7139        .expect("explicit delivery mode should be preserved");
7140
7141        assert!(
7142            result.ends_with("done"),
7143            "result should end with 'done', got: {result}"
7144        );
7145
7146        let recorded = recorded_args
7147            .lock()
7148            .expect("recorded args lock should be valid");
7149        assert_eq!(recorded[0]["delivery"], serde_json::json!({"mode": "none"}));
7150    }
7151
7152    #[tokio::test]
7153    async fn run_tool_call_loop_deduplicates_repeated_tool_calls() {
7154        let model_provider = ScriptedModelProvider::from_text_responses(vec![
7155            r#"<tool_call>
7156{"name":"count_tool","arguments":{"value":"A"}}
7157</tool_call>
7158<tool_call>
7159{"name":"count_tool","arguments":{"value":"A"}}
7160</tool_call>"#,
7161            "done",
7162        ]);
7163
7164        let invocations = Arc::new(AtomicUsize::new(0));
7165        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
7166            "count_tool",
7167            Arc::clone(&invocations),
7168        ))];
7169
7170        let mut history = vec![
7171            ChatMessage::system("test-system"),
7172            ChatMessage::user("run tool calls"),
7173        ];
7174        let observer = NoopObserver;
7175
7176        let result = run_tool_call_loop(
7177            &model_provider,
7178            &mut history,
7179            &tools_registry,
7180            &observer,
7181            "mock-provider",
7182            "mock-model",
7183            Some(0.0),
7184            true,
7185            None,
7186            "cli",
7187            None,
7188            &zeroclaw_config::schema::MultimodalConfig::default(),
7189            4,
7190            None,
7191            None,
7192            None,
7193            &[],
7194            &[],
7195            None,
7196            None,
7197            &zeroclaw_config::schema::PacingConfig::default(),
7198            false,
7199            0,
7200            0,
7201            None,
7202            None, // channel
7203            None, // receipt_generator
7204            None, // collected_receipts
7205        )
7206        .await
7207        .expect("loop should finish after deduplicating repeated calls");
7208
7209        assert!(
7210            result.ends_with("done"),
7211            "result should end with 'done', got: {result}"
7212        );
7213        assert_eq!(
7214            invocations.load(Ordering::SeqCst),
7215            1,
7216            "duplicate tool call with same args should not execute twice"
7217        );
7218
7219        let tool_results = history
7220            .iter()
7221            .find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]"))
7222            .expect("prompt-mode tool result payload should be present");
7223        assert!(tool_results.content.contains("counted:A"));
7224        assert!(tool_results.content.contains("Skipped duplicate tool call"));
7225    }
7226
7227    #[tokio::test]
7228    async fn run_tool_call_loop_allows_low_risk_shell_in_non_interactive_mode() {
7229        let model_provider = ScriptedModelProvider::from_text_responses(vec![
7230            r#"<tool_call>
7231{"name":"shell","arguments":{"command":"echo hello"}}
7232</tool_call>"#,
7233            "done",
7234        ]);
7235
7236        let tmp = TempDir::new().expect("temp dir");
7237        let security = Arc::new(crate::security::SecurityPolicy {
7238            autonomy: crate::security::AutonomyLevel::Supervised,
7239            workspace_dir: tmp.path().to_path_buf(),
7240            ..crate::security::SecurityPolicy::default()
7241        });
7242        let runtime: Arc<dyn crate::platform::RuntimeAdapter> =
7243            Arc::new(crate::platform::NativeRuntime::new());
7244        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(
7245            crate::tools::shell::ShellTool::new(security, runtime),
7246        )];
7247
7248        let mut history = vec![
7249            ChatMessage::system("test-system"),
7250            ChatMessage::user("run shell"),
7251        ];
7252        let observer = NoopObserver;
7253        let approval_mgr = ApprovalManager::for_non_interactive(
7254            &zeroclaw_config::schema::RiskProfileConfig::default(),
7255        );
7256
7257        let result = run_tool_call_loop(
7258            &model_provider,
7259            &mut history,
7260            &tools_registry,
7261            &observer,
7262            "mock-provider",
7263            "mock-model",
7264            Some(0.0),
7265            true,
7266            Some(&approval_mgr),
7267            "telegram",
7268            None,
7269            &zeroclaw_config::schema::MultimodalConfig::default(),
7270            4,
7271            None,
7272            None,
7273            None,
7274            &[],
7275            &[],
7276            None,
7277            None,
7278            &zeroclaw_config::schema::PacingConfig::default(),
7279            false,
7280            0,
7281            0,
7282            None,
7283            None, // channel
7284            None, // receipt_generator
7285            None, // collected_receipts
7286        )
7287        .await
7288        .expect("non-interactive shell should succeed for low-risk command");
7289
7290        assert!(
7291            result.ends_with("done"),
7292            "result should end with 'done', got: {result}"
7293        );
7294
7295        let tool_results = history
7296            .iter()
7297            .find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]"))
7298            .expect("tool results message should be present");
7299        assert!(tool_results.content.contains("hello"));
7300        assert!(!tool_results.content.contains("Denied by user."));
7301    }
7302
7303    #[tokio::test]
7304    async fn run_tool_call_loop_dedup_exempt_allows_repeated_calls() {
7305        let model_provider = ScriptedModelProvider::from_text_responses(vec![
7306            r#"<tool_call>
7307{"name":"count_tool","arguments":{"value":"A"}}
7308</tool_call>
7309<tool_call>
7310{"name":"count_tool","arguments":{"value":"A"}}
7311</tool_call>"#,
7312            "done",
7313        ]);
7314
7315        let invocations = Arc::new(AtomicUsize::new(0));
7316        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
7317            "count_tool",
7318            Arc::clone(&invocations),
7319        ))];
7320
7321        let mut history = vec![
7322            ChatMessage::system("test-system"),
7323            ChatMessage::user("run tool calls"),
7324        ];
7325        let observer = NoopObserver;
7326        let exempt = vec!["count_tool".to_string()];
7327
7328        let result = run_tool_call_loop(
7329            &model_provider,
7330            &mut history,
7331            &tools_registry,
7332            &observer,
7333            "mock-provider",
7334            "mock-model",
7335            Some(0.0),
7336            true,
7337            None,
7338            "cli",
7339            None,
7340            &zeroclaw_config::schema::MultimodalConfig::default(),
7341            4,
7342            None,
7343            None,
7344            None,
7345            &[],
7346            &exempt,
7347            None,
7348            None,
7349            &zeroclaw_config::schema::PacingConfig::default(),
7350            false,
7351            0,
7352            0,
7353            None,
7354            None, // channel
7355            None, // receipt_generator
7356            None, // collected_receipts
7357        )
7358        .await
7359        .expect("loop should finish with exempt tool executing twice");
7360
7361        assert!(
7362            result.ends_with("done"),
7363            "result should end with 'done', got: {result}"
7364        );
7365        assert_eq!(
7366            invocations.load(Ordering::SeqCst),
7367            2,
7368            "exempt tool should execute both duplicate calls"
7369        );
7370
7371        let tool_results = history
7372            .iter()
7373            .find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]"))
7374            .expect("prompt-mode tool result payload should be present");
7375        assert!(
7376            !tool_results.content.contains("Skipped duplicate tool call"),
7377            "exempt tool calls should not be suppressed"
7378        );
7379    }
7380
7381    #[tokio::test]
7382    async fn run_tool_call_loop_dedup_exempt_only_affects_listed_tools() {
7383        let model_provider = ScriptedModelProvider::from_text_responses(vec![
7384            r#"<tool_call>
7385{"name":"count_tool","arguments":{"value":"A"}}
7386</tool_call>
7387<tool_call>
7388{"name":"count_tool","arguments":{"value":"A"}}
7389</tool_call>
7390<tool_call>
7391{"name":"other_tool","arguments":{"value":"B"}}
7392</tool_call>
7393<tool_call>
7394{"name":"other_tool","arguments":{"value":"B"}}
7395</tool_call>"#,
7396            "done",
7397        ]);
7398
7399        let count_invocations = Arc::new(AtomicUsize::new(0));
7400        let other_invocations = Arc::new(AtomicUsize::new(0));
7401        let tools_registry: Vec<Box<dyn Tool>> = vec![
7402            Box::new(CountingTool::new(
7403                "count_tool",
7404                Arc::clone(&count_invocations),
7405            )),
7406            Box::new(CountingTool::new(
7407                "other_tool",
7408                Arc::clone(&other_invocations),
7409            )),
7410        ];
7411
7412        let mut history = vec![
7413            ChatMessage::system("test-system"),
7414            ChatMessage::user("run tool calls"),
7415        ];
7416        let observer = NoopObserver;
7417        let exempt = vec!["count_tool".to_string()];
7418
7419        let _result = run_tool_call_loop(
7420            &model_provider,
7421            &mut history,
7422            &tools_registry,
7423            &observer,
7424            "mock-provider",
7425            "mock-model",
7426            Some(0.0),
7427            true,
7428            None,
7429            "cli",
7430            None,
7431            &zeroclaw_config::schema::MultimodalConfig::default(),
7432            4,
7433            None,
7434            None,
7435            None,
7436            &[],
7437            &exempt,
7438            None,
7439            None,
7440            &zeroclaw_config::schema::PacingConfig::default(),
7441            false,
7442            0,
7443            0,
7444            None,
7445            None, // channel
7446            None, // receipt_generator
7447            None, // collected_receipts
7448        )
7449        .await
7450        .expect("loop should complete");
7451
7452        assert_eq!(
7453            count_invocations.load(Ordering::SeqCst),
7454            2,
7455            "exempt tool should execute both calls"
7456        );
7457        assert_eq!(
7458            other_invocations.load(Ordering::SeqCst),
7459            1,
7460            "non-exempt tool should still be deduped"
7461        );
7462    }
7463
7464    #[tokio::test]
7465    async fn run_tool_call_loop_native_mode_preserves_fallback_tool_call_ids() {
7466        let model_provider = ScriptedModelProvider::from_text_responses(vec![
7467            r#"{"content":"Need to call tool","tool_calls":[{"id":"call_abc","name":"count_tool","arguments":"{\"value\":\"X\"}"}]}"#,
7468            "done",
7469        ])
7470        .with_native_tool_support();
7471
7472        let invocations = Arc::new(AtomicUsize::new(0));
7473        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
7474            "count_tool",
7475            Arc::clone(&invocations),
7476        ))];
7477
7478        let mut history = vec![
7479            ChatMessage::system("test-system"),
7480            ChatMessage::user("run tool calls"),
7481        ];
7482        let observer = NoopObserver;
7483
7484        let result = run_tool_call_loop(
7485            &model_provider,
7486            &mut history,
7487            &tools_registry,
7488            &observer,
7489            "mock-provider",
7490            "mock-model",
7491            Some(0.0),
7492            true,
7493            None,
7494            "cli",
7495            None,
7496            &zeroclaw_config::schema::MultimodalConfig::default(),
7497            4,
7498            None,
7499            None,
7500            None,
7501            &[],
7502            &[],
7503            None,
7504            None,
7505            &zeroclaw_config::schema::PacingConfig::default(),
7506            false,
7507            0,
7508            0,
7509            None,
7510            None, // channel
7511            None, // receipt_generator
7512            None, // collected_receipts
7513        )
7514        .await
7515        .expect("native fallback id flow should complete");
7516
7517        assert!(
7518            result.ends_with("done"),
7519            "result should end with 'done', got: {result}"
7520        );
7521        assert_eq!(invocations.load(Ordering::SeqCst), 1);
7522        assert!(
7523            history.iter().any(|msg| {
7524                msg.role == "tool" && msg.content.contains("\"tool_call_id\":\"call_abc\"")
7525            }),
7526            "tool result should preserve parsed fallback tool_call_id in native mode"
7527        );
7528        assert!(
7529            history
7530                .iter()
7531                .all(|msg| !(msg.role == "user" && msg.content.starts_with("[Tool results]"))),
7532            "native mode should use role=tool history instead of prompt fallback wrapper"
7533        );
7534    }
7535
7536    #[tokio::test]
7537    async fn run_tool_call_loop_retries_malformed_tool_protocol_without_leaking_json() {
7538        let provider = ScriptedModelProvider::from_text_responses(vec![
7539            r#"{"toolcalls":[{"name":"count_tool","arguments":{"value":"X"}}]}"#,
7540            "Recovered answer.",
7541        ]);
7542        let invocations = Arc::new(AtomicUsize::new(0));
7543        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
7544            "count_tool",
7545            Arc::clone(&invocations),
7546        ))];
7547        let mut history = vec![
7548            ChatMessage::system("test-system"),
7549            ChatMessage::user("run tool calls"),
7550        ];
7551        let observer = NoopObserver;
7552
7553        let result = run_tool_call_loop(
7554            &provider,
7555            &mut history,
7556            &tools_registry,
7557            &observer,
7558            "mock-provider",
7559            "mock-model",
7560            Some(0.0),
7561            true,
7562            None,
7563            "matrix",
7564            None,
7565            &zeroclaw_config::schema::MultimodalConfig::default(),
7566            4,
7567            None,
7568            None,
7569            None,
7570            &[],
7571            &[],
7572            None,
7573            None,
7574            &zeroclaw_config::schema::PacingConfig::default(),
7575            false,
7576            0,
7577            0,
7578            None,
7579            None, // channel
7580            None, // receipt_generator
7581            None, // collected_receipts
7582        )
7583        .await
7584        .expect("malformed tool protocol should retry and recover");
7585
7586        assert_eq!(result, "Recovered answer.");
7587        assert!(!result.contains("toolcalls"));
7588        assert_eq!(
7589            invocations.load(Ordering::SeqCst),
7590            0,
7591            "malformed alias payload should not execute as a tool call"
7592        );
7593        assert!(
7594            history
7595                .iter()
7596                .any(|msg| msg.role == "user" && msg.content.contains("[Tool call parse error]")),
7597            "history should include internal parser feedback for the model"
7598        );
7599    }
7600
7601    #[tokio::test]
7602    async fn run_tool_call_loop_preserves_unknown_function_call_json_with_tools() {
7603        let business_json =
7604            r#"{"type":"function_call","name":"support_case","arguments":{"id":"A1"}}"#;
7605        let provider = ScriptedModelProvider::from_text_responses(vec![business_json]);
7606        let invocations = Arc::new(AtomicUsize::new(0));
7607        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
7608            "count_tool",
7609            Arc::clone(&invocations),
7610        ))];
7611        let mut history = vec![
7612            ChatMessage::system("test-system"),
7613            ChatMessage::user("return a support case JSON object"),
7614        ];
7615        let observer = NoopObserver;
7616
7617        let result = run_tool_call_loop(
7618            &provider,
7619            &mut history,
7620            &tools_registry,
7621            &observer,
7622            "mock-provider",
7623            "mock-model",
7624            Some(0.0),
7625            true,
7626            None,
7627            "matrix",
7628            None,
7629            &zeroclaw_config::schema::MultimodalConfig::default(),
7630            4,
7631            None,
7632            None,
7633            None,
7634            &[],
7635            &[],
7636            None,
7637            None,
7638            &zeroclaw_config::schema::PacingConfig::default(),
7639            false,
7640            0,
7641            0,
7642            None,
7643            None, // channel
7644            None, // receipt_generator
7645            None, // collected_receipts
7646        )
7647        .await
7648        .expect("business JSON should be returned as normal text");
7649
7650        assert_eq!(result, business_json);
7651        assert_eq!(
7652            invocations.load(Ordering::SeqCst),
7653            0,
7654            "business JSON must not execute any runtime tool"
7655        );
7656        assert!(
7657            history
7658                .iter()
7659                .all(|msg| !msg.content.contains("[Tool call parse error]")),
7660            "business JSON must not trigger internal parser feedback"
7661        );
7662    }
7663
7664    #[tokio::test]
7665    async fn run_tool_call_loop_preserves_malformed_unknown_tool_calls_json_with_tools() {
7666        let business_json = r#"{"tool_calls":[{"name":"support_case","arguments":{"id":"A1"}}"#;
7667        let provider = ScriptedModelProvider::from_text_responses(vec![business_json]);
7668        let invocations = Arc::new(AtomicUsize::new(0));
7669        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
7670            "count_tool",
7671            Arc::clone(&invocations),
7672        ))];
7673        let mut history = vec![
7674            ChatMessage::system("test-system"),
7675            ChatMessage::user("return a partial support case JSON object"),
7676        ];
7677        let observer = NoopObserver;
7678
7679        let result = run_tool_call_loop(
7680            &provider,
7681            &mut history,
7682            &tools_registry,
7683            &observer,
7684            "mock-provider",
7685            "mock-model",
7686            Some(0.0),
7687            true,
7688            None,
7689            "matrix",
7690            None,
7691            &zeroclaw_config::schema::MultimodalConfig::default(),
7692            4,
7693            None,
7694            None,
7695            None,
7696            &[],
7697            &[],
7698            None,
7699            None,
7700            &zeroclaw_config::schema::PacingConfig::default(),
7701            false,
7702            0,
7703            0,
7704            None,
7705            None, // channel
7706            None, // receipt_generator
7707            None, // collected_receipts
7708        )
7709        .await
7710        .expect("unknown business JSON should be returned as normal text");
7711
7712        assert_eq!(result, business_json);
7713        assert_eq!(
7714            invocations.load(Ordering::SeqCst),
7715            0,
7716            "business JSON must not execute any runtime tool"
7717        );
7718        assert!(
7719            history
7720                .iter()
7721                .all(|msg| !msg.content.contains("[Tool call parse error]")),
7722            "business JSON must not trigger internal parser feedback"
7723        );
7724    }
7725
7726    #[tokio::test]
7727    async fn run_tool_call_loop_falls_back_after_repeated_malformed_tool_protocol() {
7728        let provider = ScriptedModelProvider::from_text_responses(vec![
7729            r#"{"toolcalls":[{"call_id":"call_1","arguments":{"value":"X"}}]}"#,
7730            r#"{"toolcalls":[{"call_id":"call_2","arguments":{"value":"Y"}}]}"#,
7731            r#"{"toolcalls":[{"call_id":"call_3","arguments":{"value":"Z"}}]}"#,
7732        ]);
7733        let invocations = Arc::new(AtomicUsize::new(0));
7734        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
7735            "count_tool",
7736            Arc::clone(&invocations),
7737        ))];
7738        let mut history = vec![
7739            ChatMessage::system("test-system"),
7740            ChatMessage::user("run tool calls"),
7741        ];
7742        let observer = NoopObserver;
7743
7744        let result = run_tool_call_loop(
7745            &provider,
7746            &mut history,
7747            &tools_registry,
7748            &observer,
7749            "mock-provider",
7750            "mock-model",
7751            Some(0.0),
7752            true,
7753            None,
7754            "matrix",
7755            None,
7756            &zeroclaw_config::schema::MultimodalConfig::default(),
7757            6,
7758            None,
7759            None,
7760            None,
7761            &[],
7762            &[],
7763            None,
7764            None,
7765            &zeroclaw_config::schema::PacingConfig::default(),
7766            false,
7767            0,
7768            0,
7769            None,
7770            None, // channel
7771            None, // receipt_generator
7772            None, // collected_receipts
7773        )
7774        .await
7775        .expect("malformed tool protocol should return a safe fallback");
7776
7777        assert_eq!(
7778            result,
7779            crate::i18n::get_required_cli_string("channel-runtime-malformed-tool-output")
7780        );
7781        assert!(!result.contains("toolcalls"));
7782        assert_eq!(
7783            invocations.load(Ordering::SeqCst),
7784            0,
7785            "malformed protocol should never be executed as a tool call"
7786        );
7787        let feedback_count = history
7788            .iter()
7789            .filter(|msg| msg.role == "user" && msg.content.contains("[Tool call parse error]"))
7790            .count();
7791        assert_eq!(feedback_count, MAX_MALFORMED_TOOL_PROTOCOL_RETRIES);
7792    }
7793
7794    #[tokio::test]
7795    async fn run_tool_call_loop_streams_toolcalls_reference_json_when_no_tools_are_enabled() {
7796        let reference_json = r#"{"toolcalls":[{"name":"count_tool","arguments":{"value":"X"}}]}"#;
7797        let provider = StreamingScriptedModelProvider::from_text_responses(vec![reference_json]);
7798        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
7799        let mut history = vec![
7800            ChatMessage::system("test-system"),
7801            ChatMessage::user("return a toolcalls reference JSON object"),
7802        ];
7803        let observer = NoopObserver;
7804        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(16);
7805
7806        let result = run_tool_call_loop(
7807            &provider,
7808            &mut history,
7809            &tools_registry,
7810            &observer,
7811            "mock-provider",
7812            "mock-model",
7813            Some(0.0),
7814            true,
7815            None,
7816            "matrix",
7817            None,
7818            &zeroclaw_config::schema::MultimodalConfig::default(),
7819            4,
7820            None,
7821            Some(tx),
7822            None,
7823            &[],
7824            &[],
7825            None,
7826            None,
7827            &zeroclaw_config::schema::PacingConfig::default(),
7828            false,
7829            0,
7830            0,
7831            None,
7832            None, // channel
7833            None, // receipt_generator
7834            None, // collected_receipts
7835        )
7836        .await
7837        .expect("toolcalls reference JSON should remain visible without tools");
7838
7839        let mut visible_deltas = String::new();
7840        while let Some(delta) = rx.recv().await {
7841            if let StreamDelta::Text(text) = delta {
7842                visible_deltas.push_str(&text);
7843            }
7844        }
7845
7846        assert_eq!(result, reference_json);
7847        assert_eq!(visible_deltas, reference_json);
7848        assert!(
7849            history
7850                .iter()
7851                .all(|msg| !msg.content.contains("[Tool call parse error]")),
7852            "toolcalls reference JSON must not trigger internal parser feedback"
7853        );
7854    }
7855
7856    #[tokio::test]
7857    async fn run_tool_call_loop_returns_toolcalls_reference_json_when_no_tools_are_enabled() {
7858        let reference_json = r#"{"toolcalls":[{"name":"count_tool","arguments":{"value":"X"}}]}"#;
7859        let provider = ScriptedModelProvider::from_text_responses(vec![reference_json]);
7860        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
7861        let mut history = vec![
7862            ChatMessage::system("test-system"),
7863            ChatMessage::user("return a toolcalls reference JSON object"),
7864        ];
7865        let observer = NoopObserver;
7866
7867        let result = run_tool_call_loop(
7868            &provider,
7869            &mut history,
7870            &tools_registry,
7871            &observer,
7872            "mock-provider",
7873            "mock-model",
7874            Some(0.0),
7875            true,
7876            None,
7877            "cli",
7878            None,
7879            &zeroclaw_config::schema::MultimodalConfig::default(),
7880            4,
7881            None,
7882            None,
7883            None,
7884            &[],
7885            &[],
7886            None,
7887            None,
7888            &zeroclaw_config::schema::PacingConfig::default(),
7889            false,
7890            0,
7891            0,
7892            None,
7893            None, // channel
7894            None, // receipt_generator
7895            None, // collected_receipts
7896        )
7897        .await
7898        .expect("toolcalls reference JSON should remain visible without tools");
7899
7900        assert_eq!(result, reference_json);
7901        assert!(
7902            history
7903                .iter()
7904                .all(|msg| !msg.content.contains("[Tool call parse error]")),
7905            "toolcalls reference JSON must not trigger internal parser feedback"
7906        );
7907    }
7908
7909    #[tokio::test]
7910    async fn run_tool_call_loop_returns_schema_json_array_when_no_tools_are_enabled() {
7911        let schema = r#"[{"name":"planner","parameters":{"goal":"string"}}]"#;
7912        let provider = ScriptedModelProvider::from_text_responses(vec![schema]);
7913        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
7914        let mut history = vec![
7915            ChatMessage::system("test-system"),
7916            ChatMessage::user("return a JSON schema array"),
7917        ];
7918        let observer = NoopObserver;
7919
7920        let result = run_tool_call_loop(
7921            &provider,
7922            &mut history,
7923            &tools_registry,
7924            &observer,
7925            "mock-provider",
7926            "mock-model",
7927            Some(0.0),
7928            true,
7929            None,
7930            "cli",
7931            None,
7932            &zeroclaw_config::schema::MultimodalConfig::default(),
7933            4,
7934            None,
7935            None,
7936            None,
7937            &[],
7938            &[],
7939            None,
7940            None,
7941            &zeroclaw_config::schema::PacingConfig::default(),
7942            false,
7943            0,
7944            0,
7945            None,
7946            None, // channel
7947            None, // receipt_generator
7948            None, // collected_receipts
7949        )
7950        .await
7951        .expect("schema JSON should remain visible without tools");
7952
7953        assert_eq!(result, schema);
7954        assert!(
7955            history
7956                .iter()
7957                .all(|msg| !msg.content.contains("[Tool call parse error]")),
7958            "plain schema JSON must not trigger internal parser feedback"
7959        );
7960    }
7961
7962    #[tokio::test]
7963    async fn run_tool_call_loop_returns_tool_calls_audit_json_when_no_tools_are_enabled() {
7964        let audit_json =
7965            r#"{"tool_calls":[{"id":"case-1","status":"queued","service":"billing"}]}"#;
7966        let provider = ScriptedModelProvider::from_text_responses(vec![audit_json]);
7967        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
7968        let mut history = vec![
7969            ChatMessage::system("test-system"),
7970            ChatMessage::user("return a tool call audit JSON object"),
7971        ];
7972        let observer = NoopObserver;
7973
7974        let result = run_tool_call_loop(
7975            &provider,
7976            &mut history,
7977            &tools_registry,
7978            &observer,
7979            "mock-provider",
7980            "mock-model",
7981            Some(0.0),
7982            true,
7983            None,
7984            "cli",
7985            None,
7986            &zeroclaw_config::schema::MultimodalConfig::default(),
7987            4,
7988            None,
7989            None,
7990            None,
7991            &[],
7992            &[],
7993            None,
7994            None,
7995            &zeroclaw_config::schema::PacingConfig::default(),
7996            false,
7997            0,
7998            0,
7999            None,
8000            None, // channel
8001            None, // receipt_generator
8002            None, // collected_receipts
8003        )
8004        .await
8005        .expect("audit JSON should remain visible without tools");
8006
8007        assert_eq!(result, audit_json);
8008        assert!(
8009            history
8010                .iter()
8011                .all(|msg| !msg.content.contains("[Tool call parse error]")),
8012            "business tool_calls JSON must not trigger internal parser feedback"
8013        );
8014    }
8015
8016    #[tokio::test]
8017    async fn run_tool_call_loop_returns_function_call_reference_json_when_no_tools_are_enabled() {
8018        let reference_json =
8019            r#"{"type":"function_call","name":"support_case","arguments":{"id":"A1"}}"#;
8020        let provider = ScriptedModelProvider::from_text_responses(vec![reference_json]);
8021        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
8022        let mut history = vec![
8023            ChatMessage::system("test-system"),
8024            ChatMessage::user("return a function_call reference JSON object"),
8025        ];
8026        let observer = NoopObserver;
8027
8028        let result = run_tool_call_loop(
8029            &provider,
8030            &mut history,
8031            &tools_registry,
8032            &observer,
8033            "mock-provider",
8034            "mock-model",
8035            Some(0.0),
8036            true,
8037            None,
8038            "cli",
8039            None,
8040            &zeroclaw_config::schema::MultimodalConfig::default(),
8041            4,
8042            None,
8043            None,
8044            None,
8045            &[],
8046            &[],
8047            None,
8048            None,
8049            &zeroclaw_config::schema::PacingConfig::default(),
8050            false,
8051            0,
8052            0,
8053            None,
8054            None, // channel
8055            None, // receipt_generator
8056            None, // collected_receipts
8057        )
8058        .await
8059        .expect("reference JSON should remain visible without tools");
8060
8061        assert_eq!(result, reference_json);
8062        assert!(
8063            history
8064                .iter()
8065                .all(|msg| !msg.content.contains("[Tool call parse error]")),
8066            "reference function_call JSON must not trigger internal parser feedback"
8067        );
8068    }
8069
8070    #[tokio::test]
8071    async fn run_tool_call_loop_returns_tool_call_tag_example_when_no_tools_are_enabled() {
8072        let example = r#"<tool_call>
8073{"name":"shell","arguments":{"command":"pwd"}}
8074</tool_call>
8075This is an example, not an invocation."#;
8076        let provider = ScriptedModelProvider::from_text_responses(vec![example]);
8077        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
8078        let mut history = vec![
8079            ChatMessage::system("test-system"),
8080            ChatMessage::user("show a tool_call tag example"),
8081        ];
8082        let observer = NoopObserver;
8083
8084        let result = run_tool_call_loop(
8085            &provider,
8086            &mut history,
8087            &tools_registry,
8088            &observer,
8089            "mock-provider",
8090            "mock-model",
8091            Some(0.0),
8092            true,
8093            None,
8094            "cli",
8095            None,
8096            &zeroclaw_config::schema::MultimodalConfig::default(),
8097            4,
8098            None,
8099            None,
8100            None,
8101            &[],
8102            &[],
8103            None,
8104            None,
8105            &zeroclaw_config::schema::PacingConfig::default(),
8106            false,
8107            0,
8108            0,
8109            None,
8110            None, // channel
8111            None, // receipt_generator
8112            None, // collected_receipts
8113        )
8114        .await
8115        .expect("tool_call tag examples should remain visible without tools");
8116
8117        assert_eq!(result, example);
8118        assert!(
8119            history
8120                .iter()
8121                .all(|msg| !msg.content.contains("[Tool call parse error]")),
8122            "tool_call tag examples must not trigger internal parser feedback"
8123        );
8124    }
8125
8126    #[tokio::test]
8127    async fn run_tool_call_loop_streams_tool_call_fenced_example_with_registered_tool() {
8128        let example = r#"```tool_call
8129{"name":"count_tool","arguments":{"value":"X"}}
8130```
8131This is an example, not an invocation."#;
8132        let provider = StreamingScriptedModelProvider::from_text_responses(vec![example]);
8133        let invocations = Arc::new(AtomicUsize::new(0));
8134        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
8135            "count_tool",
8136            Arc::clone(&invocations),
8137        ))];
8138        let mut history = vec![
8139            ChatMessage::system("test-system"),
8140            ChatMessage::user("show a registered tool_call fenced example"),
8141        ];
8142        let observer = NoopObserver;
8143        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(16);
8144
8145        let result = run_tool_call_loop(
8146            &provider,
8147            &mut history,
8148            &tools_registry,
8149            &observer,
8150            "mock-provider",
8151            "mock-model",
8152            Some(0.0),
8153            true,
8154            None,
8155            "matrix",
8156            None,
8157            &zeroclaw_config::schema::MultimodalConfig::default(),
8158            4,
8159            None,
8160            Some(tx),
8161            None,
8162            &[],
8163            &[],
8164            None,
8165            None,
8166            &zeroclaw_config::schema::PacingConfig::default(),
8167            false,
8168            0,
8169            0,
8170            None,
8171            None, // channel
8172            None, // receipt_generator
8173            None, // collected_receipts
8174        )
8175        .await
8176        .expect("registered tool_call fenced examples should remain visible");
8177
8178        let mut visible_deltas = String::new();
8179        while let Some(delta) = rx.recv().await {
8180            if let StreamDelta::Text(text) = delta {
8181                visible_deltas.push_str(&text);
8182            }
8183        }
8184
8185        assert_eq!(result, example);
8186        assert_eq!(visible_deltas, example);
8187        assert_eq!(
8188            invocations.load(Ordering::SeqCst),
8189            0,
8190            "tool-call examples must not execute registered tools"
8191        );
8192        assert!(
8193            history
8194                .iter()
8195                .all(|msg| !msg.content.contains("[Tool call parse error]")),
8196            "tool-call examples must not trigger internal parser feedback"
8197        );
8198    }
8199
8200    #[tokio::test]
8201    async fn run_tool_call_loop_returns_tool_call_tag_example_with_registered_tool() {
8202        let example = r#"<tool_call>
8203{"name":"count_tool","arguments":{"value":"X"}}
8204</tool_call>
8205This is an example, not an invocation."#;
8206        let provider = ScriptedModelProvider::from_text_responses(vec![example]);
8207        let invocations = Arc::new(AtomicUsize::new(0));
8208        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
8209            "count_tool",
8210            Arc::clone(&invocations),
8211        ))];
8212        let mut history = vec![
8213            ChatMessage::system("test-system"),
8214            ChatMessage::user("show a registered tool_call tag example"),
8215        ];
8216        let observer = NoopObserver;
8217
8218        let result = run_tool_call_loop(
8219            &provider,
8220            &mut history,
8221            &tools_registry,
8222            &observer,
8223            "mock-provider",
8224            "mock-model",
8225            Some(0.0),
8226            true,
8227            None,
8228            "cli",
8229            None,
8230            &zeroclaw_config::schema::MultimodalConfig::default(),
8231            4,
8232            None,
8233            None,
8234            None,
8235            &[],
8236            &[],
8237            None,
8238            None,
8239            &zeroclaw_config::schema::PacingConfig::default(),
8240            false,
8241            0,
8242            0,
8243            None,
8244            None, // channel
8245            None, // receipt_generator
8246            None, // collected_receipts
8247        )
8248        .await
8249        .expect("registered tool_call tag examples should remain visible");
8250
8251        assert_eq!(result, example);
8252        assert_eq!(
8253            invocations.load(Ordering::SeqCst),
8254            0,
8255            "tool-call tag examples must not execute registered tools"
8256        );
8257    }
8258
8259    #[tokio::test]
8260    async fn run_tool_call_loop_retries_tagged_tool_call_with_trailing_text_without_tools() {
8261        let leaked = r#"<tool_call>
8262{"name":"shell","arguments":{"command":"pwd"}}
8263</tool_call>
8264Done."#;
8265        let provider =
8266            ScriptedModelProvider::from_text_responses(vec![leaked, "Recovered answer."]);
8267        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
8268        let mut history = vec![
8269            ChatMessage::system("test-system"),
8270            ChatMessage::user("run without tools"),
8271        ];
8272        let observer = NoopObserver;
8273
8274        let result = run_tool_call_loop(
8275            &provider,
8276            &mut history,
8277            &tools_registry,
8278            &observer,
8279            "mock-provider",
8280            "mock-model",
8281            Some(0.0),
8282            true,
8283            None,
8284            "cli",
8285            None,
8286            &zeroclaw_config::schema::MultimodalConfig::default(),
8287            4,
8288            None,
8289            None,
8290            None,
8291            &[],
8292            &[],
8293            None,
8294            None,
8295            &zeroclaw_config::schema::PacingConfig::default(),
8296            false,
8297            0,
8298            0,
8299            None,
8300            None, // channel
8301            None, // receipt_generator
8302            None, // collected_receipts
8303        )
8304        .await
8305        .expect("tagged tool protocol with trailing text should retry and recover");
8306
8307        assert_eq!(result, "Recovered answer.");
8308        assert!(!result.contains("<tool_call>"));
8309        assert!(
8310            history
8311                .iter()
8312                .any(|msg| msg.role == "user" && msg.content.contains("[Tool call parse error]")),
8313            "tagged tool protocol with trailing text must trigger internal parser feedback"
8314        );
8315    }
8316
8317    #[tokio::test]
8318    async fn run_tool_call_loop_retries_embedded_fenced_tool_call_without_tools() {
8319        let leaked = r#"Let me call it:
8320```tool_call
8321{"name":"shell","arguments":{"command":"pwd"}}
8322```
8323Done."#;
8324        let provider =
8325            ScriptedModelProvider::from_text_responses(vec![leaked, "Recovered answer."]);
8326        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
8327        let mut history = vec![
8328            ChatMessage::system("test-system"),
8329            ChatMessage::user("run without tools"),
8330        ];
8331        let observer = NoopObserver;
8332
8333        let result = run_tool_call_loop(
8334            &provider,
8335            &mut history,
8336            &tools_registry,
8337            &observer,
8338            "mock-provider",
8339            "mock-model",
8340            Some(0.0),
8341            true,
8342            None,
8343            "matrix",
8344            None,
8345            &zeroclaw_config::schema::MultimodalConfig::default(),
8346            4,
8347            None,
8348            None,
8349            None,
8350            &[],
8351            &[],
8352            None,
8353            None,
8354            &zeroclaw_config::schema::PacingConfig::default(),
8355            false,
8356            0,
8357            0,
8358            None,
8359            None, // channel
8360            None, // receipt_generator
8361            None, // collected_receipts
8362        )
8363        .await
8364        .expect("embedded fenced tool protocol should retry and recover");
8365
8366        assert_eq!(result, "Recovered answer.");
8367        assert!(!result.contains("```tool_call"));
8368        assert!(
8369            history
8370                .iter()
8371                .any(|msg| msg.role == "user" && msg.content.contains("[Tool call parse error]")),
8372            "embedded fenced tool protocol must trigger internal parser feedback"
8373        );
8374    }
8375
8376    #[tokio::test]
8377    async fn run_tool_call_loop_retries_malformed_tool_protocol_fenced_call_without_tools() {
8378        let leaked = r#"```tool_call
8379{"name":"shell","arguments":{"command":"pwd"}}
8380```"#;
8381        let provider =
8382            ScriptedModelProvider::from_text_responses(vec![leaked, "Recovered answer."]);
8383        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
8384        let mut history = vec![
8385            ChatMessage::system("test-system"),
8386            ChatMessage::user("run without tools"),
8387        ];
8388        let observer = NoopObserver;
8389
8390        let result = run_tool_call_loop(
8391            &provider,
8392            &mut history,
8393            &tools_registry,
8394            &observer,
8395            "mock-provider",
8396            "mock-model",
8397            Some(0.0),
8398            true,
8399            None,
8400            "cli",
8401            None,
8402            &zeroclaw_config::schema::MultimodalConfig::default(),
8403            4,
8404            None,
8405            None,
8406            None,
8407            &[],
8408            &[],
8409            None,
8410            None,
8411            &zeroclaw_config::schema::PacingConfig::default(),
8412            false,
8413            0,
8414            0,
8415            None,
8416            None, // channel
8417            None, // receipt_generator
8418            None, // collected_receipts
8419        )
8420        .await
8421        .expect("standalone tool_call fence should retry and recover without tools");
8422
8423        assert_eq!(result, "Recovered answer.");
8424        assert!(!result.contains("```tool_call"));
8425        assert!(
8426            history
8427                .iter()
8428                .any(|msg| msg.role == "user" && msg.content.contains("[Tool call parse error]")),
8429            "standalone tool_call fence must trigger internal parser feedback"
8430        );
8431    }
8432
8433    #[tokio::test]
8434    async fn run_tool_call_loop_streams_tool_call_fenced_example_when_no_tools_are_enabled() {
8435        let example = r#"```tool_call
8436{"name":"shell","arguments":{"command":"pwd"}}
8437```
8438This is an example, not an invocation."#;
8439        let provider = StreamingScriptedModelProvider::from_text_responses(vec![example]);
8440        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
8441        let mut history = vec![
8442            ChatMessage::system("test-system"),
8443            ChatMessage::user("show a tool_call fenced example"),
8444        ];
8445        let observer = NoopObserver;
8446        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(16);
8447
8448        let result = run_tool_call_loop(
8449            &provider,
8450            &mut history,
8451            &tools_registry,
8452            &observer,
8453            "mock-provider",
8454            "mock-model",
8455            Some(0.0),
8456            true,
8457            None,
8458            "matrix",
8459            None,
8460            &zeroclaw_config::schema::MultimodalConfig::default(),
8461            4,
8462            None,
8463            Some(tx),
8464            None,
8465            &[],
8466            &[],
8467            None,
8468            None,
8469            &zeroclaw_config::schema::PacingConfig::default(),
8470            false,
8471            0,
8472            0,
8473            None,
8474            None, // channel
8475            None, // receipt_generator
8476            None, // collected_receipts
8477        )
8478        .await
8479        .expect("tool_call fenced examples should remain visible without tools");
8480
8481        let mut visible_deltas = String::new();
8482        while let Some(delta) = rx.recv().await {
8483            if let StreamDelta::Text(text) = delta {
8484                visible_deltas.push_str(&text);
8485            }
8486        }
8487
8488        assert_eq!(result, example);
8489        assert_eq!(visible_deltas, example);
8490        assert!(
8491            history
8492                .iter()
8493                .all(|msg| !msg.content.contains("[Tool call parse error]")),
8494            "tool_call fenced examples must not trigger internal parser feedback"
8495        );
8496    }
8497
8498    #[tokio::test]
8499    async fn run_tool_call_loop_streams_split_tool_call_fenced_example_when_no_tools_are_enabled() {
8500        struct SplitFencedExampleProvider;
8501        impl_test_model_provider_attribution!(SplitFencedExampleProvider);
8502
8503        #[async_trait]
8504        impl ModelProvider for SplitFencedExampleProvider {
8505            async fn chat_with_system(
8506                &self,
8507                _system_prompt: Option<&str>,
8508                _message: &str,
8509                _model: &str,
8510                _temperature: Option<f64>,
8511            ) -> anyhow::Result<String> {
8512                anyhow::bail!("not used in this test")
8513            }
8514
8515            async fn chat(
8516                &self,
8517                _request: ChatRequest<'_>,
8518                _model: &str,
8519                _temperature: Option<f64>,
8520            ) -> anyhow::Result<ChatResponse> {
8521                anyhow::bail!("chat should not be called when streaming succeeds")
8522            }
8523
8524            fn supports_streaming(&self) -> bool {
8525                true
8526            }
8527
8528            fn stream_chat(
8529                &self,
8530                _request: ChatRequest<'_>,
8531                _model: &str,
8532                _temperature: Option<f64>,
8533                _options: StreamOptions,
8534            ) -> futures_util::stream::BoxStream<
8535                'static,
8536                zeroclaw_providers::traits::StreamResult<StreamEvent>,
8537            > {
8538                Box::pin(futures_util::stream::iter(vec![
8539                    Ok(StreamEvent::TextDelta(StreamChunk::delta(
8540                        "```tool_call\n{\"name\":\"shell\",\"arguments\":{\"command\":\"pwd\"}}\n```",
8541                    ))),
8542                    Ok(StreamEvent::TextDelta(StreamChunk::delta(
8543                        "\nThis is an example, not an invocation.",
8544                    ))),
8545                    Ok(StreamEvent::Final),
8546                ]))
8547            }
8548        }
8549
8550        let example = r#"```tool_call
8551{"name":"shell","arguments":{"command":"pwd"}}
8552```
8553This is an example, not an invocation."#;
8554        let provider = SplitFencedExampleProvider;
8555        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
8556        let mut history = vec![
8557            ChatMessage::system("test-system"),
8558            ChatMessage::user("show a split tool_call fenced example"),
8559        ];
8560        let observer = NoopObserver;
8561        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(16);
8562
8563        let result = run_tool_call_loop(
8564            &provider,
8565            &mut history,
8566            &tools_registry,
8567            &observer,
8568            "mock-provider",
8569            "mock-model",
8570            Some(0.0),
8571            true,
8572            None,
8573            "matrix",
8574            None,
8575            &zeroclaw_config::schema::MultimodalConfig::default(),
8576            4,
8577            None,
8578            Some(tx),
8579            None,
8580            &[],
8581            &[],
8582            None,
8583            None,
8584            &zeroclaw_config::schema::PacingConfig::default(),
8585            false,
8586            0,
8587            0,
8588            None,
8589            None, // channel
8590            None, // receipt_generator
8591            None, // collected_receipts
8592        )
8593        .await
8594        .expect("split tool_call fenced examples should remain visible without tools");
8595
8596        let mut visible_deltas = String::new();
8597        while let Some(delta) = rx.recv().await {
8598            if let StreamDelta::Text(text) = delta {
8599                visible_deltas.push_str(&text);
8600            }
8601        }
8602
8603        assert_eq!(result, example);
8604        assert_eq!(visible_deltas, example);
8605        assert!(
8606            history
8607                .iter()
8608                .all(|msg| !msg.content.contains("[Tool call parse error]")),
8609            "split tool_call fenced examples must not trigger internal parser feedback"
8610        );
8611    }
8612
8613    #[tokio::test]
8614    async fn run_tool_call_loop_streams_json_fenced_tool_protocol_example_when_no_tools_are_enabled()
8615     {
8616        let example = r#"```json
8617{"tool_calls":[{"name":"shell","arguments":{"command":"pwd"}}]}
8618```
8619This is an example, not an invocation."#;
8620        let provider = StreamingScriptedModelProvider::from_text_responses(vec![example]);
8621        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
8622        let mut history = vec![
8623            ChatMessage::system("test-system"),
8624            ChatMessage::user("show a JSON tool_calls example"),
8625        ];
8626        let observer = NoopObserver;
8627        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(16);
8628
8629        let result = run_tool_call_loop(
8630            &provider,
8631            &mut history,
8632            &tools_registry,
8633            &observer,
8634            "mock-provider",
8635            "mock-model",
8636            Some(0.0),
8637            true,
8638            None,
8639            "matrix",
8640            None,
8641            &zeroclaw_config::schema::MultimodalConfig::default(),
8642            4,
8643            None,
8644            Some(tx),
8645            None,
8646            &[],
8647            &[],
8648            None,
8649            None,
8650            &zeroclaw_config::schema::PacingConfig::default(),
8651            false,
8652            0,
8653            0,
8654            None,
8655            None, // channel
8656            None, // receipt_generator
8657            None, // collected_receipts
8658        )
8659        .await
8660        .expect("JSON-fenced tool protocol examples should remain visible without tools");
8661
8662        let mut visible_deltas = String::new();
8663        while let Some(delta) = rx.recv().await {
8664            if let StreamDelta::Text(text) = delta {
8665                visible_deltas.push_str(&text);
8666            }
8667        }
8668
8669        assert_eq!(result, example);
8670        assert_eq!(visible_deltas, example);
8671        assert!(
8672            history
8673                .iter()
8674                .all(|msg| !msg.content.contains("[Tool call parse error]")),
8675            "JSON-fenced tool protocol examples must not trigger internal parser feedback"
8676        );
8677    }
8678
8679    #[tokio::test]
8680    async fn run_tool_call_loop_executes_streamed_tool_call_fence_without_draft_leak() {
8681        let provider = StreamingScriptedModelProvider::from_text_responses(vec![
8682            r#"```tool_call
8683{"name":"count_tool","arguments":{"value":"X"}}
8684```"#,
8685            "Final answer.",
8686        ]);
8687        let invocations = Arc::new(AtomicUsize::new(0));
8688        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
8689            "count_tool",
8690            Arc::clone(&invocations),
8691        ))];
8692        let mut history = vec![
8693            ChatMessage::system("test-system"),
8694            ChatMessage::user("use the tool"),
8695        ];
8696        let observer = NoopObserver;
8697        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(16);
8698
8699        let result = run_tool_call_loop(
8700            &provider,
8701            &mut history,
8702            &tools_registry,
8703            &observer,
8704            "mock-provider",
8705            "mock-model",
8706            Some(0.0),
8707            true,
8708            None,
8709            "matrix",
8710            None,
8711            &zeroclaw_config::schema::MultimodalConfig::default(),
8712            4,
8713            None,
8714            Some(tx),
8715            None,
8716            &[],
8717            &[],
8718            None,
8719            None,
8720            &zeroclaw_config::schema::PacingConfig::default(),
8721            false,
8722            0,
8723            0,
8724            None,
8725            None, // channel
8726            None, // receipt_generator
8727            None, // collected_receipts
8728        )
8729        .await
8730        .expect("streamed fenced tool call should execute and continue");
8731
8732        let mut visible_deltas = String::new();
8733        while let Some(delta) = rx.recv().await {
8734            if let StreamDelta::Text(text) = delta {
8735                visible_deltas.push_str(&text);
8736            }
8737        }
8738
8739        assert_eq!(result, "Final answer.");
8740        assert_eq!(invocations.load(Ordering::SeqCst), 1);
8741        assert_eq!(visible_deltas, "Final answer.");
8742        assert!(
8743            !visible_deltas.contains("```tool_call"),
8744            "streamed fenced tool call must not reach draft updates before execution"
8745        );
8746    }
8747
8748    #[tokio::test]
8749    async fn run_tool_call_loop_relays_native_tool_call_text_via_on_delta() {
8750        let model_provider = ScriptedModelProvider {
8751            responses: Arc::new(Mutex::new(VecDeque::from(vec![
8752                ChatResponse {
8753                    text: Some("Task started. Waiting 30 seconds before checking status.".into()),
8754                    tool_calls: vec![ToolCall {
8755                        id: "call_wait".into(),
8756                        name: "count_tool".into(),
8757                        arguments: r#"{"value":"A"}"#.into(),
8758                        extra_content: None,
8759                    }],
8760                    usage: None,
8761                    reasoning_content: None,
8762                },
8763                ChatResponse {
8764                    text: Some("Final answer".into()),
8765                    tool_calls: Vec::new(),
8766                    usage: None,
8767                    reasoning_content: None,
8768                },
8769            ]))),
8770            capabilities: ProviderCapabilities {
8771                native_tool_calling: true,
8772                ..ProviderCapabilities::default()
8773            },
8774        };
8775
8776        let invocations = Arc::new(AtomicUsize::new(0));
8777        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
8778            "count_tool",
8779            Arc::clone(&invocations),
8780        ))];
8781
8782        let mut history = vec![
8783            ChatMessage::system("test-system"),
8784            ChatMessage::user("run tool calls"),
8785        ];
8786        let observer = NoopObserver;
8787        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
8788
8789        let result = run_tool_call_loop(
8790            &model_provider,
8791            &mut history,
8792            &tools_registry,
8793            &observer,
8794            "mock-provider",
8795            "mock-model",
8796            Some(0.0),
8797            true,
8798            None,
8799            "telegram",
8800            None,
8801            &zeroclaw_config::schema::MultimodalConfig::default(),
8802            4,
8803            None,
8804            Some(tx),
8805            None,
8806            &[],
8807            &[],
8808            None,
8809            None,
8810            &zeroclaw_config::schema::PacingConfig::default(),
8811            false,
8812            0,
8813            0,
8814            None,
8815            None, // channel
8816            None, // receipt_generator
8817            None, // collected_receipts
8818        )
8819        .await
8820        .expect("native tool-call text should be relayed through on_delta");
8821
8822        let mut deltas: Vec<DraftEvent> = Vec::new();
8823        while let Some(delta) = rx.recv().await {
8824            deltas.push(delta);
8825        }
8826
8827        assert!(
8828            deltas
8829                .iter()
8830                .any(|delta| matches!(delta, StreamDelta::Text(t) if t == "Task started. Waiting 30 seconds before checking status.\n")),
8831            "native assistant text should be relayed to on_delta"
8832        );
8833        assert!(
8834            deltas
8835                .iter()
8836                .any(|delta| matches!(delta, StreamDelta::Status(t) if t.starts_with("\u{1f4ac} Got 1 tool call(s)"))),
8837            "tool-call progress line should still be relayed"
8838        );
8839        assert!(
8840            result.ends_with("Final answer"),
8841            "accumulated result should end with final answer, got: {result}"
8842        );
8843        assert_eq!(invocations.load(Ordering::SeqCst), 1);
8844    }
8845
8846    #[tokio::test]
8847    async fn run_tool_call_loop_consumes_provider_stream_for_final_response() {
8848        let model_provider =
8849            StreamingScriptedModelProvider::from_text_responses(vec!["streamed final answer"]);
8850        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
8851        let mut history = vec![
8852            ChatMessage::system("test-system"),
8853            ChatMessage::user("say hi"),
8854        ];
8855        let observer = NoopObserver;
8856        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(32);
8857
8858        let result = run_tool_call_loop(
8859            &model_provider,
8860            &mut history,
8861            &tools_registry,
8862            &observer,
8863            "mock-provider",
8864            "mock-model",
8865            Some(0.0),
8866            true,
8867            None,
8868            "telegram",
8869            None,
8870            &zeroclaw_config::schema::MultimodalConfig::default(),
8871            4,
8872            None,
8873            Some(tx),
8874            None,
8875            &[],
8876            &[],
8877            None,
8878            None,
8879            &zeroclaw_config::schema::PacingConfig::default(),
8880            false,
8881            0,
8882            0,
8883            None,
8884            None, // channel
8885            None, // receipt_generator
8886            None, // collected_receipts
8887        )
8888        .await
8889        .expect("streaming model_provider should complete");
8890
8891        let mut visible_deltas = String::new();
8892        while let Some(delta) = rx.recv().await {
8893            match delta {
8894                StreamDelta::Status(_) => {}
8895                StreamDelta::Text(text) => {
8896                    visible_deltas.push_str(&text);
8897                }
8898            }
8899        }
8900
8901        assert_eq!(result, "streamed final answer");
8902        assert_eq!(
8903            visible_deltas, "streamed final answer",
8904            "draft should receive upstream deltas once without post-hoc duplication"
8905        );
8906        assert_eq!(model_provider.stream_calls.load(Ordering::SeqCst), 1);
8907        assert_eq!(model_provider.chat_calls.load(Ordering::SeqCst), 0);
8908    }
8909
8910    #[tokio::test]
8911    async fn run_tool_call_loop_streaming_path_preserves_tool_loop_semantics() {
8912        let model_provider = StreamingScriptedModelProvider::from_text_responses(vec![
8913            r#"<tool_call>
8914{"name":"count_tool","arguments":{"value":"A"}}
8915</tool_call>"#,
8916            "done",
8917        ]);
8918        let invocations = Arc::new(AtomicUsize::new(0));
8919        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
8920            "count_tool",
8921            Arc::clone(&invocations),
8922        ))];
8923        let mut history = vec![
8924            ChatMessage::system("test-system"),
8925            ChatMessage::user("run tool calls"),
8926        ];
8927        let observer = NoopObserver;
8928        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(64);
8929
8930        let result = run_tool_call_loop(
8931            &model_provider,
8932            &mut history,
8933            &tools_registry,
8934            &observer,
8935            "mock-provider",
8936            "mock-model",
8937            Some(0.0),
8938            true,
8939            None,
8940            "telegram",
8941            None,
8942            &zeroclaw_config::schema::MultimodalConfig::default(),
8943            5,
8944            None,
8945            Some(tx),
8946            None,
8947            &[],
8948            &[],
8949            None,
8950            None,
8951            &zeroclaw_config::schema::PacingConfig::default(),
8952            false,
8953            0,
8954            0,
8955            None,
8956            None, // channel
8957            None, // receipt_generator
8958            None, // collected_receipts
8959        )
8960        .await
8961        .expect("streaming tool loop should execute tool and finish");
8962
8963        let mut visible_deltas = String::new();
8964        while let Some(delta) = rx.recv().await {
8965            match delta {
8966                StreamDelta::Status(_) => {}
8967                StreamDelta::Text(text) => {
8968                    visible_deltas.push_str(&text);
8969                }
8970            }
8971        }
8972
8973        assert!(
8974            result.ends_with("done"),
8975            "result should end with 'done', got: {result}"
8976        );
8977        assert_eq!(invocations.load(Ordering::SeqCst), 1);
8978        assert_eq!(model_provider.stream_calls.load(Ordering::SeqCst), 2);
8979        assert_eq!(model_provider.chat_calls.load(Ordering::SeqCst), 0);
8980        assert_eq!(visible_deltas, "done");
8981        assert!(
8982            !visible_deltas.contains("<tool_call"),
8983            "draft text should not leak streamed tool payload markers"
8984        );
8985    }
8986
8987    #[tokio::test]
8988    async fn consume_provider_streaming_response_buffers_split_tool_protocol_markers() {
8989        struct SplitToolProtocolProvider;
8990        impl_test_model_provider_attribution!(SplitToolProtocolProvider);
8991
8992        #[async_trait]
8993        impl ModelProvider for SplitToolProtocolProvider {
8994            async fn chat_with_system(
8995                &self,
8996                _system_prompt: Option<&str>,
8997                _message: &str,
8998                _model: &str,
8999                _temperature: Option<f64>,
9000            ) -> anyhow::Result<String> {
9001                anyhow::bail!("not used in this test")
9002            }
9003
9004            async fn chat(
9005                &self,
9006                _request: ChatRequest<'_>,
9007                _model: &str,
9008                _temperature: Option<f64>,
9009            ) -> anyhow::Result<ChatResponse> {
9010                anyhow::bail!("not used in this test")
9011            }
9012
9013            fn supports_streaming(&self) -> bool {
9014                true
9015            }
9016
9017            fn stream_chat(
9018                &self,
9019                _request: ChatRequest<'_>,
9020                _model: &str,
9021                _temperature: Option<f64>,
9022                _options: StreamOptions,
9023            ) -> futures_util::stream::BoxStream<
9024                'static,
9025                zeroclaw_providers::traits::StreamResult<StreamEvent>,
9026            > {
9027                Box::pin(futures_util::stream::iter(vec![
9028                    Ok(StreamEvent::TextDelta(StreamChunk::delta(r#"{"tool"#))),
9029                    Ok(StreamEvent::TextDelta(StreamChunk::delta(
9030                        r#"calls":[{"name":"count_tool","arguments":{"value":"X"}}]}"#,
9031                    ))),
9032                    Ok(StreamEvent::Final),
9033                ]))
9034            }
9035        }
9036
9037        let provider = SplitToolProtocolProvider;
9038        let messages = vec![ChatMessage::user("hi")];
9039        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(8);
9040
9041        let outcome = consume_provider_streaming_response(
9042            &provider,
9043            &messages,
9044            Some(&[crate::tools::ToolSpec {
9045                name: "count_tool".to_string(),
9046                description: "Count values".to_string(),
9047                parameters: serde_json::json!({"type": "object"}),
9048            }]),
9049            "mock-model",
9050            Some(0.0),
9051            None,
9052            Some(&tx),
9053            false,
9054        )
9055        .await
9056        .expect("streaming should finish");
9057        drop(tx);
9058
9059        let mut visible_deltas = String::new();
9060        while let Some(delta) = rx.recv().await {
9061            if let StreamDelta::Text(text) = delta {
9062                visible_deltas.push_str(&text);
9063            }
9064        }
9065
9066        assert!(outcome.response_text.contains("\"toolcalls\""));
9067        assert_eq!(
9068            visible_deltas, "",
9069            "split internal protocol markers must not reach draft updates"
9070        );
9071    }
9072
9073    #[tokio::test]
9074    async fn consume_provider_streaming_response_buffers_top_level_tool_call_array() {
9075        struct TopLevelToolArrayProvider;
9076        impl_test_model_provider_attribution!(TopLevelToolArrayProvider);
9077
9078        #[async_trait]
9079        impl ModelProvider for TopLevelToolArrayProvider {
9080            async fn chat_with_system(
9081                &self,
9082                _system_prompt: Option<&str>,
9083                _message: &str,
9084                _model: &str,
9085                _temperature: Option<f64>,
9086            ) -> anyhow::Result<String> {
9087                anyhow::bail!("not used in this test")
9088            }
9089
9090            async fn chat(
9091                &self,
9092                _request: ChatRequest<'_>,
9093                _model: &str,
9094                _temperature: Option<f64>,
9095            ) -> anyhow::Result<ChatResponse> {
9096                anyhow::bail!("not used in this test")
9097            }
9098
9099            fn supports_streaming(&self) -> bool {
9100                true
9101            }
9102
9103            fn stream_chat(
9104                &self,
9105                _request: ChatRequest<'_>,
9106                _model: &str,
9107                _temperature: Option<f64>,
9108                _options: StreamOptions,
9109            ) -> futures_util::stream::BoxStream<
9110                'static,
9111                zeroclaw_providers::traits::StreamResult<StreamEvent>,
9112            > {
9113                Box::pin(futures_util::stream::iter(vec![
9114                    Ok(StreamEvent::TextDelta(StreamChunk::delta(
9115                        r#"[{"name":"count_tool","arguments":{"value":"X"}}]"#,
9116                    ))),
9117                    Ok(StreamEvent::Final),
9118                ]))
9119            }
9120        }
9121
9122        let provider = TopLevelToolArrayProvider;
9123        let messages = vec![ChatMessage::user("hi")];
9124        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(8);
9125
9126        let outcome = consume_provider_streaming_response(
9127            &provider,
9128            &messages,
9129            Some(&[crate::tools::ToolSpec {
9130                name: "count_tool".to_string(),
9131                description: "Count values".to_string(),
9132                parameters: serde_json::json!({"type": "object"}),
9133            }]),
9134            "mock-model",
9135            Some(0.0),
9136            None,
9137            Some(&tx),
9138            false,
9139        )
9140        .await
9141        .expect("streaming should finish");
9142        drop(tx);
9143
9144        let mut visible_deltas = String::new();
9145        while let Some(delta) = rx.recv().await {
9146            if let StreamDelta::Text(text) = delta {
9147                visible_deltas.push_str(&text);
9148            }
9149        }
9150
9151        assert!(outcome.response_text.contains("\"name\""));
9152        assert_eq!(
9153            visible_deltas, "",
9154            "top-level tool-call arrays must not reach draft updates"
9155        );
9156    }
9157
9158    #[tokio::test]
9159    async fn consume_provider_streaming_response_preserves_schema_array_without_tools() {
9160        let provider = StreamingScriptedModelProvider::from_text_responses(vec![
9161            r#"[{"name":"planner","parameters":{"goal":"string"}}]"#,
9162        ]);
9163        let messages = vec![ChatMessage::user("return a JSON schema array")];
9164        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(8);
9165
9166        let outcome = consume_provider_streaming_response(
9167            &provider,
9168            &messages,
9169            None,
9170            "mock-model",
9171            Some(0.0),
9172            None,
9173            Some(&tx),
9174            false,
9175        )
9176        .await
9177        .expect("streaming should finish");
9178        drop(tx);
9179
9180        let mut visible_deltas = String::new();
9181        while let Some(delta) = rx.recv().await {
9182            if let StreamDelta::Text(text) = delta {
9183                visible_deltas.push_str(&text);
9184            }
9185        }
9186
9187        assert_eq!(
9188            outcome.response_text,
9189            r#"[{"name":"planner","parameters":{"goal":"string"}}]"#
9190        );
9191        assert_eq!(visible_deltas, outcome.response_text);
9192    }
9193
9194    #[tokio::test]
9195    async fn consume_provider_streaming_response_preserves_unknown_function_call_json_with_tools() {
9196        let response = r#"{"type":"function_call","name":"support_case","arguments":{"id":"A1"}}"#;
9197        let provider = StreamingScriptedModelProvider::from_text_responses(vec![response]);
9198        let messages = vec![ChatMessage::user("return a support case JSON object")];
9199        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(8);
9200
9201        let outcome = consume_provider_streaming_response(
9202            &provider,
9203            &messages,
9204            Some(&[crate::tools::ToolSpec {
9205                name: "count_tool".to_string(),
9206                description: "Count values".to_string(),
9207                parameters: serde_json::json!({"type": "object"}),
9208            }]),
9209            "mock-model",
9210            Some(0.0),
9211            None,
9212            Some(&tx),
9213            false,
9214        )
9215        .await
9216        .expect("streaming should finish");
9217        drop(tx);
9218
9219        let mut visible_deltas = String::new();
9220        while let Some(delta) = rx.recv().await {
9221            if let StreamDelta::Text(text) = delta {
9222                visible_deltas.push_str(&text);
9223            }
9224        }
9225
9226        assert_eq!(outcome.response_text, response);
9227        assert_eq!(visible_deltas, response);
9228    }
9229
9230    #[tokio::test]
9231    async fn consume_provider_streaming_response_preserves_malformed_unknown_tool_calls_json_with_tools()
9232     {
9233        let response = r#"{"tool_calls":[{"name":"support_case","arguments":{"id":"A1"}}"#;
9234        let provider = StreamingScriptedModelProvider::from_text_responses(vec![response]);
9235        let messages = vec![ChatMessage::user(
9236            "return a partial support case JSON object",
9237        )];
9238        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(8);
9239
9240        let outcome = consume_provider_streaming_response(
9241            &provider,
9242            &messages,
9243            Some(&[crate::tools::ToolSpec {
9244                name: "count_tool".to_string(),
9245                description: "Count values".to_string(),
9246                parameters: serde_json::json!({"type": "object"}),
9247            }]),
9248            "mock-model",
9249            Some(0.0),
9250            None,
9251            Some(&tx),
9252            false,
9253        )
9254        .await
9255        .expect("streaming should finish");
9256        drop(tx);
9257
9258        let mut visible_deltas = String::new();
9259        while let Some(delta) = rx.recv().await {
9260            if let StreamDelta::Text(text) = delta {
9261                visible_deltas.push_str(&text);
9262            }
9263        }
9264
9265        assert_eq!(outcome.response_text, response);
9266        assert_eq!(visible_deltas, response);
9267        assert!(
9268            !outcome.suppressed_protocol,
9269            "unknown business JSON must not be suppressed as internal protocol"
9270        );
9271    }
9272
9273    #[tokio::test]
9274    async fn consume_provider_streaming_response_buffers_malformed_tool_protocol_json() {
9275        struct MalformedToolProtocolProvider;
9276        impl_test_model_provider_attribution!(MalformedToolProtocolProvider);
9277
9278        #[async_trait]
9279        impl ModelProvider for MalformedToolProtocolProvider {
9280            async fn chat_with_system(
9281                &self,
9282                _system_prompt: Option<&str>,
9283                _message: &str,
9284                _model: &str,
9285                _temperature: Option<f64>,
9286            ) -> anyhow::Result<String> {
9287                anyhow::bail!("not used in this test")
9288            }
9289
9290            async fn chat(
9291                &self,
9292                _request: ChatRequest<'_>,
9293                _model: &str,
9294                _temperature: Option<f64>,
9295            ) -> anyhow::Result<ChatResponse> {
9296                anyhow::bail!("not used in this test")
9297            }
9298
9299            fn supports_streaming(&self) -> bool {
9300                true
9301            }
9302
9303            fn stream_chat(
9304                &self,
9305                _request: ChatRequest<'_>,
9306                _model: &str,
9307                _temperature: Option<f64>,
9308                _options: StreamOptions,
9309            ) -> futures_util::stream::BoxStream<
9310                'static,
9311                zeroclaw_providers::traits::StreamResult<StreamEvent>,
9312            > {
9313                Box::pin(futures_util::stream::iter(vec![
9314                    Ok(StreamEvent::TextDelta(StreamChunk::delta(r#"{"tool_"#))),
9315                    Ok(StreamEvent::TextDelta(StreamChunk::delta(
9316                        r#"calls":[{"call_id":"call_1","arguments":{"value":"X"}}]}"#,
9317                    ))),
9318                    Ok(StreamEvent::Final),
9319                ]))
9320            }
9321        }
9322
9323        let provider = MalformedToolProtocolProvider;
9324        let messages = vec![ChatMessage::user("hi")];
9325        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(8);
9326
9327        let outcome = consume_provider_streaming_response(
9328            &provider,
9329            &messages,
9330            None,
9331            "mock-model",
9332            Some(0.0),
9333            None,
9334            Some(&tx),
9335            false,
9336        )
9337        .await
9338        .expect("streaming should finish");
9339        drop(tx);
9340
9341        let mut visible_deltas = String::new();
9342        while let Some(delta) = rx.recv().await {
9343            if let StreamDelta::Text(text) = delta {
9344                visible_deltas.push_str(&text);
9345            }
9346        }
9347
9348        assert!(outcome.response_text.contains("\"tool_calls\""));
9349        assert_eq!(
9350            visible_deltas, "",
9351            "malformed internal protocol JSON must not reach draft updates"
9352        );
9353    }
9354
9355    #[tokio::test]
9356    async fn consume_provider_streaming_response_drops_truncated_protocol_at_finish() {
9357        struct TruncatedProtocolProvider;
9358        impl_test_model_provider_attribution!(TruncatedProtocolProvider);
9359
9360        #[async_trait]
9361        impl ModelProvider for TruncatedProtocolProvider {
9362            async fn chat_with_system(
9363                &self,
9364                _system_prompt: Option<&str>,
9365                _message: &str,
9366                _model: &str,
9367                _temperature: Option<f64>,
9368            ) -> anyhow::Result<String> {
9369                anyhow::bail!("not used in this test")
9370            }
9371
9372            async fn chat(
9373                &self,
9374                _request: ChatRequest<'_>,
9375                _model: &str,
9376                _temperature: Option<f64>,
9377            ) -> anyhow::Result<ChatResponse> {
9378                anyhow::bail!("not used in this test")
9379            }
9380
9381            fn supports_streaming(&self) -> bool {
9382                true
9383            }
9384
9385            fn stream_chat(
9386                &self,
9387                _request: ChatRequest<'_>,
9388                _model: &str,
9389                _temperature: Option<f64>,
9390                _options: StreamOptions,
9391            ) -> futures_util::stream::BoxStream<
9392                'static,
9393                zeroclaw_providers::traits::StreamResult<StreamEvent>,
9394            > {
9395                Box::pin(futures_util::stream::iter(vec![
9396                    Ok(StreamEvent::TextDelta(StreamChunk::delta(
9397                        r#"{"tool_call_id":"call_1","content":"raw"#,
9398                    ))),
9399                    Ok(StreamEvent::Final),
9400                ]))
9401            }
9402        }
9403
9404        let provider = TruncatedProtocolProvider;
9405        let messages = vec![ChatMessage::user("hi")];
9406        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(8);
9407
9408        let outcome = consume_provider_streaming_response(
9409            &provider,
9410            &messages,
9411            None,
9412            "mock-model",
9413            Some(0.0),
9414            None,
9415            Some(&tx),
9416            false,
9417        )
9418        .await
9419        .expect("streaming should finish");
9420        drop(tx);
9421
9422        let mut visible_deltas = String::new();
9423        while let Some(delta) = rx.recv().await {
9424            if let StreamDelta::Text(text) = delta {
9425                visible_deltas.push_str(&text);
9426            }
9427        }
9428
9429        assert!(outcome.response_text.contains("\"tool_call_id\""));
9430        assert_eq!(
9431            visible_deltas, "",
9432            "truncated internal protocol must not be released at stream finish"
9433        );
9434    }
9435
9436    #[tokio::test]
9437    async fn consume_provider_streaming_response_preserves_json_fenced_tool_protocol_without_tools()
9438    {
9439        struct JsonFencedToolProtocolProvider;
9440        impl_test_model_provider_attribution!(JsonFencedToolProtocolProvider);
9441
9442        #[async_trait]
9443        impl ModelProvider for JsonFencedToolProtocolProvider {
9444            async fn chat_with_system(
9445                &self,
9446                _system_prompt: Option<&str>,
9447                _message: &str,
9448                _model: &str,
9449                _temperature: Option<f64>,
9450            ) -> anyhow::Result<String> {
9451                anyhow::bail!("not used in this test")
9452            }
9453
9454            async fn chat(
9455                &self,
9456                _request: ChatRequest<'_>,
9457                _model: &str,
9458                _temperature: Option<f64>,
9459            ) -> anyhow::Result<ChatResponse> {
9460                anyhow::bail!("not used in this test")
9461            }
9462
9463            fn supports_streaming(&self) -> bool {
9464                true
9465            }
9466
9467            fn stream_chat(
9468                &self,
9469                _request: ChatRequest<'_>,
9470                _model: &str,
9471                _temperature: Option<f64>,
9472                _options: StreamOptions,
9473            ) -> futures_util::stream::BoxStream<
9474                'static,
9475                zeroclaw_providers::traits::StreamResult<StreamEvent>,
9476            > {
9477                Box::pin(futures_util::stream::iter(vec![
9478                    Ok(StreamEvent::TextDelta(StreamChunk::delta("```json\n"))),
9479                    Ok(StreamEvent::TextDelta(StreamChunk::delta(
9480                        r#"{"tool_calls":[{"name":"count_tool","arguments":{"value":"X"}}]}"#,
9481                    ))),
9482                    Ok(StreamEvent::TextDelta(StreamChunk::delta("\n```"))),
9483                    Ok(StreamEvent::Final),
9484                ]))
9485            }
9486        }
9487
9488        let provider = JsonFencedToolProtocolProvider;
9489        let messages = vec![ChatMessage::user("hi")];
9490        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(8);
9491
9492        let outcome = consume_provider_streaming_response(
9493            &provider,
9494            &messages,
9495            None,
9496            "mock-model",
9497            Some(0.0),
9498            None,
9499            Some(&tx),
9500            false,
9501        )
9502        .await
9503        .expect("streaming should finish");
9504        drop(tx);
9505
9506        let mut visible_deltas = String::new();
9507        while let Some(delta) = rx.recv().await {
9508            if let StreamDelta::Text(text) = delta {
9509                visible_deltas.push_str(&text);
9510            }
9511        }
9512
9513        assert!(outcome.response_text.contains("\"tool_calls\""));
9514        assert_eq!(
9515            visible_deltas, outcome.response_text,
9516            "json-fenced protocol-shaped JSON should remain visible when no tools are active"
9517        );
9518    }
9519
9520    #[tokio::test]
9521    async fn consume_provider_streaming_response_buffers_tool_call_fence_with_tools() {
9522        struct ToolCallFenceProvider;
9523        impl_test_model_provider_attribution!(ToolCallFenceProvider);
9524
9525        #[async_trait]
9526        impl ModelProvider for ToolCallFenceProvider {
9527            async fn chat_with_system(
9528                &self,
9529                _system_prompt: Option<&str>,
9530                _message: &str,
9531                _model: &str,
9532                _temperature: Option<f64>,
9533            ) -> anyhow::Result<String> {
9534                anyhow::bail!("not used in this test")
9535            }
9536
9537            async fn chat(
9538                &self,
9539                _request: ChatRequest<'_>,
9540                _model: &str,
9541                _temperature: Option<f64>,
9542            ) -> anyhow::Result<ChatResponse> {
9543                anyhow::bail!("not used in this test")
9544            }
9545
9546            fn supports_streaming(&self) -> bool {
9547                true
9548            }
9549
9550            fn stream_chat(
9551                &self,
9552                _request: ChatRequest<'_>,
9553                _model: &str,
9554                _temperature: Option<f64>,
9555                _options: StreamOptions,
9556            ) -> futures_util::stream::BoxStream<
9557                'static,
9558                zeroclaw_providers::traits::StreamResult<StreamEvent>,
9559            > {
9560                Box::pin(futures_util::stream::iter(vec![
9561                    Ok(StreamEvent::TextDelta(StreamChunk::delta("```tool_call\n"))),
9562                    Ok(StreamEvent::TextDelta(StreamChunk::delta(
9563                        r#"{"name":"count_tool","arguments":{"value":"X"}}"#,
9564                    ))),
9565                    Ok(StreamEvent::TextDelta(StreamChunk::delta("\n```"))),
9566                    Ok(StreamEvent::Final),
9567                ]))
9568            }
9569        }
9570
9571        let provider = ToolCallFenceProvider;
9572        let messages = vec![ChatMessage::user("hi")];
9573        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(8);
9574
9575        let outcome = consume_provider_streaming_response(
9576            &provider,
9577            &messages,
9578            Some(&[crate::tools::ToolSpec {
9579                name: "count_tool".to_string(),
9580                description: "Count values".to_string(),
9581                parameters: serde_json::json!({"type": "object"}),
9582            }]),
9583            "mock-model",
9584            Some(0.0),
9585            None,
9586            Some(&tx),
9587            false,
9588        )
9589        .await
9590        .expect("streaming should finish");
9591        drop(tx);
9592
9593        let mut visible_deltas = String::new();
9594        while let Some(delta) = rx.recv().await {
9595            if let StreamDelta::Text(text) = delta {
9596                visible_deltas.push_str(&text);
9597            }
9598        }
9599
9600        assert!(outcome.response_text.contains("```tool_call"));
9601        assert_eq!(
9602            visible_deltas, "",
9603            "streamed tool_call fences with registered tools must not reach draft updates"
9604        );
9605    }
9606
9607    #[tokio::test]
9608    async fn consume_provider_streaming_response_preserves_plain_prefix_before_protocol_without_tools()
9609     {
9610        struct PrefixedToolProtocolProvider;
9611        impl_test_model_provider_attribution!(PrefixedToolProtocolProvider);
9612
9613        #[async_trait]
9614        impl ModelProvider for PrefixedToolProtocolProvider {
9615            async fn chat_with_system(
9616                &self,
9617                _system_prompt: Option<&str>,
9618                _message: &str,
9619                _model: &str,
9620                _temperature: Option<f64>,
9621            ) -> anyhow::Result<String> {
9622                anyhow::bail!("not used in this test")
9623            }
9624
9625            async fn chat(
9626                &self,
9627                _request: ChatRequest<'_>,
9628                _model: &str,
9629                _temperature: Option<f64>,
9630            ) -> anyhow::Result<ChatResponse> {
9631                anyhow::bail!("not used in this test")
9632            }
9633
9634            fn supports_streaming(&self) -> bool {
9635                true
9636            }
9637
9638            fn stream_chat(
9639                &self,
9640                _request: ChatRequest<'_>,
9641                _model: &str,
9642                _temperature: Option<f64>,
9643                _options: StreamOptions,
9644            ) -> futures_util::stream::BoxStream<
9645                'static,
9646                zeroclaw_providers::traits::StreamResult<StreamEvent>,
9647            > {
9648                Box::pin(futures_util::stream::iter(vec![
9649                    Ok(StreamEvent::TextDelta(StreamChunk::delta(
9650                        r#"Visible prefix {"toolcalls":[{"name":"count_tool","arguments":{"value":"X"}}]}"#,
9651                    ))),
9652                    Ok(StreamEvent::Final),
9653                ]))
9654            }
9655        }
9656
9657        let provider = PrefixedToolProtocolProvider;
9658        let messages = vec![ChatMessage::user("hi")];
9659        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(8);
9660
9661        let outcome = consume_provider_streaming_response(
9662            &provider,
9663            &messages,
9664            None,
9665            "mock-model",
9666            Some(0.0),
9667            None,
9668            Some(&tx),
9669            false,
9670        )
9671        .await
9672        .expect("streaming should finish");
9673        drop(tx);
9674
9675        let mut visible_deltas = String::new();
9676        while let Some(delta) = rx.recv().await {
9677            if let StreamDelta::Text(text) = delta {
9678                visible_deltas.push_str(&text);
9679            }
9680        }
9681
9682        assert!(outcome.response_text.contains("\"toolcalls\""));
9683        assert_eq!(
9684            visible_deltas, outcome.response_text,
9685            "prefixed protocol-shaped JSON should remain visible when no tools are active"
9686        );
9687    }
9688
9689    #[tokio::test]
9690    async fn consume_provider_streaming_response_preserves_split_protocol_after_plain_prefix_without_tools()
9691     {
9692        struct SplitPrefixedToolProtocolProvider;
9693        impl_test_model_provider_attribution!(SplitPrefixedToolProtocolProvider);
9694
9695        #[async_trait]
9696        impl ModelProvider for SplitPrefixedToolProtocolProvider {
9697            async fn chat_with_system(
9698                &self,
9699                _system_prompt: Option<&str>,
9700                _message: &str,
9701                _model: &str,
9702                _temperature: Option<f64>,
9703            ) -> anyhow::Result<String> {
9704                anyhow::bail!("not used in this test")
9705            }
9706
9707            async fn chat(
9708                &self,
9709                _request: ChatRequest<'_>,
9710                _model: &str,
9711                _temperature: Option<f64>,
9712            ) -> anyhow::Result<ChatResponse> {
9713                anyhow::bail!("not used in this test")
9714            }
9715
9716            fn supports_streaming(&self) -> bool {
9717                true
9718            }
9719
9720            fn stream_chat(
9721                &self,
9722                _request: ChatRequest<'_>,
9723                _model: &str,
9724                _temperature: Option<f64>,
9725                _options: StreamOptions,
9726            ) -> futures_util::stream::BoxStream<
9727                'static,
9728                zeroclaw_providers::traits::StreamResult<StreamEvent>,
9729            > {
9730                Box::pin(futures_util::stream::iter(vec![
9731                    Ok(StreamEvent::TextDelta(StreamChunk::delta(
9732                        r#"Visible prefix {"tool"#,
9733                    ))),
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 = SplitPrefixedToolProtocolProvider;
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            None,
9750            "mock-model",
9751            Some(0.0),
9752            None,
9753            Some(&tx),
9754            false,
9755        )
9756        .await
9757        .expect("streaming should finish");
9758        drop(tx);
9759
9760        let mut visible_deltas = String::new();
9761        while let Some(delta) = rx.recv().await {
9762            if let StreamDelta::Text(text) = delta {
9763                visible_deltas.push_str(&text);
9764            }
9765        }
9766
9767        assert!(outcome.response_text.contains("\"toolcalls\""));
9768        assert_eq!(
9769            visible_deltas, outcome.response_text,
9770            "split prefixed protocol-shaped JSON should remain visible when no tools are active"
9771        );
9772    }
9773
9774    #[tokio::test]
9775    async fn run_tool_call_loop_streams_native_tool_events_without_chat_fallback() {
9776        let model_provider = StreamingNativeToolEventModelProvider::with_turns(vec![
9777            NativeStreamTurn::ToolCall(ToolCall {
9778                id: "call_native_1".to_string(),
9779                name: "count_tool".to_string(),
9780                arguments: r#"{"value":"A"}"#.to_string(),
9781                extra_content: None,
9782            }),
9783            NativeStreamTurn::Text("done".to_string()),
9784        ]);
9785        let invocations = Arc::new(AtomicUsize::new(0));
9786        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
9787            "count_tool",
9788            Arc::clone(&invocations),
9789        ))];
9790        let mut history = vec![
9791            ChatMessage::system("test-system"),
9792            ChatMessage::user("run native tools"),
9793        ];
9794        let observer = NoopObserver;
9795        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(64);
9796
9797        let result = run_tool_call_loop(
9798            &model_provider,
9799            &mut history,
9800            &tools_registry,
9801            &observer,
9802            "mock-provider",
9803            "mock-model",
9804            Some(0.0),
9805            true,
9806            None,
9807            "telegram",
9808            None,
9809            &zeroclaw_config::schema::MultimodalConfig::default(),
9810            5,
9811            None,
9812            Some(tx),
9813            None,
9814            &[],
9815            &[],
9816            None,
9817            None,
9818            &zeroclaw_config::schema::PacingConfig::default(),
9819            false,
9820            0,
9821            0,
9822            None,
9823            None, // channel
9824            None, // receipt_generator
9825            None, // collected_receipts
9826        )
9827        .await
9828        .expect("native streaming events should preserve tool loop semantics");
9829
9830        let mut visible_deltas = String::new();
9831        while let Some(delta) = rx.recv().await {
9832            match delta {
9833                StreamDelta::Status(_) => {}
9834                StreamDelta::Text(text) => {
9835                    visible_deltas.push_str(&text);
9836                }
9837            }
9838        }
9839
9840        assert!(
9841            result.ends_with("done"),
9842            "result should end with 'done', got: {result}"
9843        );
9844        assert_eq!(invocations.load(Ordering::SeqCst), 1);
9845        assert_eq!(model_provider.stream_calls.load(Ordering::SeqCst), 2);
9846        assert_eq!(
9847            model_provider.stream_tool_requests.load(Ordering::SeqCst),
9848            2
9849        );
9850        assert_eq!(model_provider.chat_calls.load(Ordering::SeqCst), 0);
9851        assert_eq!(visible_deltas, "done");
9852    }
9853
9854    #[tokio::test]
9855    async fn run_tool_call_loop_routed_streaming_uses_live_provider_deltas_once() {
9856        let default_model_provider = RouteAwareStreamingModelProvider::new("default answer");
9857        let default_stream_calls = Arc::clone(&default_model_provider.stream_calls);
9858        let default_chat_calls = Arc::clone(&default_model_provider.chat_calls);
9859
9860        let routed_model_provider = RouteAwareStreamingModelProvider::new("routed streamed answer");
9861        let routed_stream_calls = Arc::clone(&routed_model_provider.stream_calls);
9862        let routed_chat_calls = Arc::clone(&routed_model_provider.chat_calls);
9863        let routed_last_model = Arc::clone(&routed_model_provider.last_model);
9864
9865        let router = RouterModelProvider::new(
9866            "test",
9867            vec![
9868                ("default".to_string(), Box::new(default_model_provider)),
9869                ("fast".to_string(), Box::new(routed_model_provider)),
9870            ],
9871            vec![(
9872                "fast".to_string(),
9873                Route {
9874                    provider_name: "fast".to_string(),
9875                    model: "routed-model".to_string(),
9876                },
9877            )],
9878            "default-model".to_string(),
9879        );
9880
9881        let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
9882        let mut history = vec![
9883            ChatMessage::system("test-system"),
9884            ChatMessage::user("say hi"),
9885        ];
9886        let observer = NoopObserver;
9887        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(32);
9888
9889        let result = run_tool_call_loop(
9890            &router,
9891            &mut history,
9892            &tools_registry,
9893            &observer,
9894            "router",
9895            "hint:fast",
9896            Some(0.0),
9897            true,
9898            None,
9899            "telegram",
9900            None,
9901            &zeroclaw_config::schema::MultimodalConfig::default(),
9902            4,
9903            None,
9904            Some(tx),
9905            None,
9906            &[],
9907            &[],
9908            None,
9909            None,
9910            &zeroclaw_config::schema::PacingConfig::default(),
9911            false,
9912            0,
9913            0,
9914            None,
9915            None, // channel
9916            None, // receipt_generator
9917            None, // collected_receipts
9918        )
9919        .await
9920        .expect("routed streaming model_provider should complete");
9921
9922        let mut visible_deltas = String::new();
9923        while let Some(delta) = rx.recv().await {
9924            match delta {
9925                StreamDelta::Status(_) => {}
9926                StreamDelta::Text(text) => {
9927                    visible_deltas.push_str(&text);
9928                }
9929            }
9930        }
9931
9932        assert_eq!(result, "routed streamed answer");
9933        assert_eq!(
9934            visible_deltas, "routed streamed answer",
9935            "routed draft should receive upstream deltas once without post-hoc duplication"
9936        );
9937        assert_eq!(default_stream_calls.load(Ordering::SeqCst), 0);
9938        assert_eq!(routed_stream_calls.load(Ordering::SeqCst), 1);
9939        assert_eq!(default_chat_calls.load(Ordering::SeqCst), 0);
9940        assert_eq!(routed_chat_calls.load(Ordering::SeqCst), 0);
9941        assert_eq!(
9942            routed_last_model
9943                .lock()
9944                .expect("routed_last_model lock should be valid")
9945                .as_str(),
9946            "routed-model"
9947        );
9948    }
9949
9950    #[test]
9951    fn agent_turn_executes_activated_tool_from_wrapper() {
9952        let runtime = tokio::runtime::Builder::new_current_thread()
9953            .enable_all()
9954            .build()
9955            .expect("test runtime should initialize");
9956
9957        runtime.block_on(async {
9958            let model_provider = ScriptedModelProvider::from_text_responses(vec![
9959                r#"<tool_call>
9960{"name":"pixel__get_api_health","arguments":{"value":"ok"}}
9961</tool_call>"#,
9962                "done",
9963            ]);
9964
9965            let invocations = Arc::new(AtomicUsize::new(0));
9966            let activated = Arc::new(std::sync::Mutex::new(crate::tools::ActivatedToolSet::new()));
9967            let activated_tool: Arc<dyn Tool> = Arc::new(CountingTool::new(
9968                "pixel__get_api_health",
9969                Arc::clone(&invocations),
9970            ));
9971            activated
9972                .lock()
9973                .unwrap()
9974                .activate("pixel__get_api_health".into(), activated_tool);
9975
9976            let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
9977            let mut history = vec![
9978                ChatMessage::system("test-system"),
9979                ChatMessage::user("use the activated MCP tool"),
9980            ];
9981            let observer = NoopObserver;
9982
9983            let result = agent_turn(
9984                &model_provider,
9985                &mut history,
9986                &tools_registry,
9987                &observer,
9988                "mock-provider",
9989                "mock-model",
9990                Some(0.0),
9991                true,
9992                "daemon",
9993                None,
9994                &zeroclaw_config::schema::MultimodalConfig::default(),
9995                4,
9996                None,
9997                &[],
9998                &[],
9999                Some(&activated),
10000                None,
10001                false,
10002                None, // channel
10003            )
10004            .await
10005            .expect("wrapper path should execute activated tools");
10006
10007            assert!(
10008                result.ends_with("done"),
10009                "result should end with 'done', got: {result}"
10010            );
10011            assert_eq!(invocations.load(Ordering::SeqCst), 1);
10012        });
10013    }
10014
10015    #[test]
10016    fn agent_turn_strict_tool_parsing_ignores_activated_tool_text_from_wrapper() {
10017        let runtime = tokio::runtime::Builder::new_current_thread()
10018            .enable_all()
10019            .build()
10020            .expect("test runtime should initialize");
10021
10022        runtime.block_on(async {
10023            let model_provider = ScriptedModelProvider::from_text_responses(vec![
10024                r#"<think>private reasoning</think>
10025<tool_call>
10026{"name":"pixel__get_api_health","arguments":{"value":"ignored"}}
10027</tool_call>"#,
10028            ]);
10029
10030            let invocations = Arc::new(AtomicUsize::new(0));
10031            let activated = Arc::new(std::sync::Mutex::new(crate::tools::ActivatedToolSet::new()));
10032            let activated_tool: Arc<dyn Tool> = Arc::new(CountingTool::new(
10033                "pixel__get_api_health",
10034                Arc::clone(&invocations),
10035            ));
10036            activated
10037                .lock()
10038                .unwrap()
10039                .activate("pixel__get_api_health".into(), activated_tool);
10040
10041            let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
10042            let mut history = vec![
10043                ChatMessage::system("test-system"),
10044                ChatMessage::user("do not infer activated tool calls from text"),
10045            ];
10046            let observer = NoopObserver;
10047
10048            let result = agent_turn(
10049                &model_provider,
10050                &mut history,
10051                &tools_registry,
10052                &observer,
10053                "mock-provider",
10054                "mock-model",
10055                Some(0.0),
10056                true,
10057                "daemon",
10058                None,
10059                &zeroclaw_config::schema::MultimodalConfig::default(),
10060                4,
10061                None,
10062                &[],
10063                &[],
10064                Some(&activated),
10065                None,
10066                true,
10067                None, // channel
10068            )
10069            .await
10070            .expect("strict wrapper path should preserve fallback-looking text");
10071
10072            assert_eq!(invocations.load(Ordering::SeqCst), 0);
10073            assert!(
10074                result.contains("<tool_call>"),
10075                "strict parser should return fallback-looking text, got: {result}"
10076            );
10077            assert!(
10078                !result.contains("private reasoning"),
10079                "strict parser should still strip think tags from final text, got: {result}"
10080            );
10081        });
10082    }
10083
10084    #[test]
10085    fn resolve_display_text_hides_raw_payload_for_tool_only_turns() {
10086        let display = resolve_display_text(
10087            "<tool_call>{\"name\":\"memory_store\"}</tool_call>",
10088            "",
10089            true,
10090            false,
10091        );
10092        assert!(display.is_empty());
10093    }
10094
10095    #[test]
10096    fn resolve_display_text_keeps_plain_text_for_tool_turns() {
10097        let display = resolve_display_text(
10098            "<tool_call>{\"name\":\"shell\"}</tool_call>",
10099            "Let me check that.",
10100            true,
10101            false,
10102        );
10103        assert_eq!(display, "Let me check that.");
10104    }
10105
10106    #[test]
10107    fn resolve_display_text_uses_response_text_for_native_tool_turns() {
10108        let display = resolve_display_text("Task started.", "", true, true);
10109        assert_eq!(display, "Task started.");
10110    }
10111
10112    #[test]
10113    fn resolve_display_text_uses_response_text_for_final_turns() {
10114        let display = resolve_display_text("Final answer", "", false, false);
10115        assert_eq!(display, "Final answer");
10116    }
10117
10118    #[test]
10119    fn build_tool_instructions_includes_all_tools() {
10120        use crate::security::SecurityPolicy;
10121        let security = Arc::new(SecurityPolicy::from_risk_profile(
10122            &zeroclaw_config::schema::RiskProfileConfig::default(),
10123            std::path::Path::new("/tmp"),
10124        ));
10125        let tools = tools::default_tools(security);
10126        let instructions = build_tool_instructions(&tools);
10127
10128        assert!(instructions.contains("## Tool Use Protocol"));
10129        assert!(instructions.contains("<tool_call>"));
10130        assert!(instructions.contains("shell"));
10131        assert!(instructions.contains("file_read"));
10132        assert!(instructions.contains("file_write"));
10133    }
10134
10135    #[test]
10136    fn build_tool_instructions_empty_registry_returns_empty() {
10137        let tools: Vec<Box<dyn Tool>> = vec![];
10138        let instructions = build_tool_instructions(&tools);
10139
10140        assert!(instructions.is_empty());
10141    }
10142
10143    #[test]
10144    fn tools_to_openai_format_produces_valid_schema() {
10145        use crate::security::SecurityPolicy;
10146        let security = Arc::new(SecurityPolicy::from_risk_profile(
10147            &zeroclaw_config::schema::RiskProfileConfig::default(),
10148            std::path::Path::new("/tmp"),
10149        ));
10150        let tools = tools::default_tools(security);
10151        let formatted = tools_to_openai_format(&tools);
10152
10153        assert!(!formatted.is_empty());
10154        for tool_json in &formatted {
10155            assert_eq!(tool_json["type"], "function");
10156            assert!(tool_json["function"]["name"].is_string());
10157            assert!(tool_json["function"]["description"].is_string());
10158            assert!(!tool_json["function"]["name"].as_str().unwrap().is_empty());
10159        }
10160        // Verify known tools are present
10161        let names: Vec<&str> = formatted
10162            .iter()
10163            .filter_map(|t| t["function"]["name"].as_str())
10164            .collect();
10165        assert!(names.contains(&"shell"));
10166        assert!(names.contains(&"file_read"));
10167    }
10168
10169    #[test]
10170    fn trim_history_preserves_system_prompt() {
10171        let mut history = vec![ChatMessage::system("system prompt")];
10172        for i in 0..DEFAULT_MAX_HISTORY_MESSAGES + 20 {
10173            history.push(ChatMessage::user(format!("msg {i}")));
10174        }
10175        let original_len = history.len();
10176        assert!(original_len > DEFAULT_MAX_HISTORY_MESSAGES + 1);
10177
10178        trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
10179
10180        // System prompt preserved
10181        assert_eq!(history[0].role, "system");
10182        assert_eq!(history[0].content, "system prompt");
10183        // Trimmed to limit
10184        assert_eq!(history.len(), DEFAULT_MAX_HISTORY_MESSAGES + 1); // +1 for system
10185        // Most recent messages preserved
10186        let last = &history[history.len() - 1];
10187        assert_eq!(
10188            last.content,
10189            format!("msg {}", DEFAULT_MAX_HISTORY_MESSAGES + 19)
10190        );
10191    }
10192
10193    #[test]
10194    fn trim_history_noop_when_within_limit() {
10195        let mut history = vec![
10196            ChatMessage::system("sys"),
10197            ChatMessage::user("hello"),
10198            ChatMessage::assistant("hi"),
10199        ];
10200        trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
10201        assert_eq!(history.len(), 3);
10202    }
10203
10204    #[test]
10205    fn autosave_memory_key_has_prefix_and_uniqueness() {
10206        let key1 = autosave_memory_key("user_msg");
10207        let key2 = autosave_memory_key("user_msg");
10208
10209        assert!(key1.starts_with("user_msg_"));
10210        assert!(key2.starts_with("user_msg_"));
10211        assert_ne!(key1, key2);
10212    }
10213
10214    #[tokio::test]
10215    async fn autosave_memory_keys_preserve_multiple_turns() {
10216        let tmp = TempDir::new().unwrap();
10217        let mem = SqliteMemory::new("test", tmp.path()).unwrap();
10218
10219        let key1 = autosave_memory_key("user_msg");
10220        let key2 = autosave_memory_key("user_msg");
10221
10222        mem.store(&key1, "I'm Paul", MemoryCategory::Conversation, None)
10223            .await
10224            .unwrap();
10225        mem.store(&key2, "I'm 45", MemoryCategory::Conversation, None)
10226            .await
10227            .unwrap();
10228
10229        assert_eq!(mem.count().await.unwrap(), 2);
10230
10231        let recalled = mem.recall("45", 5, None, None, None).await.unwrap();
10232        assert!(recalled.iter().any(|entry| entry.content.contains("45")));
10233    }
10234
10235    #[tokio::test]
10236    async fn build_context_ignores_legacy_assistant_autosave_entries() {
10237        let tmp = TempDir::new().unwrap();
10238        let mem = SqliteMemory::new("test", tmp.path()).unwrap();
10239        mem.store(
10240            "assistant_resp_poisoned",
10241            "User suffered a fabricated event",
10242            MemoryCategory::Daily,
10243            None,
10244        )
10245        .await
10246        .unwrap();
10247        mem.store(
10248            "user_preference",
10249            "User asked for concise status updates",
10250            MemoryCategory::Conversation,
10251            None,
10252        )
10253        .await
10254        .unwrap();
10255
10256        let context = build_context(&mem, "status updates", 0.0, None, false).await;
10257        assert!(context.contains("user_preference"));
10258        assert!(!context.contains("assistant_resp_poisoned"));
10259        assert!(!context.contains("fabricated event"));
10260    }
10261
10262    #[tokio::test]
10263    async fn build_context_ignores_user_autosave_entries() {
10264        let tmp = TempDir::new().unwrap();
10265        let mem = SqliteMemory::new("test", tmp.path()).unwrap();
10266        mem.store(
10267            "user_msg",
10268            "Original user message with full conversation history",
10269            MemoryCategory::Conversation,
10270            None,
10271        )
10272        .await
10273        .unwrap();
10274        mem.store(
10275            "user_msg_a1b2c3d4",
10276            "Follow-up user message embedding prior context verbatim",
10277            MemoryCategory::Conversation,
10278            None,
10279        )
10280        .await
10281        .unwrap();
10282        mem.store(
10283            "user_preference",
10284            "User prefers concise answers",
10285            MemoryCategory::Conversation,
10286            None,
10287        )
10288        .await
10289        .unwrap();
10290
10291        let context = build_context(&mem, "answers", 0.0, None, false).await;
10292        assert!(context.contains("user_preference"));
10293        assert!(!context.contains("user_msg"));
10294        assert!(!context.contains("embedding prior context"));
10295    }
10296
10297    /// Regression: cron / heartbeat runs must not surface chat-origin
10298    /// `Conversation` memories — the leak path the #5456 prefix filter
10299    /// missed because `agent::run` performs a second, unfiltered recall
10300    /// inside `build_context`.
10301    #[tokio::test]
10302    async fn build_context_excludes_conversation_when_flag_set() {
10303        let tmp = TempDir::new().unwrap();
10304        let mem = SqliteMemory::new("test", tmp.path()).unwrap();
10305        // A Conversation entry written by a chat channel with a non-autosave
10306        // key (autosave keys are already skipped by the existing filters).
10307        mem.store(
10308            "discord:guild:chan:msg-42",
10309            "Reminder for Alice: the API key is in 1Password vault Foo.",
10310            MemoryCategory::Conversation,
10311            Some("discord:guild:chan"),
10312        )
10313        .await
10314        .unwrap();
10315        // A non-Conversation memory that should still surface so we know the
10316        // function still does its job — only Conversation should be dropped.
10317        mem.store(
10318            "team_oncall",
10319            "Primary on-call rotates every Monday at 09:00 UTC.",
10320            MemoryCategory::Core,
10321            None,
10322        )
10323        .await
10324        .unwrap();
10325
10326        let context = build_context(&mem, "Alice on-call", 0.0, None, true).await;
10327        assert!(
10328            !context.contains("Alice"),
10329            "Conversation memory leaked into scheduled context: {context}"
10330        );
10331        assert!(
10332            !context.contains("API key"),
10333            "Conversation memory leaked into scheduled context: {context}"
10334        );
10335        assert!(
10336            context.contains("team_oncall"),
10337            "Non-Conversation memory should still surface: {context}"
10338        );
10339    }
10340
10341    // ═══════════════════════════════════════════════════════════════════════
10342    // Recovery Tests - Tool Call Parsing Edge Cases
10343    // ═══════════════════════════════════════════════════════════════════════
10344
10345    #[test]
10346    fn strip_think_tags_removes_single_block() {
10347        assert_eq!(strip_think_tags("<think>reasoning</think>Hello"), "Hello");
10348    }
10349
10350    #[test]
10351    fn strip_think_tags_removes_multiple_blocks() {
10352        assert_eq!(strip_think_tags("<think>a</think>X<think>b</think>Y"), "XY");
10353    }
10354
10355    #[test]
10356    fn strip_think_tags_handles_unclosed_block() {
10357        assert_eq!(strip_think_tags("visible<think>hidden"), "visible");
10358    }
10359
10360    #[test]
10361    fn strip_think_tags_preserves_text_without_tags() {
10362        assert_eq!(strip_think_tags("plain text"), "plain text");
10363    }
10364
10365    #[test]
10366    fn parse_tool_calls_strips_think_before_tool_call() {
10367        // Qwen regression: <think> tags before <tool_call> tags should be
10368        // stripped, allowing the tool call to be parsed correctly.
10369        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>";
10370        let (text, calls) = parse_tool_calls(response);
10371        assert_eq!(
10372            calls.len(),
10373            1,
10374            "should parse tool call after stripping think tags"
10375        );
10376        assert_eq!(calls[0].name, "shell");
10377        assert_eq!(
10378            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
10379            "ls"
10380        );
10381        assert!(text.is_empty(), "think content should not appear as text");
10382    }
10383
10384    #[test]
10385    fn parse_tool_calls_strips_think_only_returns_empty() {
10386        // When response is only <think> tags with no tool calls, should
10387        // return empty text and no calls.
10388        let response = "<think>Just thinking, no action needed</think>";
10389        let (text, calls) = parse_tool_calls(response);
10390        assert!(calls.is_empty());
10391        assert!(text.is_empty());
10392    }
10393
10394    #[test]
10395    fn parse_tool_calls_handles_qwen_think_with_multiple_tool_calls() {
10396        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>";
10397        let (_, calls) = parse_tool_calls(response);
10398        assert_eq!(calls.len(), 2);
10399        assert_eq!(
10400            calls[0].arguments.get("command").unwrap().as_str().unwrap(),
10401            "date"
10402        );
10403        assert_eq!(
10404            calls[1].arguments.get("command").unwrap().as_str().unwrap(),
10405            "pwd"
10406        );
10407    }
10408
10409    #[test]
10410    fn strip_tool_result_blocks_preserves_clean_text() {
10411        let input = "Hello, this is a normal response.";
10412        assert_eq!(strip_tool_result_blocks(input), input);
10413    }
10414
10415    #[test]
10416    fn strip_tool_result_blocks_returns_empty_for_only_tags() {
10417        let input = "<tool_result name=\"memory_recall\" status=\"ok\">\n{}\n</tool_result>";
10418        assert_eq!(strip_tool_result_blocks(input), "");
10419    }
10420
10421    #[test]
10422    fn parse_tool_calls_handles_empty_tool_calls_array() {
10423        // Recovery: Empty tool_calls array returns original response (no tool parsing)
10424        let response = r#"{"content": "Hello", "tool_calls": []}"#;
10425        let (text, calls) = parse_tool_calls(response);
10426        // When tool_calls is empty, the entire JSON is returned as text
10427        assert!(text.contains("Hello"));
10428        assert!(calls.is_empty());
10429    }
10430
10431    #[test]
10432    fn detect_tool_call_parse_issue_flags_malformed_payloads() {
10433        let response =
10434            "<tool_call>{\"name\":\"shell\",\"arguments\":{\"command\":\"pwd\"}</tool_call>";
10435        let issue = detect_tool_call_parse_issue(response, &[]);
10436        assert!(
10437            issue.is_some(),
10438            "malformed tool payload should be flagged for diagnostics"
10439        );
10440    }
10441
10442    #[test]
10443    fn detect_tool_call_parse_issue_ignores_normal_text() {
10444        let issue = detect_tool_call_parse_issue("Thanks, done.", &[]);
10445        assert!(issue.is_none());
10446    }
10447
10448    // ═══════════════════════════════════════════════════════════════════════
10449    // Recovery Tests - History Management
10450    // ═══════════════════════════════════════════════════════════════════════
10451
10452    #[test]
10453    fn trim_history_with_no_system_prompt() {
10454        // Recovery: History without system prompt should trim correctly
10455        let mut history = vec![];
10456        for i in 0..DEFAULT_MAX_HISTORY_MESSAGES + 20 {
10457            history.push(ChatMessage::user(format!("msg {i}")));
10458        }
10459        trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
10460        assert_eq!(history.len(), DEFAULT_MAX_HISTORY_MESSAGES);
10461    }
10462
10463    #[test]
10464    fn trim_history_preserves_role_ordering() {
10465        // Recovery: After trimming, role ordering should remain consistent
10466        let mut history = vec![ChatMessage::system("system")];
10467        for i in 0..DEFAULT_MAX_HISTORY_MESSAGES + 10 {
10468            history.push(ChatMessage::user(format!("user {i}")));
10469            history.push(ChatMessage::assistant(format!("assistant {i}")));
10470        }
10471        trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
10472        assert_eq!(history[0].role, "system");
10473        assert_eq!(history[history.len() - 1].role, "assistant");
10474    }
10475
10476    #[test]
10477    fn trim_history_with_only_system_prompt() {
10478        // Recovery: Only system prompt should not be trimmed
10479        let mut history = vec![ChatMessage::system("system prompt")];
10480        trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
10481        assert_eq!(history.len(), 1);
10482    }
10483
10484    // ═══════════════════════════════════════════════════════════════════════
10485    // Recovery Tests - Arguments Parsing
10486    // ═══════════════════════════════════════════════════════════════════════
10487
10488    // ═══════════════════════════════════════════════════════════════════════
10489    // Recovery Tests - JSON Extraction
10490    // ═══════════════════════════════════════════════════════════════════════
10491
10492    // ═══════════════════════════════════════════════════════════════════════
10493    // Recovery Tests - Constants Validation
10494    // ═══════════════════════════════════════════════════════════════════════
10495
10496    const _: () = {
10497        assert!(DEFAULT_MAX_TOOL_ITERATIONS > 0);
10498        assert!(DEFAULT_MAX_TOOL_ITERATIONS <= 100);
10499        assert!(DEFAULT_MAX_HISTORY_MESSAGES > 0);
10500        assert!(DEFAULT_MAX_HISTORY_MESSAGES <= 1000);
10501    };
10502
10503    #[test]
10504    fn constants_bounds_are_compile_time_checked() {
10505        // Bounds are enforced by the const assertions above.
10506    }
10507
10508    // ═══════════════════════════════════════════════════════════════════════
10509    // Recovery Tests - Tool Call Value Parsing
10510
10511    #[test]
10512    fn parse_tool_calls_handles_unclosed_tool_call_tag() {
10513        let response = "<tool_call>{\"name\":\"shell\",\"arguments\":{\"command\":\"pwd\"}}\nDone";
10514        let (text, calls) = parse_tool_calls(response);
10515        assert_eq!(calls.len(), 1);
10516        assert_eq!(calls[0].name, "shell");
10517        assert_eq!(calls[0].arguments["command"], "pwd");
10518        assert_eq!(text, "Done");
10519    }
10520
10521    // ─────────────────────────────────────────────────────────────────────
10522    // TG4 (inline): parse_tool_calls robustness — malformed/edge-case inputs
10523    // Prevents: Pattern 4 issues #746, #418, #777, #848
10524    // ─────────────────────────────────────────────────────────────────────
10525
10526    #[test]
10527    fn parse_tool_calls_empty_input_returns_empty() {
10528        let (text, calls) = parse_tool_calls("");
10529        assert!(calls.is_empty(), "empty input should produce no tool calls");
10530        assert!(text.is_empty(), "empty input should produce no text");
10531    }
10532
10533    #[test]
10534    fn parse_tool_calls_whitespace_only_returns_empty_calls() {
10535        let (text, calls) = parse_tool_calls("   \n\t  ");
10536        assert!(calls.is_empty());
10537        assert!(text.is_empty() || text.trim().is_empty());
10538    }
10539
10540    #[test]
10541    fn parse_tool_calls_nested_xml_tags_handled() {
10542        // Double-wrapped tool call should still parse the inner call
10543        let response = r#"<tool_call><tool_call>{"name":"echo","arguments":{"msg":"hi"}}</tool_call></tool_call>"#;
10544        let (_text, calls) = parse_tool_calls(response);
10545        // Should find at least one tool call
10546        assert!(
10547            !calls.is_empty(),
10548            "nested XML tags should still yield at least one tool call"
10549        );
10550    }
10551
10552    #[test]
10553    fn parse_tool_calls_truncated_json_no_panic() {
10554        // Incomplete JSON inside tool_call tags
10555        let response = r#"<tool_call>{"name":"shell","arguments":{"command":"ls"</tool_call>"#;
10556        let (_text, _calls) = parse_tool_calls(response);
10557        // Should not panic — graceful handling of truncated JSON
10558    }
10559
10560    #[test]
10561    fn parse_tool_calls_empty_json_object_in_tag() {
10562        let response = "<tool_call>{}</tool_call>";
10563        let (_text, calls) = parse_tool_calls(response);
10564        // Empty JSON object has no name field — should not produce valid tool call
10565        assert!(
10566            calls.is_empty(),
10567            "empty JSON object should not produce a tool call"
10568        );
10569    }
10570
10571    #[test]
10572    fn parse_tool_calls_closing_tag_only_returns_text() {
10573        let response = "Some text </tool_call> more text";
10574        let (text, calls) = parse_tool_calls(response);
10575        assert!(
10576            calls.is_empty(),
10577            "closing tag only should not produce calls"
10578        );
10579        assert!(
10580            !text.is_empty(),
10581            "text around orphaned closing tag should be preserved"
10582        );
10583    }
10584
10585    #[test]
10586    fn parse_tool_calls_very_large_arguments_no_panic() {
10587        let large_arg = "x".repeat(100_000);
10588        let response = format!(
10589            r#"<tool_call>{{"name":"echo","arguments":{{"message":"{}"}}}}</tool_call>"#,
10590            large_arg
10591        );
10592        let (_text, calls) = parse_tool_calls(&response);
10593        assert_eq!(calls.len(), 1, "large arguments should still parse");
10594        assert_eq!(calls[0].name, "echo");
10595    }
10596
10597    #[test]
10598    fn parse_tool_calls_special_characters_in_arguments() {
10599        let response = r#"<tool_call>{"name":"echo","arguments":{"message":"hello \"world\" <>&'\n\t"}}</tool_call>"#;
10600        let (_text, calls) = parse_tool_calls(response);
10601        assert_eq!(calls.len(), 1);
10602        assert_eq!(calls[0].name, "echo");
10603    }
10604
10605    #[test]
10606    fn parse_tool_calls_text_with_embedded_json_not_extracted() {
10607        // Raw JSON without any tags should NOT be extracted as a tool call
10608        let response = r#"Here is some data: {"name":"echo","arguments":{"message":"hi"}} end."#;
10609        let (_text, calls) = parse_tool_calls(response);
10610        assert!(
10611            calls.is_empty(),
10612            "raw JSON in text without tags should not be extracted"
10613        );
10614    }
10615
10616    #[test]
10617    fn parse_tool_calls_multiple_formats_mixed() {
10618        // Mix of text and properly tagged tool call
10619        let response = r#"I'll help you with that.
10620
10621<tool_call>
10622{"name":"shell","arguments":{"command":"echo hello"}}
10623</tool_call>
10624
10625Let me check the result."#;
10626        let (text, calls) = parse_tool_calls(response);
10627        assert_eq!(
10628            calls.len(),
10629            1,
10630            "should extract one tool call from mixed content"
10631        );
10632        assert_eq!(calls[0].name, "shell");
10633        assert!(
10634            text.contains("help you"),
10635            "text before tool call should be preserved"
10636        );
10637    }
10638
10639    // ─────────────────────────────────────────────────────────────────────
10640    // TG4 (inline): scrub_credentials edge cases
10641    // ─────────────────────────────────────────────────────────────────────
10642
10643    #[test]
10644    fn scrub_credentials_empty_input() {
10645        let result = scrub_credentials("");
10646        assert_eq!(result, "");
10647    }
10648
10649    #[test]
10650    fn scrub_credentials_no_sensitive_data() {
10651        let input = "normal text without any secrets";
10652        let result = scrub_credentials(input);
10653        assert_eq!(
10654            result, input,
10655            "non-sensitive text should pass through unchanged"
10656        );
10657    }
10658
10659    #[test]
10660    fn scrub_credentials_multibyte_chars_no_panic() {
10661        // Regression test for #3024: byte index 4 is not a char boundary
10662        // when the captured value contains multi-byte UTF-8 characters.
10663        // The regex only matches quoted values for non-ASCII content, since
10664        // capture group 4 is restricted to [a-zA-Z0-9_\-\.].
10665        let input = "password=\"\u{4f60}\u{7684}WiFi\u{5bc6}\u{7801}ab\"";
10666        let result = scrub_credentials(input);
10667        assert!(
10668            result.contains("[REDACTED]"),
10669            "multi-byte quoted value should be redacted without panic, got: {result}"
10670        );
10671    }
10672
10673    #[test]
10674    fn scrub_credentials_short_values_not_redacted() {
10675        // Values shorter than 8 chars should not be redacted
10676        let input = r#"api_key="short""#;
10677        let result = scrub_credentials(input);
10678        assert_eq!(result, input, "short values should not be redacted");
10679    }
10680
10681    // ─────────────────────────────────────────────────────────────────────
10682    // TG4 (inline): trim_history edge cases
10683    // ─────────────────────────────────────────────────────────────────────
10684
10685    #[test]
10686    fn trim_history_empty_history() {
10687        let mut history: Vec<ChatMessage> = vec![];
10688        trim_history(&mut history, 10);
10689        assert!(history.is_empty());
10690    }
10691
10692    #[test]
10693    fn trim_history_system_only() {
10694        let mut history = vec![ChatMessage::system("system prompt")];
10695        trim_history(&mut history, 10);
10696        assert_eq!(history.len(), 1);
10697        assert_eq!(history[0].role, "system");
10698    }
10699
10700    #[test]
10701    fn trim_history_exactly_at_limit() {
10702        let mut history = vec![
10703            ChatMessage::system("system"),
10704            ChatMessage::user("msg 1"),
10705            ChatMessage::assistant("reply 1"),
10706        ];
10707        trim_history(&mut history, 2); // 2 non-system messages = exactly at limit
10708        assert_eq!(history.len(), 3, "should not trim when exactly at limit");
10709    }
10710
10711    #[test]
10712    fn trim_history_removes_oldest_non_system() {
10713        let mut history = vec![
10714            ChatMessage::system("system"),
10715            ChatMessage::user("old msg"),
10716            ChatMessage::assistant("old reply"),
10717            ChatMessage::user("new msg"),
10718            ChatMessage::assistant("new reply"),
10719        ];
10720        trim_history(&mut history, 2);
10721        assert_eq!(history.len(), 3); // system + 2 kept
10722        assert_eq!(history[0].role, "system");
10723        assert_eq!(history[1].content, "new msg");
10724    }
10725
10726    /// When `build_system_prompt_with_mode` is called with `native_tools = true`,
10727    /// the output must contain ZERO XML protocol artifacts and must not inject
10728    /// the duplicate non-native tools summary.
10729    #[test]
10730    fn native_tools_system_prompt_contains_zero_xml() {
10731        use crate::agent::system_prompt::build_system_prompt_with_mode;
10732
10733        let workspace = tempdir().unwrap();
10734        let tool_summaries: Vec<(&str, &str)> = vec![
10735            ("shell", "Execute shell commands"),
10736            ("file_read", "Read files"),
10737        ];
10738
10739        let system_prompt = build_system_prompt_with_mode(
10740            workspace.path(),
10741            "test-model",
10742            &tool_summaries,
10743            &[],  // no skills
10744            None, // no identity config
10745            None, // no bootstrap_max_chars
10746            true, // native_tools
10747            zeroclaw_config::schema::SkillsPromptInjectionMode::Full,
10748            crate::security::AutonomyLevel::default(),
10749        );
10750
10751        // Must contain zero XML protocol artifacts
10752        assert!(
10753            !system_prompt.contains("<tool_call>"),
10754            "Native prompt must not contain <tool_call>"
10755        );
10756        assert!(
10757            !system_prompt.contains("</tool_call>"),
10758            "Native prompt must not contain </tool_call>"
10759        );
10760        assert!(
10761            !system_prompt.contains("<tool_result>"),
10762            "Native prompt must not contain <tool_result>"
10763        );
10764        assert!(
10765            !system_prompt.contains("</tool_result>"),
10766            "Native prompt must not contain </tool_result>"
10767        );
10768        assert!(
10769            !system_prompt.contains("## Tool Use Protocol"),
10770            "Native prompt must not contain XML protocol header"
10771        );
10772
10773        // Positive: native prompt should still contain native-task framing.
10774        assert!(
10775            !system_prompt.contains("## Tools"),
10776            "Native prompt should skip the duplicate tools summary"
10777        );
10778        assert!(
10779            system_prompt.contains("## Your Task"),
10780            "Native prompt should contain task instructions"
10781        );
10782    }
10783
10784    #[test]
10785    fn non_native_system_prompt_with_no_tools_contains_zero_tool_protocol() {
10786        use crate::agent::system_prompt::build_system_prompt_with_mode;
10787
10788        let tool_summaries: Vec<(&str, &str)> = vec![];
10789
10790        let system_prompt = build_system_prompt_with_mode(
10791            std::path::Path::new("/tmp"),
10792            "test-model",
10793            &tool_summaries,
10794            &[],
10795            None,
10796            None,
10797            false,
10798            zeroclaw_config::schema::SkillsPromptInjectionMode::Full,
10799            crate::security::AutonomyLevel::default(),
10800        );
10801
10802        assert!(
10803            !system_prompt.contains("## Tools"),
10804            "No-tools prompt must not include a Tools section"
10805        );
10806        assert!(
10807            !system_prompt.contains("## Tool Use Protocol"),
10808            "No-tools prompt must not include tool protocol"
10809        );
10810        assert!(
10811            !system_prompt.contains("<tool_call>"),
10812            "No-tools prompt must not mention XML tool calls"
10813        );
10814        assert!(
10815            !system_prompt.contains("<tool_result>"),
10816            "No-tools prompt must not mention XML tool results"
10817        );
10818        assert!(
10819            !system_prompt.contains("Use the tools"),
10820            "No-tools prompt must not instruct the model to use unavailable tools"
10821        );
10822        assert!(
10823            system_prompt.contains("No tools are available for this turn"),
10824            "No-tools prompt should explicitly describe the current capability boundary"
10825        );
10826    }
10827
10828    #[test]
10829    fn strict_non_native_prompt_policy_hides_text_tool_protocol_inputs() {
10830        let mut tool_descs = vec![("shell", "Run commands")];
10831        let mut deferred_section = "## Deferred MCP Tools\n\n- mcp__example".to_string();
10832
10833        let expose_text_protocol =
10834            apply_text_tool_prompt_policy(false, true, &mut tool_descs, &mut deferred_section);
10835
10836        assert!(!expose_text_protocol);
10837        assert!(
10838            tool_descs.is_empty(),
10839            "strict non-native prompt paths must not advertise text tools"
10840        );
10841        assert!(
10842            deferred_section.is_empty(),
10843            "strict non-native prompt paths must not advertise deferred text tools"
10844        );
10845    }
10846
10847    // ── Cross-Alias & GLM Shortened Body Tests ──────────────────────────
10848
10849    #[test]
10850    fn parse_tool_calls_cross_alias_close_tag_with_json() {
10851        // <tool_call> opened but closed with </invoke> — JSON body
10852        let input = r#"<tool_call>{"name": "shell", "arguments": {"command": "ls"}}</invoke>"#;
10853        let (text, calls) = parse_tool_calls(input);
10854        assert_eq!(calls.len(), 1);
10855        assert_eq!(calls[0].name, "shell");
10856        assert_eq!(calls[0].arguments["command"], "ls");
10857        assert!(text.is_empty());
10858    }
10859
10860    #[test]
10861    fn parse_tool_calls_cross_alias_close_tag_with_glm_shortened() {
10862        // <tool_call>shell>uname -a</invoke> — GLM shortened inside cross-alias tags
10863        let input = "<tool_call>shell>uname -a</invoke>";
10864        let (text, calls) = parse_tool_calls(input);
10865        assert_eq!(calls.len(), 1);
10866        assert_eq!(calls[0].name, "shell");
10867        assert_eq!(calls[0].arguments["command"], "uname -a");
10868        assert!(text.is_empty());
10869    }
10870
10871    #[test]
10872    fn parse_tool_calls_glm_shortened_body_in_matched_tags() {
10873        // <tool_call>shell>pwd</tool_call> — GLM shortened in matched tags
10874        let input = "<tool_call>shell>pwd</tool_call>";
10875        let (text, calls) = parse_tool_calls(input);
10876        assert_eq!(calls.len(), 1);
10877        assert_eq!(calls[0].name, "shell");
10878        assert_eq!(calls[0].arguments["command"], "pwd");
10879        assert!(text.is_empty());
10880    }
10881
10882    #[test]
10883    fn parse_tool_calls_glm_yaml_style_in_tags() {
10884        // <tool_call>shell>\ncommand: date\napproved: true</invoke>
10885        let input = "<tool_call>shell>\ncommand: date\napproved: true</invoke>";
10886        let (text, calls) = parse_tool_calls(input);
10887        assert_eq!(calls.len(), 1);
10888        assert_eq!(calls[0].name, "shell");
10889        assert_eq!(calls[0].arguments["command"], "date");
10890        assert_eq!(calls[0].arguments["approved"], true);
10891        assert!(text.is_empty());
10892    }
10893
10894    #[test]
10895    fn parse_tool_calls_attribute_style_in_tags() {
10896        // <tool_call>shell command="date" /></tool_call>
10897        let input = r#"<tool_call>shell command="date" /></tool_call>"#;
10898        let (text, calls) = parse_tool_calls(input);
10899        assert_eq!(calls.len(), 1);
10900        assert_eq!(calls[0].name, "shell");
10901        assert_eq!(calls[0].arguments["command"], "date");
10902        assert!(text.is_empty());
10903    }
10904
10905    #[test]
10906    fn parse_tool_calls_file_read_shortened_in_cross_alias() {
10907        // <tool_call>file_read path=".env" /></invoke>
10908        let input = r#"<tool_call>file_read path=".env" /></invoke>"#;
10909        let (text, calls) = parse_tool_calls(input);
10910        assert_eq!(calls.len(), 1);
10911        assert_eq!(calls[0].name, "file_read");
10912        assert_eq!(calls[0].arguments["path"], ".env");
10913        assert!(text.is_empty());
10914    }
10915
10916    #[test]
10917    fn parse_tool_calls_unclosed_glm_shortened_no_close_tag() {
10918        // <tool_call>shell>ls -la (no close tag at all)
10919        let input = "<tool_call>shell>ls -la";
10920        let (text, calls) = parse_tool_calls(input);
10921        assert_eq!(calls.len(), 1);
10922        assert_eq!(calls[0].name, "shell");
10923        assert_eq!(calls[0].arguments["command"], "ls -la");
10924        assert!(text.is_empty());
10925    }
10926
10927    #[test]
10928    fn parse_tool_calls_text_before_cross_alias() {
10929        // Text before and after cross-alias tool call
10930        let input = "Let me check that.\n<tool_call>shell>uname -a</invoke>\nDone.";
10931        let (text, calls) = parse_tool_calls(input);
10932        assert_eq!(calls.len(), 1);
10933        assert_eq!(calls[0].name, "shell");
10934        assert_eq!(calls[0].arguments["command"], "uname -a");
10935        assert!(text.contains("Let me check that."));
10936        assert!(text.contains("Done."));
10937    }
10938
10939    // ═══════════════════════════════════════════════════════════════════════
10940    // reasoning_content pass-through tests for history builders
10941    // ═══════════════════════════════════════════════════════════════════════
10942
10943    #[test]
10944    fn build_native_assistant_history_includes_reasoning_content() {
10945        let calls = vec![ToolCall {
10946            id: "call_1".into(),
10947            name: "shell".into(),
10948            arguments: "{}".into(),
10949            extra_content: None,
10950        }];
10951        let result = build_native_assistant_history("answer", &calls, Some("thinking step"));
10952        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
10953        assert_eq!(parsed["content"].as_str(), Some("answer"));
10954        assert_eq!(parsed["reasoning_content"].as_str(), Some("thinking step"));
10955        assert!(parsed["tool_calls"].is_array());
10956    }
10957
10958    #[test]
10959    fn build_native_assistant_history_omits_reasoning_content_when_none() {
10960        let calls = vec![ToolCall {
10961            id: "call_1".into(),
10962            name: "shell".into(),
10963            arguments: "{}".into(),
10964            extra_content: None,
10965        }];
10966        let result = build_native_assistant_history("answer", &calls, None);
10967        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
10968        assert_eq!(parsed["content"].as_str(), Some("answer"));
10969        assert!(parsed.get("reasoning_content").is_none());
10970    }
10971
10972    #[test]
10973    fn build_native_assistant_history_from_parsed_calls_includes_reasoning_content() {
10974        let calls = vec![ParsedToolCall {
10975            name: "shell".into(),
10976            arguments: serde_json::json!({"command": "pwd"}),
10977            tool_call_id: Some("call_2".into()),
10978        }];
10979        let result = build_native_assistant_history_from_parsed_calls(
10980            "answer",
10981            &calls,
10982            Some("deep thought"),
10983        );
10984        assert!(result.is_some());
10985        let parsed: serde_json::Value = serde_json::from_str(result.as_deref().unwrap()).unwrap();
10986        assert_eq!(parsed["content"].as_str(), Some("answer"));
10987        assert_eq!(parsed["reasoning_content"].as_str(), Some("deep thought"));
10988        assert!(parsed["tool_calls"].is_array());
10989    }
10990
10991    #[test]
10992    fn build_native_assistant_history_from_parsed_calls_omits_reasoning_content_when_none() {
10993        let calls = vec![ParsedToolCall {
10994            name: "shell".into(),
10995            arguments: serde_json::json!({"command": "pwd"}),
10996            tool_call_id: Some("call_2".into()),
10997        }];
10998        let result = build_native_assistant_history_from_parsed_calls("answer", &calls, None);
10999        assert!(result.is_some());
11000        let parsed: serde_json::Value = serde_json::from_str(result.as_deref().unwrap()).unwrap();
11001        assert_eq!(parsed["content"].as_str(), Some("answer"));
11002        assert!(parsed.get("reasoning_content").is_none());
11003    }
11004
11005    /// Regression test for issue #6059 — DeepSeek V4 thinking-mode tool-call
11006    /// replay rejected with `400` because the assistant's prior
11007    /// `reasoning_content` was missing from the next request.
11008    ///
11009    /// Before the fix, the streaming consumer dropped reasoning chunks on the
11010    /// floor (`chunk.delta.is_empty()` short-circuit + hardcoded
11011    /// `reasoning_content: None` on the synthesized `ChatResponse`). After
11012    /// the fix, reasoning deltas accumulate into `StreamedChatOutcome` and
11013    /// surface on the response so the agent's history layer can persist them
11014    /// and replay them on subsequent turns.
11015    #[tokio::test]
11016    async fn consume_provider_streaming_response_captures_reasoning_content() {
11017        let model_provider = StreamingNativeToolEventModelProvider::with_turns(vec![
11018            NativeStreamTurn::TextWithReasoning {
11019                text: "Listing the directory now.".to_string(),
11020                reasoning: "I need to call the shell tool to list files.".to_string(),
11021            },
11022        ]);
11023        let messages = vec![ChatMessage::user(
11024            "List the folders in the current directory",
11025        )];
11026
11027        let outcome = consume_provider_streaming_response(
11028            &model_provider,
11029            &messages,
11030            None,
11031            "deepseek-v4-pro",
11032            Some(0.2),
11033            None,
11034            None,
11035            false,
11036        )
11037        .await
11038        .expect("streaming should succeed");
11039
11040        assert_eq!(outcome.response_text, "Listing the directory now.");
11041        assert_eq!(
11042            outcome.reasoning_content,
11043            "I need to call the shell tool to list files."
11044        );
11045        assert!(
11046            outcome.tool_calls.is_empty(),
11047            "this turn does not emit native tool calls"
11048        );
11049    }
11050
11051    #[tokio::test]
11052    async fn consume_provider_streaming_response_accumulates_split_reasoning_chunks() {
11053        // Scripted multi-event stream: two reasoning chunks straddling a text
11054        // delta. The outcome should concatenate the reasoning chunks in order
11055        // and keep them out of the visible response text.
11056        struct MultiChunkModelProvider;
11057
11058        #[async_trait]
11059        impl ModelProvider for MultiChunkModelProvider {
11060            async fn chat_with_system(
11061                &self,
11062                _system_prompt: Option<&str>,
11063                _message: &str,
11064                _model: &str,
11065                _temperature: Option<f64>,
11066            ) -> anyhow::Result<String> {
11067                anyhow::bail!("not used in this test")
11068            }
11069
11070            async fn chat(
11071                &self,
11072                _request: ChatRequest<'_>,
11073                _model: &str,
11074                _temperature: Option<f64>,
11075            ) -> anyhow::Result<ChatResponse> {
11076                anyhow::bail!("not used in this test")
11077            }
11078
11079            fn supports_streaming(&self) -> bool {
11080                true
11081            }
11082
11083            fn stream_chat(
11084                &self,
11085                _request: ChatRequest<'_>,
11086                _model: &str,
11087                _temperature: Option<f64>,
11088                _options: StreamOptions,
11089            ) -> futures_util::stream::BoxStream<
11090                'static,
11091                zeroclaw_providers::traits::StreamResult<StreamEvent>,
11092            > {
11093                Box::pin(futures_util::stream::iter(vec![
11094                    Ok(StreamEvent::TextDelta(StreamChunk::reasoning("Step 1: "))),
11095                    Ok(StreamEvent::TextDelta(StreamChunk::delta("Hello "))),
11096                    Ok(StreamEvent::TextDelta(StreamChunk::reasoning(
11097                        "consider options.",
11098                    ))),
11099                    Ok(StreamEvent::TextDelta(StreamChunk::delta("there."))),
11100                    Ok(StreamEvent::Final),
11101                ]))
11102            }
11103        }
11104        impl ::zeroclaw_api::attribution::Attributable for MultiChunkModelProvider {
11105            fn role(&self) -> ::zeroclaw_api::attribution::Role {
11106                ::zeroclaw_api::attribution::Role::Provider(
11107                    ::zeroclaw_api::attribution::ProviderKind::Model(
11108                        ::zeroclaw_api::attribution::ModelProviderKind::Custom,
11109                    ),
11110                )
11111            }
11112            fn alias(&self) -> &str {
11113                "MultiChunkModelProvider"
11114            }
11115        }
11116
11117        let model_provider = MultiChunkModelProvider;
11118        let messages = vec![ChatMessage::user("hi")];
11119
11120        let outcome = consume_provider_streaming_response(
11121            &model_provider,
11122            &messages,
11123            None,
11124            "deepseek-v4-flash",
11125            Some(0.2),
11126            None,
11127            None,
11128            false,
11129        )
11130        .await
11131        .expect("streaming should succeed");
11132
11133        assert_eq!(outcome.response_text, "Hello there.");
11134        assert_eq!(outcome.reasoning_content, "Step 1: consider options.");
11135    }
11136
11137    // ── glob_match tests ──────────────────────────────────────────────────────
11138
11139    #[test]
11140    fn glob_match_exact_no_wildcard() {
11141        assert!(glob_match("mcp_browser_navigate", "mcp_browser_navigate"));
11142        assert!(!glob_match("mcp_browser_navigate", "mcp_browser_click"));
11143    }
11144
11145    #[test]
11146    fn glob_match_prefix_wildcard() {
11147        // Suffix pattern: mcp_browser_*
11148        assert!(glob_match("mcp_browser_*", "mcp_browser_navigate"));
11149        assert!(glob_match("mcp_browser_*", "mcp_browser_click"));
11150        assert!(!glob_match("mcp_browser_*", "mcp_filesystem_read"));
11151
11152        // Prefix pattern: *_read
11153        assert!(glob_match("*_read", "mcp_filesystem_read"));
11154        assert!(!glob_match("*_read", "mcp_filesystem_write"));
11155
11156        // Infix: mcp_*_navigate
11157        assert!(glob_match("mcp_*_navigate", "mcp_browser_navigate"));
11158        assert!(!glob_match("mcp_*_navigate", "mcp_browser_click"));
11159    }
11160
11161    #[test]
11162    fn glob_match_star_matches_everything() {
11163        assert!(glob_match("*", "anything_at_all"));
11164        assert!(glob_match("*", ""));
11165    }
11166
11167    // ── filter_tool_specs_for_turn tests ──────────────────────────────────────
11168
11169    fn make_spec(name: &str) -> crate::tools::ToolSpec {
11170        crate::tools::ToolSpec {
11171            name: name.to_string(),
11172            description: String::new(),
11173            parameters: serde_json::json!({}),
11174        }
11175    }
11176
11177    #[test]
11178    fn filter_tool_specs_no_groups_returns_all() {
11179        let specs = vec![
11180            make_spec("shell_exec"),
11181            make_spec("mcp_browser_navigate"),
11182            make_spec("mcp_filesystem_read"),
11183        ];
11184        let result = filter_tool_specs_for_turn(specs, &[], "hello");
11185        assert_eq!(result.len(), 3);
11186    }
11187
11188    #[test]
11189    fn filter_tool_specs_always_group_includes_matching_mcp_tool() {
11190        use zeroclaw_config::schema::{ToolFilterGroup, ToolFilterGroupMode};
11191
11192        let specs = vec![
11193            make_spec("shell_exec"),
11194            make_spec("mcp_browser_navigate"),
11195            make_spec("mcp_filesystem_read"),
11196        ];
11197        let groups = vec![ToolFilterGroup {
11198            mode: ToolFilterGroupMode::Always,
11199            tools: vec!["mcp_filesystem_*".into()],
11200            keywords: vec![],
11201            filter_builtins: false,
11202        }];
11203        let result = filter_tool_specs_for_turn(specs, &groups, "anything");
11204        let names: Vec<&str> = result.iter().map(|s| s.name.as_str()).collect();
11205        // Built-in passes through, matched MCP passes, unmatched MCP excluded.
11206        assert!(names.contains(&"shell_exec"));
11207        assert!(names.contains(&"mcp_filesystem_read"));
11208        assert!(!names.contains(&"mcp_browser_navigate"));
11209    }
11210
11211    #[test]
11212    fn filter_tool_specs_dynamic_group_included_on_keyword_match() {
11213        use zeroclaw_config::schema::{ToolFilterGroup, ToolFilterGroupMode};
11214
11215        let specs = vec![make_spec("shell_exec"), make_spec("mcp_browser_navigate")];
11216        let groups = vec![ToolFilterGroup {
11217            mode: ToolFilterGroupMode::Dynamic,
11218            tools: vec!["mcp_browser_*".into()],
11219            keywords: vec!["browse".into(), "website".into()],
11220            filter_builtins: false,
11221        }];
11222        let result = filter_tool_specs_for_turn(specs, &groups, "please browse this page");
11223        let names: Vec<&str> = result.iter().map(|s| s.name.as_str()).collect();
11224        assert!(names.contains(&"shell_exec"));
11225        assert!(names.contains(&"mcp_browser_navigate"));
11226    }
11227
11228    #[test]
11229    fn filter_tool_specs_dynamic_group_excluded_on_no_keyword_match() {
11230        use zeroclaw_config::schema::{ToolFilterGroup, ToolFilterGroupMode};
11231
11232        let specs = vec![make_spec("shell_exec"), make_spec("mcp_browser_navigate")];
11233        let groups = vec![ToolFilterGroup {
11234            mode: ToolFilterGroupMode::Dynamic,
11235            tools: vec!["mcp_browser_*".into()],
11236            keywords: vec!["browse".into(), "website".into()],
11237            filter_builtins: false,
11238        }];
11239        let result = filter_tool_specs_for_turn(specs, &groups, "read the file /etc/hosts");
11240        let names: Vec<&str> = result.iter().map(|s| s.name.as_str()).collect();
11241        assert!(names.contains(&"shell_exec"));
11242        assert!(!names.contains(&"mcp_browser_navigate"));
11243    }
11244
11245    #[test]
11246    fn filter_tool_specs_dynamic_keyword_match_is_case_insensitive() {
11247        use zeroclaw_config::schema::{ToolFilterGroup, ToolFilterGroupMode};
11248
11249        let specs = vec![make_spec("mcp_browser_navigate")];
11250        let groups = vec![ToolFilterGroup {
11251            mode: ToolFilterGroupMode::Dynamic,
11252            tools: vec!["mcp_browser_*".into()],
11253            keywords: vec!["Browse".into()],
11254            filter_builtins: false,
11255        }];
11256        let result = filter_tool_specs_for_turn(specs, &groups, "BROWSE the site");
11257        assert_eq!(result.len(), 1);
11258    }
11259
11260    // ── Token-based compaction tests ──────────────────────────
11261
11262    #[test]
11263    fn estimate_history_tokens_empty() {
11264        assert_eq!(super::estimate_history_tokens(&[]), 0);
11265    }
11266
11267    #[test]
11268    fn estimate_history_tokens_single_message() {
11269        let history = vec![ChatMessage::user("hello world")]; // 11 chars
11270        let tokens = super::estimate_history_tokens(&history);
11271        // 11.div_ceil(4) + 4 = 3 + 4 = 7
11272        assert_eq!(tokens, 7);
11273    }
11274
11275    #[test]
11276    fn estimate_history_tokens_multiple_messages() {
11277        let history = vec![
11278            ChatMessage::system("You are helpful."), // 16 chars → 4 + 4 = 8
11279            ChatMessage::user("What is Rust?"),      // 13 chars → 4 + 4 = 8
11280            ChatMessage::assistant("A language."),   // 11 chars → 3 + 4 = 7
11281        ];
11282        let tokens = super::estimate_history_tokens(&history);
11283        assert_eq!(tokens, 23);
11284    }
11285
11286    #[tokio::test]
11287    async fn run_tool_call_loop_surfaces_tool_failure_reason_in_on_delta() {
11288        let model_provider = ScriptedModelProvider::from_text_responses(vec![
11289            r#"<tool_call>
11290{"name":"failing_shell","arguments":{"command":"rm -rf /"}}
11291</tool_call>"#,
11292            "I could not execute that command.",
11293        ]);
11294
11295        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(FailingTool::new(
11296            "failing_shell",
11297            "Command not allowed by security policy: rm -rf /",
11298        ))];
11299
11300        let mut history = vec![
11301            ChatMessage::system("test-system"),
11302            ChatMessage::user("delete everything"),
11303        ];
11304        let observer = NoopObserver;
11305
11306        let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(64);
11307
11308        let result = run_tool_call_loop(
11309            &model_provider,
11310            &mut history,
11311            &tools_registry,
11312            &observer,
11313            "mock-provider",
11314            "mock-model",
11315            Some(0.0),
11316            true,
11317            None,
11318            "telegram",
11319            None,
11320            &zeroclaw_config::schema::MultimodalConfig::default(),
11321            4,
11322            None,
11323            Some(tx),
11324            None,
11325            &[],
11326            &[],
11327            None,
11328            None,
11329            &zeroclaw_config::schema::PacingConfig::default(),
11330            false,
11331            0,
11332            0,
11333            None,
11334            None, // channel
11335            None, // receipt_generator
11336            None, // collected_receipts
11337        )
11338        .await
11339        .expect("tool loop should complete");
11340
11341        // Collect all messages sent to the on_delta channel.
11342        let mut deltas = Vec::new();
11343        while let Ok(msg) = rx.try_recv() {
11344            deltas.push(msg);
11345        }
11346
11347        let all_deltas: String = deltas
11348            .iter()
11349            .map(|d| match d {
11350                StreamDelta::Status(t) | StreamDelta::Text(t) => t.as_str(),
11351            })
11352            .collect();
11353
11354        // The failure reason should appear in the progress messages.
11355        assert!(
11356            all_deltas.contains("Command not allowed by security policy"),
11357            "on_delta messages should include the tool failure reason, got: {all_deltas}"
11358        );
11359
11360        // Should also contain the cross mark (❌) icon to indicate failure.
11361        assert!(
11362            all_deltas.contains('\u{274c}'),
11363            "on_delta messages should include ❌ for failed tool calls, got: {all_deltas}"
11364        );
11365
11366        assert!(
11367            result.ends_with("I could not execute that command."),
11368            "result should end with error message, got: {result}"
11369        );
11370    }
11371
11372    // ── filter_by_allowed_tools tests ─────────────────────────────────────
11373
11374    #[test]
11375    fn filter_by_allowed_tools_none_passes_all() {
11376        let specs = vec![
11377            make_spec("shell"),
11378            make_spec("memory_store"),
11379            make_spec("file_read"),
11380        ];
11381        let result = filter_by_allowed_tools(specs, None);
11382        assert_eq!(result.len(), 3);
11383    }
11384
11385    #[test]
11386    fn filter_by_allowed_tools_some_restricts_to_listed() {
11387        let specs = vec![
11388            make_spec("shell"),
11389            make_spec("memory_store"),
11390            make_spec("file_read"),
11391        ];
11392        let allowed = vec!["shell".to_string(), "memory_store".to_string()];
11393        let result = filter_by_allowed_tools(specs, Some(&allowed));
11394        let names: Vec<&str> = result.iter().map(|s| s.name.as_str()).collect();
11395        assert_eq!(names.len(), 2);
11396        assert!(names.contains(&"shell"));
11397        assert!(names.contains(&"memory_store"));
11398        assert!(!names.contains(&"file_read"));
11399    }
11400
11401    #[test]
11402    fn filter_by_allowed_tools_unknown_names_silently_ignored() {
11403        let specs = vec![make_spec("shell"), make_spec("file_read")];
11404        let allowed = vec![
11405            "shell".to_string(),
11406            "nonexistent_tool".to_string(),
11407            "another_missing".to_string(),
11408        ];
11409        let result = filter_by_allowed_tools(specs, Some(&allowed));
11410        let names: Vec<&str> = result.iter().map(|s| s.name.as_str()).collect();
11411        assert_eq!(names.len(), 1);
11412        assert!(names.contains(&"shell"));
11413    }
11414
11415    #[test]
11416    fn filter_by_allowed_tools_empty_list_excludes_all() {
11417        let specs = vec![make_spec("shell"), make_spec("file_read")];
11418        let allowed: Vec<String> = vec![];
11419        let result = filter_by_allowed_tools(specs, Some(&allowed));
11420        assert!(result.is_empty());
11421    }
11422
11423    // ── Cost tracking tests ──
11424
11425    #[tokio::test]
11426    async fn cost_tracking_records_usage_when_scoped() {
11427        use super::{
11428            TOOL_LOOP_COST_TRACKING_CONTEXT, ToolLoopCostTrackingContext, run_tool_call_loop,
11429        };
11430        use crate::cost::CostTracker;
11431        use crate::observability::noop::NoopObserver;
11432        use std::collections::HashMap;
11433
11434        let model_provider = ScriptedModelProvider {
11435            responses: Arc::new(Mutex::new(VecDeque::from([ChatResponse {
11436                text: Some("done".to_string()),
11437                tool_calls: Vec::new(),
11438                usage: Some(zeroclaw_providers::traits::TokenUsage {
11439                    input_tokens: Some(1_000),
11440                    output_tokens: Some(200),
11441                    cached_input_tokens: None,
11442                }),
11443                reasoning_content: None,
11444            }]))),
11445            capabilities: ProviderCapabilities::default(),
11446        };
11447        let observer = NoopObserver;
11448        let workspace = tempfile::TempDir::new().unwrap();
11449        let cost_config = zeroclaw_config::schema::CostConfig {
11450            enabled: true,
11451            ..zeroclaw_config::schema::CostConfig::default()
11452        };
11453        let tracker = Arc::new(CostTracker::new(cost_config.clone(), workspace.path()).unwrap());
11454        let mut model_pricing: HashMap<String, f64> = HashMap::new();
11455        model_pricing.insert("mock-model.input".to_string(), 3.0);
11456        model_pricing.insert("mock-model.output".to_string(), 15.0);
11457        let mut pricing: crate::agent::cost::ModelProviderPricing = HashMap::new();
11458        pricing.insert("mock-provider".to_string(), model_pricing);
11459        let ctx = ToolLoopCostTrackingContext::new(Arc::clone(&tracker), Arc::new(pricing));
11460        let mut history = vec![ChatMessage::system("test"), ChatMessage::user("hello")];
11461
11462        let result = TOOL_LOOP_COST_TRACKING_CONTEXT
11463            .scope(
11464                Some(ctx),
11465                run_tool_call_loop(
11466                    &model_provider,
11467                    &mut history,
11468                    &[],
11469                    &observer,
11470                    "mock-provider",
11471                    "mock-model",
11472                    Some(0.0),
11473                    true,
11474                    None,
11475                    "test",
11476                    None,
11477                    &zeroclaw_config::schema::MultimodalConfig::default(),
11478                    2,
11479                    None,
11480                    None,
11481                    None,
11482                    &[],
11483                    &[],
11484                    None,
11485                    None,
11486                    &zeroclaw_config::schema::PacingConfig::default(),
11487                    false,
11488                    0,
11489                    0,
11490                    None,
11491                    None, // channel
11492                    None, // receipt_generator
11493                    None, // collected_receipts
11494                ),
11495            )
11496            .await
11497            .expect("tool loop should succeed");
11498
11499        assert!(
11500            result.ends_with("done"),
11501            "result should end with 'done', got: {result}"
11502        );
11503        let summary = tracker.get_summary().unwrap();
11504        assert_eq!(summary.request_count, 1);
11505        assert_eq!(summary.total_tokens, 1_200);
11506        assert!(summary.session_cost_usd > 0.0);
11507    }
11508
11509    #[tokio::test]
11510    async fn tool_loop_normalizes_non_leading_system_messages_before_provider_request() {
11511        let provider = RecordingModelProvider::new();
11512        let requests = Arc::clone(&provider.requests);
11513        let observer = NoopObserver;
11514        let mut history = vec![
11515            ChatMessage::system("base system"),
11516            ChatMessage::user("first question"),
11517            ChatMessage::assistant("first answer"),
11518            ChatMessage::system("late loop-detection guidance"),
11519            ChatMessage::user("follow-up"),
11520        ];
11521
11522        let result = run_tool_call_loop(
11523            &provider,
11524            &mut history,
11525            &[],
11526            &observer,
11527            "recording-provider",
11528            "mock-model",
11529            Some(0.0),
11530            true,
11531            None,
11532            "test",
11533            None,
11534            &zeroclaw_config::schema::MultimodalConfig::default(),
11535            2,
11536            None,
11537            None,
11538            None,
11539            &[],
11540            &[],
11541            None,
11542            None,
11543            &zeroclaw_config::schema::PacingConfig::default(),
11544            false,
11545            0,
11546            0,
11547            None,
11548            None,
11549            None,
11550            None,
11551        )
11552        .await
11553        .expect("tool loop should complete");
11554
11555        assert_eq!(result, "done");
11556        let requests = requests.lock().expect("requests lock should be valid");
11557        assert_eq!(requests.len(), 1);
11558        let sent = &requests[0];
11559        assert_eq!(sent[0].role, "system");
11560        assert_eq!(
11561            sent.iter().filter(|msg| msg.role == "system").count(),
11562            1,
11563            "provider request must not contain non-leading system messages: {:?}",
11564            sent.iter().map(|msg| msg.role.as_str()).collect::<Vec<_>>()
11565        );
11566        assert!(sent[0].content.contains("base system"));
11567        assert!(sent[0].content.contains("late loop-detection guidance"));
11568        assert_eq!(
11569            sent.iter().map(|msg| msg.role.as_str()).collect::<Vec<_>>(),
11570            vec!["system", "user", "assistant", "user"]
11571        );
11572    }
11573
11574    #[tokio::test]
11575    async fn cost_tracking_enforces_budget() {
11576        use super::{
11577            TOOL_LOOP_COST_TRACKING_CONTEXT, ToolLoopCostTrackingContext, run_tool_call_loop,
11578        };
11579        use crate::cost::CostTracker;
11580        use crate::observability::noop::NoopObserver;
11581        use std::collections::HashMap;
11582
11583        let model_provider =
11584            ScriptedModelProvider::from_text_responses(vec!["should not reach this"]);
11585        let observer = NoopObserver;
11586        let workspace = tempfile::TempDir::new().unwrap();
11587        let cost_config = zeroclaw_config::schema::CostConfig {
11588            enabled: true,
11589            daily_limit_usd: 0.001, // very low limit
11590            ..zeroclaw_config::schema::CostConfig::default()
11591        };
11592        let tracker = Arc::new(CostTracker::new(cost_config.clone(), workspace.path()).unwrap());
11593        // Record a usage that already exceeds the limit
11594        tracker
11595            .record_usage(crate::cost::types::TokenUsage::new(
11596                "mock-model",
11597                100_000,
11598                50_000,
11599                0,
11600                1.0,
11601                1.0,
11602                0.0,
11603            ))
11604            .unwrap();
11605
11606        let mut model_pricing: HashMap<String, f64> = HashMap::new();
11607        model_pricing.insert("mock-model.input".to_string(), 1.0);
11608        model_pricing.insert("mock-model.output".to_string(), 1.0);
11609        let mut pricing: crate::agent::cost::ModelProviderPricing = HashMap::new();
11610        pricing.insert("mock-provider".to_string(), model_pricing);
11611        let ctx = ToolLoopCostTrackingContext::new(Arc::clone(&tracker), Arc::new(pricing));
11612        let mut history = vec![ChatMessage::system("test"), ChatMessage::user("hello")];
11613
11614        let err = TOOL_LOOP_COST_TRACKING_CONTEXT
11615            .scope(
11616                Some(ctx),
11617                run_tool_call_loop(
11618                    &model_provider,
11619                    &mut history,
11620                    &[],
11621                    &observer,
11622                    "mock-provider",
11623                    "mock-model",
11624                    Some(0.0),
11625                    true,
11626                    None,
11627                    "test",
11628                    None,
11629                    &zeroclaw_config::schema::MultimodalConfig::default(),
11630                    2,
11631                    None,
11632                    None,
11633                    None,
11634                    &[],
11635                    &[],
11636                    None,
11637                    None,
11638                    &zeroclaw_config::schema::PacingConfig::default(),
11639                    false,
11640                    0,
11641                    0,
11642                    None,
11643                    None, // channel
11644                    None, // receipt_generator
11645                    None, // collected_receipts
11646                ),
11647            )
11648            .await
11649            .expect_err("should fail with budget exceeded");
11650
11651        assert!(
11652            err.to_string().contains("Budget exceeded"),
11653            "error should mention budget: {err}"
11654        );
11655    }
11656
11657    #[tokio::test]
11658    async fn cost_tracking_is_noop_without_scope() {
11659        use super::run_tool_call_loop;
11660        use crate::observability::noop::NoopObserver;
11661
11662        // No TOOL_LOOP_COST_TRACKING_CONTEXT scoped — should run fine
11663        let model_provider = ScriptedModelProvider {
11664            responses: Arc::new(Mutex::new(VecDeque::from([ChatResponse {
11665                text: Some("ok".to_string()),
11666                tool_calls: Vec::new(),
11667                usage: Some(zeroclaw_providers::traits::TokenUsage {
11668                    input_tokens: Some(500),
11669                    output_tokens: Some(100),
11670                    cached_input_tokens: None,
11671                }),
11672                reasoning_content: None,
11673            }]))),
11674            capabilities: ProviderCapabilities::default(),
11675        };
11676        let observer = NoopObserver;
11677        let mut history = vec![ChatMessage::system("test"), ChatMessage::user("hello")];
11678
11679        let result = run_tool_call_loop(
11680            &model_provider,
11681            &mut history,
11682            &[],
11683            &observer,
11684            "mock-provider",
11685            "mock-model",
11686            Some(0.0),
11687            true,
11688            None,
11689            "test",
11690            None,
11691            &zeroclaw_config::schema::MultimodalConfig::default(),
11692            2,
11693            None,
11694            None,
11695            None,
11696            &[],
11697            &[],
11698            None,
11699            None,
11700            &zeroclaw_config::schema::PacingConfig::default(),
11701            false,
11702            0,
11703            0,
11704            None,
11705            None, // channel
11706            None, // receipt_generator
11707            None, // collected_receipts
11708        )
11709        .await
11710        .expect("should succeed without cost scope");
11711
11712        assert_eq!(result, "ok");
11713    }
11714
11715    // ── apply_policy_tool_filter coverage ─────────────────────
11716    //
11717    // The dispatch-site filter must consult both the parent agent's
11718    // SecurityPolicy.allowed_tools / .excluded_tools AND the
11719    // caller-supplied allowed_tools list, with both gates composing
11720    // by intersection. A tool name absent from either falls out.
11721
11722    use zeroclaw_api::tool::Tool as TestTool;
11723    use zeroclaw_config::policy::SecurityPolicy as TestPolicy;
11724
11725    struct NamedMockTool {
11726        the_name: &'static str,
11727    }
11728
11729    #[async_trait]
11730    impl TestTool for NamedMockTool {
11731        fn name(&self) -> &str {
11732            self.the_name
11733        }
11734        fn description(&self) -> &str {
11735            ""
11736        }
11737        fn parameters_schema(&self) -> serde_json::Value {
11738            serde_json::json!({})
11739        }
11740        async fn execute(
11741            &self,
11742            _args: serde_json::Value,
11743        ) -> anyhow::Result<crate::tools::ToolResult> {
11744            Ok(crate::tools::ToolResult {
11745                success: true,
11746                output: String::new(),
11747                error: None,
11748            })
11749        }
11750    }
11751
11752    fn mock_tool(name: &'static str) -> Box<dyn TestTool> {
11753        Box::new(NamedMockTool { the_name: name })
11754    }
11755
11756    fn tool_names(tools: &[Box<dyn TestTool>]) -> Vec<&str> {
11757        tools.iter().map(|t| t.name()).collect()
11758    }
11759
11760    #[test]
11761    fn apply_policy_tool_filter_no_gates_keeps_everything() {
11762        let mut tools = vec![
11763            mock_tool("shell"),
11764            mock_tool("spawn_subagent"),
11765            mock_tool("memory_recall"),
11766        ];
11767        super::apply_policy_tool_filter(&mut tools, None, None);
11768        assert_eq!(
11769            tool_names(&tools),
11770            vec!["shell", "spawn_subagent", "memory_recall"]
11771        );
11772    }
11773
11774    #[test]
11775    fn apply_policy_tool_filter_policy_allowlist_restricts() {
11776        let mut tools = vec![
11777            mock_tool("shell"),
11778            mock_tool("spawn_subagent"),
11779            mock_tool("memory_recall"),
11780        ];
11781        let policy = TestPolicy {
11782            allowed_tools: Some(vec!["shell".into(), "memory_recall".into()]),
11783            ..TestPolicy::default()
11784        };
11785
11786        super::apply_policy_tool_filter(&mut tools, Some(&policy), None);
11787        assert_eq!(tool_names(&tools), vec!["shell", "memory_recall"]);
11788    }
11789
11790    #[test]
11791    fn apply_policy_tool_filter_policy_excluded_subtracts_from_unrestricted() {
11792        let mut tools = vec![mock_tool("shell"), mock_tool("spawn_subagent")];
11793        let policy = TestPolicy {
11794            excluded_tools: Some(vec!["spawn_subagent".into()]),
11795            ..TestPolicy::default()
11796        };
11797
11798        super::apply_policy_tool_filter(&mut tools, Some(&policy), None);
11799        assert_eq!(tool_names(&tools), vec!["shell"]);
11800    }
11801
11802    #[test]
11803    fn apply_policy_tool_filter_caller_filter_alone_restricts() {
11804        let mut tools = vec![
11805            mock_tool("shell"),
11806            mock_tool("spawn_subagent"),
11807            mock_tool("memory_recall"),
11808        ];
11809        let caller = vec!["memory_recall".to_string()];
11810
11811        super::apply_policy_tool_filter(&mut tools, None, Some(&caller));
11812        assert_eq!(tool_names(&tools), vec!["memory_recall"]);
11813    }
11814
11815    #[test]
11816    fn apply_policy_tool_filter_policy_and_caller_intersect() {
11817        let mut tools = vec![
11818            mock_tool("shell"),
11819            mock_tool("spawn_subagent"),
11820            mock_tool("memory_recall"),
11821        ];
11822        let policy = TestPolicy {
11823            allowed_tools: Some(vec!["shell".into(), "memory_recall".into()]),
11824            ..TestPolicy::default()
11825        };
11826        let caller = vec!["shell".to_string(), "spawn_subagent".to_string()];
11827
11828        super::apply_policy_tool_filter(&mut tools, Some(&policy), Some(&caller));
11829        // Only `shell` survives — it's the intersection of the policy
11830        // allowlist {shell, memory_recall} and the caller filter
11831        // {shell, spawn_subagent}.
11832        assert_eq!(tool_names(&tools), vec!["shell"]);
11833    }
11834
11835    #[test]
11836    fn apply_policy_tool_filter_policy_deny_all_drops_everything() {
11837        let mut tools = vec![mock_tool("shell"), mock_tool("spawn_subagent")];
11838        let policy = TestPolicy {
11839            allowed_tools: Some(vec![]),
11840            ..TestPolicy::default()
11841        };
11842
11843        super::apply_policy_tool_filter(&mut tools, Some(&policy), None);
11844        assert!(
11845            tools.is_empty(),
11846            "Some(vec![]) on policy must deny every tool"
11847        );
11848    }
11849
11850    /// Regression: process_message must NOT expose channel targets or channel_send
11851    /// schema when channel_send is in the risk-profile excluded_tools list.
11852    /// The prompt-visible tool set must match the execution-time denylist.
11853    /// See PR #6665 — Audacity88 second review, blocking item.
11854    #[test]
11855    fn process_message_path_excludes_channel_send_from_prompt() {
11856        use std::collections::HashSet;
11857
11858        // Simulate the tool registry that all_tools_with_runtime returns.
11859        let tools_registry: Vec<Box<dyn Tool>> = vec![
11860            mock_tool("shell"),
11861            mock_tool("file_read"),
11862            mock_tool("channel_send"),
11863            mock_tool("memory_store"),
11864        ];
11865
11866        // Simulate risk-profile excluding channel_send.
11867        let excluded_tools: Vec<String> = vec!["channel_send".to_string()];
11868
11869        // Derive effective tool names the way the fixed process_message path does:
11870        // filter out excluded tools before building the prompt.
11871        let effective_tool_names: HashSet<&str> = tools_registry
11872            .iter()
11873            .map(|tool| tool.name())
11874            .filter(|name| !excluded_tools.iter().any(|ex| ex == *name))
11875            .collect();
11876
11877        // channel_send must NOT be in the effective set.
11878        assert!(
11879            !effective_tool_names.contains("channel_send"),
11880            "channel_send must be excluded from effective_tool_names when in excluded_tools"
11881        );
11882
11883        // The channel_targets guard checks effective_tool_names — since
11884        // channel_send is absent, it will NOT inject target identifiers.
11885        // This is the contract we're verifying.
11886    }
11887
11888    // ── agent_provider_composite regression ───────────────────────────────
11889
11890    #[test]
11891    fn agent_provider_composite_returns_dotted_ref_not_bare_family() {
11892        use zeroclaw_config::providers::ModelProviderRef;
11893        use zeroclaw_config::schema::{
11894            AliasedAgentConfig, ModelProviderConfig, OpenAIModelProviderConfig,
11895        };
11896
11897        let alias = "qwertfoozp";
11898
11899        let mut config = zeroclaw_config::schema::Config::default();
11900        config.providers.models.openai.insert(
11901            alias.to_string(),
11902            OpenAIModelProviderConfig {
11903                base: ModelProviderConfig {
11904                    requires_openai_auth: true,
11905                    ..Default::default()
11906                },
11907            },
11908        );
11909        config.agents.insert(
11910            "my_agent".to_string(),
11911            AliasedAgentConfig {
11912                model_provider: ModelProviderRef::new(format!("openai.{alias}")),
11913                ..Default::default()
11914            },
11915        );
11916
11917        let result = super::agent_provider_composite(&config, "my_agent");
11918
11919        // Must be the full dotted ref so the alias-aware factory path is taken.
11920        assert_eq!(
11921            result.as_deref(),
11922            Some("openai.qwertfoozp"),
11923            "agent_provider_composite must return the dotted composite ref"
11924        );
11925        // Explicitly assert it is NOT the bare family — this is the regression
11926        // this test protects against.
11927        assert_ne!(
11928            result.as_deref(),
11929            Some("openai"),
11930            "bare family name would bypass the alias-aware factory path and drop \
11931             requires_openai_auth from the config, routing to the wrong provider"
11932        );
11933    }
11934
11935    // ── process_message() path regression (#6959) ─────────────────
11936    //
11937    // The bug was not that `apply_policy_tool_filter` filtered wrong; it
11938    // was that the daemon/channel `process_message` path never called it,
11939    // so a restrictive SecurityPolicy did not apply when the same agent was
11940    // reached through a channel. This drives the exact seam that path now
11941    // calls (`filter_channel_builtin_tools`) against the *real* eager
11942    // built-in registry produced by `all_tools`, and proves an agent
11943    // allowlisted to `file_read` does not get raw `shell` / `file_write`.
11944    #[test]
11945    fn process_message_policy_filters_eager_builtins() {
11946        use std::sync::Arc;
11947
11948        let config = zeroclaw_config::schema::Config::default();
11949        let security = Arc::new(TestPolicy {
11950            workspace_dir: std::env::temp_dir(),
11951            ..TestPolicy::default()
11952        });
11953        let risk = zeroclaw_config::schema::RiskProfileConfig::default();
11954        let mem: Arc<dyn zeroclaw_memory::Memory> =
11955            Arc::new(zeroclaw_memory::NoneMemory::new("test"));
11956
11957        let (mut registry, ..) = crate::tools::all_tools(
11958            Arc::new(config.clone()),
11959            &security,
11960            &risk,
11961            "test",
11962            mem,
11963            None,
11964            None,
11965            &config.browser,
11966            &config.http_request,
11967            &config.web_fetch,
11968            &security.workspace_dir,
11969            &config.agents,
11970            None,
11971            &config,
11972            None,
11973            false,
11974        );
11975
11976        // Sanity: the unrestricted channel registry exposes the dangerous
11977        // eager built-ins a restrictive policy is expected to remove.
11978        let unrestricted = tool_names(&registry);
11979        assert!(
11980            unrestricted.contains(&"file_read"),
11981            "expected file_read in unrestricted registry, got {unrestricted:?}"
11982        );
11983        assert!(
11984            unrestricted.contains(&"shell"),
11985            "expected shell in unrestricted registry, got {unrestricted:?}"
11986        );
11987        assert!(
11988            unrestricted.contains(&"file_write"),
11989            "expected file_write in unrestricted registry, got {unrestricted:?}"
11990        );
11991
11992        // Allowlist the agent to `file_read` only, then run the exact filter
11993        // `process_message` applies.
11994        let policy = TestPolicy {
11995            allowed_tools: Some(vec!["file_read".into()]),
11996            ..TestPolicy::default()
11997        };
11998        super::filter_channel_builtin_tools(&mut registry, &policy);
11999
12000        let filtered = tool_names(&registry);
12001        assert!(
12002            filtered.contains(&"file_read"),
12003            "allowlisted tool must survive on process_message path, got {filtered:?}"
12004        );
12005        assert!(
12006            !filtered.contains(&"shell"),
12007            "shell must be filtered out on process_message path, got {filtered:?}"
12008        );
12009        assert!(
12010            !filtered.contains(&"file_write"),
12011            "file_write must be filtered out on process_message path, got {filtered:?}"
12012        );
12013
12014        // Denylist variant: an exclusion drops only the named tool.
12015        let (mut registry2, ..) = crate::tools::all_tools(
12016            Arc::new(config.clone()),
12017            &security,
12018            &risk,
12019            "test",
12020            Arc::new(zeroclaw_memory::NoneMemory::new("test")),
12021            None,
12022            None,
12023            &config.browser,
12024            &config.http_request,
12025            &config.web_fetch,
12026            &security.workspace_dir,
12027            &config.agents,
12028            None,
12029            &config,
12030            None,
12031            false,
12032        );
12033        let deny = TestPolicy {
12034            excluded_tools: Some(vec!["shell".into()]),
12035            ..TestPolicy::default()
12036        };
12037        super::filter_channel_builtin_tools(&mut registry2, &deny);
12038        let after_deny = tool_names(&registry2);
12039        assert!(
12040            !after_deny.contains(&"shell"),
12041            "excluded shell must be removed on process_message path, got {after_deny:?}"
12042        );
12043        assert!(
12044            after_deny.contains(&"file_read"),
12045            "non-excluded file_read must remain, got {after_deny:?}"
12046        );
12047    }
12048}