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