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