1use crate::approval::{ApprovalManager, ApprovalRequest, ApprovalResponse};
2use crate::config::Config;
3use crate::cost::types::BudgetCheck;
4use crate::i18n::ToolDescriptions;
5use crate::memory::{self, Memory, MemoryCategory, decay};
6use crate::multimodal;
7use crate::observability::{self, Observer, ObserverEvent, runtime_trace};
8use crate::providers::traits::StreamEvent;
9use crate::providers::{
10 self, ChatMessage, ChatRequest, Provider, ProviderCapabilityError, ToolCall,
11};
12use crate::runtime;
13use crate::security::{AutonomyLevel, SecurityPolicy};
14use crate::tools::{self, Tool};
15use crate::util::truncate_with_ellipsis;
16use anyhow::Result;
17use futures_util::StreamExt;
18use regex::{Regex, RegexSet};
19use std::collections::HashSet;
20use std::fmt::Write;
21use std::io::Write as _;
22use std::path::PathBuf;
23use std::sync::{Arc, LazyLock, Mutex};
24use std::time::{Duration, Instant};
25use tokio_util::sync::CancellationToken;
26use uuid::Uuid;
27
28pub(crate) use super::cost::{
30 TOOL_LOOP_COST_TRACKING_CONTEXT, ToolLoopCostTrackingContext, check_tool_loop_budget,
31 record_tool_loop_cost_usage,
32};
33
34const STREAM_CHUNK_MIN_CHARS: usize = 80;
36const STREAM_TOOL_MARKER_WINDOW_CHARS: usize = 512;
38
39const DEFAULT_MAX_TOOL_ITERATIONS: usize = 10;
42
43pub(crate) fn effective_max_tool_iterations(config: &crate::config::Config) -> usize {
49 if config.operator.enabled && config.operator.max_tool_iterations > 0 {
50 config.operator.max_tool_iterations
51 } else {
52 config.agent.max_tool_iterations
53 }
54}
55
56pub(crate) use super::history::{
58 emergency_history_trim, estimate_history_tokens, fast_trim_tool_results,
59 load_interactive_session_history, save_interactive_session_history, trim_history,
60 truncate_tool_result,
61};
62
63const AUTOSAVE_MIN_MESSAGE_CHARS: usize = 20;
66
67pub type ModelSwitchCallback = Arc<Mutex<Option<(String, String)>>>;
70
71#[allow(clippy::type_complexity)]
74static MODEL_SWITCH_REQUEST: LazyLock<Arc<Mutex<Option<(String, String)>>>> =
75 LazyLock::new(|| Arc::new(Mutex::new(None)));
76
77pub fn get_model_switch_state() -> ModelSwitchCallback {
79 Arc::clone(&MODEL_SWITCH_REQUEST)
80}
81
82pub fn clear_model_switch_request() {
84 if let Ok(guard) = MODEL_SWITCH_REQUEST.lock() {
85 let mut guard = guard;
86 *guard = None;
87 }
88}
89
90fn glob_match(pattern: &str, name: &str) -> bool {
91 match pattern.find('*') {
92 None => pattern == name,
93 Some(star) => {
94 let prefix = &pattern[..star];
95 let suffix = &pattern[star + 1..];
96 name.starts_with(prefix)
97 && name.ends_with(suffix)
98 && name.len() >= prefix.len() + suffix.len()
99 }
100 }
101}
102
103fn is_tool_excluded(tool_name: &str, excluded: &[String]) -> bool {
109 excluded.iter().any(|ex| {
110 if let Some(prefix) = ex.strip_suffix('*') {
111 tool_name.starts_with(prefix)
112 } else {
113 ex == tool_name
114 }
115 })
116}
117
118pub(crate) fn filter_tool_specs_for_turn(
128 tool_specs: Vec<crate::tools::ToolSpec>,
129 groups: &[crate::config::schema::ToolFilterGroup],
130 user_message: &str,
131) -> Vec<crate::tools::ToolSpec> {
132 use crate::config::schema::ToolFilterGroupMode;
133
134 if groups.is_empty() {
135 return tool_specs;
136 }
137
138 let msg_lower = user_message.to_ascii_lowercase();
139
140 tool_specs
141 .into_iter()
142 .filter(|spec| {
143 if !spec.name.starts_with("mcp_") {
145 return true;
146 }
147 groups.iter().any(|group| {
149 let pattern_matches = group.tools.iter().any(|pat| glob_match(pat, &spec.name));
150 if !pattern_matches {
151 return false;
152 }
153 match group.mode {
154 ToolFilterGroupMode::Always => true,
155 ToolFilterGroupMode::Dynamic => group
156 .keywords
157 .iter()
158 .any(|kw| msg_lower.contains(&kw.to_ascii_lowercase())),
159 }
160 })
161 })
162 .collect()
163}
164
165pub(crate) fn filter_by_allowed_tools(
171 specs: Vec<crate::tools::ToolSpec>,
172 allowed: Option<&[String]>,
173) -> Vec<crate::tools::ToolSpec> {
174 match allowed {
175 None => specs,
176 Some(list) => specs
177 .into_iter()
178 .filter(|spec| list.iter().any(|name| name == &spec.name))
179 .collect(),
180 }
181}
182
183fn compute_excluded_mcp_tools(
188 tools_registry: &[Box<dyn Tool>],
189 groups: &[crate::config::schema::ToolFilterGroup],
190 user_message: &str,
191) -> Vec<String> {
192 if groups.is_empty() {
193 return Vec::new();
194 }
195 let filtered_specs = filter_tool_specs_for_turn(
196 tools_registry.iter().map(|t| t.spec()).collect(),
197 groups,
198 user_message,
199 );
200 let included: HashSet<&str> = filtered_specs.iter().map(|s| s.name.as_str()).collect();
201 tools_registry
202 .iter()
203 .filter(|t| t.name().starts_with("mcp_") && !included.contains(t.name()))
204 .map(|t| t.name().to_string())
205 .collect()
206}
207
208static SENSITIVE_KEY_PATTERNS: LazyLock<RegexSet> = LazyLock::new(|| {
209 RegexSet::new([
210 r"(?i)token",
211 r"(?i)api[_-]?key",
212 r"(?i)password",
213 r"(?i)secret",
214 r"(?i)user[_-]?key",
215 r"(?i)bearer",
216 r"(?i)credential",
217 ])
218 .unwrap()
219});
220
221static SENSITIVE_KV_REGEX: LazyLock<Regex> = LazyLock::new(|| {
222 Regex::new(r#"(?i)(token|api[_-]?key|password|secret|user[_-]?key|bearer|credential)["']?\s*[:=]\s*(?:"([^"]{8,})"|'([^']{8,})'|([a-zA-Z0-9_\-\.]{8,}))"#).unwrap()
223});
224
225pub(crate) fn scrub_credentials(input: &str) -> String {
229 SENSITIVE_KV_REGEX
230 .replace_all(input, |caps: ®ex::Captures| {
231 let full_match = &caps[0];
232 let key = &caps[1];
233 let val = caps
234 .get(2)
235 .or(caps.get(3))
236 .or(caps.get(4))
237 .map(|m| m.as_str())
238 .unwrap_or("");
239
240 let prefix = if val.len() > 4 {
244 val.char_indices()
245 .nth(4)
246 .map(|(byte_idx, _)| &val[..byte_idx])
247 .unwrap_or(val)
248 } else {
249 ""
250 };
251
252 if full_match.contains(':') {
253 if full_match.contains('"') {
254 format!("\"{}\": \"{}*[REDACTED]\"", key, prefix)
255 } else {
256 format!("{}: {}*[REDACTED]", key, prefix)
257 }
258 } else if full_match.contains('=') {
259 if full_match.contains('"') {
260 format!("{}=\"{}*[REDACTED]\"", key, prefix)
261 } else {
262 format!("{}={}*[REDACTED]", key, prefix)
263 }
264 } else {
265 format!("{}: {}*[REDACTED]", key, prefix)
266 }
267 })
268 .to_string()
269}
270
271pub(crate) const PROGRESS_MIN_INTERVAL_MS: u64 = 500;
276
277#[derive(Debug, Clone)]
280pub enum DraftEvent {
281 Clear,
283 Progress(String),
286 Content(String),
288}
289
290tokio::task_local! {
291 pub(crate) static TOOL_CHOICE_OVERRIDE: Option<String>;
292}
293
294fn tools_to_openai_format(tools_registry: &[Box<dyn Tool>]) -> Vec<serde_json::Value> {
296 tools_registry
297 .iter()
298 .map(|tool| {
299 serde_json::json!({
300 "type": "function",
301 "function": {
302 "name": tool.name(),
303 "description": tool.description(),
304 "parameters": tool.parameters_schema()
305 }
306 })
307 })
308 .collect()
309}
310
311fn autosave_memory_key(prefix: &str) -> String {
312 format!("{prefix}_{}", Uuid::new_v4())
313}
314
315async fn build_context(
320 mem: &dyn Memory,
321 user_msg: &str,
322 min_relevance_score: f64,
323 session_id: Option<&str>,
324) -> String {
325 let mut context = String::new();
326
327 if let Ok(mut entries) = mem.recall(user_msg, 5, session_id, None, None).await {
329 decay::apply_time_decay(&mut entries, decay::DEFAULT_HALF_LIFE_DAYS);
331
332 let relevant: Vec<_> = entries
333 .iter()
334 .filter(|e| match e.score {
335 Some(score) => score >= min_relevance_score,
336 None => true,
337 })
338 .collect();
339
340 if !relevant.is_empty() {
341 context.push_str("[Memory context]\n");
342 for entry in &relevant {
343 if memory::is_assistant_autosave_key(&entry.key) {
344 continue;
345 }
346 if memory::should_skip_autosave_content(&entry.content) {
347 continue;
348 }
349 if entry.content.contains("<tool_result") {
353 continue;
354 }
355 let _ = writeln!(context, "- {}: {}", entry.key, entry.content);
356 }
357 if context == "[Memory context]\n" {
358 context.clear();
359 } else {
360 context.push_str("[/Memory context]\n\n");
361 }
362 }
363 }
364
365 context
366}
367
368fn build_hardware_context(
371 rag: &crate::rag::HardwareRag,
372 user_msg: &str,
373 boards: &[String],
374 chunk_limit: usize,
375) -> String {
376 if rag.is_empty() || boards.is_empty() {
377 return String::new();
378 }
379
380 let mut context = String::new();
381
382 let pin_ctx = rag.pin_alias_context(user_msg, boards);
384 if !pin_ctx.is_empty() {
385 context.push_str(&pin_ctx);
386 }
387
388 let chunks = rag.retrieve(user_msg, boards, chunk_limit);
389 if chunks.is_empty() && pin_ctx.is_empty() {
390 return String::new();
391 }
392
393 if !chunks.is_empty() {
394 context.push_str("[Hardware documentation]\n");
395 }
396 for chunk in chunks {
397 let board_tag = chunk.board.as_deref().unwrap_or("generic");
398 let _ = writeln!(
399 context,
400 "--- {} ({}) ---\n{}\n",
401 chunk.source, board_tag, chunk.content
402 );
403 }
404 context.push('\n');
405 context
406}
407
408pub(crate) use super::tool_execution::{
410 ToolExecutionOutcome, execute_tools_parallel, execute_tools_sequential,
411 should_execute_tools_in_parallel,
412};
413
414fn parse_arguments_value(raw: Option<&serde_json::Value>) -> serde_json::Value {
415 match raw {
416 Some(serde_json::Value::String(s)) => serde_json::from_str::<serde_json::Value>(s)
417 .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new())),
418 Some(value) => value.clone(),
419 None => serde_json::Value::Object(serde_json::Map::new()),
420 }
421}
422
423fn parse_tool_call_id(
424 root: &serde_json::Value,
425 function: Option<&serde_json::Value>,
426) -> Option<String> {
427 function
428 .and_then(|func| func.get("id"))
429 .or_else(|| root.get("id"))
430 .or_else(|| root.get("tool_call_id"))
431 .or_else(|| root.get("call_id"))
432 .and_then(serde_json::Value::as_str)
433 .map(str::trim)
434 .filter(|id| !id.is_empty())
435 .map(ToString::to_string)
436}
437
438fn canonicalize_json_for_tool_signature(value: &serde_json::Value) -> serde_json::Value {
439 match value {
440 serde_json::Value::Object(map) => {
441 let mut keys: Vec<String> = map.keys().cloned().collect();
442 keys.sort_unstable();
443 let mut ordered = serde_json::Map::new();
444 for key in keys {
445 if let Some(child) = map.get(&key) {
446 ordered.insert(key, canonicalize_json_for_tool_signature(child));
447 }
448 }
449 serde_json::Value::Object(ordered)
450 }
451 serde_json::Value::Array(items) => serde_json::Value::Array(
452 items
453 .iter()
454 .map(canonicalize_json_for_tool_signature)
455 .collect(),
456 ),
457 _ => value.clone(),
458 }
459}
460
461fn parse_tool_call_value(value: &serde_json::Value) -> Option<ParsedToolCall> {
462 if let Some(function) = value.get("function") {
463 let tool_call_id = parse_tool_call_id(value, Some(function));
464 let name = function
465 .get("name")
466 .and_then(|v| v.as_str())
467 .unwrap_or("")
468 .trim()
469 .to_string();
470 if !name.is_empty() {
471 let arguments = parse_arguments_value(
472 function
473 .get("arguments")
474 .or_else(|| function.get("parameters")),
475 );
476 return Some(ParsedToolCall {
477 name,
478 arguments,
479 tool_call_id,
480 });
481 }
482 }
483
484 let tool_call_id = parse_tool_call_id(value, None);
485 let name = value
486 .get("name")
487 .and_then(|v| v.as_str())
488 .unwrap_or("")
489 .trim()
490 .to_string();
491
492 if name.is_empty() {
493 return None;
494 }
495
496 let arguments =
497 parse_arguments_value(value.get("arguments").or_else(|| value.get("parameters")));
498 Some(ParsedToolCall {
499 name,
500 arguments,
501 tool_call_id,
502 })
503}
504
505fn parse_tool_calls_from_json_value(value: &serde_json::Value) -> Vec<ParsedToolCall> {
506 let mut calls = Vec::new();
507
508 if let Some(tool_calls) = value.get("tool_calls").and_then(|v| v.as_array()) {
509 for call in tool_calls {
510 if let Some(parsed) = parse_tool_call_value(call) {
511 calls.push(parsed);
512 }
513 }
514
515 if !calls.is_empty() {
516 return calls;
517 }
518 }
519
520 if let Some(array) = value.as_array() {
521 for item in array {
522 if let Some(parsed) = parse_tool_call_value(item) {
523 calls.push(parsed);
524 }
525 }
526 return calls;
527 }
528
529 if let Some(parsed) = parse_tool_call_value(value) {
530 calls.push(parsed);
531 }
532
533 calls
534}
535
536fn is_xml_meta_tag(tag: &str) -> bool {
537 let normalized = tag.to_ascii_lowercase();
538 matches!(
539 normalized.as_str(),
540 "tool_call"
541 | "toolcall"
542 | "tool-call"
543 | "invoke"
544 | "thinking"
545 | "thought"
546 | "analysis"
547 | "reasoning"
548 | "reflection"
549 )
550}
551
552static XML_OPEN_TAG_RE: LazyLock<Regex> =
554 LazyLock::new(|| Regex::new(r"<([a-zA-Z_][a-zA-Z0-9_-]*)>").unwrap());
555
556static MINIMAX_INVOKE_RE: LazyLock<Regex> = LazyLock::new(|| {
559 Regex::new(r#"(?is)<invoke\b[^>]*\bname\s*=\s*(?:"([^"]+)"|'([^']+)')[^>]*>(.*?)</invoke>"#)
560 .unwrap()
561});
562
563static MINIMAX_PARAMETER_RE: LazyLock<Regex> = LazyLock::new(|| {
564 Regex::new(
565 r#"(?is)<parameter\b[^>]*\bname\s*=\s*(?:"([^"]+)"|'([^']+)')[^>]*>(.*?)</parameter>"#,
566 )
567 .unwrap()
568});
569
570fn extract_xml_pairs(input: &str) -> Vec<(&str, &str)> {
573 let mut results = Vec::new();
574 let mut search_start = 0;
575 while let Some(open_cap) = XML_OPEN_TAG_RE.captures(&input[search_start..]) {
576 let full_open = open_cap.get(0).unwrap();
577 let tag_name = open_cap.get(1).unwrap().as_str();
578 let open_end = search_start + full_open.end();
579
580 let closing_tag = format!("</{tag_name}>");
581 if let Some(close_pos) = input[open_end..].find(&closing_tag) {
582 let inner = &input[open_end..open_end + close_pos];
583 results.push((tag_name, inner.trim()));
584 search_start = open_end + close_pos + closing_tag.len();
585 } else {
586 search_start = open_end;
587 }
588 }
589 results
590}
591
592fn parse_xml_tool_calls(xml_content: &str) -> Option<Vec<ParsedToolCall>> {
597 let mut calls = Vec::new();
598 let trimmed = xml_content.trim();
599
600 if !trimmed.starts_with('<') || !trimmed.contains('>') {
601 return None;
602 }
603
604 for (tool_name_str, inner_content) in extract_xml_pairs(trimmed) {
605 let tool_name = tool_name_str.to_string();
606 if is_xml_meta_tag(&tool_name) {
607 continue;
608 }
609
610 if inner_content.is_empty() {
611 continue;
612 }
613
614 let mut args = serde_json::Map::new();
615
616 if let Some(first_json) = extract_json_values(inner_content).into_iter().next() {
617 match first_json {
618 serde_json::Value::Object(object_args) => {
619 args = object_args;
620 }
621 other => {
622 args.insert("value".to_string(), other);
623 }
624 }
625 } else {
626 for (key_str, value) in extract_xml_pairs(inner_content) {
627 let key = key_str.to_string();
628 if is_xml_meta_tag(&key) {
629 continue;
630 }
631 if !value.is_empty() {
632 args.insert(key, serde_json::Value::String(value.to_string()));
633 }
634 }
635
636 if args.is_empty() {
637 args.insert(
638 "content".to_string(),
639 serde_json::Value::String(inner_content.to_string()),
640 );
641 }
642 }
643
644 calls.push(ParsedToolCall {
645 name: tool_name,
646 arguments: serde_json::Value::Object(args),
647 tool_call_id: None,
648 });
649 }
650
651 if calls.is_empty() { None } else { Some(calls) }
652}
653
654fn parse_minimax_invoke_calls(response: &str) -> Option<(String, Vec<ParsedToolCall>)> {
656 let mut calls = Vec::new();
657 let mut text_parts = Vec::new();
658 let mut last_end = 0usize;
659
660 for cap in MINIMAX_INVOKE_RE.captures_iter(response) {
661 let Some(full_match) = cap.get(0) else {
662 continue;
663 };
664
665 let before = response[last_end..full_match.start()].trim();
666 if !before.is_empty() {
667 text_parts.push(before.to_string());
668 }
669
670 let name = cap
671 .get(1)
672 .or_else(|| cap.get(2))
673 .map(|m| m.as_str().trim())
674 .filter(|v| !v.is_empty());
675 let body = cap.get(3).map(|m| m.as_str()).unwrap_or("").trim();
676 last_end = full_match.end();
677
678 let Some(name) = name else {
679 continue;
680 };
681
682 let mut args = serde_json::Map::new();
683 for param_cap in MINIMAX_PARAMETER_RE.captures_iter(body) {
684 let key = param_cap
685 .get(1)
686 .or_else(|| param_cap.get(2))
687 .map(|m| m.as_str().trim())
688 .unwrap_or_default();
689 if key.is_empty() {
690 continue;
691 }
692 let value = param_cap
693 .get(3)
694 .map(|m| m.as_str().trim())
695 .unwrap_or_default();
696 if value.is_empty() {
697 continue;
698 }
699
700 let parsed = extract_json_values(value).into_iter().next();
701 args.insert(
702 key.to_string(),
703 parsed.unwrap_or_else(|| serde_json::Value::String(value.to_string())),
704 );
705 }
706
707 if args.is_empty() {
708 if let Some(first_json) = extract_json_values(body).into_iter().next() {
709 match first_json {
710 serde_json::Value::Object(obj) => args = obj,
711 other => {
712 args.insert("value".to_string(), other);
713 }
714 }
715 } else if !body.is_empty() {
716 args.insert(
717 "content".to_string(),
718 serde_json::Value::String(body.to_string()),
719 );
720 }
721 }
722
723 calls.push(ParsedToolCall {
724 name: name.to_string(),
725 arguments: serde_json::Value::Object(args),
726 tool_call_id: None,
727 });
728 }
729
730 if calls.is_empty() {
731 return None;
732 }
733
734 let after = response[last_end..].trim();
735 if !after.is_empty() {
736 text_parts.push(after.to_string());
737 }
738
739 let text = text_parts
740 .join("\n")
741 .replace("<minimax:tool_call>", "")
742 .replace("</minimax:tool_call>", "")
743 .replace("<minimax:toolcall>", "")
744 .replace("</minimax:toolcall>", "")
745 .trim()
746 .to_string();
747
748 Some((text, calls))
749}
750
751const TOOL_CALL_OPEN_TAGS: [&str; 6] = [
752 "<tool_call>",
753 "<toolcall>",
754 "<tool-call>",
755 "<invoke>",
756 "<minimax:tool_call>",
757 "<minimax:toolcall>",
758];
759
760const TOOL_CALL_CLOSE_TAGS: [&str; 6] = [
761 "</tool_call>",
762 "</toolcall>",
763 "</tool-call>",
764 "</invoke>",
765 "</minimax:tool_call>",
766 "</minimax:toolcall>",
767];
768
769fn find_first_tag<'a>(haystack: &str, tags: &'a [&'a str]) -> Option<(usize, &'a str)> {
770 tags.iter()
771 .filter_map(|tag| haystack.find(tag).map(|idx| (idx, *tag)))
772 .min_by_key(|(idx, _)| *idx)
773}
774
775fn extract_first_json_value_with_end(input: &str) -> Option<(serde_json::Value, usize)> {
776 let trimmed = input.trim_start();
777 let trim_offset = input.len().saturating_sub(trimmed.len());
778
779 for (byte_idx, ch) in trimmed.char_indices() {
780 if ch != '{' && ch != '[' {
781 continue;
782 }
783
784 let slice = &trimmed[byte_idx..];
785 let mut stream = serde_json::Deserializer::from_str(slice).into_iter::<serde_json::Value>();
786 if let Some(Ok(value)) = stream.next() {
787 let consumed = stream.byte_offset();
788 if consumed > 0 {
789 return Some((value, trim_offset + byte_idx + consumed));
790 }
791 }
792 }
793
794 None
795}
796
797fn strip_leading_close_tags(mut input: &str) -> &str {
798 loop {
799 let trimmed = input.trim_start();
800 if !trimmed.starts_with("</") {
801 return trimmed;
802 }
803
804 let Some(close_end) = trimmed.find('>') else {
805 return "";
806 };
807 input = &trimmed[close_end + 1..];
808 }
809}
810
811fn extract_json_values(input: &str) -> Vec<serde_json::Value> {
821 let mut values = Vec::new();
822 let trimmed = input.trim();
823 if trimmed.is_empty() {
824 return values;
825 }
826
827 if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
828 values.push(value);
829 return values;
830 }
831
832 let char_positions: Vec<(usize, char)> = trimmed.char_indices().collect();
833 let mut idx = 0;
834 while idx < char_positions.len() {
835 let (byte_idx, ch) = char_positions[idx];
836 if ch == '{' || ch == '[' {
837 let slice = &trimmed[byte_idx..];
838 let mut stream =
839 serde_json::Deserializer::from_str(slice).into_iter::<serde_json::Value>();
840 if let Some(Ok(value)) = stream.next() {
841 let consumed = stream.byte_offset();
842 if consumed > 0 {
843 values.push(value);
844 let next_byte = byte_idx + consumed;
845 while idx < char_positions.len() && char_positions[idx].0 < next_byte {
846 idx += 1;
847 }
848 continue;
849 }
850 }
851 }
852 idx += 1;
853 }
854
855 values
856}
857
858fn find_json_end(input: &str) -> Option<usize> {
860 let trimmed = input.trim_start();
861 let offset = input.len() - trimmed.len();
862
863 if !trimmed.starts_with('{') {
864 return None;
865 }
866
867 let mut depth = 0;
868 let mut in_string = false;
869 let mut escape_next = false;
870
871 for (i, ch) in trimmed.char_indices() {
872 if escape_next {
873 escape_next = false;
874 continue;
875 }
876
877 match ch {
878 '\\' if in_string => escape_next = true,
879 '"' => in_string = !in_string,
880 '{' if !in_string => depth += 1,
881 '}' if !in_string => {
882 depth -= 1;
883 if depth == 0 {
884 return Some(offset + i + ch.len_utf8());
885 }
886 }
887 _ => {}
888 }
889 }
890
891 None
892}
893
894fn parse_xml_attribute_tool_calls(response: &str) -> Vec<ParsedToolCall> {
904 let mut calls = Vec::new();
905
906 static INVOKE_RE: LazyLock<Regex> = LazyLock::new(|| {
908 Regex::new(r#"(?s)<invoke\s+name="([^"]+)"[^>]*>(.*?)</invoke>"#).unwrap()
909 });
910
911 static PARAM_RE: LazyLock<Regex> = LazyLock::new(|| {
913 Regex::new(r#"<parameter\s+name="([^"]+)"[^>]*>([^<]*)</parameter>"#).unwrap()
914 });
915
916 for cap in INVOKE_RE.captures_iter(response) {
917 let tool_name = cap.get(1).map(|m| m.as_str()).unwrap_or("");
918 let inner = cap.get(2).map(|m| m.as_str()).unwrap_or("");
919
920 if tool_name.is_empty() {
921 continue;
922 }
923
924 let mut arguments = serde_json::Map::new();
925
926 for param_cap in PARAM_RE.captures_iter(inner) {
927 let param_name = param_cap.get(1).map(|m| m.as_str()).unwrap_or("");
928 let param_value = param_cap.get(2).map(|m| m.as_str()).unwrap_or("");
929
930 if !param_name.is_empty() {
931 arguments.insert(
932 param_name.to_string(),
933 serde_json::Value::String(param_value.to_string()),
934 );
935 }
936 }
937
938 if !arguments.is_empty() {
939 calls.push(ParsedToolCall {
940 name: map_tool_name_alias(tool_name).to_string(),
941 arguments: serde_json::Value::Object(arguments),
942 tool_call_id: None,
943 });
944 }
945 }
946
947 calls
948}
949
950fn parse_perl_style_tool_calls(response: &str) -> Vec<ParsedToolCall> {
965 let mut calls = Vec::new();
966
967 static PERL_RE: LazyLock<Regex> = LazyLock::new(|| {
970 Regex::new(r"(?s)(?:\[TOOL_CALL\]|TOOL_CALL)\s*\{(.+?)\}\}\s*(?:\[/TOOL_CALL\]|/TOOL_CALL)")
971 .unwrap()
972 });
973
974 static TOOL_NAME_RE: LazyLock<Regex> =
976 LazyLock::new(|| Regex::new(r#"tool\s*=>\s*"([^"]+)""#).unwrap());
977
978 static ARGS_BLOCK_RE: LazyLock<Regex> =
982 LazyLock::new(|| Regex::new(r"(?s)args\s*=>\s*\{(.+?)(?:\}|$)").unwrap());
983
984 static ARGS_RE: LazyLock<Regex> =
986 LazyLock::new(|| Regex::new(r#"--(\w+)\s+"([^"]+)""#).unwrap());
987
988 for cap in PERL_RE.captures_iter(response) {
989 let content = cap.get(1).map(|m| m.as_str()).unwrap_or("");
990
991 let tool_name = TOOL_NAME_RE
993 .captures(content)
994 .and_then(|c| c.get(1))
995 .map(|m| m.as_str())
996 .unwrap_or("");
997
998 if tool_name.is_empty() {
999 continue;
1000 }
1001
1002 let args_block = ARGS_BLOCK_RE
1004 .captures(content)
1005 .and_then(|c| c.get(1))
1006 .map(|m| m.as_str())
1007 .unwrap_or("");
1008
1009 let mut arguments = serde_json::Map::new();
1010
1011 for arg_cap in ARGS_RE.captures_iter(args_block) {
1012 let key = arg_cap.get(1).map(|m| m.as_str()).unwrap_or("");
1013 let value = arg_cap.get(2).map(|m| m.as_str()).unwrap_or("");
1014
1015 if !key.is_empty() {
1016 arguments.insert(
1017 key.to_string(),
1018 serde_json::Value::String(value.to_string()),
1019 );
1020 }
1021 }
1022
1023 if !arguments.is_empty() {
1024 calls.push(ParsedToolCall {
1025 name: map_tool_name_alias(tool_name).to_string(),
1026 arguments: serde_json::Value::Object(arguments),
1027 tool_call_id: None,
1028 });
1029 }
1030 }
1031
1032 calls
1033}
1034
1035fn parse_function_call_tool_calls(response: &str) -> Vec<ParsedToolCall> {
1044 let mut calls = Vec::new();
1045
1046 static FUNC_RE: LazyLock<Regex> = LazyLock::new(|| {
1048 Regex::new(r"(?s)<FunctionCall>\s*(\w+)\s*<code>([^<]+)</code>\s*</FunctionCall>").unwrap()
1049 });
1050
1051 for cap in FUNC_RE.captures_iter(response) {
1052 let tool_name = cap.get(1).map(|m| m.as_str()).unwrap_or("");
1053 let args_text = cap.get(2).map(|m| m.as_str()).unwrap_or("");
1054
1055 if tool_name.is_empty() {
1056 continue;
1057 }
1058
1059 let mut arguments = serde_json::Map::new();
1061 for line in args_text.lines() {
1062 let line = line.trim();
1063 if let Some(pos) = line.find('>') {
1064 let key = line[..pos].trim();
1065 let value = line[pos + 1..].trim();
1066 if !key.is_empty() && !value.is_empty() {
1067 arguments.insert(
1068 key.to_string(),
1069 serde_json::Value::String(value.to_string()),
1070 );
1071 }
1072 }
1073 }
1074
1075 if !arguments.is_empty() {
1076 calls.push(ParsedToolCall {
1077 name: map_tool_name_alias(tool_name).to_string(),
1078 arguments: serde_json::Value::Object(arguments),
1079 tool_call_id: None,
1080 });
1081 }
1082 }
1083
1084 calls
1085}
1086
1087fn map_tool_name_alias(tool_name: &str) -> &str {
1091 match tool_name {
1092 "shell" | "bash" | "sh" | "exec" | "command" | "cmd" | "browser_open" | "browser"
1094 | "web_search" => "shell",
1095 "send_message" | "sendmessage" => "message_send",
1097 "fileread" | "file_read" | "readfile" | "read_file" | "file" => "file_read",
1099 "filewrite" | "file_write" | "writefile" | "write_file" => "file_write",
1100 "filelist" | "file_list" | "listfiles" | "list_files" => "file_list",
1101 "memoryrecall" | "memory_recall" | "recall" | "memrecall" => "memory_recall",
1103 "memorystore" | "memory_store" | "store" | "memstore" => "memory_store",
1104 "memoryforget" | "memory_forget" | "forget" | "memforget" => "memory_forget",
1105 "http_request" | "http" | "fetch" | "curl" | "wget" => "http_request",
1107 _ => tool_name,
1108 }
1109}
1110
1111fn build_curl_command(url: &str) -> Option<String> {
1112 if !(url.starts_with("http://") || url.starts_with("https://")) {
1113 return None;
1114 }
1115
1116 if url.chars().any(char::is_whitespace) {
1117 return None;
1118 }
1119
1120 let escaped = url.replace('\'', r#"'\\''"#);
1121 Some(format!("curl -s '{}'", escaped))
1122}
1123
1124fn parse_glm_style_tool_calls(text: &str) -> Vec<(String, serde_json::Value, Option<String>)> {
1125 let mut calls = Vec::new();
1126
1127 for line in text.lines() {
1128 let line = line.trim();
1129 if line.is_empty() {
1130 continue;
1131 }
1132
1133 if let Some(pos) = line.find('/') {
1135 let tool_part = &line[..pos];
1136 let rest = &line[pos + 1..];
1137
1138 if tool_part.chars().all(|c| c.is_alphanumeric() || c == '_') {
1139 let tool_name = map_tool_name_alias(tool_part);
1140
1141 if let Some(gt_pos) = rest.find('>') {
1142 let param_name = rest[..gt_pos].trim();
1143 let value = rest[gt_pos + 1..].trim();
1144
1145 let arguments = match tool_name {
1146 "shell" => {
1147 if param_name == "url" {
1148 let Some(command) = build_curl_command(value) else {
1149 continue;
1150 };
1151 serde_json::json!({ "command": command })
1152 } else if value.starts_with("http://") || value.starts_with("https://")
1153 {
1154 if let Some(command) = build_curl_command(value) {
1155 serde_json::json!({ "command": command })
1156 } else {
1157 serde_json::json!({ "command": value })
1158 }
1159 } else {
1160 serde_json::json!({ "command": value })
1161 }
1162 }
1163 "http_request" => {
1164 serde_json::json!({"url": value, "method": "GET"})
1165 }
1166 _ => serde_json::json!({ param_name: value }),
1167 };
1168
1169 calls.push((tool_name.to_string(), arguments, Some(line.to_string())));
1170 continue;
1171 }
1172
1173 if rest.starts_with('{') {
1174 if let Ok(json_args) = serde_json::from_str::<serde_json::Value>(rest) {
1175 calls.push((tool_name.to_string(), json_args, Some(line.to_string())));
1176 }
1177 }
1178 }
1179 }
1180 }
1181
1182 calls
1183}
1184
1185fn default_param_for_tool(tool: &str) -> &'static str {
1191 match tool {
1192 "shell" | "bash" | "sh" | "exec" | "command" | "cmd" => "command",
1193 "file_read" | "fileread" | "readfile" | "read_file" | "file" | "file_write"
1195 | "filewrite" | "writefile" | "write_file" | "file_edit" | "fileedit" | "editfile"
1196 | "edit_file" | "file_list" | "filelist" | "listfiles" | "list_files" => "path",
1197 "memory_recall" | "memoryrecall" | "recall" | "memrecall" | "memory_forget"
1199 | "memoryforget" | "forget" | "memforget" | "web_search_tool" | "web_search"
1200 | "websearch" | "search" => "query",
1201 "memory_store" | "memorystore" | "store" | "memstore" => "content",
1202 "http_request" | "http" | "fetch" | "curl" | "wget" | "browser_open" | "browser" => "url",
1204 _ => "input",
1205 }
1206}
1207
1208fn parse_glm_shortened_body(body: &str) -> Option<ParsedToolCall> {
1220 let body = body.trim();
1221 if body.is_empty() {
1222 return None;
1223 }
1224
1225 let function_style = body.find('(').and_then(|open| {
1226 if body.ends_with(')') && open > 0 {
1227 Some((body[..open].trim(), body[open + 1..body.len() - 1].trim()))
1228 } else {
1229 None
1230 }
1231 });
1232
1233 let (tool_raw, value_part) = if let Some((tool, args)) = function_style {
1237 (tool, args)
1238 } else if body.contains("=\"") {
1239 let split_pos = body.find(|c: char| c.is_whitespace()).unwrap_or(body.len());
1241 let tool = body[..split_pos].trim();
1242 let attrs = body[split_pos..]
1243 .trim()
1244 .trim_end_matches("/>")
1245 .trim_end_matches('>')
1246 .trim_end_matches('/')
1247 .trim();
1248 (tool, attrs)
1249 } else if let Some(gt_pos) = body.find('>') {
1250 let tool = body[..gt_pos].trim();
1252 let value = body[gt_pos + 1..].trim();
1253 let value = value.trim_end_matches("/>").trim_end_matches('/').trim();
1255 (tool, value)
1256 } else {
1257 return None;
1258 };
1259
1260 let tool_raw = tool_raw.trim_end_matches(|c: char| c.is_whitespace());
1262 if tool_raw.is_empty() || !tool_raw.chars().all(|c| c.is_alphanumeric() || c == '_') {
1263 return None;
1264 }
1265
1266 let tool_name = map_tool_name_alias(tool_raw);
1267
1268 if value_part.contains("=\"") {
1270 let mut args = serde_json::Map::new();
1271 let mut rest = value_part;
1273 while let Some(eq_pos) = rest.find("=\"") {
1274 let key_start = rest[..eq_pos]
1275 .rfind(|c: char| c.is_whitespace())
1276 .map(|p| p + 1)
1277 .unwrap_or(0);
1278 let key = rest[key_start..eq_pos]
1279 .trim()
1280 .trim_matches(|c: char| c == ',' || c == ';');
1281 let after_quote = &rest[eq_pos + 2..];
1282 if let Some(end_quote) = after_quote.find('"') {
1283 let value = &after_quote[..end_quote];
1284 if !key.is_empty() {
1285 args.insert(
1286 key.to_string(),
1287 serde_json::Value::String(value.to_string()),
1288 );
1289 }
1290 rest = &after_quote[end_quote + 1..];
1291 } else {
1292 break;
1293 }
1294 }
1295 if !args.is_empty() {
1296 return Some(ParsedToolCall {
1297 name: tool_name.to_string(),
1298 arguments: serde_json::Value::Object(args),
1299 tool_call_id: None,
1300 });
1301 }
1302 }
1303
1304 if value_part.contains('\n') {
1306 let mut args = serde_json::Map::new();
1307 for line in value_part.lines() {
1308 let line = line.trim();
1309 if line.is_empty() {
1310 continue;
1311 }
1312 if let Some(colon_pos) = line.find(':') {
1313 let key = line[..colon_pos].trim();
1314 let value = line[colon_pos + 1..].trim();
1315 if !key.is_empty() && !value.is_empty() {
1316 let json_value = match value {
1318 "true" | "yes" => serde_json::Value::Bool(true),
1319 "false" | "no" => serde_json::Value::Bool(false),
1320 _ => serde_json::Value::String(value.to_string()),
1321 };
1322 args.insert(key.to_string(), json_value);
1323 }
1324 }
1325 }
1326 if !args.is_empty() {
1327 return Some(ParsedToolCall {
1328 name: tool_name.to_string(),
1329 arguments: serde_json::Value::Object(args),
1330 tool_call_id: None,
1331 });
1332 }
1333 }
1334
1335 if !value_part.is_empty() {
1337 let param = default_param_for_tool(tool_raw);
1338 let arguments = match tool_name {
1339 "shell" => {
1340 if value_part.starts_with("http://") || value_part.starts_with("https://") {
1341 if let Some(cmd) = build_curl_command(value_part) {
1342 serde_json::json!({ "command": cmd })
1343 } else {
1344 serde_json::json!({ "command": value_part })
1345 }
1346 } else {
1347 serde_json::json!({ "command": value_part })
1348 }
1349 }
1350 "http_request" => serde_json::json!({"url": value_part, "method": "GET"}),
1351 _ => serde_json::json!({ param: value_part }),
1352 };
1353 return Some(ParsedToolCall {
1354 name: tool_name.to_string(),
1355 arguments,
1356 tool_call_id: None,
1357 });
1358 }
1359
1360 None
1361}
1362
1363fn parse_tool_calls(response: &str) -> (String, Vec<ParsedToolCall>) {
1388 let cleaned = strip_think_tags(response);
1393 let response = cleaned.as_str();
1394
1395 let mut text_parts = Vec::new();
1396 let mut calls = Vec::new();
1397 let mut remaining = response;
1398
1399 if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(response.trim()) {
1402 calls = parse_tool_calls_from_json_value(&json_value);
1403 if !calls.is_empty() {
1404 if let Some(content) = json_value.get("content").and_then(|v| v.as_str()) {
1406 if !content.trim().is_empty() {
1407 text_parts.push(content.trim().to_string());
1408 }
1409 }
1410 return (text_parts.join("\n"), calls);
1411 }
1412 }
1413
1414 if let Some((minimax_text, minimax_calls)) = parse_minimax_invoke_calls(response) {
1415 if !minimax_calls.is_empty() {
1416 return (minimax_text, minimax_calls);
1417 }
1418 }
1419
1420 while let Some((start, open_tag)) = find_first_tag(remaining, &TOOL_CALL_OPEN_TAGS) {
1422 let before = &remaining[..start];
1424 if !before.trim().is_empty() {
1425 text_parts.push(before.trim().to_string());
1426 }
1427
1428 let Some(close_tag) = (match open_tag {
1429 "<tool_call>" => Some("</tool_call>"),
1430 "<toolcall>" => Some("</toolcall>"),
1431 "<tool-call>" => Some("</tool-call>"),
1432 "<invoke>" => Some("</invoke>"),
1433 "<minimax:tool_call>" => Some("</minimax:tool_call>"),
1434 "<minimax:toolcall>" => Some("</minimax:toolcall>"),
1435 _ => None,
1436 }) else {
1437 break;
1438 };
1439
1440 let after_open = &remaining[start + open_tag.len()..];
1441 if let Some(close_idx) = after_open.find(close_tag) {
1442 let inner = &after_open[..close_idx];
1443 let mut parsed_any = false;
1444
1445 let json_values = extract_json_values(inner);
1447 for value in json_values {
1448 let parsed_calls = parse_tool_calls_from_json_value(&value);
1449 if !parsed_calls.is_empty() {
1450 parsed_any = true;
1451 calls.extend(parsed_calls);
1452 }
1453 }
1454
1455 if !parsed_any {
1457 if let Some(xml_calls) = parse_xml_tool_calls(inner) {
1458 calls.extend(xml_calls);
1459 parsed_any = true;
1460 }
1461 }
1462
1463 if !parsed_any {
1464 if let Some(glm_call) = parse_glm_shortened_body(inner) {
1466 calls.push(glm_call);
1467 parsed_any = true;
1468 }
1469 }
1470
1471 if !parsed_any {
1472 tracing::warn!(
1473 "Malformed <tool_call>: expected tool-call object in tag body (JSON/XML/GLM)"
1474 );
1475 }
1476
1477 remaining = &after_open[close_idx + close_tag.len()..];
1478 } else {
1479 let mut resolved = false;
1482 if let Some((cross_idx, cross_tag)) = find_first_tag(after_open, &TOOL_CALL_CLOSE_TAGS)
1483 {
1484 let inner = &after_open[..cross_idx];
1485 let mut parsed_any = false;
1486
1487 let json_values = extract_json_values(inner);
1489 for value in json_values {
1490 let parsed_calls = parse_tool_calls_from_json_value(&value);
1491 if !parsed_calls.is_empty() {
1492 parsed_any = true;
1493 calls.extend(parsed_calls);
1494 }
1495 }
1496
1497 if !parsed_any {
1499 if let Some(xml_calls) = parse_xml_tool_calls(inner) {
1500 calls.extend(xml_calls);
1501 parsed_any = true;
1502 }
1503 }
1504
1505 if !parsed_any {
1507 if let Some(glm_call) = parse_glm_shortened_body(inner) {
1508 calls.push(glm_call);
1509 parsed_any = true;
1510 }
1511 }
1512
1513 if parsed_any {
1514 remaining = &after_open[cross_idx + cross_tag.len()..];
1515 resolved = true;
1516 }
1517 }
1518
1519 if resolved {
1520 continue;
1521 }
1522
1523 if let Some(json_end) = find_json_end(after_open) {
1526 if let Ok(value) =
1527 serde_json::from_str::<serde_json::Value>(&after_open[..json_end])
1528 {
1529 let parsed_calls = parse_tool_calls_from_json_value(&value);
1530 if !parsed_calls.is_empty() {
1531 calls.extend(parsed_calls);
1532 remaining = strip_leading_close_tags(&after_open[json_end..]);
1533 continue;
1534 }
1535 }
1536 }
1537
1538 if let Some((value, consumed_end)) = extract_first_json_value_with_end(after_open) {
1539 let parsed_calls = parse_tool_calls_from_json_value(&value);
1540 if !parsed_calls.is_empty() {
1541 calls.extend(parsed_calls);
1542 remaining = strip_leading_close_tags(&after_open[consumed_end..]);
1543 continue;
1544 }
1545 }
1546
1547 let glm_input = after_open.trim();
1550 if let Some(glm_call) = parse_glm_shortened_body(glm_input) {
1551 calls.push(glm_call);
1552 remaining = "";
1553 continue;
1554 }
1555
1556 remaining = &remaining[start..];
1557 break;
1558 }
1559 }
1560
1561 if calls.is_empty() {
1565 static MD_TOOL_CALL_RE: LazyLock<Regex> = LazyLock::new(|| {
1566 Regex::new(
1567 r"(?s)```(?:tool[_-]?call|invoke)\s*\n(.*?)(?:```|</tool[_-]?call>|</toolcall>|</invoke>|</minimax:toolcall>)",
1568 )
1569 .unwrap()
1570 });
1571 let mut md_text_parts: Vec<String> = Vec::new();
1572 let mut last_end = 0;
1573
1574 for cap in MD_TOOL_CALL_RE.captures_iter(response) {
1575 let full_match = cap.get(0).unwrap();
1576 let before = &response[last_end..full_match.start()];
1577 if !before.trim().is_empty() {
1578 md_text_parts.push(before.trim().to_string());
1579 }
1580 let inner = &cap[1];
1581 let json_values = extract_json_values(inner);
1582 for value in json_values {
1583 let parsed_calls = parse_tool_calls_from_json_value(&value);
1584 calls.extend(parsed_calls);
1585 }
1586 last_end = full_match.end();
1587 }
1588
1589 if !calls.is_empty() {
1590 let after = &response[last_end..];
1591 if !after.trim().is_empty() {
1592 md_text_parts.push(after.trim().to_string());
1593 }
1594 text_parts = md_text_parts;
1595 remaining = "";
1596 }
1597 }
1598
1599 if calls.is_empty() {
1602 static MD_TOOL_NAME_RE: LazyLock<Regex> =
1603 LazyLock::new(|| Regex::new(r"(?s)```tool\s+(\w+)\s*\n(.*?)(?:```|$)").unwrap());
1604 let mut md_text_parts: Vec<String> = Vec::new();
1605 let mut last_end = 0;
1606
1607 for cap in MD_TOOL_NAME_RE.captures_iter(response) {
1608 let full_match = cap.get(0).unwrap();
1609 let before = &response[last_end..full_match.start()];
1610 if !before.trim().is_empty() {
1611 md_text_parts.push(before.trim().to_string());
1612 }
1613 let tool_name = &cap[1];
1614 let inner = &cap[2];
1615
1616 let json_values = extract_json_values(inner);
1618 if json_values.is_empty() {
1619 tracing::warn!(
1621 tool_name = %tool_name,
1622 inner = %inner.chars().take(100).collect::<String>(),
1623 "Found ```tool <name> block but could not parse JSON arguments"
1624 );
1625 } else {
1626 for value in json_values {
1627 let arguments = if value.is_object() {
1628 value
1629 } else {
1630 serde_json::Value::Object(serde_json::Map::new())
1631 };
1632 calls.push(ParsedToolCall {
1633 name: tool_name.to_string(),
1634 arguments,
1635 tool_call_id: None,
1636 });
1637 }
1638 }
1639 last_end = full_match.end();
1640 }
1641
1642 if !calls.is_empty() {
1643 let after = &response[last_end..];
1644 if !after.trim().is_empty() {
1645 md_text_parts.push(after.trim().to_string());
1646 }
1647 text_parts = md_text_parts;
1648 remaining = "";
1649 }
1650 }
1651
1652 if calls.is_empty() {
1659 let xml_calls = parse_xml_attribute_tool_calls(remaining);
1660 if !xml_calls.is_empty() {
1661 let mut cleaned_text = remaining.to_string();
1662 for call in xml_calls {
1663 calls.push(call);
1664 if let Some(start) = cleaned_text.find("<minimax:toolcall>") {
1666 if let Some(end) = cleaned_text.find("</minimax:toolcall>") {
1667 let end_pos = end + "</minimax:toolcall>".len();
1668 if end_pos <= cleaned_text.len() {
1669 cleaned_text =
1670 format!("{}{}", &cleaned_text[..start], &cleaned_text[end_pos..]);
1671 }
1672 }
1673 }
1674 }
1675 if !cleaned_text.trim().is_empty() {
1676 text_parts.push(cleaned_text.trim().to_string());
1677 }
1678 remaining = "";
1679 }
1680 }
1681
1682 if calls.is_empty() {
1690 let perl_calls = parse_perl_style_tool_calls(remaining);
1691 if !perl_calls.is_empty() {
1692 let mut cleaned_text = remaining.to_string();
1693 for call in perl_calls {
1694 calls.push(call);
1695 while let Some(start) = cleaned_text.find("TOOL_CALL") {
1697 if let Some(end) = cleaned_text.find("/TOOL_CALL") {
1698 let end_pos = end + "/TOOL_CALL".len();
1699 if end_pos <= cleaned_text.len() {
1700 cleaned_text =
1701 format!("{}{}", &cleaned_text[..start], &cleaned_text[end_pos..]);
1702 }
1703 } else {
1704 break;
1705 }
1706 }
1707 }
1708 if !cleaned_text.trim().is_empty() {
1709 text_parts.push(cleaned_text.trim().to_string());
1710 }
1711 remaining = "";
1712 }
1713 }
1714
1715 if calls.is_empty() {
1720 let func_calls = parse_function_call_tool_calls(remaining);
1721 if !func_calls.is_empty() {
1722 let mut cleaned_text = remaining.to_string();
1723 for call in func_calls {
1724 calls.push(call);
1725 while let Some(start) = cleaned_text.find("<FunctionCall>") {
1727 if let Some(end) = cleaned_text.find("</FunctionCall>") {
1728 let end_pos = end + "</FunctionCall>".len();
1729 if end_pos <= cleaned_text.len() {
1730 cleaned_text =
1731 format!("{}{}", &cleaned_text[..start], &cleaned_text[end_pos..]);
1732 }
1733 } else {
1734 break;
1735 }
1736 }
1737 }
1738 if !cleaned_text.trim().is_empty() {
1739 text_parts.push(cleaned_text.trim().to_string());
1740 }
1741 remaining = "";
1742 }
1743 }
1744
1745 if calls.is_empty() {
1747 let glm_calls = parse_glm_style_tool_calls(remaining);
1748 if !glm_calls.is_empty() {
1749 let mut cleaned_text = remaining.to_string();
1750 for (name, args, raw) in &glm_calls {
1751 calls.push(ParsedToolCall {
1752 name: name.clone(),
1753 arguments: args.clone(),
1754 tool_call_id: None,
1755 });
1756 if let Some(r) = raw {
1757 cleaned_text = cleaned_text.replace(r, "");
1758 }
1759 }
1760 if !cleaned_text.trim().is_empty() {
1761 text_parts.push(cleaned_text.trim().to_string());
1762 }
1763 remaining = "";
1764 }
1765 }
1766
1767 if !remaining.trim().is_empty() {
1779 text_parts.push(remaining.trim().to_string());
1780 }
1781
1782 (text_parts.join("\n"), calls)
1783}
1784
1785fn strip_think_tags(s: &str) -> String {
1790 let mut result = String::with_capacity(s.len());
1791 let mut rest = s;
1792 loop {
1793 if let Some(start) = rest.find("<think>") {
1794 result.push_str(&rest[..start]);
1795 if let Some(end) = rest[start..].find("</think>") {
1796 rest = &rest[start + end + "</think>".len()..];
1797 } else {
1798 break;
1800 }
1801 } else {
1802 result.push_str(rest);
1803 break;
1804 }
1805 }
1806 result.trim().to_string()
1807}
1808
1809fn strip_tool_result_blocks(text: &str) -> String {
1812 static TOOL_RESULT_RE: LazyLock<Regex> =
1813 LazyLock::new(|| Regex::new(r"(?s)<tool_result[^>]*>.*?</tool_result>").unwrap());
1814 static THINKING_RE: LazyLock<Regex> =
1815 LazyLock::new(|| Regex::new(r"(?s)<thinking>.*?</thinking>").unwrap());
1816 static THINK_RE: LazyLock<Regex> =
1817 LazyLock::new(|| Regex::new(r"(?s)<think>.*?</think>").unwrap());
1818 static TOOL_RESULTS_PREFIX_RE: LazyLock<Regex> =
1819 LazyLock::new(|| Regex::new(r"(?m)^\[Tool results\]\s*\n?").unwrap());
1820 static EXCESS_BLANK_LINES_RE: LazyLock<Regex> =
1821 LazyLock::new(|| Regex::new(r"\n{3,}").unwrap());
1822
1823 let result = TOOL_RESULT_RE.replace_all(text, "");
1824 let result = THINKING_RE.replace_all(&result, "");
1825 let result = THINK_RE.replace_all(&result, "");
1826 let result = TOOL_RESULTS_PREFIX_RE.replace_all(&result, "");
1827 let result = EXCESS_BLANK_LINES_RE.replace_all(result.trim(), "\n\n");
1828
1829 result.trim().to_string()
1830}
1831
1832fn detect_tool_call_parse_issue(response: &str, parsed_calls: &[ParsedToolCall]) -> Option<String> {
1833 if !parsed_calls.is_empty() {
1834 return None;
1835 }
1836
1837 let trimmed = response.trim();
1838 if trimmed.is_empty() {
1839 return None;
1840 }
1841
1842 let looks_like_tool_payload = trimmed.contains("<tool_call")
1843 || trimmed.contains("<toolcall")
1844 || trimmed.contains("<tool-call")
1845 || trimmed.contains("```tool_call")
1846 || trimmed.contains("```toolcall")
1847 || trimmed.contains("```tool-call")
1848 || trimmed.contains("```tool file_")
1849 || trimmed.contains("```tool shell")
1850 || trimmed.contains("```tool web_")
1851 || trimmed.contains("```tool memory_")
1852 || trimmed.contains("```tool ") || trimmed.contains("\"tool_calls\"")
1854 || trimmed.contains("TOOL_CALL")
1855 || trimmed.contains("[TOOL_CALL]")
1856 || trimmed.contains("<FunctionCall>");
1857
1858 if looks_like_tool_payload {
1859 Some("response resembled a tool-call payload but no valid tool call could be parsed".into())
1860 } else {
1861 None
1862 }
1863}
1864
1865fn build_native_assistant_history(
1869 text: &str,
1870 tool_calls: &[ToolCall],
1871 reasoning_content: Option<&str>,
1872) -> String {
1873 let calls_json: Vec<serde_json::Value> = tool_calls
1874 .iter()
1875 .map(|tc| {
1876 serde_json::json!({
1877 "id": tc.id,
1878 "name": tc.name,
1879 "arguments": tc.arguments,
1880 })
1881 })
1882 .collect();
1883
1884 let content = if text.trim().is_empty() {
1885 serde_json::Value::Null
1886 } else {
1887 serde_json::Value::String(text.trim().to_string())
1888 };
1889
1890 let mut obj = serde_json::json!({
1891 "content": content,
1892 "tool_calls": calls_json,
1893 });
1894
1895 if let Some(rc) = reasoning_content {
1896 obj.as_object_mut().unwrap().insert(
1897 "reasoning_content".to_string(),
1898 serde_json::Value::String(rc.to_string()),
1899 );
1900 }
1901
1902 obj.to_string()
1903}
1904
1905fn build_native_assistant_history_from_parsed_calls(
1906 text: &str,
1907 tool_calls: &[ParsedToolCall],
1908 reasoning_content: Option<&str>,
1909) -> Option<String> {
1910 let calls_json = tool_calls
1911 .iter()
1912 .map(|tc| {
1913 Some(serde_json::json!({
1914 "id": tc.tool_call_id.clone()?,
1915 "name": tc.name,
1916 "arguments": serde_json::to_string(&tc.arguments).unwrap_or_else(|_| "{}".to_string()),
1917 }))
1918 })
1919 .collect::<Option<Vec<_>>>()?;
1920
1921 let content = if text.trim().is_empty() {
1922 serde_json::Value::Null
1923 } else {
1924 serde_json::Value::String(text.trim().to_string())
1925 };
1926
1927 let mut obj = serde_json::json!({
1928 "content": content,
1929 "tool_calls": calls_json,
1930 });
1931
1932 if let Some(rc) = reasoning_content {
1933 obj.as_object_mut().unwrap().insert(
1934 "reasoning_content".to_string(),
1935 serde_json::Value::String(rc.to_string()),
1936 );
1937 }
1938
1939 Some(obj.to_string())
1940}
1941
1942fn resolve_display_text(
1943 response_text: &str,
1944 parsed_text: &str,
1945 has_tool_calls: bool,
1946 has_native_tool_calls: bool,
1947) -> String {
1948 if has_tool_calls {
1949 if !parsed_text.is_empty() {
1950 return parsed_text.to_string();
1951 }
1952 if has_native_tool_calls {
1953 return response_text.to_string();
1954 }
1955 return String::new();
1956 }
1957
1958 if parsed_text.is_empty() {
1959 response_text.to_string()
1960 } else {
1961 parsed_text.to_string()
1962 }
1963}
1964
1965#[derive(Debug, Clone)]
1966pub(crate) struct ParsedToolCall {
1967 pub(crate) name: String,
1968 pub(crate) arguments: serde_json::Value,
1969 pub(crate) tool_call_id: Option<String>,
1970}
1971
1972#[derive(Debug)]
1973pub(crate) struct ToolLoopCancelled;
1974
1975impl std::fmt::Display for ToolLoopCancelled {
1976 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1977 f.write_str("tool loop cancelled")
1978 }
1979}
1980
1981impl std::error::Error for ToolLoopCancelled {}
1982
1983pub(crate) fn is_tool_loop_cancelled(err: &anyhow::Error) -> bool {
1984 err.chain().any(|source| source.is::<ToolLoopCancelled>())
1985}
1986
1987#[derive(Debug)]
1988pub(crate) struct ModelSwitchRequested {
1989 pub provider: String,
1990 pub model: String,
1991}
1992
1993impl std::fmt::Display for ModelSwitchRequested {
1994 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1995 write!(
1996 f,
1997 "model switch requested to {} {}",
1998 self.provider, self.model
1999 )
2000 }
2001}
2002
2003impl std::error::Error for ModelSwitchRequested {}
2004
2005pub(crate) fn is_model_switch_requested(err: &anyhow::Error) -> Option<(String, String)> {
2006 err.chain()
2007 .filter_map(|source| source.downcast_ref::<ModelSwitchRequested>())
2008 .map(|e| (e.provider.clone(), e.model.clone()))
2009 .next()
2010}
2011
2012#[derive(Debug, Default)]
2013struct StreamedChatOutcome {
2014 response_text: String,
2015 tool_calls: Vec<ToolCall>,
2016 forwarded_live_deltas: bool,
2017}
2018
2019async fn consume_provider_streaming_response(
2020 provider: &dyn Provider,
2021 messages: &[ChatMessage],
2022 request_tools: Option<&[crate::tools::ToolSpec]>,
2023 model: &str,
2024 temperature: f64,
2025 cancellation_token: Option<&CancellationToken>,
2026 on_delta: Option<&tokio::sync::mpsc::Sender<DraftEvent>>,
2027) -> Result<StreamedChatOutcome> {
2028 let mut provider_stream = provider.stream_chat(
2029 ChatRequest {
2030 messages,
2031 tools: request_tools,
2032 },
2033 model,
2034 temperature,
2035 crate::providers::traits::StreamOptions::new(true),
2036 );
2037 let mut outcome = StreamedChatOutcome::default();
2038 let mut delta_sender = on_delta;
2039 let mut suppress_forwarding = false;
2040 let mut marker_window = String::new();
2041
2042 loop {
2043 let next_chunk = if let Some(token) = cancellation_token {
2044 tokio::select! {
2045 () = token.cancelled() => return Err(ToolLoopCancelled.into()),
2046 chunk = provider_stream.next() => chunk,
2047 }
2048 } else {
2049 provider_stream.next().await
2050 };
2051
2052 let Some(event_result) = next_chunk else {
2053 break;
2054 };
2055
2056 let event = event_result.map_err(|err| anyhow::anyhow!("provider stream error: {err}"))?;
2057 match event {
2058 StreamEvent::Final => break,
2059 StreamEvent::ToolCall(tool_call) => {
2060 outcome.tool_calls.push(tool_call);
2061 suppress_forwarding = true;
2062 if outcome.forwarded_live_deltas {
2063 if let Some(tx) = delta_sender {
2064 let _ = tx.send(DraftEvent::Clear).await;
2065 }
2066 outcome.forwarded_live_deltas = false;
2067 }
2068 }
2069 StreamEvent::PreExecutedToolCall { .. } | StreamEvent::PreExecutedToolResult { .. } => {
2070 }
2074 StreamEvent::Usage(_) => {
2075 }
2078 StreamEvent::TextDelta(chunk) => {
2079 if chunk.delta.is_empty() {
2080 continue;
2081 }
2082
2083 outcome.response_text.push_str(&chunk.delta);
2084 marker_window.push_str(&chunk.delta);
2085
2086 if marker_window.len() > STREAM_TOOL_MARKER_WINDOW_CHARS {
2087 let keep_from = marker_window.len() - STREAM_TOOL_MARKER_WINDOW_CHARS;
2088 let boundary = marker_window
2089 .char_indices()
2090 .find(|(idx, _)| *idx >= keep_from)
2091 .map_or(0, |(idx, _)| idx);
2092 marker_window.drain(..boundary);
2093 }
2094
2095 if !suppress_forwarding && {
2096 let lowered = marker_window.to_ascii_lowercase();
2097 lowered.contains("<tool_call")
2098 || lowered.contains("<toolcall")
2099 || lowered.contains("\"tool_calls\"")
2100 } {
2101 suppress_forwarding = true;
2102 if outcome.forwarded_live_deltas {
2103 if let Some(tx) = delta_sender {
2104 let _ = tx.send(DraftEvent::Clear).await;
2105 }
2106 outcome.forwarded_live_deltas = false;
2107 }
2108 }
2109
2110 if suppress_forwarding {
2111 continue;
2112 }
2113
2114 if let Some(tx) = delta_sender {
2115 if !outcome.forwarded_live_deltas {
2116 let _ = tx.send(DraftEvent::Clear).await;
2117 outcome.forwarded_live_deltas = true;
2118 }
2119 if tx.send(DraftEvent::Content(chunk.delta)).await.is_err() {
2120 delta_sender = None;
2121 }
2122 }
2123 }
2124 }
2125 }
2126
2127 Ok(outcome)
2128}
2129
2130#[allow(clippy::too_many_arguments)]
2134pub(crate) async fn agent_turn(
2135 provider: &dyn Provider,
2136 history: &mut Vec<ChatMessage>,
2137 tools_registry: &[Box<dyn Tool>],
2138 observer: &dyn Observer,
2139 provider_name: &str,
2140 model: &str,
2141 temperature: f64,
2142 silent: bool,
2143 channel_name: &str,
2144 channel_reply_target: Option<&str>,
2145 multimodal_config: &crate::config::MultimodalConfig,
2146 max_tool_iterations: usize,
2147 approval: Option<&ApprovalManager>,
2148 excluded_tools: &[String],
2149 dedup_exempt_tools: &[String],
2150 activated_tools: Option<&std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>>,
2151 model_switch_callback: Option<ModelSwitchCallback>,
2152) -> Result<String> {
2153 run_tool_call_loop(
2154 provider,
2155 history,
2156 tools_registry,
2157 observer,
2158 provider_name,
2159 model,
2160 temperature,
2161 silent,
2162 approval,
2163 channel_name,
2164 channel_reply_target,
2165 multimodal_config,
2166 max_tool_iterations,
2167 None,
2168 None,
2169 None,
2170 excluded_tools,
2171 dedup_exempt_tools,
2172 activated_tools,
2173 model_switch_callback,
2174 &crate::config::PacingConfig::default(),
2175 0, 0, None, )
2179 .await
2180}
2181
2182fn maybe_inject_channel_delivery_defaults(
2183 tool_name: &str,
2184 tool_args: &mut serde_json::Value,
2185 channel_name: &str,
2186 channel_reply_target: Option<&str>,
2187) {
2188 if tool_name != "cron_add" {
2189 return;
2190 }
2191
2192 if !matches!(
2193 channel_name,
2194 "telegram" | "discord" | "slack" | "mattermost" | "matrix"
2195 ) {
2196 return;
2197 }
2198
2199 let Some(reply_target) = channel_reply_target
2200 .map(str::trim)
2201 .filter(|value| !value.is_empty())
2202 else {
2203 return;
2204 };
2205
2206 let Some(args) = tool_args.as_object_mut() else {
2207 return;
2208 };
2209
2210 let is_agent_job = args
2211 .get("job_type")
2212 .and_then(serde_json::Value::as_str)
2213 .is_some_and(|job_type| job_type.eq_ignore_ascii_case("agent"))
2214 || args
2215 .get("prompt")
2216 .and_then(serde_json::Value::as_str)
2217 .is_some_and(|prompt| !prompt.trim().is_empty());
2218 if !is_agent_job {
2219 return;
2220 }
2221
2222 let default_delivery = || {
2223 serde_json::json!({
2224 "mode": "announce",
2225 "channel": channel_name,
2226 "to": reply_target,
2227 })
2228 };
2229
2230 match args.get_mut("delivery") {
2231 None => {
2232 args.insert("delivery".to_string(), default_delivery());
2233 }
2234 Some(serde_json::Value::Null) => {
2235 *args.get_mut("delivery").expect("delivery key exists") = default_delivery();
2236 }
2237 Some(serde_json::Value::Object(delivery)) => {
2238 if delivery
2239 .get("mode")
2240 .and_then(serde_json::Value::as_str)
2241 .is_some_and(|mode| mode.eq_ignore_ascii_case("none"))
2242 {
2243 return;
2244 }
2245
2246 delivery
2247 .entry("mode".to_string())
2248 .or_insert_with(|| serde_json::Value::String("announce".to_string()));
2249
2250 let needs_channel = delivery
2251 .get("channel")
2252 .and_then(serde_json::Value::as_str)
2253 .is_none_or(|value| value.trim().is_empty());
2254 if needs_channel {
2255 delivery.insert(
2256 "channel".to_string(),
2257 serde_json::Value::String(channel_name.to_string()),
2258 );
2259 }
2260
2261 let needs_target = delivery
2262 .get("to")
2263 .and_then(serde_json::Value::as_str)
2264 .is_none_or(|value| value.trim().is_empty());
2265 if needs_target {
2266 delivery.insert(
2267 "to".to_string(),
2268 serde_json::Value::String(reply_target.to_string()),
2269 );
2270 }
2271 }
2272 Some(_) => {}
2273 }
2274}
2275
2276#[allow(clippy::too_many_arguments)]
2291pub(crate) async fn run_tool_call_loop(
2292 provider: &dyn Provider,
2293 history: &mut Vec<ChatMessage>,
2294 tools_registry: &[Box<dyn Tool>],
2295 observer: &dyn Observer,
2296 provider_name: &str,
2297 model: &str,
2298 temperature: f64,
2299 silent: bool,
2300 approval: Option<&ApprovalManager>,
2301 channel_name: &str,
2302 channel_reply_target: Option<&str>,
2303 multimodal_config: &crate::config::MultimodalConfig,
2304 max_tool_iterations: usize,
2305 cancellation_token: Option<CancellationToken>,
2306 on_delta: Option<tokio::sync::mpsc::Sender<DraftEvent>>,
2307 hooks: Option<&crate::hooks::HookRunner>,
2308 excluded_tools: &[String],
2309 dedup_exempt_tools: &[String],
2310 activated_tools: Option<&std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>>,
2311 model_switch_callback: Option<ModelSwitchCallback>,
2312 pacing: &crate::config::PacingConfig,
2313 max_tool_result_chars: usize,
2314 context_token_budget: usize,
2315 shared_budget: Option<Arc<std::sync::atomic::AtomicUsize>>,
2316) -> Result<String> {
2317 let max_iterations = if max_tool_iterations == 0 {
2318 DEFAULT_MAX_TOOL_ITERATIONS
2319 } else {
2320 max_tool_iterations
2321 };
2322
2323 let turn_id = Uuid::new_v4().to_string();
2324 let loop_started_at = Instant::now();
2325 let loop_ignore_tools: HashSet<&str> = pacing
2326 .loop_ignore_tools
2327 .iter()
2328 .map(String::as_str)
2329 .collect();
2330 let mut consecutive_identical_outputs: usize = 0;
2331 let mut last_tool_output_hash: Option<u64> = None;
2332
2333 let mut loop_detector = crate::agent::loop_detector::LoopDetector::new(
2334 crate::agent::loop_detector::LoopDetectorConfig {
2335 enabled: pacing.loop_detection_enabled,
2336 window_size: pacing.loop_detection_window_size,
2337 max_repeats: pacing.loop_detection_max_repeats,
2338 },
2339 );
2340
2341 for iteration in 0..max_iterations {
2342 let mut seen_tool_signatures: HashSet<(String, String)> = HashSet::new();
2343
2344 if cancellation_token
2345 .as_ref()
2346 .is_some_and(CancellationToken::is_cancelled)
2347 {
2348 return Err(ToolLoopCancelled.into());
2349 }
2350
2351 if let Some(ref budget) = shared_budget {
2353 let remaining = budget.load(std::sync::atomic::Ordering::Relaxed);
2354 if remaining == 0 {
2355 tracing::warn!("Shared iteration budget exhausted at iteration {iteration}");
2356 break;
2357 }
2358 budget.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
2359 }
2360
2361 if context_token_budget > 0 {
2363 let estimated = estimate_history_tokens(history);
2364 if estimated > context_token_budget {
2365 tracing::info!(
2366 estimated,
2367 budget = context_token_budget,
2368 iteration = iteration + 1,
2369 "Preemptive context trim: estimated tokens exceed budget"
2370 );
2371 let chars_saved = fast_trim_tool_results(history, 4);
2372 if chars_saved > 0 {
2373 tracing::info!(chars_saved, "Preemptive fast-trim applied");
2374 }
2375 let recheck = estimate_history_tokens(history);
2377 if recheck > context_token_budget {
2378 let stats = crate::agent::history_pruner::prune_history(
2379 history,
2380 &crate::agent::history_pruner::HistoryPrunerConfig {
2381 enabled: true,
2382 max_tokens: context_token_budget,
2383 keep_recent: 4,
2384 collapse_tool_results: true,
2385 },
2386 );
2387 if stats.dropped_messages > 0 || stats.collapsed_pairs > 0 {
2388 tracing::info!(
2389 collapsed = stats.collapsed_pairs,
2390 dropped = stats.dropped_messages,
2391 "Preemptive history prune applied"
2392 );
2393 }
2394 }
2395 }
2396 }
2397
2398 if let Some(ref callback) = model_switch_callback {
2400 if let Ok(guard) = callback.lock() {
2401 if let Some((new_provider, new_model)) = guard.as_ref() {
2402 if new_provider != provider_name || new_model != model {
2403 tracing::info!(
2404 "Model switch detected: {} {} -> {} {}",
2405 provider_name,
2406 model,
2407 new_provider,
2408 new_model
2409 );
2410 return Err(ModelSwitchRequested {
2411 provider: new_provider.clone(),
2412 model: new_model.clone(),
2413 }
2414 .into());
2415 }
2416 }
2417 }
2418 }
2419
2420 let mut tool_specs: Vec<crate::tools::ToolSpec> = tools_registry
2422 .iter()
2423 .filter(|tool| !is_tool_excluded(tool.name(), excluded_tools))
2424 .map(|tool| tool.spec())
2425 .collect();
2426 if let Some(at) = activated_tools {
2427 for spec in at.lock().unwrap().tool_specs() {
2428 if !is_tool_excluded(&spec.name, excluded_tools) {
2429 tool_specs.push(spec);
2430 }
2431 }
2432 }
2433 let use_native_tools = provider.supports_native_tools() && !tool_specs.is_empty();
2434 let send_tools = !tool_specs.is_empty();
2438
2439 let image_marker_count = multimodal::count_image_markers(history);
2440
2441 let vision_provider_box: Option<Box<dyn Provider>> = if image_marker_count > 0
2446 && !provider.supports_vision()
2447 {
2448 if let Some(ref vp) = multimodal_config.vision_provider {
2449 let vp_instance = providers::create_provider(vp, None)
2450 .map_err(|e| anyhow::anyhow!("failed to create vision provider '{vp}': {e}"))?;
2451 if !vp_instance.supports_vision() {
2452 return Err(ProviderCapabilityError {
2453 provider: vp.clone(),
2454 capability: "vision".to_string(),
2455 message: format!(
2456 "configured vision_provider '{vp}' does not support vision input"
2457 ),
2458 }
2459 .into());
2460 }
2461 Some(vp_instance)
2462 } else {
2463 return Err(ProviderCapabilityError {
2464 provider: provider_name.to_string(),
2465 capability: "vision".to_string(),
2466 message: format!(
2467 "received {image_marker_count} image marker(s), but this provider does not support vision input"
2468 ),
2469 }
2470 .into());
2471 }
2472 } else {
2473 None
2474 };
2475
2476 let (active_provider, active_provider_name, active_model): (&dyn Provider, &str, &str) =
2477 if let Some(ref vp_box) = vision_provider_box {
2478 let vp_name = multimodal_config
2479 .vision_provider
2480 .as_deref()
2481 .unwrap_or(provider_name);
2482 let vm = multimodal_config.vision_model.as_deref().unwrap_or(model);
2483 (vp_box.as_ref(), vp_name, vm)
2484 } else {
2485 (provider, provider_name, model)
2486 };
2487
2488 let prepared_messages =
2489 multimodal::prepare_messages_for_provider(history, multimodal_config).await?;
2490
2491 if let Some(ref tx) = on_delta {
2493 let phase = if iteration == 0 {
2494 "\u{1f914} Thinking...\n".to_string()
2495 } else {
2496 format!("\u{1f914} Thinking (round {})...\n", iteration + 1)
2497 };
2498 let _ = tx.send(DraftEvent::Progress(phase)).await;
2499 }
2500
2501 observer.record_event(&ObserverEvent::LlmRequest {
2502 provider: active_provider_name.to_string(),
2503 model: active_model.to_string(),
2504 messages_count: history.len(),
2505 });
2506 runtime_trace::record_event(
2507 "llm_request",
2508 Some(channel_name),
2509 Some(active_provider_name),
2510 Some(active_model),
2511 Some(&turn_id),
2512 None,
2513 None,
2514 serde_json::json!({
2515 "iteration": iteration + 1,
2516 "messages_count": history.len(),
2517 }),
2518 );
2519
2520 let llm_started_at = Instant::now();
2521
2522 if let Some(hooks) = hooks {
2524 hooks.fire_llm_input(history, model).await;
2525 }
2526
2527 if let Some(BudgetCheck::Exceeded {
2529 current_usd,
2530 limit_usd,
2531 period,
2532 }) = check_tool_loop_budget()
2533 {
2534 return Err(anyhow::anyhow!(
2535 "Budget exceeded: ${:.4} of ${:.2} {:?} limit. Cannot make further API calls until the budget resets.",
2536 current_usd,
2537 limit_usd,
2538 period
2539 ));
2540 }
2541
2542 let request_tools = if send_tools {
2545 Some(tool_specs.as_slice())
2546 } else {
2547 None
2548 };
2549 let should_consume_provider_stream = on_delta.is_some()
2550 && provider.supports_streaming()
2551 && (request_tools.is_none() || provider.supports_streaming_tool_events());
2552 tracing::debug!(
2553 has_on_delta = on_delta.is_some(),
2554 supports_streaming = provider.supports_streaming(),
2555 should_consume_provider_stream,
2556 "Streaming decision for iteration {}",
2557 iteration + 1,
2558 );
2559 let mut streamed_live_deltas = false;
2560
2561 let chat_result = if should_consume_provider_stream {
2562 match consume_provider_streaming_response(
2563 active_provider,
2564 &prepared_messages.messages,
2565 request_tools,
2566 active_model,
2567 temperature,
2568 cancellation_token.as_ref(),
2569 on_delta.as_ref(),
2570 )
2571 .await
2572 {
2573 Ok(streamed) => {
2574 streamed_live_deltas = streamed.forwarded_live_deltas;
2575 Ok(crate::providers::ChatResponse {
2576 text: Some(streamed.response_text),
2577 tool_calls: streamed.tool_calls,
2578 usage: None,
2579 reasoning_content: None,
2580 })
2581 }
2582 Err(stream_err) => {
2583 tracing::warn!(
2584 provider = active_provider_name,
2585 model = active_model,
2586 iteration = iteration + 1,
2587 "provider streaming failed, falling back to non-streaming chat: {stream_err}"
2588 );
2589 runtime_trace::record_event(
2590 "llm_stream_fallback",
2591 Some(channel_name),
2592 Some(active_provider_name),
2593 Some(active_model),
2594 Some(&turn_id),
2595 Some(false),
2596 Some("provider stream failed; fallback to non-streaming chat"),
2597 serde_json::json!({
2598 "iteration": iteration + 1,
2599 "error": scrub_credentials(&stream_err.to_string()),
2600 }),
2601 );
2602 if let Some(ref tx) = on_delta {
2603 let _ = tx.send(DraftEvent::Clear).await;
2604 }
2605 {
2606 let chat_future = active_provider.chat(
2607 ChatRequest {
2608 messages: &prepared_messages.messages,
2609 tools: request_tools,
2610 },
2611 active_model,
2612 temperature,
2613 );
2614 if let Some(token) = cancellation_token.as_ref() {
2615 tokio::select! {
2616 () = token.cancelled() => Err(ToolLoopCancelled.into()),
2617 result = chat_future => result,
2618 }
2619 } else {
2620 chat_future.await
2621 }
2622 }
2623 }
2624 }
2625 } else {
2626 let chat_future = active_provider.chat(
2629 ChatRequest {
2630 messages: &prepared_messages.messages,
2631 tools: request_tools,
2632 },
2633 active_model,
2634 temperature,
2635 );
2636
2637 match pacing.step_timeout_secs {
2638 Some(step_secs) if step_secs > 0 => {
2639 let step_timeout = Duration::from_secs(step_secs);
2640 if let Some(token) = cancellation_token.as_ref() {
2641 tokio::select! {
2642 () = token.cancelled() => return Err(ToolLoopCancelled.into()),
2643 result = tokio::time::timeout(step_timeout, chat_future) => {
2644 match result {
2645 Ok(inner) => inner,
2646 Err(_) => anyhow::bail!(
2647 "LLM inference step timed out after {step_secs}s (step_timeout_secs)"
2648 ),
2649 }
2650 },
2651 }
2652 } else {
2653 match tokio::time::timeout(step_timeout, chat_future).await {
2654 Ok(inner) => inner,
2655 Err(_) => anyhow::bail!(
2656 "LLM inference step timed out after {step_secs}s (step_timeout_secs)"
2657 ),
2658 }
2659 }
2660 }
2661 _ => {
2662 if let Some(token) = cancellation_token.as_ref() {
2663 tokio::select! {
2664 () = token.cancelled() => return Err(ToolLoopCancelled.into()),
2665 result = chat_future => result,
2666 }
2667 } else {
2668 chat_future.await
2669 }
2670 }
2671 }
2672 };
2673
2674 let (
2675 response_text,
2676 parsed_text,
2677 tool_calls,
2678 assistant_history_content,
2679 native_tool_calls,
2680 _parse_issue_detected,
2681 response_streamed_live,
2682 ) = match chat_result {
2683 Ok(resp) => {
2684 let (resp_input_tokens, resp_output_tokens) = resp
2685 .usage
2686 .as_ref()
2687 .map(|u| (u.input_tokens, u.output_tokens))
2688 .unwrap_or((None, None));
2689
2690 observer.record_event(&ObserverEvent::LlmResponse {
2691 provider: provider_name.to_string(),
2692 model: model.to_string(),
2693 duration: llm_started_at.elapsed(),
2694 success: true,
2695 error_message: None,
2696 input_tokens: resp_input_tokens,
2697 output_tokens: resp_output_tokens,
2698 });
2699
2700 let usage_for_cost = resp
2705 .usage
2706 .clone()
2707 .unwrap_or_else(crate::providers::traits::TokenUsage::default);
2708 let _ = record_tool_loop_cost_usage(provider_name, model, &usage_for_cost);
2709
2710 let response_text = resp.text_or_empty().to_string();
2711 let mut calls: Vec<ParsedToolCall> = resp
2716 .tool_calls
2717 .iter()
2718 .map(|call| ParsedToolCall {
2719 name: call.name.clone(),
2720 arguments: serde_json::from_str::<serde_json::Value>(&call.arguments)
2721 .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new())),
2722 tool_call_id: Some(call.id.clone()),
2723 })
2724 .collect();
2725 let mut parsed_text = String::new();
2726
2727 if calls.is_empty() {
2728 let (fallback_text, fallback_calls) = parse_tool_calls(&response_text);
2729 if !fallback_text.is_empty() {
2730 parsed_text = fallback_text;
2731 }
2732 calls = fallback_calls;
2733 }
2734
2735 let parse_issue = detect_tool_call_parse_issue(&response_text, &calls);
2736 if let Some(ref issue) = parse_issue {
2737 runtime_trace::record_event(
2738 "tool_call_parse_issue",
2739 Some(channel_name),
2740 Some(provider_name),
2741 Some(model),
2742 Some(&turn_id),
2743 Some(false),
2744 Some(issue.as_str()),
2745 serde_json::json!({
2746 "iteration": iteration + 1,
2747 "response_excerpt": truncate_with_ellipsis(
2748 &scrub_credentials(&response_text),
2749 600
2750 ),
2751 }),
2752 );
2753 }
2754
2755 runtime_trace::record_event(
2756 "llm_response",
2757 Some(channel_name),
2758 Some(provider_name),
2759 Some(model),
2760 Some(&turn_id),
2761 Some(true),
2762 None,
2763 serde_json::json!({
2764 "iteration": iteration + 1,
2765 "duration_ms": llm_started_at.elapsed().as_millis(),
2766 "input_tokens": resp_input_tokens,
2767 "output_tokens": resp_output_tokens,
2768 "raw_response": scrub_credentials(&response_text),
2769 "native_tool_calls": resp.tool_calls.len(),
2770 "parsed_tool_calls": calls.len(),
2771 }),
2772 );
2773
2774 let reasoning_content = resp.reasoning_content.clone();
2777 let assistant_history_content = if resp.tool_calls.is_empty() {
2778 if use_native_tools {
2779 build_native_assistant_history_from_parsed_calls(
2780 &response_text,
2781 &calls,
2782 reasoning_content.as_deref(),
2783 )
2784 .unwrap_or_else(|| response_text.clone())
2785 } else {
2786 response_text.clone()
2787 }
2788 } else {
2789 build_native_assistant_history(
2790 &response_text,
2791 &resp.tool_calls,
2792 reasoning_content.as_deref(),
2793 )
2794 };
2795
2796 let native_calls = resp.tool_calls;
2797 (
2798 response_text,
2799 parsed_text,
2800 calls,
2801 assistant_history_content,
2802 native_calls,
2803 parse_issue.is_some(),
2804 streamed_live_deltas,
2805 )
2806 }
2807 Err(e) => {
2808 let safe_error = crate::providers::sanitize_api_error(&e.to_string());
2809 observer.record_event(&ObserverEvent::LlmResponse {
2810 provider: provider_name.to_string(),
2811 model: model.to_string(),
2812 duration: llm_started_at.elapsed(),
2813 success: false,
2814 error_message: Some(safe_error.clone()),
2815 input_tokens: None,
2816 output_tokens: None,
2817 });
2818 runtime_trace::record_event(
2819 "llm_response",
2820 Some(channel_name),
2821 Some(provider_name),
2822 Some(model),
2823 Some(&turn_id),
2824 Some(false),
2825 Some(&safe_error),
2826 serde_json::json!({
2827 "iteration": iteration + 1,
2828 "duration_ms": llm_started_at.elapsed().as_millis(),
2829 }),
2830 );
2831
2832 if crate::providers::reliable::is_context_window_exceeded(&e) {
2834 tracing::warn!(
2835 iteration = iteration + 1,
2836 "Context window exceeded, attempting in-loop recovery"
2837 );
2838
2839 let chars_saved = fast_trim_tool_results(history, 4);
2841 if chars_saved > 0 {
2842 tracing::info!(
2843 chars_saved,
2844 "Context recovery: trimmed old tool results, retrying"
2845 );
2846 continue;
2847 }
2848
2849 let dropped = emergency_history_trim(history, 4);
2851 if dropped > 0 {
2852 tracing::info!(dropped, "Context recovery: dropped old messages, retrying");
2853 continue;
2854 }
2855
2856 tracing::error!("Context overflow unrecoverable: no trimmable messages");
2858 }
2859
2860 return Err(e);
2861 }
2862 };
2863
2864 let display_text = if parsed_text.is_empty() {
2865 response_text.clone()
2866 } else {
2867 parsed_text
2868 };
2869
2870 if let Some(ref tx) = on_delta {
2872 let llm_secs = llm_started_at.elapsed().as_secs();
2873 if !tool_calls.is_empty() {
2874 let _ = tx
2875 .send(DraftEvent::Progress(format!(
2876 "\u{1f4ac} Got {} tool call(s) ({llm_secs}s)\n",
2877 tool_calls.len()
2878 )))
2879 .await;
2880 }
2881 }
2882
2883 if tool_calls.is_empty() {
2884 runtime_trace::record_event(
2885 "turn_final_response",
2886 Some(channel_name),
2887 Some(provider_name),
2888 Some(model),
2889 Some(&turn_id),
2890 Some(true),
2891 None,
2892 serde_json::json!({
2893 "iteration": iteration + 1,
2894 "text": scrub_credentials(&display_text),
2895 }),
2896 );
2897 if let Some(ref tx) = on_delta {
2901 let should_emit_post_hoc_chunks =
2902 !response_streamed_live || display_text != response_text;
2903 if !should_emit_post_hoc_chunks {
2904 history.push(ChatMessage::assistant(response_text.clone()));
2905 return Ok(display_text);
2906 }
2907 let _ = tx.send(DraftEvent::Clear).await;
2909 let mut chunk = String::new();
2912 for word in display_text.split_inclusive(char::is_whitespace) {
2913 if cancellation_token
2914 .as_ref()
2915 .is_some_and(CancellationToken::is_cancelled)
2916 {
2917 return Err(ToolLoopCancelled.into());
2918 }
2919 chunk.push_str(word);
2920 if chunk.len() >= STREAM_CHUNK_MIN_CHARS
2921 && tx
2922 .send(DraftEvent::Content(std::mem::take(&mut chunk)))
2923 .await
2924 .is_err()
2925 {
2926 break; }
2928 }
2929 if !chunk.is_empty() {
2930 let _ = tx.send(DraftEvent::Content(chunk)).await;
2931 }
2932 }
2933 history.push(ChatMessage::assistant(response_text.clone()));
2934 return Ok(display_text);
2935 }
2936
2937 if !display_text.is_empty() {
2940 if !native_tool_calls.is_empty() {
2941 if let Some(ref tx) = on_delta {
2942 let mut narration = display_text.clone();
2943 if !narration.ends_with('\n') {
2944 narration.push('\n');
2945 }
2946 let _ = tx.send(DraftEvent::Content(narration)).await;
2947 }
2948 }
2949 if !silent {
2950 print!("{display_text}");
2951 let _ = std::io::stdout().flush();
2952 }
2953 }
2954
2955 let mut tool_results = String::new();
2961 let mut individual_results: Vec<(Option<String>, String)> = Vec::new();
2962 let mut ordered_results: Vec<Option<(String, Option<String>, ToolExecutionOutcome)>> =
2963 (0..tool_calls.len()).map(|_| None).collect();
2964 let allow_parallel_execution = should_execute_tools_in_parallel(&tool_calls, approval);
2965 let mut executable_indices: Vec<usize> = Vec::new();
2966 let mut executable_calls: Vec<ParsedToolCall> = Vec::new();
2967
2968 for (idx, call) in tool_calls.iter().enumerate() {
2969 let mut tool_name = call.name.clone();
2971 let mut tool_args = call.arguments.clone();
2972 if let Some(hooks) = hooks {
2973 match hooks
2974 .run_before_tool_call(tool_name.clone(), tool_args.clone())
2975 .await
2976 {
2977 crate::hooks::HookResult::Cancel(reason) => {
2978 tracing::info!(tool = %call.name, %reason, "tool call cancelled by hook");
2979 let cancelled = format!("Cancelled by hook: {reason}");
2980 runtime_trace::record_event(
2981 "tool_call_result",
2982 Some(channel_name),
2983 Some(provider_name),
2984 Some(model),
2985 Some(&turn_id),
2986 Some(false),
2987 Some(&cancelled),
2988 serde_json::json!({
2989 "iteration": iteration + 1,
2990 "tool": call.name,
2991 "arguments": scrub_credentials(&tool_args.to_string()),
2992 }),
2993 );
2994 if let Some(ref tx) = on_delta {
2995 let _ = tx
2996 .send(DraftEvent::Progress(format!(
2997 "\u{274c} {}: {}\n",
2998 call.name,
2999 truncate_with_ellipsis(&scrub_credentials(&cancelled), 200)
3000 )))
3001 .await;
3002 }
3003 ordered_results[idx] = Some((
3004 call.name.clone(),
3005 call.tool_call_id.clone(),
3006 ToolExecutionOutcome {
3007 output: cancelled,
3008 success: false,
3009 error_reason: Some(scrub_credentials(&reason)),
3010 duration: Duration::ZERO,
3011 },
3012 ));
3013 continue;
3014 }
3015 crate::hooks::HookResult::Continue((name, args)) => {
3016 tool_name = name;
3017 tool_args = args;
3018 }
3019 }
3020 }
3021
3022 maybe_inject_channel_delivery_defaults(
3023 &tool_name,
3024 &mut tool_args,
3025 channel_name,
3026 channel_reply_target,
3027 );
3028
3029 if let Some(mgr) = approval {
3031 if mgr.needs_approval(&tool_name) {
3032 let request = ApprovalRequest {
3033 tool_name: tool_name.clone(),
3034 arguments: tool_args.clone(),
3035 };
3036
3037 let decision = if mgr.is_non_interactive() {
3041 ApprovalResponse::No
3042 } else {
3043 mgr.prompt_cli(&request)
3044 };
3045
3046 mgr.record_decision(&tool_name, &tool_args, decision, channel_name);
3047
3048 if decision == ApprovalResponse::No {
3049 let denied = "Denied by user.".to_string();
3050 runtime_trace::record_event(
3051 "tool_call_result",
3052 Some(channel_name),
3053 Some(provider_name),
3054 Some(model),
3055 Some(&turn_id),
3056 Some(false),
3057 Some(&denied),
3058 serde_json::json!({
3059 "iteration": iteration + 1,
3060 "tool": tool_name.clone(),
3061 "arguments": scrub_credentials(&tool_args.to_string()),
3062 }),
3063 );
3064 if let Some(ref tx) = on_delta {
3065 let _ = tx
3066 .send(DraftEvent::Progress(format!(
3067 "\u{274c} {}: {}\n",
3068 tool_name, denied
3069 )))
3070 .await;
3071 }
3072 ordered_results[idx] = Some((
3073 tool_name.clone(),
3074 call.tool_call_id.clone(),
3075 ToolExecutionOutcome {
3076 output: denied.clone(),
3077 success: false,
3078 error_reason: Some(denied),
3079 duration: Duration::ZERO,
3080 },
3081 ));
3082 continue;
3083 }
3084 }
3085 }
3086
3087 let signature = {
3088 let canonical_args = canonicalize_json_for_tool_signature(&tool_args);
3089 let args_json =
3090 serde_json::to_string(&canonical_args).unwrap_or_else(|_| "{}".to_string());
3091 (tool_name.trim().to_ascii_lowercase(), args_json)
3092 };
3093 let dedup_exempt = dedup_exempt_tools.iter().any(|e| e == &tool_name);
3094 if !dedup_exempt && !seen_tool_signatures.insert(signature) {
3095 let duplicate = format!(
3096 "Skipped duplicate tool call '{tool_name}' with identical arguments in this turn."
3097 );
3098 runtime_trace::record_event(
3099 "tool_call_result",
3100 Some(channel_name),
3101 Some(provider_name),
3102 Some(model),
3103 Some(&turn_id),
3104 Some(false),
3105 Some(&duplicate),
3106 serde_json::json!({
3107 "iteration": iteration + 1,
3108 "tool": tool_name.clone(),
3109 "arguments": scrub_credentials(&tool_args.to_string()),
3110 "deduplicated": true,
3111 }),
3112 );
3113 if let Some(ref tx) = on_delta {
3114 let _ = tx
3115 .send(DraftEvent::Progress(format!(
3116 "\u{274c} {}: {}\n",
3117 tool_name, duplicate
3118 )))
3119 .await;
3120 }
3121 ordered_results[idx] = Some((
3122 tool_name.clone(),
3123 call.tool_call_id.clone(),
3124 ToolExecutionOutcome {
3125 output: duplicate.clone(),
3126 success: false,
3127 error_reason: Some(duplicate),
3128 duration: Duration::ZERO,
3129 },
3130 ));
3131 continue;
3132 }
3133
3134 runtime_trace::record_event(
3135 "tool_call_start",
3136 Some(channel_name),
3137 Some(provider_name),
3138 Some(model),
3139 Some(&turn_id),
3140 None,
3141 None,
3142 serde_json::json!({
3143 "iteration": iteration + 1,
3144 "tool": tool_name.clone(),
3145 "arguments": scrub_credentials(&tool_args.to_string()),
3146 }),
3147 );
3148
3149 if let Some(ref tx) = on_delta {
3151 let progress = if let Some(suffix) = tool_name.strip_prefix("construct-operator__")
3152 {
3153 match suffix {
3155 "create_agent" => {
3156 let title = tool_args
3157 .get("title")
3158 .and_then(|v| v.as_str())
3159 .unwrap_or("agent");
3160 format!("\u{1f916} Spawning agent: {title}\n")
3161 }
3162 "wait_for_agent" => "\u{23f3} Waiting for agent to finish…\n".to_string(),
3163 "send_agent_prompt" => {
3164 "\u{1f4e8} Sending follow-up to agent…\n".to_string()
3165 }
3166 "get_agent_activity" => "\u{1f4cb} Collecting agent results…\n".to_string(),
3167 "get_agent_status" => "\u{1f50d} Checking agent status…\n".to_string(),
3168 "list_agents" => "\u{1f4cb} Listing active agents…\n".to_string(),
3169 "search_agent_pool" | "list_agent_templates" => {
3170 "\u{1f50d} Searching agent pool…\n".to_string()
3171 }
3172 "save_agent_template" => {
3173 let name = tool_args
3174 .get("name")
3175 .and_then(|v| v.as_str())
3176 .unwrap_or("template");
3177 format!("\u{1f4be} Saving agent template: {name}\n")
3178 }
3179 "list_teams" | "search_teams" => "\u{1f50d} Searching teams…\n".to_string(),
3180 "get_team" => "\u{1f4cb} Loading team details…\n".to_string(),
3181 "spawn_team" => "\u{1f680} Deploying team…\n".to_string(),
3182 "create_team" => {
3183 let name = tool_args
3184 .get("name")
3185 .and_then(|v| v.as_str())
3186 .unwrap_or("team");
3187 format!("\u{1f4be} Creating team: {name}\n")
3188 }
3189 "get_budget_status" => "\u{1f4b0} Checking budget…\n".to_string(),
3190 "save_plan" => "\u{1f4be} Saving execution plan…\n".to_string(),
3191 "recall_plans" => "\u{1f50d} Searching past plans…\n".to_string(),
3192 "create_goal" => {
3193 let name = tool_args
3194 .get("name")
3195 .and_then(|v| v.as_str())
3196 .unwrap_or("goal");
3197 format!("\u{1f3af} Creating goal: {name}\n")
3198 }
3199 "get_goals" => "\u{1f3af} Loading goals…\n".to_string(),
3200 "update_goal" => "\u{1f3af} Updating goal…\n".to_string(),
3201 "record_agent_outcome" => {
3202 "\u{1f4ca} Recording agent outcome…\n".to_string()
3203 }
3204 "get_agent_trust" => "\u{1f4ca} Checking trust scores…\n".to_string(),
3205 "publish_to_clawhub" => "\u{1f4e4} Publishing to ClawHub…\n".to_string(),
3206 "search_clawhub" => {
3207 "\u{1f50d} Searching ClawHub marketplace…\n".to_string()
3208 }
3209 "install_from_clawhub" => {
3210 "\u{1f4e5} Installing from ClawHub…\n".to_string()
3211 }
3212 "list_nodes" => "\u{1f310} Discovering connected nodes…\n".to_string(),
3213 "invoke_node" => "\u{1f4e1} Invoking node capability…\n".to_string(),
3214 "get_session_history" => "\u{1f4c3} Loading session history…\n".to_string(),
3215 "archive_session" => "\u{1f4e6} Archiving session…\n".to_string(),
3216 "capture_skill" => {
3217 let name = tool_args
3218 .get("name")
3219 .and_then(|v| v.as_str())
3220 .unwrap_or("skill");
3221 format!("\u{1f4da} Capturing skill: {name}\n")
3222 }
3223 _ => format!("\u{2699}\u{fe0f} Operator: {suffix}\n"),
3224 }
3225 } else {
3226 let hint = {
3227 let raw = match tool_name.as_str() {
3228 "shell" => tool_args.get("command").and_then(|v| v.as_str()),
3229 "file_read" | "file_write" => {
3230 tool_args.get("path").and_then(|v| v.as_str())
3231 }
3232 _ => tool_args
3233 .get("action")
3234 .and_then(|v| v.as_str())
3235 .or_else(|| tool_args.get("query").and_then(|v| v.as_str())),
3236 };
3237 match raw {
3238 Some(s) => truncate_with_ellipsis(s, 60),
3239 None => String::new(),
3240 }
3241 };
3242 if hint.is_empty() {
3243 format!("\u{23f3} {}\n", tool_name)
3244 } else {
3245 format!("\u{23f3} {}: {hint}\n", tool_name)
3246 }
3247 };
3248 tracing::debug!(tool = %tool_name, "Sending progress start to draft");
3249 let _ = tx.send(DraftEvent::Progress(progress)).await;
3250 }
3251
3252 executable_indices.push(idx);
3253 executable_calls.push(ParsedToolCall {
3254 name: tool_name,
3255 arguments: tool_args,
3256 tool_call_id: call.tool_call_id.clone(),
3257 });
3258 }
3259
3260 let executed_outcomes = if allow_parallel_execution && executable_calls.len() > 1 {
3261 execute_tools_parallel(
3262 &executable_calls,
3263 tools_registry,
3264 activated_tools,
3265 observer,
3266 cancellation_token.as_ref(),
3267 )
3268 .await?
3269 } else {
3270 execute_tools_sequential(
3271 &executable_calls,
3272 tools_registry,
3273 activated_tools,
3274 observer,
3275 cancellation_token.as_ref(),
3276 )
3277 .await?
3278 };
3279
3280 for ((idx, call), outcome) in executable_indices
3281 .iter()
3282 .zip(executable_calls.iter())
3283 .zip(executed_outcomes.into_iter())
3284 {
3285 runtime_trace::record_event(
3286 "tool_call_result",
3287 Some(channel_name),
3288 Some(provider_name),
3289 Some(model),
3290 Some(&turn_id),
3291 Some(outcome.success),
3292 outcome.error_reason.as_deref(),
3293 serde_json::json!({
3294 "iteration": iteration + 1,
3295 "tool": call.name.clone(),
3296 "duration_ms": outcome.duration.as_millis(),
3297 "output": scrub_credentials(&outcome.output),
3298 }),
3299 );
3300
3301 if let Some(hooks) = hooks {
3303 let tool_result_obj = crate::tools::ToolResult {
3304 success: outcome.success,
3305 output: outcome.output.clone(),
3306 error: None,
3307 };
3308 hooks
3309 .fire_after_tool_call(&call.name, &tool_result_obj, outcome.duration)
3310 .await;
3311 }
3312
3313 if let Some(ref tx) = on_delta {
3315 let secs = outcome.duration.as_secs();
3316 let progress_msg = if let Some(suffix) =
3317 call.name.strip_prefix("construct-operator__")
3318 {
3319 if outcome.success {
3321 match suffix {
3322 "create_agent" => format!("\u{2705} Agent spawned ({secs}s)\n"),
3323 "wait_for_agent" => format!("\u{2705} Agent finished ({secs}s)\n"),
3324 "get_agent_activity" => {
3325 format!("\u{2705} Results collected ({secs}s)\n")
3326 }
3327 "save_agent_template" => format!("\u{2705} Template saved ({secs}s)\n"),
3328 "send_agent_prompt" => format!("\u{2705} Follow-up sent ({secs}s)\n"),
3329 "search_agent_pool" | "list_agent_templates" => {
3330 format!("\u{2705} Pool search done ({secs}s)\n")
3331 }
3332 "list_teams" | "search_teams" => {
3333 format!("\u{2705} Team search done ({secs}s)\n")
3334 }
3335 "get_team" => format!("\u{2705} Team loaded ({secs}s)\n"),
3336 "spawn_team" => format!("\u{2705} Team deployed ({secs}s)\n"),
3337 "create_team" => format!("\u{2705} Team created ({secs}s)\n"),
3338 "get_budget_status" => format!("\u{2705} Budget checked ({secs}s)\n"),
3339 "save_plan" => format!("\u{2705} Plan saved ({secs}s)\n"),
3340 "recall_plans" => format!("\u{2705} Plans retrieved ({secs}s)\n"),
3341 "create_goal" => format!("\u{2705} Goal created ({secs}s)\n"),
3342 "get_goals" => format!("\u{2705} Goals loaded ({secs}s)\n"),
3343 "update_goal" => format!("\u{2705} Goal updated ({secs}s)\n"),
3344 "record_agent_outcome" => {
3345 format!("\u{2705} Outcome recorded ({secs}s)\n")
3346 }
3347 "get_agent_trust" => {
3348 format!("\u{2705} Trust scores loaded ({secs}s)\n")
3349 }
3350 "capture_skill" => format!("\u{2705} Skill captured ({secs}s)\n"),
3351 "publish_to_clawhub" => {
3352 format!("\u{2705} Published to ClawHub ({secs}s)\n")
3353 }
3354 "search_clawhub" => {
3355 format!("\u{2705} ClawHub search complete ({secs}s)\n")
3356 }
3357 "install_from_clawhub" => {
3358 format!("\u{2705} Installed from ClawHub ({secs}s)\n")
3359 }
3360 "list_nodes" => format!("\u{2705} Nodes discovered ({secs}s)\n"),
3361 "invoke_node" => {
3362 format!("\u{2705} Node invocation complete ({secs}s)\n")
3363 }
3364 "get_session_history" => {
3365 format!("\u{2705} Session history loaded ({secs}s)\n")
3366 }
3367 "archive_session" => format!("\u{2705} Session archived ({secs}s)\n"),
3368 _ => format!("\u{2705} {suffix} ({secs}s)\n"),
3369 }
3370 } else {
3371 let reason_hint = outcome.error_reason.as_deref().unwrap_or("failed");
3372 format!(
3373 "\u{274c} {suffix} ({secs}s): {}\n",
3374 truncate_with_ellipsis(reason_hint, 200)
3375 )
3376 }
3377 } else if outcome.success {
3378 format!("\u{2705} {} ({secs}s)\n", call.name)
3379 } else if let Some(ref reason) = outcome.error_reason {
3380 format!(
3381 "\u{274c} {} ({secs}s): {}\n",
3382 call.name,
3383 truncate_with_ellipsis(reason, 200)
3384 )
3385 } else {
3386 format!("\u{274c} {} ({secs}s)\n", call.name)
3387 };
3388 tracing::debug!(tool = %call.name, secs, "Sending progress complete to draft");
3389 let _ = tx.send(DraftEvent::Progress(progress_msg)).await;
3390 }
3391
3392 ordered_results[*idx] = Some((call.name.clone(), call.tool_call_id.clone(), outcome));
3393 }
3394
3395 let mut detection_relevant_output = String::new();
3398 for (result_index, (tool_name, tool_call_id, outcome)) in ordered_results
3401 .into_iter()
3402 .enumerate()
3403 .filter_map(|(i, opt)| opt.map(|v| (i, v)))
3404 {
3405 if !loop_ignore_tools.contains(tool_name.as_str()) {
3406 detection_relevant_output.push_str(&outcome.output);
3407
3408 let args = tool_calls
3410 .get(result_index)
3411 .map(|c| &c.arguments)
3412 .unwrap_or(&serde_json::Value::Null);
3413 let det_result = loop_detector.record(&tool_name, args, &outcome.output);
3414 match det_result {
3415 crate::agent::loop_detector::LoopDetectionResult::Ok => {}
3416 crate::agent::loop_detector::LoopDetectionResult::Warning(ref msg) => {
3417 tracing::warn!(tool = %tool_name, %msg, "loop detector warning");
3418 history.push(ChatMessage::system(format!("[Loop Detection] {msg}")));
3420 }
3421 crate::agent::loop_detector::LoopDetectionResult::Block(ref msg) => {
3422 tracing::warn!(tool = %tool_name, %msg, "loop detector blocked tool call");
3423 history.push(ChatMessage::system(format!(
3426 "[Loop Detection — BLOCKED] {msg}"
3427 )));
3428 }
3429 crate::agent::loop_detector::LoopDetectionResult::Break(msg) => {
3430 runtime_trace::record_event(
3431 "loop_detector_circuit_breaker",
3432 Some(channel_name),
3433 Some(provider_name),
3434 Some(model),
3435 Some(&turn_id),
3436 Some(false),
3437 Some(&msg),
3438 serde_json::json!({
3439 "iteration": iteration + 1,
3440 "tool": tool_name,
3441 }),
3442 );
3443 anyhow::bail!("Agent loop aborted by loop detector: {msg}");
3444 }
3445 }
3446 }
3447 let result_output = truncate_tool_result(&outcome.output, max_tool_result_chars);
3448 individual_results.push((tool_call_id, result_output.clone()));
3449 let _ = writeln!(
3450 tool_results,
3451 "<tool_result name=\"{}\">\n{}\n</tool_result>",
3452 tool_name, result_output
3453 );
3454 }
3455
3456 let loop_detection_active = match pacing.loop_detection_min_elapsed_secs {
3464 Some(min_secs) => loop_started_at.elapsed() >= Duration::from_secs(min_secs),
3465 None => false, };
3467
3468 if loop_detection_active && !detection_relevant_output.is_empty() {
3469 use std::hash::{Hash, Hasher};
3470 let mut hasher = std::collections::hash_map::DefaultHasher::new();
3471 detection_relevant_output.hash(&mut hasher);
3472 let current_hash = hasher.finish();
3473
3474 if last_tool_output_hash == Some(current_hash) {
3475 consecutive_identical_outputs += 1;
3476 } else {
3477 consecutive_identical_outputs = 0;
3478 last_tool_output_hash = Some(current_hash);
3479 }
3480
3481 if consecutive_identical_outputs >= 3 {
3483 runtime_trace::record_event(
3484 "tool_loop_identical_output_abort",
3485 Some(channel_name),
3486 Some(provider_name),
3487 Some(model),
3488 Some(&turn_id),
3489 Some(false),
3490 Some("identical tool output detected 3 consecutive times"),
3491 serde_json::json!({
3492 "iteration": iteration + 1,
3493 "consecutive_identical": consecutive_identical_outputs,
3494 }),
3495 );
3496 anyhow::bail!(
3497 "Agent loop aborted: identical tool output detected {} consecutive times",
3498 consecutive_identical_outputs
3499 );
3500 }
3501 }
3502
3503 history.push(ChatMessage::assistant(assistant_history_content));
3508 if native_tool_calls.is_empty() {
3509 let all_results_have_ids = use_native_tools
3510 && !individual_results.is_empty()
3511 && individual_results
3512 .iter()
3513 .all(|(tool_call_id, _)| tool_call_id.is_some());
3514 if all_results_have_ids {
3515 for (tool_call_id, result) in &individual_results {
3516 let tool_msg = serde_json::json!({
3517 "tool_call_id": tool_call_id,
3518 "content": result,
3519 });
3520 history.push(ChatMessage::tool(tool_msg.to_string()));
3521 }
3522 } else {
3523 history.push(ChatMessage::user(format!("[Tool results]\n{tool_results}")));
3524 }
3525 } else {
3526 for (native_call, (_, result)) in
3527 native_tool_calls.iter().zip(individual_results.iter())
3528 {
3529 let tool_msg = serde_json::json!({
3530 "tool_call_id": native_call.id,
3531 "content": result,
3532 });
3533 history.push(ChatMessage::tool(tool_msg.to_string()));
3534 }
3535 }
3536 }
3537
3538 runtime_trace::record_event(
3539 "tool_loop_exhausted",
3540 Some(channel_name),
3541 Some(provider_name),
3542 Some(model),
3543 Some(&turn_id),
3544 Some(false),
3545 Some("agent exceeded maximum tool iterations"),
3546 serde_json::json!({
3547 "max_iterations": max_iterations,
3548 }),
3549 );
3550
3551 tracing::warn!(
3553 max_iterations,
3554 "Max iterations reached, requesting final summary"
3555 );
3556 history.push(ChatMessage::user(
3557 "You have reached the maximum number of tool iterations. \
3558 Please provide your best answer based on the work completed so far. \
3559 Summarize what you accomplished and what remains to be done."
3560 .to_string(),
3561 ));
3562
3563 let summary_request = crate::providers::ChatRequest {
3564 messages: history,
3565 tools: None, };
3567 match provider.chat(summary_request, model, temperature).await {
3568 Ok(resp) => {
3569 let text = resp.text.unwrap_or_default();
3570 if text.is_empty() {
3571 anyhow::bail!("Agent exceeded maximum tool iterations ({max_iterations})")
3572 }
3573 Ok(text)
3574 }
3575 Err(e) => {
3576 tracing::warn!(error = %e, "Final summary LLM call failed, bailing");
3577 anyhow::bail!("Agent exceeded maximum tool iterations ({max_iterations})")
3578 }
3579 }
3580}
3581
3582pub(crate) fn build_tool_instructions(
3585 tools_registry: &[Box<dyn Tool>],
3586 tool_descriptions: Option<&ToolDescriptions>,
3587) -> String {
3588 let mut instructions = String::new();
3589 instructions.push_str("\n## Tool Use Protocol\n\n");
3590 instructions.push_str("To use a tool, wrap a JSON object in <tool_call></tool_call> tags:\n\n");
3591 instructions.push_str("```\n<tool_call>\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n```\n\n");
3592 instructions.push_str(
3593 "CRITICAL: Output actual <tool_call> tags—never describe steps or give examples.\n\n",
3594 );
3595 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");
3596 instructions.push_str("You may use multiple tool calls in a single response. ");
3597 instructions.push_str("After tool execution, results appear in <tool_result> tags. ");
3598 instructions
3599 .push_str("Continue reasoning with the results until you can give a final answer.\n\n");
3600 instructions.push_str("### Available Tools\n\n");
3601
3602 for tool in tools_registry {
3603 let desc = tool_descriptions
3604 .and_then(|td| td.get(tool.name()))
3605 .unwrap_or_else(|| tool.description());
3606 let _ = writeln!(
3607 instructions,
3608 "**{}**: {}\nParameters: `{}`\n",
3609 tool.name(),
3610 desc,
3611 tool.parameters_schema()
3612 );
3613 }
3614
3615 instructions
3616}
3617
3618#[allow(clippy::too_many_lines)]
3625pub async fn run(
3626 config: Config,
3627 message: Option<String>,
3628 provider_override: Option<String>,
3629 model_override: Option<String>,
3630 temperature: f64,
3631 peripheral_overrides: Vec<String>,
3632 interactive: bool,
3633 session_state_file: Option<PathBuf>,
3634 allowed_tools: Option<Vec<String>>,
3635) -> Result<String> {
3636 let base_observer = observability::create_observer(&config.observability);
3638 let observer: Arc<dyn Observer> = Arc::from(base_observer);
3639 let runtime: Arc<dyn runtime::RuntimeAdapter> =
3640 Arc::from(runtime::create_runtime(&config.runtime)?);
3641 let security = Arc::new(SecurityPolicy::from_config(
3642 &config.autonomy,
3643 &config.workspace_dir,
3644 ));
3645
3646 let mem: Arc<dyn Memory> = Arc::from(memory::create_memory_with_storage_and_routes(
3648 &config.memory,
3649 &config.embedding_routes,
3650 Some(&config.storage.provider.config),
3651 &config.workspace_dir,
3652 config.api_key.as_deref(),
3653 )?);
3654 tracing::info!(backend = mem.name(), "Memory initialized");
3655
3656 if !peripheral_overrides.is_empty() {
3658 tracing::info!(
3659 peripherals = ?peripheral_overrides,
3660 "Peripheral overrides from CLI (config boards take precedence)"
3661 );
3662 }
3663
3664 let (composio_key, composio_entity_id) = if config.composio.enabled {
3666 (
3667 config.composio.api_key.as_deref(),
3668 Some(config.composio.entity_id.as_str()),
3669 )
3670 } else {
3671 (None, None)
3672 };
3673 let (
3674 mut tools_registry,
3675 delegate_handle,
3676 _reaction_handle,
3677 _channel_map_handle,
3678 _ask_user_handle,
3679 _escalate_handle,
3680 ) = tools::all_tools_with_runtime(
3681 Arc::new(config.clone()),
3682 &security,
3683 runtime,
3684 mem.clone(),
3685 composio_key,
3686 composio_entity_id,
3687 &config.browser,
3688 &config.http_request,
3689 &config.web_fetch,
3690 &config.workspace_dir,
3691 &config.agents,
3692 config.api_key.as_deref(),
3693 &config,
3694 None,
3695 );
3696
3697 let peripheral_tools: Vec<Box<dyn Tool>> =
3698 crate::peripherals::create_peripheral_tools(&config.peripherals).await?;
3699 if !peripheral_tools.is_empty() {
3700 tracing::info!(count = peripheral_tools.len(), "Peripheral tools added");
3701 tools_registry.extend(peripheral_tools);
3702 }
3703
3704 if let Some(ref allow_list) = allowed_tools {
3709 tools_registry.retain(|t| allow_list.iter().any(|name| name == t.name()));
3710 tracing::info!(
3711 allowed = allow_list.len(),
3712 retained = tools_registry.len(),
3713 "Applied capability-based tool access filter"
3714 );
3715 }
3716
3717 let config = crate::agent::kumiho::inject_kumiho(config, false);
3721
3722 let config = crate::agent::operator::inject_operator(config, false);
3724
3725 let mut deferred_section = String::new();
3736 let mut activated_handle: Option<
3737 std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>,
3738 > = None;
3739 if config.mcp.enabled && !config.mcp.servers.is_empty() {
3740 tracing::info!(
3741 "Initializing MCP client — {} server(s) configured",
3742 config.mcp.servers.len()
3743 );
3744 match crate::tools::McpRegistry::connect_all(&config.mcp.servers).await {
3745 Ok(registry) => {
3746 let registry = std::sync::Arc::new(registry);
3747 if config.mcp.deferred_loading {
3748 let early_provider = provider_override
3757 .as_deref()
3758 .or(config.default_provider.as_deref())
3759 .unwrap_or("openrouter");
3760 let is_local_provider = early_provider == "ollama";
3761 let is_eager_tool = |name: &str| -> bool {
3762 if is_local_provider {
3763 crate::tools::mcp_deferred::is_local_model_eager_tool(name)
3764 } else {
3765 crate::tools::mcp_deferred::is_operator_seat_eager_tool(name)
3766 }
3767 };
3768
3769 let all_names = registry.tool_names();
3770 let mut eager_count = 0usize;
3771
3772 for name in &all_names {
3773 if is_eager_tool(name) {
3774 if let Some(def) =
3775 registry.get_tool_def(name).await
3776 {
3777 let wrapper: std::sync::Arc<dyn Tool> =
3778 std::sync::Arc::new(crate::tools::McpToolWrapper::new(
3779 name.clone(),
3780 def,
3781 std::sync::Arc::clone(®istry),
3782 ));
3783 if let Some(ref handle) = delegate_handle {
3784 handle.write().push(std::sync::Arc::clone(&wrapper));
3785 }
3786 tools_registry.push(Box::new(crate::tools::ArcToolRef(wrapper)));
3787 eager_count += 1;
3788 }
3789 }
3790 }
3791
3792 let deferred_set = crate::tools::DeferredMcpToolSet::from_registry_filtered(
3794 std::sync::Arc::clone(®istry),
3795 move |name: &str| {
3796 if is_local_provider {
3797 !crate::tools::mcp_deferred::is_local_model_eager_tool(name)
3798 } else {
3799 !crate::tools::mcp_deferred::is_operator_seat_eager_tool(name)
3800 }
3801 },
3802 )
3803 .await;
3804 tracing::info!(
3805 "MCP hybrid: {} eager tool(s), {} deferred stub(s) from {} server(s) (local_provider={})",
3806 eager_count,
3807 deferred_set.len(),
3808 registry.server_count(),
3809 is_local_provider,
3810 );
3811 deferred_section =
3812 crate::tools::mcp_deferred::build_deferred_tools_section(&deferred_set);
3813 let activated = std::sync::Arc::new(std::sync::Mutex::new(
3814 crate::tools::ActivatedToolSet::new(),
3815 ));
3816 activated_handle = Some(std::sync::Arc::clone(&activated));
3817 tools_registry.push(Box::new(crate::tools::ToolSearchTool::new(
3818 deferred_set,
3819 activated,
3820 )));
3821 } else {
3822 let names = registry.tool_names();
3824 let mut registered = 0usize;
3825 for name in names {
3826 if let Some(def) = registry.get_tool_def(&name).await {
3827 let wrapper: std::sync::Arc<dyn Tool> =
3828 std::sync::Arc::new(crate::tools::McpToolWrapper::new(
3829 name,
3830 def,
3831 std::sync::Arc::clone(®istry),
3832 ));
3833 if let Some(ref handle) = delegate_handle {
3834 handle.write().push(std::sync::Arc::clone(&wrapper));
3835 }
3836 tools_registry.push(Box::new(crate::tools::ArcToolRef(wrapper)));
3837 registered += 1;
3838 }
3839 }
3840 tracing::info!(
3841 "MCP: {} tool(s) registered from {} server(s)",
3842 registered,
3843 registry.server_count()
3844 );
3845 }
3846 }
3847 Err(e) => {
3848 tracing::error!("MCP registry failed to initialize: {e:#}");
3849 }
3850 }
3851 }
3852
3853 let mut provider_name = provider_override
3855 .as_deref()
3856 .or(config.default_provider.as_deref())
3857 .unwrap_or("openrouter")
3858 .to_string();
3859
3860 let mut model_name = model_override
3861 .as_deref()
3862 .or(config.default_model.as_deref())
3863 .unwrap_or("anthropic/claude-sonnet-4")
3864 .to_string();
3865
3866 let provider_runtime_options = providers::provider_runtime_options_from_config(&config);
3867
3868 let mut provider: Box<dyn Provider> = providers::create_routed_provider_with_options(
3869 &provider_name,
3870 config.api_key.as_deref(),
3871 config.api_url.as_deref(),
3872 &config.reliability,
3873 &config.model_routes,
3874 &model_name,
3875 &provider_runtime_options,
3876 )?;
3877
3878 let model_switch_callback = get_model_switch_state();
3879
3880 observer.record_event(&ObserverEvent::AgentStart {
3881 provider: provider_name.to_string(),
3882 model: model_name.to_string(),
3883 });
3884
3885 let hardware_rag: Option<crate::rag::HardwareRag> = config
3887 .peripherals
3888 .datasheet_dir
3889 .as_ref()
3890 .filter(|d| !d.trim().is_empty())
3891 .map(|dir| crate::rag::HardwareRag::load(&config.workspace_dir, dir.trim()))
3892 .and_then(Result::ok)
3893 .filter(|r: &crate::rag::HardwareRag| !r.is_empty());
3894 if let Some(ref rag) = hardware_rag {
3895 tracing::info!(chunks = rag.len(), "Hardware RAG loaded");
3896 }
3897
3898 let board_names: Vec<String> = config
3899 .peripherals
3900 .boards
3901 .iter()
3902 .map(|b| b.board.clone())
3903 .collect();
3904
3905 let i18n_locale = config
3907 .locale
3908 .as_deref()
3909 .filter(|s| !s.is_empty())
3910 .map(ToString::to_string)
3911 .unwrap_or_else(crate::i18n::detect_locale);
3912 let i18n_search_dirs = crate::i18n::default_search_dirs(&config.workspace_dir);
3913 let i18n_descs = crate::i18n::ToolDescriptions::load(&i18n_locale, &i18n_search_dirs);
3914
3915 let skills = crate::skills::load_skills_with_config(&config.workspace_dir, &config);
3917
3918 tools::register_skill_tools(&mut tools_registry, &skills, security.clone());
3921
3922 let mut tool_descs: Vec<(&str, &str)> = vec![
3923 (
3924 "shell",
3925 "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.",
3926 ),
3927 (
3928 "file_read",
3929 "Read file contents. Use when: inspecting project files, configs, logs. Don't use when: a targeted search is enough.",
3930 ),
3931 (
3932 "file_write",
3933 "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.",
3934 ),
3935 (
3936 "memory_store",
3937 "Save to memory. Use when: preserving durable preferences, decisions, key context. Don't use when: information is transient/noisy/sensitive without need.",
3938 ),
3939 (
3940 "memory_recall",
3941 "Search memory. Use when: retrieving prior decisions, user preferences, historical context. Don't use when: answer is already in current context.",
3942 ),
3943 (
3944 "memory_forget",
3945 "Delete a memory entry. Use when: memory is incorrect/stale or explicitly requested for removal. Don't use when: impact is uncertain.",
3946 ),
3947 ];
3948 if matches!(
3949 config.skills.prompt_injection_mode,
3950 crate::config::SkillsPromptInjectionMode::Compact
3951 ) {
3952 tool_descs.push((
3953 "read_skill",
3954 "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.",
3955 ));
3956 }
3957 tool_descs.push((
3958 "cron_add",
3959 "Create a cron job. Supports schedule kinds: cron, at, every; and job types: shell or agent.",
3960 ));
3961 tool_descs.push((
3962 "cron_list",
3963 "List all cron jobs with schedule, status, and metadata.",
3964 ));
3965 tool_descs.push(("cron_remove", "Remove a cron job by job_id."));
3966 tool_descs.push((
3967 "cron_update",
3968 "Patch a cron job (schedule, enabled, command/prompt, model, delivery, session_target).",
3969 ));
3970 tool_descs.push((
3971 "cron_run",
3972 "Force-run a cron job immediately and record a run history entry.",
3973 ));
3974 tool_descs.push(("cron_runs", "Show recent run history for a cron job."));
3975 tool_descs.push((
3976 "screenshot",
3977 "Capture a screenshot of the current screen. Returns file path and base64-encoded PNG. Use when: visual verification, UI inspection, debugging displays.",
3978 ));
3979 tool_descs.push((
3980 "image_info",
3981 "Read image file metadata (format, dimensions, size) and optionally base64-encode it. Use when: inspecting images, preparing visual data for analysis.",
3982 ));
3983 if config.browser.enabled {
3984 tool_descs.push((
3985 "browser_open",
3986 "Open approved HTTPS URLs in system browser (allowlist-only, no scraping)",
3987 ));
3988 }
3989 if config.composio.enabled {
3990 tool_descs.push((
3991 "composio",
3992 "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.",
3993 ));
3994 }
3995 tool_descs.push((
3996 "schedule",
3997 "Manage scheduled tasks (create/list/get/cancel/pause/resume). Supports recurring cron and one-shot delays.",
3998 ));
3999 tool_descs.push((
4000 "model_routing_config",
4001 "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'.",
4002 ));
4003 if !config.agents.is_empty() {
4004 tool_descs.push((
4005 "delegate",
4006 "Delegate a sub-task to a specialized agent. Use when: task needs different model/capability, or to parallelize work.",
4007 ));
4008 }
4009 if config.peripherals.enabled && !config.peripherals.boards.is_empty() {
4010 tool_descs.push((
4011 "gpio_read",
4012 "Read GPIO pin value (0 or 1) on connected hardware (STM32, Arduino). Use when: checking sensor/button state, LED status.",
4013 ));
4014 tool_descs.push((
4015 "gpio_write",
4016 "Set GPIO pin high (1) or low (0) on connected hardware. Use when: turning LED on/off, controlling actuators.",
4017 ));
4018 tool_descs.push((
4019 "arduino_upload",
4020 "Upload agent-generated Arduino sketch. Use when: user asks for 'make a heart', 'blink pattern', or custom LED behavior on Arduino. You write the full .ino code; Construct compiles and uploads it. Pin 13 = built-in LED on Uno.",
4021 ));
4022 tool_descs.push((
4023 "hardware_memory_map",
4024 "Return flash and RAM address ranges for connected hardware. Use when: user asks for 'upper and lower memory addresses', 'memory map', or 'readable addresses'.",
4025 ));
4026 tool_descs.push((
4027 "hardware_board_info",
4028 "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'.",
4029 ));
4030 tool_descs.push((
4031 "hardware_memory_read",
4032 "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).",
4033 ));
4034 tool_descs.push((
4035 "hardware_capabilities",
4036 "Query connected hardware for reported GPIO pins and LED pin. Use when: user asks what pins are available.",
4037 ));
4038 }
4039 let bootstrap_max_chars = if config.agent.compact_context {
4040 Some(6000)
4041 } else {
4042 None
4043 };
4044 let native_tools = provider.supports_native_tools();
4045 let mut system_prompt = crate::channels::build_system_prompt_with_mode_and_autonomy(
4046 &config.workspace_dir,
4047 &model_name,
4048 &tool_descs,
4049 &skills,
4050 Some(&config.identity),
4051 bootstrap_max_chars,
4052 Some(&config.autonomy),
4053 native_tools,
4054 config.skills.prompt_injection_mode,
4055 config.agent.compact_context,
4056 config.agent.max_system_prompt_chars,
4057 );
4058
4059 if !native_tools {
4061 system_prompt.push_str(&build_tool_instructions(&tools_registry, Some(&i18n_descs)));
4062 }
4063
4064 if !deferred_section.is_empty() {
4066 system_prompt.push('\n');
4067 system_prompt.push_str(&deferred_section);
4068 }
4069
4070 crate::agent::kumiho::append_kumiho_bootstrap(&mut system_prompt, &config, false);
4072
4073 crate::agent::operator::append_operator_prompt(&mut system_prompt, &config, false, &model_name);
4075
4076 let approval_manager = if interactive {
4078 let trust_tracker = std::sync::Arc::new(parking_lot::Mutex::new(
4079 crate::trust::TrustTracker::new(config.trust.clone()),
4080 ));
4081 Some(ApprovalManager::from_config(&config.autonomy).with_trust_tracker(trust_tracker))
4082 } else {
4083 None
4084 };
4085 let channel_name = if interactive { "cli" } else { "daemon" };
4086 let memory_session_id = session_state_file.as_deref().and_then(|path| {
4087 let raw = path.to_string_lossy().trim().to_string();
4088 if raw.is_empty() {
4089 None
4090 } else {
4091 Some(format!("cli:{raw}"))
4092 }
4093 });
4094
4095 let start = Instant::now();
4097
4098 let mut final_output = String::new();
4099
4100 let base_system_prompt = system_prompt.clone();
4103
4104 if let Some(msg) = message {
4105 let (thinking_directive, effective_msg) =
4107 match crate::agent::thinking::parse_thinking_directive(&msg) {
4108 Some((level, remaining)) => {
4109 tracing::info!(thinking_level = ?level, "Thinking directive parsed from message");
4110 (Some(level), remaining)
4111 }
4112 None => (None, msg.clone()),
4113 };
4114 let thinking_level = crate::agent::thinking::resolve_thinking_level(
4115 thinking_directive,
4116 None,
4117 &config.agent.thinking,
4118 );
4119 let thinking_params = crate::agent::thinking::apply_thinking_level(thinking_level);
4120 let effective_temperature = crate::agent::thinking::clamp_temperature(
4121 temperature + thinking_params.temperature_adjustment,
4122 );
4123
4124 if let Some(ref prefix) = thinking_params.system_prompt_prefix {
4126 system_prompt = format!("{prefix}\n\n{system_prompt}");
4127 }
4128
4129 if config.memory.auto_save
4131 && effective_msg.chars().count() >= AUTOSAVE_MIN_MESSAGE_CHARS
4132 && !memory::should_skip_autosave_content(&effective_msg)
4133 {
4134 let user_key = autosave_memory_key("user_msg");
4135 let _ = mem
4136 .store(
4137 &user_key,
4138 &effective_msg,
4139 MemoryCategory::Conversation,
4140 memory_session_id.as_deref(),
4141 )
4142 .await;
4143 }
4144
4145 let mem_context = build_context(
4147 mem.as_ref(),
4148 &effective_msg,
4149 config.memory.min_relevance_score,
4150 memory_session_id.as_deref(),
4151 )
4152 .await;
4153 let rag_limit = if config.agent.compact_context { 2 } else { 5 };
4154 let hw_context = hardware_rag
4155 .as_ref()
4156 .map(|r| build_hardware_context(r, &effective_msg, &board_names, rag_limit))
4157 .unwrap_or_default();
4158 let context = format!("{mem_context}{hw_context}");
4159 let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z");
4160 let enriched = if context.is_empty() {
4161 format!("[{now}] {effective_msg}")
4162 } else {
4163 format!("{context}[{now}] {effective_msg}")
4164 };
4165
4166 let mut history = vec![
4167 ChatMessage::system(&system_prompt),
4168 ChatMessage::user(&enriched),
4169 ];
4170
4171 if config.agent.history_pruning.enabled {
4173 let _stats = crate::agent::history_pruner::prune_history(
4174 &mut history,
4175 &config.agent.history_pruning,
4176 );
4177 }
4178
4179 let excluded_tools = compute_excluded_mcp_tools(
4181 &tools_registry,
4182 &config.agent.tool_filter_groups,
4183 &effective_msg,
4184 );
4185
4186 #[allow(unused_assignments)]
4187 let mut response = String::new();
4188 loop {
4189 match run_tool_call_loop(
4190 provider.as_ref(),
4191 &mut history,
4192 &tools_registry,
4193 observer.as_ref(),
4194 &provider_name,
4195 &model_name,
4196 effective_temperature,
4197 false,
4198 approval_manager.as_ref(),
4199 channel_name,
4200 None,
4201 &config.multimodal,
4202 effective_max_tool_iterations(&config),
4203 None,
4204 None,
4205 None,
4206 &excluded_tools,
4207 &config.agent.tool_call_dedup_exempt,
4208 activated_handle.as_ref(),
4209 Some(model_switch_callback.clone()),
4210 &config.pacing,
4211 config.agent.max_tool_result_chars,
4212 config.agent.max_context_tokens,
4213 None, )
4215 .await
4216 {
4217 Ok(resp) => {
4218 response = resp;
4219 break;
4220 }
4221 Err(e) => {
4222 if let Some((new_provider, new_model)) = is_model_switch_requested(&e) {
4223 tracing::info!(
4224 "Model switch requested, switching from {} {} to {} {}",
4225 provider_name,
4226 model_name,
4227 new_provider,
4228 new_model
4229 );
4230
4231 provider = providers::create_routed_provider_with_options(
4232 &new_provider,
4233 config.api_key.as_deref(),
4234 config.api_url.as_deref(),
4235 &config.reliability,
4236 &config.model_routes,
4237 &new_model,
4238 &provider_runtime_options,
4239 )?;
4240
4241 provider_name = new_provider;
4242 model_name = new_model;
4243
4244 clear_model_switch_request();
4245
4246 observer.record_event(&ObserverEvent::AgentStart {
4247 provider: provider_name.to_string(),
4248 model: model_name.to_string(),
4249 });
4250
4251 continue;
4252 }
4253 return Err(e);
4254 }
4255 }
4256 }
4257
4258 #[cfg(feature = "skill-creation")]
4260 if config.skills.skill_creation.enabled {
4261 let tool_calls = crate::skills::creator::extract_tool_calls_from_history(&history);
4262 if tool_calls.len() >= 2 {
4263 let creator = crate::skills::creator::SkillCreator::new(
4264 config.workspace_dir.clone(),
4265 config.skills.skill_creation.clone(),
4266 );
4267 match creator.create_from_execution(&msg, &tool_calls, None).await {
4268 Ok(Some(slug)) => {
4269 tracing::info!(slug, "Auto-created skill from execution");
4270 }
4271 Ok(None) => {
4272 tracing::debug!("Skill creation skipped (duplicate or disabled)");
4273 }
4274 Err(e) => tracing::warn!("Skill creation failed: {e}"),
4275 }
4276 }
4277 }
4278 final_output = response.clone();
4279 println!("{response}");
4280 observer.record_event(&ObserverEvent::TurnComplete);
4281 } else {
4282 println!("🦀 Construct Interactive Mode");
4283 println!("Type /help for commands.\n");
4284 let cli = crate::channels::CliChannel::new();
4285
4286 let mut history = if let Some(path) = session_state_file.as_deref() {
4288 load_interactive_session_history(path, &system_prompt)?
4289 } else {
4290 vec![ChatMessage::system(&system_prompt)]
4291 };
4292
4293 loop {
4294 print!("> ");
4295 let _ = std::io::stdout().flush();
4296
4297 let mut raw = Vec::new();
4301 match std::io::BufRead::read_until(&mut std::io::stdin().lock(), b'\n', &mut raw) {
4302 Ok(0) => break,
4303 Ok(_) => {}
4304 Err(e) => {
4305 eprintln!("\nError reading input: {e}\n");
4306 break;
4307 }
4308 }
4309 let input = String::from_utf8_lossy(&raw).into_owned();
4310
4311 let user_input = input.trim().to_string();
4312 if user_input.is_empty() {
4313 continue;
4314 }
4315 match user_input.as_str() {
4316 "/quit" | "/exit" => break,
4317 "/help" => {
4318 println!("Available commands:");
4319 println!(" /help Show this help message");
4320 println!(" /clear /new Clear conversation history");
4321 println!(" /quit /exit Exit interactive mode");
4322 println!(
4323 " /think:<level> Set reasoning depth (off|minimal|low|medium|high|max)\n"
4324 );
4325 continue;
4326 }
4327 "/clear" | "/new" => {
4328 println!(
4329 "This will clear the current conversation and delete all session memory."
4330 );
4331 println!("Core memories (long-term facts/preferences) will be preserved.");
4332 print!("Continue? [y/N] ");
4333 let _ = std::io::stdout().flush();
4334
4335 let mut confirm_raw = Vec::new();
4336 if std::io::BufRead::read_until(
4337 &mut std::io::stdin().lock(),
4338 b'\n',
4339 &mut confirm_raw,
4340 )
4341 .is_err()
4342 {
4343 continue;
4344 }
4345 let confirm = String::from_utf8_lossy(&confirm_raw);
4346 if !matches!(confirm.trim().to_lowercase().as_str(), "y" | "yes") {
4347 println!("Cancelled.\n");
4348 continue;
4349 }
4350
4351 history.clear();
4352 history.push(ChatMessage::system(&system_prompt));
4353 let mut cleared = 0;
4355 for category in [MemoryCategory::Conversation, MemoryCategory::Daily] {
4356 let entries = mem.list(Some(&category), None).await.unwrap_or_default();
4357 for entry in entries {
4358 if mem.forget(&entry.key).await.unwrap_or(false) {
4359 cleared += 1;
4360 }
4361 }
4362 }
4363 if cleared > 0 {
4364 println!("Conversation cleared ({cleared} memory entries removed).\n");
4365 } else {
4366 println!("Conversation cleared.\n");
4367 }
4368 if let Some(path) = session_state_file.as_deref() {
4369 save_interactive_session_history(path, &history)?;
4370 }
4371 continue;
4372 }
4373 _ => {}
4374 }
4375
4376 let (thinking_directive, effective_input) =
4378 match crate::agent::thinking::parse_thinking_directive(&user_input) {
4379 Some((level, remaining)) => {
4380 tracing::info!(thinking_level = ?level, "Thinking directive parsed");
4381 (Some(level), remaining)
4382 }
4383 None => (None, user_input.clone()),
4384 };
4385 let thinking_level = crate::agent::thinking::resolve_thinking_level(
4386 thinking_directive,
4387 None,
4388 &config.agent.thinking,
4389 );
4390 let thinking_params = crate::agent::thinking::apply_thinking_level(thinking_level);
4391 let turn_temperature = crate::agent::thinking::clamp_temperature(
4392 temperature + thinking_params.temperature_adjustment,
4393 );
4394
4395 let turn_system_prompt;
4397 if let Some(ref prefix) = thinking_params.system_prompt_prefix {
4398 turn_system_prompt = format!("{prefix}\n\n{system_prompt}");
4399 if let Some(sys_msg) = history.first_mut() {
4401 if sys_msg.role == "system" {
4402 sys_msg.content = turn_system_prompt.clone();
4403 }
4404 }
4405 }
4406
4407 if config.memory.auto_save
4409 && effective_input.chars().count() >= AUTOSAVE_MIN_MESSAGE_CHARS
4410 && !memory::should_skip_autosave_content(&effective_input)
4411 {
4412 let user_key = autosave_memory_key("user_msg");
4413 let _ = mem
4414 .store(
4415 &user_key,
4416 &effective_input,
4417 MemoryCategory::Conversation,
4418 memory_session_id.as_deref(),
4419 )
4420 .await;
4421 }
4422
4423 let mem_context = build_context(
4425 mem.as_ref(),
4426 &effective_input,
4427 config.memory.min_relevance_score,
4428 memory_session_id.as_deref(),
4429 )
4430 .await;
4431 let rag_limit = if config.agent.compact_context { 2 } else { 5 };
4432 let hw_context = hardware_rag
4433 .as_ref()
4434 .map(|r| build_hardware_context(r, &effective_input, &board_names, rag_limit))
4435 .unwrap_or_default();
4436 let context = format!("{mem_context}{hw_context}");
4437 let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z");
4438 let enriched = if context.is_empty() {
4439 format!("[{now}] {effective_input}")
4440 } else {
4441 format!("{context}[{now}] {effective_input}")
4442 };
4443
4444 history.push(ChatMessage::user(&enriched));
4445
4446 let excluded_tools = compute_excluded_mcp_tools(
4448 &tools_registry,
4449 &config.agent.tool_filter_groups,
4450 &effective_input,
4451 );
4452
4453 let (delta_tx, mut delta_rx) = tokio::sync::mpsc::channel::<DraftEvent>(64);
4456 let content_was_streamed =
4457 std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
4458 let content_streamed_flag = content_was_streamed.clone();
4459 let is_tty = std::io::IsTerminal::is_terminal(&std::io::stderr());
4460
4461 let consumer_handle = tokio::spawn(async move {
4462 use std::io::Write;
4463 while let Some(event) = delta_rx.recv().await {
4464 match event {
4465 DraftEvent::Clear => {
4466 let _ = writeln!(std::io::stderr());
4467 }
4468 DraftEvent::Progress(text) => {
4469 if is_tty {
4470 let _ = write!(std::io::stderr(), "\x1b[2m{text}\x1b[0m");
4471 } else {
4472 let _ = write!(std::io::stderr(), "{text}");
4473 }
4474 let _ = std::io::stderr().flush();
4475 }
4476 DraftEvent::Content(text) => {
4477 content_streamed_flag.store(true, std::sync::atomic::Ordering::Relaxed);
4478 print!("{text}");
4479 let _ = std::io::stdout().flush();
4480 }
4481 }
4482 }
4483 });
4484
4485 let cancel_token = CancellationToken::new();
4487 let cancel_token_clone = cancel_token.clone();
4488 let ctrlc_handle = tokio::spawn(async move {
4489 if tokio::signal::ctrl_c().await.is_ok() {
4490 cancel_token_clone.cancel();
4491 }
4492 });
4493
4494 let response = loop {
4495 match run_tool_call_loop(
4496 provider.as_ref(),
4497 &mut history,
4498 &tools_registry,
4499 observer.as_ref(),
4500 &provider_name,
4501 &model_name,
4502 turn_temperature,
4503 true,
4504 approval_manager.as_ref(),
4505 channel_name,
4506 None,
4507 &config.multimodal,
4508 effective_max_tool_iterations(&config),
4509 Some(cancel_token.clone()),
4510 Some(delta_tx.clone()),
4511 None,
4512 &excluded_tools,
4513 &config.agent.tool_call_dedup_exempt,
4514 activated_handle.as_ref(),
4515 Some(model_switch_callback.clone()),
4516 &config.pacing,
4517 config.agent.max_tool_result_chars,
4518 config.agent.max_context_tokens,
4519 None, )
4521 .await
4522 {
4523 Ok(resp) => break resp,
4524 Err(e) => {
4525 if is_tool_loop_cancelled(&e) {
4526 eprintln!("\n\x1b[2m(cancelled)\x1b[0m");
4527 break String::new();
4528 }
4529 if let Some((new_provider, new_model)) = is_model_switch_requested(&e) {
4530 tracing::info!(
4531 "Model switch requested, switching from {} {} to {} {}",
4532 provider_name,
4533 model_name,
4534 new_provider,
4535 new_model
4536 );
4537
4538 provider = providers::create_routed_provider_with_options(
4539 &new_provider,
4540 config.api_key.as_deref(),
4541 config.api_url.as_deref(),
4542 &config.reliability,
4543 &config.model_routes,
4544 &new_model,
4545 &provider_runtime_options,
4546 )?;
4547
4548 provider_name = new_provider;
4549 model_name = new_model;
4550
4551 clear_model_switch_request();
4552
4553 observer.record_event(&ObserverEvent::AgentStart {
4554 provider: provider_name.to_string(),
4555 model: model_name.to_string(),
4556 });
4557
4558 continue;
4559 }
4560 if crate::providers::reliable::is_context_window_exceeded(&e) {
4562 tracing::warn!(
4563 "Context overflow in interactive loop, attempting recovery"
4564 );
4565 let mut compressor =
4566 crate::agent::context_compressor::ContextCompressor::new(
4567 config.agent.context_compression.clone(),
4568 config.agent.max_context_tokens,
4569 )
4570 .with_memory(mem.clone());
4571 let error_msg = format!("{e}");
4572 match compressor
4573 .compress_on_error(
4574 &mut history,
4575 provider.as_ref(),
4576 &model_name,
4577 &error_msg,
4578 )
4579 .await
4580 {
4581 Ok(true) => {
4582 tracing::info!(
4583 "Context recovered via compression, retrying turn"
4584 );
4585 continue;
4586 }
4587 Ok(false) => {
4588 tracing::warn!("Compression ran but couldn't reduce enough");
4589 }
4590 Err(compress_err) => {
4591 tracing::warn!(
4592 error = %compress_err,
4593 "Compression failed during recovery"
4594 );
4595 }
4596 }
4597 }
4598
4599 eprintln!("\nError: {e}\n");
4600 break String::new();
4601 }
4602 }
4603 };
4604
4605 ctrlc_handle.abort();
4607 drop(delta_tx);
4608 let _ = consumer_handle.await;
4609
4610 final_output = response.clone();
4611 if content_was_streamed.load(std::sync::atomic::Ordering::Relaxed) {
4612 println!();
4613 } else if let Err(e) = crate::channels::Channel::send(
4614 &cli,
4615 &crate::channels::traits::SendMessage::new(format!("\n{response}\n"), "user"),
4616 )
4617 .await
4618 {
4619 eprintln!("\nError sending CLI response: {e}\n");
4620 }
4621 observer.record_event(&ObserverEvent::TurnComplete);
4622
4623 {
4625 let compressor = crate::agent::context_compressor::ContextCompressor::new(
4626 config.agent.context_compression.clone(),
4627 config.agent.max_context_tokens,
4628 )
4629 .with_memory(mem.clone());
4630 match compressor
4631 .compress_if_needed(&mut history, provider.as_ref(), &model_name)
4632 .await
4633 {
4634 Ok(result) if result.compressed => {
4635 tracing::info!(
4636 passes = result.passes_used,
4637 before = result.tokens_before,
4638 after = result.tokens_after,
4639 "Context compression complete"
4640 );
4641 }
4642 Ok(_) => {} Err(e) => {
4644 tracing::warn!(
4645 error = %e,
4646 "Context compression failed, falling back to history trim"
4647 );
4648 trim_history(&mut history, config.agent.max_history_messages / 2);
4649 }
4650 }
4651 }
4652
4653 trim_history(&mut history, config.agent.max_history_messages);
4655
4656 if thinking_params.system_prompt_prefix.is_some() {
4658 if let Some(sys_msg) = history.first_mut() {
4659 if sys_msg.role == "system" {
4660 sys_msg.content.clone_from(&base_system_prompt);
4661 }
4662 }
4663 }
4664
4665 if let Some(path) = session_state_file.as_deref() {
4666 save_interactive_session_history(path, &history)?;
4667 }
4668 }
4669 }
4670
4671 let duration = start.elapsed();
4672 observer.record_event(&ObserverEvent::AgentEnd {
4673 provider: provider_name.to_string(),
4674 model: model_name.to_string(),
4675 duration,
4676 tokens_used: None,
4677 cost_usd: None,
4678 });
4679
4680 Ok(final_output)
4681}
4682
4683pub async fn process_message(
4686 config: Config,
4687 message: &str,
4688 session_id: Option<&str>,
4689) -> Result<String> {
4690 let observer: Arc<dyn Observer> =
4691 Arc::from(observability::create_observer(&config.observability));
4692 let runtime: Arc<dyn runtime::RuntimeAdapter> =
4693 Arc::from(runtime::create_runtime(&config.runtime)?);
4694 let security = Arc::new(SecurityPolicy::from_config(
4695 &config.autonomy,
4696 &config.workspace_dir,
4697 ));
4698 let approval_manager = ApprovalManager::for_non_interactive(&config.autonomy);
4699 let mem: Arc<dyn Memory> = Arc::from(memory::create_memory_with_storage_and_routes(
4700 &config.memory,
4701 &config.embedding_routes,
4702 Some(&config.storage.provider.config),
4703 &config.workspace_dir,
4704 config.api_key.as_deref(),
4705 )?);
4706
4707 let (composio_key, composio_entity_id) = if config.composio.enabled {
4708 (
4709 config.composio.api_key.as_deref(),
4710 Some(config.composio.entity_id.as_str()),
4711 )
4712 } else {
4713 (None, None)
4714 };
4715 let (
4716 mut tools_registry,
4717 delegate_handle_pm,
4718 _reaction_handle_pm,
4719 _channel_map_handle_pm,
4720 _ask_user_handle_pm,
4721 _escalate_handle_pm,
4722 ) = tools::all_tools_with_runtime(
4723 Arc::new(config.clone()),
4724 &security,
4725 runtime,
4726 mem.clone(),
4727 composio_key,
4728 composio_entity_id,
4729 &config.browser,
4730 &config.http_request,
4731 &config.web_fetch,
4732 &config.workspace_dir,
4733 &config.agents,
4734 config.api_key.as_deref(),
4735 &config,
4736 None,
4737 );
4738 let peripheral_tools: Vec<Box<dyn Tool>> =
4739 crate::peripherals::create_peripheral_tools(&config.peripherals).await?;
4740 tools_registry.extend(peripheral_tools);
4741
4742 let config = crate::agent::kumiho::inject_kumiho(config, false);
4744
4745 let config = crate::agent::operator::inject_operator(config, false);
4747
4748 let mut deferred_section = String::new();
4753 let mut activated_handle_pm: Option<
4754 std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>,
4755 > = None;
4756 if config.mcp.enabled && !config.mcp.servers.is_empty() {
4757 tracing::info!(
4758 "Initializing MCP client — {} server(s) configured",
4759 config.mcp.servers.len()
4760 );
4761 match crate::tools::McpRegistry::connect_all(&config.mcp.servers).await {
4762 Ok(registry) => {
4763 let registry = std::sync::Arc::new(registry);
4764 if config.mcp.deferred_loading {
4765 let operator_prefix =
4767 format!("{}__", crate::agent::operator::OPERATOR_SERVER_NAME);
4768 let all_names = registry.tool_names();
4769 let mut eager_count = 0usize;
4770
4771 let is_eager = |name: &str| -> bool {
4772 name.starts_with(&operator_prefix)
4773 || name == "kumiho-memory__kumiho_memory_engage"
4774 || name == "kumiho-memory__kumiho_memory_reflect"
4775 };
4776
4777 for name in &all_names {
4778 if is_eager(name) {
4779 if let Some(def) = registry.get_tool_def(name).await {
4780 let wrapper: std::sync::Arc<dyn Tool> =
4781 std::sync::Arc::new(crate::tools::McpToolWrapper::new(
4782 name.clone(),
4783 def,
4784 std::sync::Arc::clone(®istry),
4785 ));
4786 if let Some(ref handle) = delegate_handle_pm {
4787 handle.write().push(std::sync::Arc::clone(&wrapper));
4788 }
4789 tools_registry.push(Box::new(crate::tools::ArcToolRef(wrapper)));
4790 eager_count += 1;
4791 }
4792 }
4793 }
4794
4795 let operator_pfx = operator_prefix.clone();
4796 let deferred_set = crate::tools::DeferredMcpToolSet::from_registry_filtered(
4797 std::sync::Arc::clone(®istry),
4798 move |name: &str| {
4799 !(name.starts_with(&operator_pfx)
4800 || name == "kumiho-memory__kumiho_memory_engage"
4801 || name == "kumiho-memory__kumiho_memory_reflect")
4802 },
4803 )
4804 .await;
4805 tracing::info!(
4806 "MCP hybrid: {} eager tool(s) (operator + kumiho reflexes), {} deferred stub(s) from {} server(s)",
4807 eager_count,
4808 deferred_set.len(),
4809 registry.server_count()
4810 );
4811 deferred_section =
4812 crate::tools::mcp_deferred::build_deferred_tools_section(&deferred_set);
4813 let activated = std::sync::Arc::new(std::sync::Mutex::new(
4814 crate::tools::ActivatedToolSet::new(),
4815 ));
4816 activated_handle_pm = Some(std::sync::Arc::clone(&activated));
4817 tools_registry.push(Box::new(crate::tools::ToolSearchTool::new(
4818 deferred_set,
4819 activated,
4820 )));
4821 } else {
4822 let names = registry.tool_names();
4823 let mut registered = 0usize;
4824 for name in names {
4825 if let Some(def) = registry.get_tool_def(&name).await {
4826 let wrapper: std::sync::Arc<dyn Tool> =
4827 std::sync::Arc::new(crate::tools::McpToolWrapper::new(
4828 name,
4829 def,
4830 std::sync::Arc::clone(®istry),
4831 ));
4832 if let Some(ref handle) = delegate_handle_pm {
4833 handle.write().push(std::sync::Arc::clone(&wrapper));
4834 }
4835 tools_registry.push(Box::new(crate::tools::ArcToolRef(wrapper)));
4836 registered += 1;
4837 }
4838 }
4839 tracing::info!(
4840 "MCP: {} tool(s) registered from {} server(s)",
4841 registered,
4842 registry.server_count()
4843 );
4844 }
4845 }
4846 Err(e) => {
4847 tracing::error!("MCP registry failed to initialize: {e:#}");
4848 }
4849 }
4850 }
4851
4852 let provider_name = config.default_provider.as_deref().unwrap_or("openrouter");
4853 let model_name = config
4854 .default_model
4855 .clone()
4856 .unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into());
4857 let provider_runtime_options = providers::provider_runtime_options_from_config(&config);
4858 let provider: Box<dyn Provider> = providers::create_routed_provider_with_options(
4859 provider_name,
4860 config.api_key.as_deref(),
4861 config.api_url.as_deref(),
4862 &config.reliability,
4863 &config.model_routes,
4864 &model_name,
4865 &provider_runtime_options,
4866 )?;
4867
4868 let hardware_rag: Option<crate::rag::HardwareRag> = config
4869 .peripherals
4870 .datasheet_dir
4871 .as_ref()
4872 .filter(|d| !d.trim().is_empty())
4873 .map(|dir| crate::rag::HardwareRag::load(&config.workspace_dir, dir.trim()))
4874 .and_then(Result::ok)
4875 .filter(|r: &crate::rag::HardwareRag| !r.is_empty());
4876 let board_names: Vec<String> = config
4877 .peripherals
4878 .boards
4879 .iter()
4880 .map(|b| b.board.clone())
4881 .collect();
4882
4883 let i18n_locale = config
4885 .locale
4886 .as_deref()
4887 .filter(|s| !s.is_empty())
4888 .map(ToString::to_string)
4889 .unwrap_or_else(crate::i18n::detect_locale);
4890 let i18n_search_dirs = crate::i18n::default_search_dirs(&config.workspace_dir);
4891 let i18n_descs = crate::i18n::ToolDescriptions::load(&i18n_locale, &i18n_search_dirs);
4892
4893 let skills = crate::skills::load_skills_with_config(&config.workspace_dir, &config);
4894
4895 tools::register_skill_tools(&mut tools_registry, &skills, security.clone());
4897
4898 let mut tool_descs: Vec<(&str, &str)> = vec![
4899 ("shell", "Execute terminal commands."),
4900 ("file_read", "Read file contents."),
4901 ("file_write", "Write file contents."),
4902 ("memory_store", "Save to memory."),
4903 ("memory_recall", "Search memory."),
4904 ("memory_forget", "Delete a memory entry."),
4905 (
4906 "model_routing_config",
4907 "Configure default model, scenario routing, and delegate agents.",
4908 ),
4909 ("screenshot", "Capture a screenshot."),
4910 ("image_info", "Read image metadata."),
4911 ];
4912 if matches!(
4913 config.skills.prompt_injection_mode,
4914 crate::config::SkillsPromptInjectionMode::Compact
4915 ) {
4916 tool_descs.push((
4917 "read_skill",
4918 "Load the full source for an available skill by name.",
4919 ));
4920 }
4921 if config.browser.enabled {
4922 tool_descs.push(("browser_open", "Open approved URLs in browser."));
4923 }
4924 if config.composio.enabled {
4925 tool_descs.push(("composio", "Execute actions on 1000+ apps via Composio."));
4926 }
4927 if config.peripherals.enabled && !config.peripherals.boards.is_empty() {
4928 tool_descs.push(("gpio_read", "Read GPIO pin value on connected hardware."));
4929 tool_descs.push((
4930 "gpio_write",
4931 "Set GPIO pin high or low on connected hardware.",
4932 ));
4933 tool_descs.push((
4934 "arduino_upload",
4935 "Upload Arduino sketch. Use for 'make a heart', custom patterns. You write full .ino code; Construct uploads it.",
4936 ));
4937 tool_descs.push((
4938 "hardware_memory_map",
4939 "Return flash and RAM address ranges. Use when user asks for memory addresses or memory map.",
4940 ));
4941 tool_descs.push((
4942 "hardware_board_info",
4943 "Return full board info (chip, architecture, memory map). Use when user asks for board info, what board, connected hardware, or chip info.",
4944 ));
4945 tool_descs.push((
4946 "hardware_memory_read",
4947 "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.",
4948 ));
4949 tool_descs.push((
4950 "hardware_capabilities",
4951 "Query connected hardware for reported GPIO pins and LED pin. Use when user asks what pins are available.",
4952 ));
4953 }
4954
4955 if config.autonomy.level != AutonomyLevel::Full {
4958 let excluded = &config.autonomy.non_cli_excluded_tools;
4959 if !excluded.is_empty() {
4960 tool_descs.retain(|(name, _)| !excluded.iter().any(|ex| ex == name));
4961 }
4962 }
4963
4964 let bootstrap_max_chars = if config.agent.compact_context {
4965 Some(6000)
4966 } else {
4967 None
4968 };
4969 let native_tools = provider.supports_native_tools();
4970 let mut system_prompt = crate::channels::build_system_prompt_with_mode_and_autonomy(
4971 &config.workspace_dir,
4972 &model_name,
4973 &tool_descs,
4974 &skills,
4975 Some(&config.identity),
4976 bootstrap_max_chars,
4977 Some(&config.autonomy),
4978 native_tools,
4979 config.skills.prompt_injection_mode,
4980 config.agent.compact_context,
4981 config.agent.max_system_prompt_chars,
4982 );
4983 if !native_tools {
4984 system_prompt.push_str(&build_tool_instructions(&tools_registry, Some(&i18n_descs)));
4985 }
4986 if !deferred_section.is_empty() {
4987 system_prompt.push('\n');
4988 system_prompt.push_str(&deferred_section);
4989 }
4990
4991 crate::agent::kumiho::append_kumiho_bootstrap(&mut system_prompt, &config, false);
4993
4994 crate::agent::operator::append_operator_prompt(&mut system_prompt, &config, false, &model_name);
4996
4997 let (thinking_directive, effective_message) =
4999 match crate::agent::thinking::parse_thinking_directive(message) {
5000 Some((level, remaining)) => {
5001 tracing::info!(thinking_level = ?level, "Thinking directive parsed from message");
5002 (Some(level), remaining)
5003 }
5004 None => (None, message.to_string()),
5005 };
5006 let thinking_level = crate::agent::thinking::resolve_thinking_level(
5007 thinking_directive,
5008 None,
5009 &config.agent.thinking,
5010 );
5011 let thinking_params = crate::agent::thinking::apply_thinking_level(thinking_level);
5012 let effective_temperature = crate::agent::thinking::clamp_temperature(
5013 config.default_temperature + thinking_params.temperature_adjustment,
5014 );
5015
5016 if let Some(ref prefix) = thinking_params.system_prompt_prefix {
5018 system_prompt = format!("{prefix}\n\n{system_prompt}");
5019 }
5020
5021 let effective_msg_ref = effective_message.as_str();
5022 let mem_context = build_context(
5023 mem.as_ref(),
5024 effective_msg_ref,
5025 config.memory.min_relevance_score,
5026 session_id,
5027 )
5028 .await;
5029 let rag_limit = if config.agent.compact_context { 2 } else { 5 };
5030 let hw_context = hardware_rag
5031 .as_ref()
5032 .map(|r| build_hardware_context(r, effective_msg_ref, &board_names, rag_limit))
5033 .unwrap_or_default();
5034 let context = format!("{mem_context}{hw_context}");
5035 let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z");
5036 let enriched = if context.is_empty() {
5037 format!("[{now}] {effective_message}")
5038 } else {
5039 format!("{context}[{now}] {effective_message}")
5040 };
5041
5042 let mut history = vec![
5043 ChatMessage::system(&system_prompt),
5044 ChatMessage::user(&enriched),
5045 ];
5046 let mut excluded_tools = compute_excluded_mcp_tools(
5047 &tools_registry,
5048 &config.agent.tool_filter_groups,
5049 effective_msg_ref,
5050 );
5051 if config.autonomy.level != AutonomyLevel::Full {
5052 excluded_tools.extend(config.autonomy.non_cli_excluded_tools.iter().cloned());
5053 }
5054
5055 agent_turn(
5056 provider.as_ref(),
5057 &mut history,
5058 &tools_registry,
5059 observer.as_ref(),
5060 provider_name,
5061 &model_name,
5062 effective_temperature,
5063 true,
5064 "daemon",
5065 None,
5066 &config.multimodal,
5067 config.agent.max_tool_iterations,
5068 Some(&approval_manager),
5069 &excluded_tools,
5070 &config.agent.tool_call_dedup_exempt,
5071 activated_handle_pm.as_ref(),
5072 None,
5073 )
5074 .await
5075}
5076
5077#[cfg(test)]
5078mod tests {
5079 use super::{
5080 emergency_history_trim, estimate_history_tokens, fast_trim_tool_results,
5081 load_interactive_session_history, save_interactive_session_history, truncate_tool_result,
5082 };
5083 use crate::agent::history::{DEFAULT_MAX_HISTORY_MESSAGES, InteractiveSessionState};
5084 use crate::agent::tool_execution::execute_one_tool;
5085 use crate::providers::ChatMessage;
5086 use tempfile::tempdir;
5087
5088 #[test]
5091 fn truncate_tool_result_short_passthrough() {
5092 let output = "short output";
5093 assert_eq!(truncate_tool_result(output, 100), output);
5094 }
5095
5096 #[test]
5097 fn truncate_tool_result_exact_boundary() {
5098 let output = "a".repeat(100);
5099 assert_eq!(truncate_tool_result(&output, 100), output);
5100 }
5101
5102 #[test]
5103 fn truncate_tool_result_zero_disables() {
5104 let output = "a".repeat(200_000);
5105 assert_eq!(truncate_tool_result(&output, 0), output);
5106 }
5107
5108 #[test]
5109 fn truncate_tool_result_truncates_with_marker() {
5110 let output = "a".repeat(200);
5111 let result = truncate_tool_result(&output, 100);
5112 assert!(result.contains("[... "));
5113 assert!(result.contains("characters truncated ...]\n\n"));
5114 assert!(result.starts_with("aaa"));
5116 assert!(result.ends_with("aaa"));
5117 assert!(result.len() < output.len());
5119 }
5120
5121 #[test]
5122 fn truncate_tool_result_preserves_head_tail_ratio() {
5123 let output: String = (0u32..1000)
5124 .map(|i| char::from(b'a' + (i % 26) as u8))
5125 .collect();
5126 let result = truncate_tool_result(&output, 300);
5127 let marker_start = result.find("[... ").unwrap();
5130 let marker_end = result.find("characters truncated ...]\n\n").unwrap()
5131 + "characters truncated ...]\n\n".len();
5132 let head = &result[..marker_start - 2]; let tail = &result[marker_end..];
5134 assert!(
5135 head.len() >= 190 && head.len() <= 210,
5136 "head len={}",
5137 head.len()
5138 );
5139 assert!(
5140 tail.len() >= 90 && tail.len() <= 110,
5141 "tail len={}",
5142 tail.len()
5143 );
5144 }
5145
5146 #[test]
5147 fn truncate_tool_result_utf8_boundary_safety() {
5148 let output = "🦀".repeat(100); let result = truncate_tool_result(&output, 50);
5152 assert!(result.contains("[... "));
5153 let _ = result.len();
5155 }
5156
5157 #[test]
5158 fn truncate_tool_result_very_small_max() {
5159 let output = "abcdefghijklmnopqrstuvwxyz";
5160 let result = truncate_tool_result(output, 5);
5163 assert!(result.contains("[... "));
5164 assert!(result.starts_with("abc"));
5166 assert!(result.ends_with("yz"));
5167 }
5168
5169 #[test]
5172 fn fast_trim_protects_recent_messages() {
5173 let mut history = vec![
5174 ChatMessage::system("sys"),
5175 ChatMessage::tool("a".repeat(5000)),
5176 ChatMessage::tool("b".repeat(5000)),
5177 ChatMessage::user("recent user msg"),
5178 ChatMessage::tool("c".repeat(5000)), ];
5180 let saved = fast_trim_tool_results(&mut history, 2);
5182 assert!(saved > 0);
5183 assert!(history[1].content.len() <= 2100);
5185 assert!(history[2].content.len() <= 2100);
5186 assert_eq!(history[4].content.len(), 5000);
5188 }
5189
5190 #[test]
5191 fn fast_trim_skips_non_tool_messages() {
5192 let mut history = vec![
5193 ChatMessage::system("sys"),
5194 ChatMessage::user("a".repeat(5000)),
5195 ChatMessage::assistant("b".repeat(5000)),
5196 ];
5197 let saved = fast_trim_tool_results(&mut history, 0);
5198 assert_eq!(saved, 0);
5199 assert_eq!(history[1].content.len(), 5000);
5200 assert_eq!(history[2].content.len(), 5000);
5201 }
5202
5203 #[test]
5204 fn fast_trim_small_tool_results_unchanged() {
5205 let mut history = vec![
5206 ChatMessage::system("sys"),
5207 ChatMessage::tool("short result"),
5208 ];
5209 let saved = fast_trim_tool_results(&mut history, 0);
5210 assert_eq!(saved, 0);
5211 assert_eq!(history[1].content, "short result");
5212 }
5213
5214 #[test]
5217 fn emergency_trim_preserves_system() {
5218 let mut history = vec![
5219 ChatMessage::system("sys"),
5220 ChatMessage::user("msg1"),
5221 ChatMessage::assistant("resp1"),
5222 ChatMessage::user("msg2"),
5223 ChatMessage::assistant("resp2"),
5224 ChatMessage::user("msg3"),
5225 ];
5226 let dropped = emergency_history_trim(&mut history, 2);
5227 assert!(dropped > 0);
5228 assert_eq!(history[0].role, "system");
5230 assert_eq!(history[0].content, "sys");
5231 let len = history.len();
5233 assert_eq!(history[len - 1].content, "msg3");
5234 }
5235
5236 #[test]
5237 fn emergency_trim_preserves_recent() {
5238 let mut history = vec![
5239 ChatMessage::system("sys"),
5240 ChatMessage::user("old1"),
5241 ChatMessage::user("old2"),
5242 ChatMessage::user("recent1"),
5243 ChatMessage::user("recent2"),
5244 ];
5245 let dropped = emergency_history_trim(&mut history, 2);
5246 assert!(dropped > 0);
5247 let len = history.len();
5249 assert_eq!(history[len - 1].content, "recent2");
5250 assert_eq!(history[len - 2].content, "recent1");
5251 }
5252
5253 #[test]
5254 fn emergency_trim_nothing_to_drop() {
5255 let mut history = vec![
5256 ChatMessage::system("sys"),
5257 ChatMessage::user("only user msg"),
5258 ];
5259 let dropped = emergency_history_trim(&mut history, 1);
5262 assert_eq!(dropped, 0);
5263 }
5264
5265 #[test]
5268 fn estimate_tokens_empty_history() {
5269 let history: Vec<ChatMessage> = vec![];
5270 assert_eq!(estimate_history_tokens(&history), 0);
5271 }
5272
5273 #[test]
5274 fn estimate_tokens_single_message() {
5275 let msg = "a".repeat(40);
5277 let history = vec![ChatMessage::user(&msg)];
5278 let est = estimate_history_tokens(&history);
5279 assert_eq!(est, 14);
5280 }
5281
5282 #[test]
5283 fn estimate_tokens_multiple_messages() {
5284 let history = vec![
5285 ChatMessage::system("system prompt here"), ChatMessage::user("hello"), ChatMessage::assistant("world"), ];
5289 let est = estimate_history_tokens(&history);
5290 assert_eq!(est, 21);
5293 }
5294
5295 #[test]
5296 fn estimate_tokens_large_tool_result() {
5297 let big = "x".repeat(40_000);
5298 let history = vec![ChatMessage::tool(&big)];
5299 let est = estimate_history_tokens(&history);
5300 assert_eq!(est, 10_004);
5302 }
5303
5304 #[test]
5307 fn shared_budget_decrement_logic() {
5308 use std::sync::Arc;
5309 use std::sync::atomic::{AtomicUsize, Ordering};
5310
5311 let budget = Arc::new(AtomicUsize::new(3));
5312
5313 for i in 0..3 {
5315 let remaining = budget.load(Ordering::Relaxed);
5316 assert!(remaining > 0, "Budget should be >0 at iteration {i}");
5317 budget.fetch_sub(1, Ordering::Relaxed);
5318 }
5319
5320 assert_eq!(budget.load(Ordering::Relaxed), 0);
5322 }
5323
5324 #[test]
5325 fn shared_budget_none_has_no_effect() {
5326 let budget: Option<Arc<std::sync::atomic::AtomicUsize>> = None;
5328 assert!(budget.is_none());
5329 }
5330
5331 #[test]
5334 fn interactive_session_state_round_trips_history() {
5335 let dir = tempdir().unwrap();
5336 let path = dir.path().join("session.json");
5337 let history = vec![
5338 ChatMessage::system("system"),
5339 ChatMessage::user("hello"),
5340 ChatMessage::assistant("hi"),
5341 ];
5342
5343 save_interactive_session_history(&path, &history).unwrap();
5344 let restored = load_interactive_session_history(&path, "fallback").unwrap();
5345
5346 assert_eq!(restored.len(), 3);
5347 assert_eq!(restored[0].role, "system");
5348 assert_eq!(restored[1].content, "hello");
5349 assert_eq!(restored[2].content, "hi");
5350 }
5351
5352 #[test]
5353 fn interactive_session_state_adds_missing_system_prompt() {
5354 let dir = tempdir().unwrap();
5355 let path = dir.path().join("session.json");
5356 let payload = serde_json::to_string_pretty(&InteractiveSessionState {
5357 version: 1,
5358 history: vec![ChatMessage::user("orphan")],
5359 })
5360 .unwrap();
5361 std::fs::write(&path, payload).unwrap();
5362
5363 let restored = load_interactive_session_history(&path, "fallback system").unwrap();
5364
5365 assert_eq!(restored[0].role, "system");
5366 assert_eq!(restored[0].content, "fallback system");
5367 assert_eq!(restored[1].content, "orphan");
5368 }
5369
5370 use super::*;
5371 use async_trait::async_trait;
5372 use base64::{Engine as _, engine::general_purpose::STANDARD};
5373 use std::collections::VecDeque;
5374 use std::sync::atomic::{AtomicUsize, Ordering};
5375 use std::sync::{Arc, Mutex};
5376 use std::time::Duration;
5377
5378 #[test]
5379 fn scrub_credentials_redacts_bearer_token() {
5380 let input = "API_KEY=sk-1234567890abcdef; token: 1234567890; password=\"secret123456\"";
5381 let scrubbed = scrub_credentials(input);
5382 assert!(scrubbed.contains("API_KEY=sk-1*[REDACTED]"));
5383 assert!(scrubbed.contains("token: 1234*[REDACTED]"));
5384 assert!(scrubbed.contains("password=\"secr*[REDACTED]\""));
5385 assert!(!scrubbed.contains("abcdef"));
5386 assert!(!scrubbed.contains("secret123456"));
5387 }
5388
5389 #[test]
5390 fn scrub_credentials_redacts_json_api_key() {
5391 let input = r#"{"api_key": "sk-1234567890", "other": "public"}"#;
5392 let scrubbed = scrub_credentials(input);
5393 assert!(scrubbed.contains("\"api_key\": \"sk-1*[REDACTED]\""));
5394 assert!(scrubbed.contains("public"));
5395 }
5396
5397 #[tokio::test]
5398 async fn execute_one_tool_does_not_panic_on_utf8_boundary() {
5399 let call_arguments = (0..600)
5400 .map(|n| serde_json::json!({ "content": format!("{}:tail", "a".repeat(n)) }))
5401 .find(|args| {
5402 let raw = args.to_string();
5403 raw.len() > 300 && !raw.is_char_boundary(300)
5404 })
5405 .expect("should produce a sample whose byte index 300 is not a char boundary");
5406
5407 let observer = NoopObserver;
5408 let result =
5409 execute_one_tool("unknown_tool", call_arguments, &[], None, &observer, None).await;
5410 assert!(result.is_ok(), "execute_one_tool should not panic or error");
5411
5412 let outcome = result.unwrap();
5413 assert!(!outcome.success);
5414 assert!(outcome.output.contains("Unknown tool: unknown_tool"));
5415 }
5416
5417 #[tokio::test]
5418 async fn execute_one_tool_resolves_unique_activated_tool_suffix() {
5419 let observer = NoopObserver;
5420 let invocations = Arc::new(AtomicUsize::new(0));
5421 let activated = Arc::new(std::sync::Mutex::new(crate::tools::ActivatedToolSet::new()));
5422 let activated_tool: Arc<dyn Tool> = Arc::new(CountingTool::new(
5423 "docker-mcp__extract_text",
5424 Arc::clone(&invocations),
5425 ));
5426 activated
5427 .lock()
5428 .unwrap()
5429 .activate("docker-mcp__extract_text".into(), activated_tool);
5430
5431 let outcome = execute_one_tool(
5432 "extract_text",
5433 serde_json::json!({ "value": "ok" }),
5434 &[],
5435 Some(&activated),
5436 &observer,
5437 None,
5438 )
5439 .await
5440 .expect("suffix alias should execute the unique activated tool");
5441
5442 assert!(outcome.success);
5443 assert_eq!(outcome.output, "counted:ok");
5444 assert_eq!(invocations.load(Ordering::SeqCst), 1);
5445 }
5446
5447 use crate::observability::NoopObserver;
5448 use crate::providers::ChatResponse;
5449 use crate::providers::router::{Route, RouterProvider};
5450 use crate::providers::traits::{ProviderCapabilities, StreamChunk, StreamEvent, StreamOptions};
5451 use tempfile::TempDir;
5452
5453 struct NonVisionProvider {
5454 calls: Arc<AtomicUsize>,
5455 }
5456
5457 #[async_trait]
5458 impl Provider for NonVisionProvider {
5459 async fn chat_with_system(
5460 &self,
5461 _system_prompt: Option<&str>,
5462 _message: &str,
5463 _model: &str,
5464 _temperature: f64,
5465 ) -> anyhow::Result<String> {
5466 self.calls.fetch_add(1, Ordering::SeqCst);
5467 Ok("ok".to_string())
5468 }
5469 }
5470
5471 struct VisionProvider {
5472 calls: Arc<AtomicUsize>,
5473 }
5474
5475 #[async_trait]
5476 impl Provider for VisionProvider {
5477 fn capabilities(&self) -> ProviderCapabilities {
5478 ProviderCapabilities {
5479 native_tool_calling: false,
5480 vision: true,
5481 prompt_caching: false,
5482 }
5483 }
5484
5485 async fn chat_with_system(
5486 &self,
5487 _system_prompt: Option<&str>,
5488 _message: &str,
5489 _model: &str,
5490 _temperature: f64,
5491 ) -> anyhow::Result<String> {
5492 self.calls.fetch_add(1, Ordering::SeqCst);
5493 Ok("ok".to_string())
5494 }
5495
5496 async fn chat(
5497 &self,
5498 request: ChatRequest<'_>,
5499 _model: &str,
5500 _temperature: f64,
5501 ) -> anyhow::Result<ChatResponse> {
5502 self.calls.fetch_add(1, Ordering::SeqCst);
5503 let marker_count = crate::multimodal::count_image_markers(request.messages);
5504 if marker_count == 0 {
5505 anyhow::bail!("expected image markers in request messages");
5506 }
5507
5508 if request.tools.is_some() {
5509 anyhow::bail!("no tools should be attached for this test");
5510 }
5511
5512 Ok(ChatResponse {
5513 text: Some("vision-ok".to_string()),
5514 tool_calls: Vec::new(),
5515 usage: None,
5516 reasoning_content: None,
5517 })
5518 }
5519 }
5520
5521 struct ScriptedProvider {
5522 responses: Arc<Mutex<VecDeque<ChatResponse>>>,
5523 capabilities: ProviderCapabilities,
5524 }
5525
5526 impl ScriptedProvider {
5527 fn from_text_responses(responses: Vec<&str>) -> Self {
5528 let scripted = responses
5529 .into_iter()
5530 .map(|text| ChatResponse {
5531 text: Some(text.to_string()),
5532 tool_calls: Vec::new(),
5533 usage: None,
5534 reasoning_content: None,
5535 })
5536 .collect();
5537 Self {
5538 responses: Arc::new(Mutex::new(scripted)),
5539 capabilities: ProviderCapabilities::default(),
5540 }
5541 }
5542
5543 fn with_native_tool_support(mut self) -> Self {
5544 self.capabilities.native_tool_calling = true;
5545 self
5546 }
5547 }
5548
5549 #[async_trait]
5550 impl Provider for ScriptedProvider {
5551 fn capabilities(&self) -> ProviderCapabilities {
5552 self.capabilities.clone()
5553 }
5554
5555 async fn chat_with_system(
5556 &self,
5557 _system_prompt: Option<&str>,
5558 _message: &str,
5559 _model: &str,
5560 _temperature: f64,
5561 ) -> anyhow::Result<String> {
5562 anyhow::bail!("chat_with_system should not be used in scripted provider tests");
5563 }
5564
5565 async fn chat(
5566 &self,
5567 _request: ChatRequest<'_>,
5568 _model: &str,
5569 _temperature: f64,
5570 ) -> anyhow::Result<ChatResponse> {
5571 let mut responses = self
5572 .responses
5573 .lock()
5574 .expect("responses lock should be valid");
5575 responses
5576 .pop_front()
5577 .ok_or_else(|| anyhow::anyhow!("scripted provider exhausted responses"))
5578 }
5579 }
5580
5581 struct StreamingScriptedProvider {
5582 responses: Arc<Mutex<VecDeque<String>>>,
5583 stream_calls: Arc<AtomicUsize>,
5584 chat_calls: Arc<AtomicUsize>,
5585 }
5586
5587 impl StreamingScriptedProvider {
5588 fn from_text_responses(responses: Vec<&str>) -> Self {
5589 Self {
5590 responses: Arc::new(Mutex::new(
5591 responses.into_iter().map(ToString::to_string).collect(),
5592 )),
5593 stream_calls: Arc::new(AtomicUsize::new(0)),
5594 chat_calls: Arc::new(AtomicUsize::new(0)),
5595 }
5596 }
5597 }
5598
5599 #[async_trait]
5600 impl Provider for StreamingScriptedProvider {
5601 async fn chat_with_system(
5602 &self,
5603 _system_prompt: Option<&str>,
5604 _message: &str,
5605 _model: &str,
5606 _temperature: f64,
5607 ) -> anyhow::Result<String> {
5608 anyhow::bail!(
5609 "chat_with_system should not be used in streaming scripted provider tests"
5610 );
5611 }
5612
5613 async fn chat(
5614 &self,
5615 _request: ChatRequest<'_>,
5616 _model: &str,
5617 _temperature: f64,
5618 ) -> anyhow::Result<ChatResponse> {
5619 self.chat_calls.fetch_add(1, Ordering::SeqCst);
5620 anyhow::bail!("chat should not be called when streaming succeeds")
5621 }
5622
5623 fn supports_streaming(&self) -> bool {
5624 true
5625 }
5626
5627 fn stream_chat_with_history(
5628 &self,
5629 _messages: &[ChatMessage],
5630 _model: &str,
5631 _temperature: f64,
5632 options: StreamOptions,
5633 ) -> futures_util::stream::BoxStream<
5634 'static,
5635 crate::providers::traits::StreamResult<StreamChunk>,
5636 > {
5637 self.stream_calls.fetch_add(1, Ordering::SeqCst);
5638 if !options.enabled {
5639 return Box::pin(futures_util::stream::empty());
5640 }
5641
5642 let response = self
5643 .responses
5644 .lock()
5645 .expect("responses lock should be valid")
5646 .pop_front()
5647 .unwrap_or_default();
5648
5649 Box::pin(futures_util::stream::iter(vec![
5650 Ok(StreamChunk::delta(response)),
5651 Ok(StreamChunk::final_chunk()),
5652 ]))
5653 }
5654 }
5655
5656 enum NativeStreamTurn {
5657 ToolCall(ToolCall),
5658 Text(String),
5659 }
5660
5661 struct StreamingNativeToolEventProvider {
5662 turns: Arc<Mutex<VecDeque<NativeStreamTurn>>>,
5663 stream_calls: Arc<AtomicUsize>,
5664 stream_tool_requests: Arc<AtomicUsize>,
5665 chat_calls: Arc<AtomicUsize>,
5666 }
5667
5668 impl StreamingNativeToolEventProvider {
5669 fn with_turns(turns: Vec<NativeStreamTurn>) -> Self {
5670 Self {
5671 turns: Arc::new(Mutex::new(turns.into())),
5672 stream_calls: Arc::new(AtomicUsize::new(0)),
5673 stream_tool_requests: Arc::new(AtomicUsize::new(0)),
5674 chat_calls: Arc::new(AtomicUsize::new(0)),
5675 }
5676 }
5677 }
5678
5679 #[async_trait]
5680 impl Provider for StreamingNativeToolEventProvider {
5681 fn capabilities(&self) -> ProviderCapabilities {
5682 ProviderCapabilities {
5683 native_tool_calling: true,
5684 vision: false,
5685 prompt_caching: false,
5686 }
5687 }
5688
5689 async fn chat_with_system(
5690 &self,
5691 _system_prompt: Option<&str>,
5692 _message: &str,
5693 _model: &str,
5694 _temperature: f64,
5695 ) -> anyhow::Result<String> {
5696 anyhow::bail!(
5697 "chat_with_system should not be used in streaming native tool event provider tests"
5698 );
5699 }
5700
5701 async fn chat(
5702 &self,
5703 _request: ChatRequest<'_>,
5704 _model: &str,
5705 _temperature: f64,
5706 ) -> anyhow::Result<ChatResponse> {
5707 self.chat_calls.fetch_add(1, Ordering::SeqCst);
5708 anyhow::bail!("chat should not be called when native streaming events succeed")
5709 }
5710
5711 fn supports_streaming(&self) -> bool {
5712 true
5713 }
5714
5715 fn supports_streaming_tool_events(&self) -> bool {
5716 true
5717 }
5718
5719 fn stream_chat(
5720 &self,
5721 request: ChatRequest<'_>,
5722 _model: &str,
5723 _temperature: f64,
5724 options: StreamOptions,
5725 ) -> futures_util::stream::BoxStream<
5726 'static,
5727 crate::providers::traits::StreamResult<StreamEvent>,
5728 > {
5729 self.stream_calls.fetch_add(1, Ordering::SeqCst);
5730 if request.tools.is_some_and(|tools| !tools.is_empty()) {
5731 self.stream_tool_requests.fetch_add(1, Ordering::SeqCst);
5732 }
5733 if !options.enabled {
5734 return Box::pin(futures_util::stream::empty());
5735 }
5736
5737 let turn = self
5738 .turns
5739 .lock()
5740 .expect("turns lock should be valid")
5741 .pop_front()
5742 .expect("streaming turns should have scripted output");
5743 match turn {
5744 NativeStreamTurn::ToolCall(tool_call) => {
5745 Box::pin(futures_util::stream::iter(vec![
5746 Ok(StreamEvent::ToolCall(tool_call)),
5747 Ok(StreamEvent::Final),
5748 ]))
5749 }
5750 NativeStreamTurn::Text(text) => Box::pin(futures_util::stream::iter(vec![
5751 Ok(StreamEvent::TextDelta(StreamChunk::delta(text))),
5752 Ok(StreamEvent::Final),
5753 ])),
5754 }
5755 }
5756 }
5757
5758 struct RouteAwareStreamingProvider {
5759 response: String,
5760 stream_calls: Arc<AtomicUsize>,
5761 chat_calls: Arc<AtomicUsize>,
5762 last_model: Arc<Mutex<String>>,
5763 }
5764
5765 impl RouteAwareStreamingProvider {
5766 fn new(response: &str) -> Self {
5767 Self {
5768 response: response.to_string(),
5769 stream_calls: Arc::new(AtomicUsize::new(0)),
5770 chat_calls: Arc::new(AtomicUsize::new(0)),
5771 last_model: Arc::new(Mutex::new(String::new())),
5772 }
5773 }
5774 }
5775
5776 #[async_trait]
5777 impl Provider for RouteAwareStreamingProvider {
5778 async fn chat_with_system(
5779 &self,
5780 _system_prompt: Option<&str>,
5781 _message: &str,
5782 _model: &str,
5783 _temperature: f64,
5784 ) -> anyhow::Result<String> {
5785 anyhow::bail!("chat_with_system should not be used in route-aware stream tests");
5786 }
5787
5788 async fn chat(
5789 &self,
5790 _request: ChatRequest<'_>,
5791 _model: &str,
5792 _temperature: f64,
5793 ) -> anyhow::Result<ChatResponse> {
5794 self.chat_calls.fetch_add(1, Ordering::SeqCst);
5795 anyhow::bail!("chat should not be called when routed streaming succeeds")
5796 }
5797
5798 fn supports_streaming(&self) -> bool {
5799 true
5800 }
5801
5802 fn stream_chat_with_history(
5803 &self,
5804 _messages: &[ChatMessage],
5805 model: &str,
5806 _temperature: f64,
5807 options: StreamOptions,
5808 ) -> futures_util::stream::BoxStream<
5809 'static,
5810 crate::providers::traits::StreamResult<StreamChunk>,
5811 > {
5812 self.stream_calls.fetch_add(1, Ordering::SeqCst);
5813 *self
5814 .last_model
5815 .lock()
5816 .expect("last_model lock should be valid") = model.to_string();
5817 if !options.enabled {
5818 return Box::pin(futures_util::stream::empty());
5819 }
5820
5821 Box::pin(futures_util::stream::iter(vec![
5822 Ok(StreamChunk::delta(self.response.clone())),
5823 Ok(StreamChunk::final_chunk()),
5824 ]))
5825 }
5826 }
5827
5828 struct CountingTool {
5829 name: String,
5830 invocations: Arc<AtomicUsize>,
5831 }
5832
5833 impl CountingTool {
5834 fn new(name: &str, invocations: Arc<AtomicUsize>) -> Self {
5835 Self {
5836 name: name.to_string(),
5837 invocations,
5838 }
5839 }
5840 }
5841
5842 #[async_trait]
5843 impl Tool for CountingTool {
5844 fn name(&self) -> &str {
5845 &self.name
5846 }
5847
5848 fn description(&self) -> &str {
5849 "Counts executions for loop-stability tests"
5850 }
5851
5852 fn parameters_schema(&self) -> serde_json::Value {
5853 serde_json::json!({
5854 "type": "object",
5855 "properties": {
5856 "value": { "type": "string" }
5857 }
5858 })
5859 }
5860
5861 async fn execute(
5862 &self,
5863 args: serde_json::Value,
5864 ) -> anyhow::Result<crate::tools::ToolResult> {
5865 self.invocations.fetch_add(1, Ordering::SeqCst);
5866 let value = args
5867 .get("value")
5868 .and_then(serde_json::Value::as_str)
5869 .unwrap_or_default();
5870 Ok(crate::tools::ToolResult {
5871 success: true,
5872 output: format!("counted:{value}"),
5873 error: None,
5874 })
5875 }
5876 }
5877
5878 struct RecordingArgsTool {
5879 name: String,
5880 recorded_args: Arc<Mutex<Vec<serde_json::Value>>>,
5881 }
5882
5883 impl RecordingArgsTool {
5884 fn new(name: &str, recorded_args: Arc<Mutex<Vec<serde_json::Value>>>) -> Self {
5885 Self {
5886 name: name.to_string(),
5887 recorded_args,
5888 }
5889 }
5890 }
5891
5892 #[async_trait]
5893 impl Tool for RecordingArgsTool {
5894 fn name(&self) -> &str {
5895 &self.name
5896 }
5897
5898 fn description(&self) -> &str {
5899 "Records tool arguments for regression tests"
5900 }
5901
5902 fn parameters_schema(&self) -> serde_json::Value {
5903 serde_json::json!({
5904 "type": "object",
5905 "properties": {
5906 "prompt": { "type": "string" },
5907 "schedule": { "type": "object" },
5908 "delivery": { "type": "object" }
5909 }
5910 })
5911 }
5912
5913 async fn execute(
5914 &self,
5915 args: serde_json::Value,
5916 ) -> anyhow::Result<crate::tools::ToolResult> {
5917 self.recorded_args
5918 .lock()
5919 .expect("recorded args lock should be valid")
5920 .push(args.clone());
5921 Ok(crate::tools::ToolResult {
5922 success: true,
5923 output: args.to_string(),
5924 error: None,
5925 })
5926 }
5927 }
5928
5929 struct DelayTool {
5930 name: String,
5931 delay_ms: u64,
5932 active: Arc<AtomicUsize>,
5933 max_active: Arc<AtomicUsize>,
5934 }
5935
5936 impl DelayTool {
5937 fn new(
5938 name: &str,
5939 delay_ms: u64,
5940 active: Arc<AtomicUsize>,
5941 max_active: Arc<AtomicUsize>,
5942 ) -> Self {
5943 Self {
5944 name: name.to_string(),
5945 delay_ms,
5946 active,
5947 max_active,
5948 }
5949 }
5950 }
5951
5952 #[async_trait]
5953 impl Tool for DelayTool {
5954 fn name(&self) -> &str {
5955 &self.name
5956 }
5957
5958 fn description(&self) -> &str {
5959 "Delay tool for testing parallel tool execution"
5960 }
5961
5962 fn parameters_schema(&self) -> serde_json::Value {
5963 serde_json::json!({
5964 "type": "object",
5965 "properties": {
5966 "value": { "type": "string" }
5967 },
5968 "required": ["value"]
5969 })
5970 }
5971
5972 async fn execute(
5973 &self,
5974 args: serde_json::Value,
5975 ) -> anyhow::Result<crate::tools::ToolResult> {
5976 let now_active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
5977 self.max_active.fetch_max(now_active, Ordering::SeqCst);
5978
5979 tokio::time::sleep(Duration::from_millis(self.delay_ms)).await;
5980
5981 self.active.fetch_sub(1, Ordering::SeqCst);
5982
5983 let value = args
5984 .get("value")
5985 .and_then(serde_json::Value::as_str)
5986 .unwrap_or_default()
5987 .to_string();
5988
5989 Ok(crate::tools::ToolResult {
5990 success: true,
5991 output: format!("ok:{value}"),
5992 error: None,
5993 })
5994 }
5995 }
5996
5997 struct FailingTool {
5999 tool_name: String,
6000 error_reason: String,
6001 }
6002
6003 impl FailingTool {
6004 fn new(name: &str, error_reason: &str) -> Self {
6005 Self {
6006 tool_name: name.to_string(),
6007 error_reason: error_reason.to_string(),
6008 }
6009 }
6010 }
6011
6012 #[async_trait]
6013 impl Tool for FailingTool {
6014 fn name(&self) -> &str {
6015 &self.tool_name
6016 }
6017
6018 fn description(&self) -> &str {
6019 "A tool that always fails for testing failure surfacing"
6020 }
6021
6022 fn parameters_schema(&self) -> serde_json::Value {
6023 serde_json::json!({
6024 "type": "object",
6025 "properties": {
6026 "command": { "type": "string" }
6027 }
6028 })
6029 }
6030
6031 async fn execute(
6032 &self,
6033 _args: serde_json::Value,
6034 ) -> anyhow::Result<crate::tools::ToolResult> {
6035 Ok(crate::tools::ToolResult {
6036 success: false,
6037 output: String::new(),
6038 error: Some(self.error_reason.clone()),
6039 })
6040 }
6041 }
6042
6043 #[tokio::test]
6044 async fn run_tool_call_loop_returns_structured_error_for_non_vision_provider() {
6045 let calls = Arc::new(AtomicUsize::new(0));
6046 let provider = NonVisionProvider {
6047 calls: Arc::clone(&calls),
6048 };
6049
6050 let mut history = vec![ChatMessage::user(
6051 "please inspect [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
6052 )];
6053 let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6054 let observer = NoopObserver;
6055
6056 let err = run_tool_call_loop(
6057 &provider,
6058 &mut history,
6059 &tools_registry,
6060 &observer,
6061 "mock-provider",
6062 "mock-model",
6063 0.0,
6064 true,
6065 None,
6066 "cli",
6067 None,
6068 &crate::config::MultimodalConfig::default(),
6069 3,
6070 None,
6071 None,
6072 None,
6073 &[],
6074 &[],
6075 None,
6076 None,
6077 &crate::config::PacingConfig::default(),
6078 0,
6079 0,
6080 None,
6081 )
6082 .await
6083 .expect_err("provider without vision support should fail");
6084
6085 assert!(err.to_string().contains("provider_capability_error"));
6086 assert!(err.to_string().contains("capability=vision"));
6087 assert_eq!(calls.load(Ordering::SeqCst), 0);
6088 }
6089
6090 #[tokio::test]
6091 async fn run_tool_call_loop_rejects_oversized_image_payload() {
6092 let calls = Arc::new(AtomicUsize::new(0));
6093 let provider = VisionProvider {
6094 calls: Arc::clone(&calls),
6095 };
6096
6097 let oversized_payload = STANDARD.encode(vec![0_u8; (1024 * 1024) + 1]);
6098 let mut history = vec![ChatMessage::user(format!(
6099 "[IMAGE:data:image/png;base64,{oversized_payload}]"
6100 ))];
6101
6102 let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6103 let observer = NoopObserver;
6104 let multimodal = crate::config::MultimodalConfig {
6105 max_images: 4,
6106 max_image_size_mb: 1,
6107 allow_remote_fetch: false,
6108 ..Default::default()
6109 };
6110
6111 let err = run_tool_call_loop(
6112 &provider,
6113 &mut history,
6114 &tools_registry,
6115 &observer,
6116 "mock-provider",
6117 "mock-model",
6118 0.0,
6119 true,
6120 None,
6121 "cli",
6122 None,
6123 &multimodal,
6124 3,
6125 None,
6126 None,
6127 None,
6128 &[],
6129 &[],
6130 None,
6131 None,
6132 &crate::config::PacingConfig::default(),
6133 0,
6134 0,
6135 None,
6136 )
6137 .await
6138 .expect_err("oversized payload must fail");
6139
6140 assert!(
6141 err.to_string()
6142 .contains("multimodal image size limit exceeded")
6143 );
6144 assert_eq!(calls.load(Ordering::SeqCst), 0);
6145 }
6146
6147 #[tokio::test]
6148 async fn run_tool_call_loop_accepts_valid_multimodal_request_flow() {
6149 let calls = Arc::new(AtomicUsize::new(0));
6150 let provider = VisionProvider {
6151 calls: Arc::clone(&calls),
6152 };
6153
6154 let mut history = vec![ChatMessage::user(
6155 "Analyze this [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
6156 )];
6157 let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6158 let observer = NoopObserver;
6159
6160 let result = run_tool_call_loop(
6161 &provider,
6162 &mut history,
6163 &tools_registry,
6164 &observer,
6165 "mock-provider",
6166 "mock-model",
6167 0.0,
6168 true,
6169 None,
6170 "cli",
6171 None,
6172 &crate::config::MultimodalConfig::default(),
6173 3,
6174 None,
6175 None,
6176 None,
6177 &[],
6178 &[],
6179 None,
6180 None,
6181 &crate::config::PacingConfig::default(),
6182 0,
6183 0,
6184 None,
6185 )
6186 .await
6187 .expect("valid multimodal payload should pass");
6188
6189 assert_eq!(result, "vision-ok");
6190 assert_eq!(calls.load(Ordering::SeqCst), 1);
6191 }
6192
6193 #[tokio::test]
6196 async fn run_tool_call_loop_no_vision_provider_config_preserves_error() {
6197 let calls = Arc::new(AtomicUsize::new(0));
6198 let provider = NonVisionProvider {
6199 calls: Arc::clone(&calls),
6200 };
6201
6202 let mut history = vec![ChatMessage::user(
6203 "check [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
6204 )];
6205 let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6206 let observer = NoopObserver;
6207
6208 let err = run_tool_call_loop(
6209 &provider,
6210 &mut history,
6211 &tools_registry,
6212 &observer,
6213 "mock-provider",
6214 "mock-model",
6215 0.0,
6216 true,
6217 None,
6218 "cli",
6219 None,
6220 &crate::config::MultimodalConfig::default(),
6221 3,
6222 None,
6223 None,
6224 None,
6225 &[],
6226 &[],
6227 None,
6228 None,
6229 &crate::config::PacingConfig::default(),
6230 0,
6231 0,
6232 None,
6233 )
6234 .await
6235 .expect_err("should fail without vision_provider config");
6236
6237 assert!(err.to_string().contains("capability=vision"));
6238 assert_eq!(calls.load(Ordering::SeqCst), 0);
6239 }
6240
6241 #[tokio::test]
6245 async fn run_tool_call_loop_vision_provider_creation_failure() {
6246 let calls = Arc::new(AtomicUsize::new(0));
6247 let provider = NonVisionProvider {
6248 calls: Arc::clone(&calls),
6249 };
6250
6251 let mut history = vec![ChatMessage::user(
6252 "inspect [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
6253 )];
6254 let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6255 let observer = NoopObserver;
6256
6257 let multimodal = crate::config::MultimodalConfig {
6258 vision_provider: Some("nonexistent-provider-xyz".to_string()),
6259 vision_model: Some("some-model".to_string()),
6260 ..Default::default()
6261 };
6262
6263 let err = run_tool_call_loop(
6264 &provider,
6265 &mut history,
6266 &tools_registry,
6267 &observer,
6268 "mock-provider",
6269 "mock-model",
6270 0.0,
6271 true,
6272 None,
6273 "cli",
6274 None,
6275 &multimodal,
6276 3,
6277 None,
6278 None,
6279 None,
6280 &[],
6281 &[],
6282 None,
6283 None,
6284 &crate::config::PacingConfig::default(),
6285 0,
6286 0,
6287 None,
6288 )
6289 .await
6290 .expect_err("should fail when vision provider cannot be created");
6291
6292 assert!(
6293 err.to_string().contains("failed to create vision provider"),
6294 "expected creation failure error, got: {}",
6295 err
6296 );
6297 assert_eq!(calls.load(Ordering::SeqCst), 0);
6298 }
6299
6300 #[tokio::test]
6303 async fn run_tool_call_loop_no_images_uses_default_provider() {
6304 let provider = ScriptedProvider::from_text_responses(vec!["hello world"]);
6305
6306 let mut history = vec![ChatMessage::user("just text, no images".to_string())];
6307 let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6308 let observer = NoopObserver;
6309
6310 let multimodal = crate::config::MultimodalConfig {
6311 vision_provider: Some("nonexistent-provider-xyz".to_string()),
6312 vision_model: Some("some-model".to_string()),
6313 ..Default::default()
6314 };
6315
6316 let result = run_tool_call_loop(
6319 &provider,
6320 &mut history,
6321 &tools_registry,
6322 &observer,
6323 "scripted",
6324 "scripted-model",
6325 0.0,
6326 true,
6327 None,
6328 "cli",
6329 None,
6330 &multimodal,
6331 3,
6332 None,
6333 None,
6334 None,
6335 &[],
6336 &[],
6337 None,
6338 None,
6339 &crate::config::PacingConfig::default(),
6340 0,
6341 0,
6342 None,
6343 )
6344 .await
6345 .expect("text-only messages should succeed with default provider");
6346
6347 assert_eq!(result, "hello world");
6348 }
6349
6350 #[tokio::test]
6353 async fn run_tool_call_loop_vision_provider_without_model_falls_back() {
6354 let calls = Arc::new(AtomicUsize::new(0));
6355 let provider = NonVisionProvider {
6356 calls: Arc::clone(&calls),
6357 };
6358
6359 let mut history = vec![ChatMessage::user(
6360 "look [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
6361 )];
6362 let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6363 let observer = NoopObserver;
6364
6365 let multimodal = crate::config::MultimodalConfig {
6369 vision_provider: Some("nonexistent-provider-xyz".to_string()),
6370 vision_model: None,
6371 ..Default::default()
6372 };
6373
6374 let err = run_tool_call_loop(
6375 &provider,
6376 &mut history,
6377 &tools_registry,
6378 &observer,
6379 "mock-provider",
6380 "mock-model",
6381 0.0,
6382 true,
6383 None,
6384 "cli",
6385 None,
6386 &multimodal,
6387 3,
6388 None,
6389 None,
6390 None,
6391 &[],
6392 &[],
6393 None,
6394 None,
6395 &crate::config::PacingConfig::default(),
6396 0,
6397 0,
6398 None,
6399 )
6400 .await
6401 .expect_err("should fail due to nonexistent vision provider");
6402
6403 assert!(
6405 err.to_string().contains("failed to create vision provider"),
6406 "expected creation failure, got: {}",
6407 err
6408 );
6409 }
6410
6411 #[tokio::test]
6414 async fn run_tool_call_loop_empty_image_markers_use_default_provider() {
6415 let provider = ScriptedProvider::from_text_responses(vec!["handled"]);
6416
6417 let mut history = vec![ChatMessage::user(
6418 "empty marker [IMAGE:] should be ignored".to_string(),
6419 )];
6420 let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6421 let observer = NoopObserver;
6422
6423 let multimodal = crate::config::MultimodalConfig {
6424 vision_provider: Some("nonexistent-provider-xyz".to_string()),
6425 ..Default::default()
6426 };
6427
6428 let result = run_tool_call_loop(
6429 &provider,
6430 &mut history,
6431 &tools_registry,
6432 &observer,
6433 "scripted",
6434 "scripted-model",
6435 0.0,
6436 true,
6437 None,
6438 "cli",
6439 None,
6440 &multimodal,
6441 3,
6442 None,
6443 None,
6444 None,
6445 &[],
6446 &[],
6447 None,
6448 None,
6449 &crate::config::PacingConfig::default(),
6450 0,
6451 0,
6452 None,
6453 )
6454 .await
6455 .expect("empty image markers should not trigger vision routing");
6456
6457 assert_eq!(result, "handled");
6458 }
6459
6460 #[tokio::test]
6463 async fn run_tool_call_loop_multiple_images_trigger_vision_routing() {
6464 let calls = Arc::new(AtomicUsize::new(0));
6465 let provider = NonVisionProvider {
6466 calls: Arc::clone(&calls),
6467 };
6468
6469 let mut history = vec![ChatMessage::user(
6470 "two images [IMAGE:data:image/png;base64,aQ==] and [IMAGE:data:image/png;base64,bQ==]"
6471 .to_string(),
6472 )];
6473 let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6474 let observer = NoopObserver;
6475
6476 let multimodal = crate::config::MultimodalConfig {
6477 vision_provider: Some("nonexistent-provider-xyz".to_string()),
6478 vision_model: Some("llava:7b".to_string()),
6479 ..Default::default()
6480 };
6481
6482 let err = run_tool_call_loop(
6483 &provider,
6484 &mut history,
6485 &tools_registry,
6486 &observer,
6487 "mock-provider",
6488 "mock-model",
6489 0.0,
6490 true,
6491 None,
6492 "cli",
6493 None,
6494 &multimodal,
6495 3,
6496 None,
6497 None,
6498 None,
6499 &[],
6500 &[],
6501 None,
6502 None,
6503 &crate::config::PacingConfig::default(),
6504 0,
6505 0,
6506 None,
6507 )
6508 .await
6509 .expect_err("should attempt vision provider creation for multiple images");
6510
6511 assert!(
6512 err.to_string().contains("failed to create vision provider"),
6513 "expected creation failure for multiple images, got: {}",
6514 err
6515 );
6516 }
6517
6518 #[test]
6519 fn should_execute_tools_in_parallel_returns_false_for_single_call() {
6520 let calls = vec![ParsedToolCall {
6521 name: "file_read".to_string(),
6522 arguments: serde_json::json!({"path": "a.txt"}),
6523 tool_call_id: None,
6524 }];
6525
6526 assert!(!should_execute_tools_in_parallel(&calls, None));
6527 }
6528
6529 #[test]
6530 fn should_execute_tools_in_parallel_returns_false_when_approval_is_required() {
6531 let calls = vec![
6532 ParsedToolCall {
6533 name: "shell".to_string(),
6534 arguments: serde_json::json!({"command": "pwd"}),
6535 tool_call_id: None,
6536 },
6537 ParsedToolCall {
6538 name: "http_request".to_string(),
6539 arguments: serde_json::json!({"url": "https://example.com"}),
6540 tool_call_id: None,
6541 },
6542 ];
6543 let approval_cfg = crate::config::AutonomyConfig::default();
6544 let approval_mgr = ApprovalManager::from_config(&approval_cfg);
6545
6546 assert!(!should_execute_tools_in_parallel(
6547 &calls,
6548 Some(&approval_mgr)
6549 ));
6550 }
6551
6552 #[test]
6553 fn should_execute_tools_in_parallel_returns_true_when_cli_has_no_interactive_approvals() {
6554 let calls = vec![
6555 ParsedToolCall {
6556 name: "shell".to_string(),
6557 arguments: serde_json::json!({"command": "pwd"}),
6558 tool_call_id: None,
6559 },
6560 ParsedToolCall {
6561 name: "http_request".to_string(),
6562 arguments: serde_json::json!({"url": "https://example.com"}),
6563 tool_call_id: None,
6564 },
6565 ];
6566 let approval_cfg = crate::config::AutonomyConfig {
6567 level: crate::security::AutonomyLevel::Full,
6568 ..crate::config::AutonomyConfig::default()
6569 };
6570 let approval_mgr = ApprovalManager::from_config(&approval_cfg);
6571
6572 assert!(should_execute_tools_in_parallel(
6573 &calls,
6574 Some(&approval_mgr)
6575 ));
6576 }
6577
6578 #[tokio::test]
6579 async fn run_tool_call_loop_executes_multiple_tools_with_ordered_results() {
6580 let provider = ScriptedProvider::from_text_responses(vec![
6581 r#"<tool_call>
6582{"name":"delay_a","arguments":{"value":"A"}}
6583</tool_call>
6584<tool_call>
6585{"name":"delay_b","arguments":{"value":"B"}}
6586</tool_call>"#,
6587 "done",
6588 ]);
6589
6590 let active = Arc::new(AtomicUsize::new(0));
6591 let max_active = Arc::new(AtomicUsize::new(0));
6592 let tools_registry: Vec<Box<dyn Tool>> = vec![
6593 Box::new(DelayTool::new(
6594 "delay_a",
6595 200,
6596 Arc::clone(&active),
6597 Arc::clone(&max_active),
6598 )),
6599 Box::new(DelayTool::new(
6600 "delay_b",
6601 200,
6602 Arc::clone(&active),
6603 Arc::clone(&max_active),
6604 )),
6605 ];
6606
6607 let approval_cfg = crate::config::AutonomyConfig {
6608 level: crate::security::AutonomyLevel::Full,
6609 ..crate::config::AutonomyConfig::default()
6610 };
6611 let approval_mgr = ApprovalManager::from_config(&approval_cfg);
6612
6613 let mut history = vec![
6614 ChatMessage::system("test-system"),
6615 ChatMessage::user("run tool calls"),
6616 ];
6617 let observer = NoopObserver;
6618
6619 let result = run_tool_call_loop(
6620 &provider,
6621 &mut history,
6622 &tools_registry,
6623 &observer,
6624 "mock-provider",
6625 "mock-model",
6626 0.0,
6627 true,
6628 Some(&approval_mgr),
6629 "telegram",
6630 None,
6631 &crate::config::MultimodalConfig::default(),
6632 4,
6633 None,
6634 None,
6635 None,
6636 &[],
6637 &[],
6638 None,
6639 None,
6640 &crate::config::PacingConfig::default(),
6641 0,
6642 0,
6643 None,
6644 )
6645 .await
6646 .expect("parallel execution should complete");
6647
6648 assert_eq!(result, "done");
6649 assert!(
6650 max_active.load(Ordering::SeqCst) >= 1,
6651 "tools should execute successfully"
6652 );
6653
6654 let tool_results_message = history
6655 .iter()
6656 .find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]"))
6657 .expect("tool results message should be present");
6658 let idx_a = tool_results_message
6659 .content
6660 .find("name=\"delay_a\"")
6661 .expect("delay_a result should be present");
6662 let idx_b = tool_results_message
6663 .content
6664 .find("name=\"delay_b\"")
6665 .expect("delay_b result should be present");
6666 assert!(
6667 idx_a < idx_b,
6668 "tool results should preserve input order for tool call mapping"
6669 );
6670 }
6671
6672 #[tokio::test]
6673 async fn run_tool_call_loop_injects_channel_delivery_defaults_for_cron_add() {
6674 let provider = ScriptedProvider::from_text_responses(vec![
6675 r#"<tool_call>
6676{"name":"cron_add","arguments":{"job_type":"agent","prompt":"remind me later","schedule":{"kind":"every","every_ms":60000}}}
6677</tool_call>"#,
6678 "done",
6679 ]);
6680
6681 let recorded_args = Arc::new(Mutex::new(Vec::new()));
6682 let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(RecordingArgsTool::new(
6683 "cron_add",
6684 Arc::clone(&recorded_args),
6685 ))];
6686
6687 let mut history = vec![
6688 ChatMessage::system("test-system"),
6689 ChatMessage::user("schedule a reminder"),
6690 ];
6691 let observer = NoopObserver;
6692
6693 let result = run_tool_call_loop(
6694 &provider,
6695 &mut history,
6696 &tools_registry,
6697 &observer,
6698 "mock-provider",
6699 "mock-model",
6700 0.0,
6701 true,
6702 None,
6703 "telegram",
6704 Some("chat-42"),
6705 &crate::config::MultimodalConfig::default(),
6706 4,
6707 None,
6708 None,
6709 None,
6710 &[],
6711 &[],
6712 None,
6713 None,
6714 &crate::config::PacingConfig::default(),
6715 0,
6716 0,
6717 None,
6718 )
6719 .await
6720 .expect("cron_add delivery defaults should be injected");
6721
6722 assert_eq!(result, "done");
6723
6724 let recorded = recorded_args
6725 .lock()
6726 .expect("recorded args lock should be valid");
6727 let delivery = recorded[0]["delivery"].clone();
6728 assert_eq!(
6729 delivery,
6730 serde_json::json!({
6731 "mode": "announce",
6732 "channel": "telegram",
6733 "to": "chat-42",
6734 })
6735 );
6736 }
6737
6738 #[tokio::test]
6739 async fn run_tool_call_loop_preserves_explicit_cron_delivery_none() {
6740 let provider = ScriptedProvider::from_text_responses(vec![
6741 r#"<tool_call>
6742{"name":"cron_add","arguments":{"job_type":"agent","prompt":"run silently","schedule":{"kind":"every","every_ms":60000},"delivery":{"mode":"none"}}}
6743</tool_call>"#,
6744 "done",
6745 ]);
6746
6747 let recorded_args = Arc::new(Mutex::new(Vec::new()));
6748 let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(RecordingArgsTool::new(
6749 "cron_add",
6750 Arc::clone(&recorded_args),
6751 ))];
6752
6753 let mut history = vec![
6754 ChatMessage::system("test-system"),
6755 ChatMessage::user("schedule a quiet cron job"),
6756 ];
6757 let observer = NoopObserver;
6758
6759 let result = run_tool_call_loop(
6760 &provider,
6761 &mut history,
6762 &tools_registry,
6763 &observer,
6764 "mock-provider",
6765 "mock-model",
6766 0.0,
6767 true,
6768 None,
6769 "telegram",
6770 Some("chat-42"),
6771 &crate::config::MultimodalConfig::default(),
6772 4,
6773 None,
6774 None,
6775 None,
6776 &[],
6777 &[],
6778 None,
6779 None,
6780 &crate::config::PacingConfig::default(),
6781 0,
6782 0,
6783 None,
6784 )
6785 .await
6786 .expect("explicit delivery mode should be preserved");
6787
6788 assert_eq!(result, "done");
6789
6790 let recorded = recorded_args
6791 .lock()
6792 .expect("recorded args lock should be valid");
6793 assert_eq!(recorded[0]["delivery"], serde_json::json!({"mode": "none"}));
6794 }
6795
6796 #[tokio::test]
6797 async fn run_tool_call_loop_deduplicates_repeated_tool_calls() {
6798 let provider = ScriptedProvider::from_text_responses(vec![
6799 r#"<tool_call>
6800{"name":"count_tool","arguments":{"value":"A"}}
6801</tool_call>
6802<tool_call>
6803{"name":"count_tool","arguments":{"value":"A"}}
6804</tool_call>"#,
6805 "done",
6806 ]);
6807
6808 let invocations = Arc::new(AtomicUsize::new(0));
6809 let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
6810 "count_tool",
6811 Arc::clone(&invocations),
6812 ))];
6813
6814 let mut history = vec![
6815 ChatMessage::system("test-system"),
6816 ChatMessage::user("run tool calls"),
6817 ];
6818 let observer = NoopObserver;
6819
6820 let result = run_tool_call_loop(
6821 &provider,
6822 &mut history,
6823 &tools_registry,
6824 &observer,
6825 "mock-provider",
6826 "mock-model",
6827 0.0,
6828 true,
6829 None,
6830 "cli",
6831 None,
6832 &crate::config::MultimodalConfig::default(),
6833 4,
6834 None,
6835 None,
6836 None,
6837 &[],
6838 &[],
6839 None,
6840 None,
6841 &crate::config::PacingConfig::default(),
6842 0,
6843 0,
6844 None,
6845 )
6846 .await
6847 .expect("loop should finish after deduplicating repeated calls");
6848
6849 assert_eq!(result, "done");
6850 assert_eq!(
6851 invocations.load(Ordering::SeqCst),
6852 1,
6853 "duplicate tool call with same args should not execute twice"
6854 );
6855
6856 let tool_results = history
6857 .iter()
6858 .find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]"))
6859 .expect("prompt-mode tool result payload should be present");
6860 assert!(tool_results.content.contains("counted:A"));
6861 assert!(tool_results.content.contains("Skipped duplicate tool call"));
6862 }
6863
6864 #[tokio::test]
6865 async fn run_tool_call_loop_allows_low_risk_shell_in_non_interactive_mode() {
6866 let provider = ScriptedProvider::from_text_responses(vec![
6867 r#"<tool_call>
6868{"name":"shell","arguments":{"command":"echo hello"}}
6869</tool_call>"#,
6870 "done",
6871 ]);
6872
6873 let tmp = TempDir::new().expect("temp dir");
6874 let security = Arc::new(crate::security::SecurityPolicy {
6875 autonomy: crate::security::AutonomyLevel::Supervised,
6876 workspace_dir: tmp.path().to_path_buf(),
6877 ..crate::security::SecurityPolicy::default()
6878 });
6879 let runtime: Arc<dyn crate::runtime::RuntimeAdapter> =
6880 Arc::new(crate::runtime::NativeRuntime::new());
6881 let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(
6882 crate::tools::shell::ShellTool::new(security, runtime),
6883 )];
6884
6885 let mut history = vec![
6886 ChatMessage::system("test-system"),
6887 ChatMessage::user("run shell"),
6888 ];
6889 let observer = NoopObserver;
6890 let approval_mgr =
6891 ApprovalManager::for_non_interactive(&crate::config::AutonomyConfig::default());
6892
6893 let result = run_tool_call_loop(
6894 &provider,
6895 &mut history,
6896 &tools_registry,
6897 &observer,
6898 "mock-provider",
6899 "mock-model",
6900 0.0,
6901 true,
6902 Some(&approval_mgr),
6903 "telegram",
6904 None,
6905 &crate::config::MultimodalConfig::default(),
6906 4,
6907 None,
6908 None,
6909 None,
6910 &[],
6911 &[],
6912 None,
6913 None,
6914 &crate::config::PacingConfig::default(),
6915 0,
6916 0,
6917 None,
6918 )
6919 .await
6920 .expect("non-interactive shell should succeed for low-risk command");
6921
6922 assert_eq!(result, "done");
6923
6924 let tool_results = history
6925 .iter()
6926 .find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]"))
6927 .expect("tool results message should be present");
6928 assert!(tool_results.content.contains("hello"));
6929 assert!(!tool_results.content.contains("Denied by user."));
6930 }
6931
6932 #[tokio::test]
6933 async fn run_tool_call_loop_dedup_exempt_allows_repeated_calls() {
6934 let provider = ScriptedProvider::from_text_responses(vec![
6935 r#"<tool_call>
6936{"name":"count_tool","arguments":{"value":"A"}}
6937</tool_call>
6938<tool_call>
6939{"name":"count_tool","arguments":{"value":"A"}}
6940</tool_call>"#,
6941 "done",
6942 ]);
6943
6944 let invocations = Arc::new(AtomicUsize::new(0));
6945 let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
6946 "count_tool",
6947 Arc::clone(&invocations),
6948 ))];
6949
6950 let mut history = vec![
6951 ChatMessage::system("test-system"),
6952 ChatMessage::user("run tool calls"),
6953 ];
6954 let observer = NoopObserver;
6955 let exempt = vec!["count_tool".to_string()];
6956
6957 let result = run_tool_call_loop(
6958 &provider,
6959 &mut history,
6960 &tools_registry,
6961 &observer,
6962 "mock-provider",
6963 "mock-model",
6964 0.0,
6965 true,
6966 None,
6967 "cli",
6968 None,
6969 &crate::config::MultimodalConfig::default(),
6970 4,
6971 None,
6972 None,
6973 None,
6974 &[],
6975 &exempt,
6976 None,
6977 None,
6978 &crate::config::PacingConfig::default(),
6979 0,
6980 0,
6981 None,
6982 )
6983 .await
6984 .expect("loop should finish with exempt tool executing twice");
6985
6986 assert_eq!(result, "done");
6987 assert_eq!(
6988 invocations.load(Ordering::SeqCst),
6989 2,
6990 "exempt tool should execute both duplicate calls"
6991 );
6992
6993 let tool_results = history
6994 .iter()
6995 .find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]"))
6996 .expect("prompt-mode tool result payload should be present");
6997 assert!(
6998 !tool_results.content.contains("Skipped duplicate tool call"),
6999 "exempt tool calls should not be suppressed"
7000 );
7001 }
7002
7003 #[tokio::test]
7004 async fn run_tool_call_loop_dedup_exempt_only_affects_listed_tools() {
7005 let provider = ScriptedProvider::from_text_responses(vec![
7006 r#"<tool_call>
7007{"name":"count_tool","arguments":{"value":"A"}}
7008</tool_call>
7009<tool_call>
7010{"name":"count_tool","arguments":{"value":"A"}}
7011</tool_call>
7012<tool_call>
7013{"name":"other_tool","arguments":{"value":"B"}}
7014</tool_call>
7015<tool_call>
7016{"name":"other_tool","arguments":{"value":"B"}}
7017</tool_call>"#,
7018 "done",
7019 ]);
7020
7021 let count_invocations = Arc::new(AtomicUsize::new(0));
7022 let other_invocations = Arc::new(AtomicUsize::new(0));
7023 let tools_registry: Vec<Box<dyn Tool>> = vec![
7024 Box::new(CountingTool::new(
7025 "count_tool",
7026 Arc::clone(&count_invocations),
7027 )),
7028 Box::new(CountingTool::new(
7029 "other_tool",
7030 Arc::clone(&other_invocations),
7031 )),
7032 ];
7033
7034 let mut history = vec![
7035 ChatMessage::system("test-system"),
7036 ChatMessage::user("run tool calls"),
7037 ];
7038 let observer = NoopObserver;
7039 let exempt = vec!["count_tool".to_string()];
7040
7041 let _result = run_tool_call_loop(
7042 &provider,
7043 &mut history,
7044 &tools_registry,
7045 &observer,
7046 "mock-provider",
7047 "mock-model",
7048 0.0,
7049 true,
7050 None,
7051 "cli",
7052 None,
7053 &crate::config::MultimodalConfig::default(),
7054 4,
7055 None,
7056 None,
7057 None,
7058 &[],
7059 &exempt,
7060 None,
7061 None,
7062 &crate::config::PacingConfig::default(),
7063 0,
7064 0,
7065 None,
7066 )
7067 .await
7068 .expect("loop should complete");
7069
7070 assert_eq!(
7071 count_invocations.load(Ordering::SeqCst),
7072 2,
7073 "exempt tool should execute both calls"
7074 );
7075 assert_eq!(
7076 other_invocations.load(Ordering::SeqCst),
7077 1,
7078 "non-exempt tool should still be deduped"
7079 );
7080 }
7081
7082 #[tokio::test]
7083 async fn run_tool_call_loop_native_mode_preserves_fallback_tool_call_ids() {
7084 let provider = ScriptedProvider::from_text_responses(vec![
7085 r#"{"content":"Need to call tool","tool_calls":[{"id":"call_abc","name":"count_tool","arguments":"{\"value\":\"X\"}"}]}"#,
7086 "done",
7087 ])
7088 .with_native_tool_support();
7089
7090 let invocations = Arc::new(AtomicUsize::new(0));
7091 let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
7092 "count_tool",
7093 Arc::clone(&invocations),
7094 ))];
7095
7096 let mut history = vec![
7097 ChatMessage::system("test-system"),
7098 ChatMessage::user("run tool calls"),
7099 ];
7100 let observer = NoopObserver;
7101
7102 let result = run_tool_call_loop(
7103 &provider,
7104 &mut history,
7105 &tools_registry,
7106 &observer,
7107 "mock-provider",
7108 "mock-model",
7109 0.0,
7110 true,
7111 None,
7112 "cli",
7113 None,
7114 &crate::config::MultimodalConfig::default(),
7115 4,
7116 None,
7117 None,
7118 None,
7119 &[],
7120 &[],
7121 None,
7122 None,
7123 &crate::config::PacingConfig::default(),
7124 0,
7125 0,
7126 None,
7127 )
7128 .await
7129 .expect("native fallback id flow should complete");
7130
7131 assert_eq!(result, "done");
7132 assert_eq!(invocations.load(Ordering::SeqCst), 1);
7133 assert!(
7134 history.iter().any(|msg| {
7135 msg.role == "tool" && msg.content.contains("\"tool_call_id\":\"call_abc\"")
7136 }),
7137 "tool result should preserve parsed fallback tool_call_id in native mode"
7138 );
7139 assert!(
7140 history
7141 .iter()
7142 .all(|msg| !(msg.role == "user" && msg.content.starts_with("[Tool results]"))),
7143 "native mode should use role=tool history instead of prompt fallback wrapper"
7144 );
7145 }
7146
7147 #[tokio::test]
7148 async fn run_tool_call_loop_relays_native_tool_call_text_via_on_delta() {
7149 let provider = ScriptedProvider {
7150 responses: Arc::new(Mutex::new(VecDeque::from(vec![
7151 ChatResponse {
7152 text: Some("Task started. Waiting 30 seconds before checking status.".into()),
7153 tool_calls: vec![ToolCall {
7154 id: "call_wait".into(),
7155 name: "count_tool".into(),
7156 arguments: r#"{"value":"A"}"#.into(),
7157 }],
7158 usage: None,
7159 reasoning_content: None,
7160 },
7161 ChatResponse {
7162 text: Some("Final answer".into()),
7163 tool_calls: Vec::new(),
7164 usage: None,
7165 reasoning_content: None,
7166 },
7167 ]))),
7168 capabilities: ProviderCapabilities {
7169 native_tool_calling: true,
7170 ..ProviderCapabilities::default()
7171 },
7172 };
7173
7174 let invocations = Arc::new(AtomicUsize::new(0));
7175 let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
7176 "count_tool",
7177 Arc::clone(&invocations),
7178 ))];
7179
7180 let mut history = vec![
7181 ChatMessage::system("test-system"),
7182 ChatMessage::user("run tool calls"),
7183 ];
7184 let observer = NoopObserver;
7185 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
7186
7187 let result = run_tool_call_loop(
7188 &provider,
7189 &mut history,
7190 &tools_registry,
7191 &observer,
7192 "mock-provider",
7193 "mock-model",
7194 0.0,
7195 true,
7196 None,
7197 "telegram",
7198 None,
7199 &crate::config::MultimodalConfig::default(),
7200 4,
7201 None,
7202 Some(tx),
7203 None,
7204 &[],
7205 &[],
7206 None,
7207 None,
7208 &crate::config::PacingConfig::default(),
7209 0,
7210 0,
7211 None,
7212 )
7213 .await
7214 .expect("native tool-call text should be relayed through on_delta");
7215
7216 let mut deltas: Vec<DraftEvent> = Vec::new();
7217 while let Some(delta) = rx.recv().await {
7218 deltas.push(delta);
7219 }
7220
7221 let explanation_idx = deltas
7222 .iter()
7223 .position(|delta| matches!(delta, DraftEvent::Content(t) if t == "Task started. Waiting 30 seconds before checking status.\n"))
7224 .expect("native assistant text should be relayed to on_delta");
7225 let clear_idx = deltas
7226 .iter()
7227 .position(|delta| matches!(delta, DraftEvent::Clear))
7228 .expect("final answer streaming should clear prior draft state");
7229
7230 assert!(
7231 deltas
7232 .iter()
7233 .any(|delta| matches!(delta, DraftEvent::Progress(t) if t.starts_with("\u{1f4ac} Got 1 tool call(s)"))),
7234 "tool-call progress line should still be relayed"
7235 );
7236 assert!(
7237 explanation_idx < clear_idx,
7238 "native assistant text should arrive before final-answer draft clearing"
7239 );
7240 assert_eq!(result, "Final answer");
7241 assert_eq!(invocations.load(Ordering::SeqCst), 1);
7242 }
7243
7244 #[tokio::test]
7245 async fn run_tool_call_loop_consumes_provider_stream_for_final_response() {
7246 let provider =
7247 StreamingScriptedProvider::from_text_responses(vec!["streamed final answer"]);
7248 let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
7249 let mut history = vec![
7250 ChatMessage::system("test-system"),
7251 ChatMessage::user("say hi"),
7252 ];
7253 let observer = NoopObserver;
7254 let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(32);
7255
7256 let result = run_tool_call_loop(
7257 &provider,
7258 &mut history,
7259 &tools_registry,
7260 &observer,
7261 "mock-provider",
7262 "mock-model",
7263 0.0,
7264 true,
7265 None,
7266 "telegram",
7267 None,
7268 &crate::config::MultimodalConfig::default(),
7269 4,
7270 None,
7271 Some(tx),
7272 None,
7273 &[],
7274 &[],
7275 None,
7276 None,
7277 &crate::config::PacingConfig::default(),
7278 0,
7279 0,
7280 None,
7281 )
7282 .await
7283 .expect("streaming provider should complete");
7284
7285 let mut visible_deltas = String::new();
7286 while let Some(delta) = rx.recv().await {
7287 match delta {
7288 DraftEvent::Clear => {
7289 visible_deltas.clear();
7290 }
7291 DraftEvent::Progress(_) => {}
7292 DraftEvent::Content(text) => {
7293 visible_deltas.push_str(&text);
7294 }
7295 }
7296 }
7297
7298 assert_eq!(result, "streamed final answer");
7299 assert_eq!(
7300 visible_deltas, "streamed final answer",
7301 "draft should receive upstream deltas once without post-hoc duplication"
7302 );
7303 assert_eq!(provider.stream_calls.load(Ordering::SeqCst), 1);
7304 assert_eq!(provider.chat_calls.load(Ordering::SeqCst), 0);
7305 }
7306
7307 #[tokio::test]
7308 async fn run_tool_call_loop_streaming_path_preserves_tool_loop_semantics() {
7309 let provider = StreamingScriptedProvider::from_text_responses(vec![
7310 r#"<tool_call>
7311{"name":"count_tool","arguments":{"value":"A"}}
7312</tool_call>"#,
7313 "done",
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 let mut history = vec![
7321 ChatMessage::system("test-system"),
7322 ChatMessage::user("run tool calls"),
7323 ];
7324 let observer = NoopObserver;
7325 let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(64);
7326
7327 let result = run_tool_call_loop(
7328 &provider,
7329 &mut history,
7330 &tools_registry,
7331 &observer,
7332 "mock-provider",
7333 "mock-model",
7334 0.0,
7335 true,
7336 None,
7337 "telegram",
7338 None,
7339 &crate::config::MultimodalConfig::default(),
7340 5,
7341 None,
7342 Some(tx),
7343 None,
7344 &[],
7345 &[],
7346 None,
7347 None,
7348 &crate::config::PacingConfig::default(),
7349 0,
7350 0,
7351 None,
7352 )
7353 .await
7354 .expect("streaming tool loop should execute tool and finish");
7355
7356 let mut visible_deltas = String::new();
7357 while let Some(delta) = rx.recv().await {
7358 match delta {
7359 DraftEvent::Clear => {
7360 visible_deltas.clear();
7361 }
7362 DraftEvent::Progress(_) => {}
7363 DraftEvent::Content(text) => {
7364 visible_deltas.push_str(&text);
7365 }
7366 }
7367 }
7368
7369 assert_eq!(result, "done");
7370 assert_eq!(invocations.load(Ordering::SeqCst), 1);
7371 assert_eq!(provider.stream_calls.load(Ordering::SeqCst), 2);
7372 assert_eq!(provider.chat_calls.load(Ordering::SeqCst), 0);
7373 assert_eq!(visible_deltas, "done");
7374 assert!(
7375 !visible_deltas.contains("<tool_call"),
7376 "draft text should not leak streamed tool payload markers"
7377 );
7378 }
7379
7380 #[tokio::test]
7381 async fn run_tool_call_loop_streams_native_tool_events_without_chat_fallback() {
7382 let provider = StreamingNativeToolEventProvider::with_turns(vec![
7383 NativeStreamTurn::ToolCall(ToolCall {
7384 id: "call_native_1".to_string(),
7385 name: "count_tool".to_string(),
7386 arguments: r#"{"value":"A"}"#.to_string(),
7387 }),
7388 NativeStreamTurn::Text("done".to_string()),
7389 ]);
7390 let invocations = Arc::new(AtomicUsize::new(0));
7391 let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
7392 "count_tool",
7393 Arc::clone(&invocations),
7394 ))];
7395 let mut history = vec![
7396 ChatMessage::system("test-system"),
7397 ChatMessage::user("run native tools"),
7398 ];
7399 let observer = NoopObserver;
7400 let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(64);
7401
7402 let result = run_tool_call_loop(
7403 &provider,
7404 &mut history,
7405 &tools_registry,
7406 &observer,
7407 "mock-provider",
7408 "mock-model",
7409 0.0,
7410 true,
7411 None,
7412 "telegram",
7413 None,
7414 &crate::config::MultimodalConfig::default(),
7415 5,
7416 None,
7417 Some(tx),
7418 None,
7419 &[],
7420 &[],
7421 None,
7422 None,
7423 &crate::config::PacingConfig::default(),
7424 0,
7425 0,
7426 None,
7427 )
7428 .await
7429 .expect("native streaming events should preserve tool loop semantics");
7430
7431 let mut visible_deltas = String::new();
7432 while let Some(delta) = rx.recv().await {
7433 match delta {
7434 DraftEvent::Clear => {
7435 visible_deltas.clear();
7436 }
7437 DraftEvent::Progress(_) => {}
7438 DraftEvent::Content(text) => {
7439 visible_deltas.push_str(&text);
7440 }
7441 }
7442 }
7443
7444 assert_eq!(result, "done");
7445 assert_eq!(invocations.load(Ordering::SeqCst), 1);
7446 assert_eq!(provider.stream_calls.load(Ordering::SeqCst), 2);
7447 assert_eq!(provider.stream_tool_requests.load(Ordering::SeqCst), 2);
7448 assert_eq!(provider.chat_calls.load(Ordering::SeqCst), 0);
7449 assert_eq!(visible_deltas, "done");
7450 }
7451
7452 #[tokio::test]
7453 async fn run_tool_call_loop_routed_streaming_uses_live_provider_deltas_once() {
7454 let default_provider = RouteAwareStreamingProvider::new("default answer");
7455 let default_stream_calls = Arc::clone(&default_provider.stream_calls);
7456 let default_chat_calls = Arc::clone(&default_provider.chat_calls);
7457
7458 let routed_provider = RouteAwareStreamingProvider::new("routed streamed answer");
7459 let routed_stream_calls = Arc::clone(&routed_provider.stream_calls);
7460 let routed_chat_calls = Arc::clone(&routed_provider.chat_calls);
7461 let routed_last_model = Arc::clone(&routed_provider.last_model);
7462
7463 let router = RouterProvider::new(
7464 vec![
7465 ("default".to_string(), Box::new(default_provider)),
7466 ("fast".to_string(), Box::new(routed_provider)),
7467 ],
7468 vec![(
7469 "fast".to_string(),
7470 Route {
7471 provider_name: "fast".to_string(),
7472 model: "routed-model".to_string(),
7473 },
7474 )],
7475 "default-model".to_string(),
7476 );
7477
7478 let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
7479 let mut history = vec![
7480 ChatMessage::system("test-system"),
7481 ChatMessage::user("say hi"),
7482 ];
7483 let observer = NoopObserver;
7484 let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(32);
7485
7486 let result = run_tool_call_loop(
7487 &router,
7488 &mut history,
7489 &tools_registry,
7490 &observer,
7491 "router",
7492 "hint:fast",
7493 0.0,
7494 true,
7495 None,
7496 "telegram",
7497 None,
7498 &crate::config::MultimodalConfig::default(),
7499 4,
7500 None,
7501 Some(tx),
7502 None,
7503 &[],
7504 &[],
7505 None,
7506 None,
7507 &crate::config::PacingConfig::default(),
7508 0,
7509 0,
7510 None,
7511 )
7512 .await
7513 .expect("routed streaming provider should complete");
7514
7515 let mut visible_deltas = String::new();
7516 while let Some(delta) = rx.recv().await {
7517 match delta {
7518 DraftEvent::Clear => {
7519 visible_deltas.clear();
7520 }
7521 DraftEvent::Progress(_) => {}
7522 DraftEvent::Content(text) => {
7523 visible_deltas.push_str(&text);
7524 }
7525 }
7526 }
7527
7528 assert_eq!(result, "routed streamed answer");
7529 assert_eq!(
7530 visible_deltas, "routed streamed answer",
7531 "routed draft should receive upstream deltas once without post-hoc duplication"
7532 );
7533 assert_eq!(default_stream_calls.load(Ordering::SeqCst), 0);
7534 assert_eq!(routed_stream_calls.load(Ordering::SeqCst), 1);
7535 assert_eq!(default_chat_calls.load(Ordering::SeqCst), 0);
7536 assert_eq!(routed_chat_calls.load(Ordering::SeqCst), 0);
7537 assert_eq!(
7538 routed_last_model
7539 .lock()
7540 .expect("routed_last_model lock should be valid")
7541 .as_str(),
7542 "routed-model"
7543 );
7544 }
7545
7546 #[test]
7547 fn agent_turn_executes_activated_tool_from_wrapper() {
7548 let runtime = tokio::runtime::Builder::new_current_thread()
7549 .enable_all()
7550 .build()
7551 .expect("test runtime should initialize");
7552
7553 runtime.block_on(async {
7554 let provider = ScriptedProvider::from_text_responses(vec![
7555 r#"<tool_call>
7556{"name":"pixel__get_api_health","arguments":{"value":"ok"}}
7557</tool_call>"#,
7558 "done",
7559 ]);
7560
7561 let invocations = Arc::new(AtomicUsize::new(0));
7562 let activated = Arc::new(std::sync::Mutex::new(crate::tools::ActivatedToolSet::new()));
7563 let activated_tool: Arc<dyn Tool> = Arc::new(CountingTool::new(
7564 "pixel__get_api_health",
7565 Arc::clone(&invocations),
7566 ));
7567 activated
7568 .lock()
7569 .unwrap()
7570 .activate("pixel__get_api_health".into(), activated_tool);
7571
7572 let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
7573 let mut history = vec![
7574 ChatMessage::system("test-system"),
7575 ChatMessage::user("use the activated MCP tool"),
7576 ];
7577 let observer = NoopObserver;
7578
7579 let result = agent_turn(
7580 &provider,
7581 &mut history,
7582 &tools_registry,
7583 &observer,
7584 "mock-provider",
7585 "mock-model",
7586 0.0,
7587 true,
7588 "daemon",
7589 None,
7590 &crate::config::MultimodalConfig::default(),
7591 4,
7592 None,
7593 &[],
7594 &[],
7595 Some(&activated),
7596 None,
7597 )
7598 .await
7599 .expect("wrapper path should execute activated tools");
7600
7601 assert_eq!(result, "done");
7602 assert_eq!(invocations.load(Ordering::SeqCst), 1);
7603 });
7604 }
7605
7606 #[test]
7607 fn resolve_display_text_hides_raw_payload_for_tool_only_turns() {
7608 let display = resolve_display_text(
7609 "<tool_call>{\"name\":\"memory_store\"}</tool_call>",
7610 "",
7611 true,
7612 false,
7613 );
7614 assert!(display.is_empty());
7615 }
7616
7617 #[test]
7618 fn resolve_display_text_keeps_plain_text_for_tool_turns() {
7619 let display = resolve_display_text(
7620 "<tool_call>{\"name\":\"shell\"}</tool_call>",
7621 "Let me check that.",
7622 true,
7623 false,
7624 );
7625 assert_eq!(display, "Let me check that.");
7626 }
7627
7628 #[test]
7629 fn resolve_display_text_uses_response_text_for_native_tool_turns() {
7630 let display = resolve_display_text("Task started.", "", true, true);
7631 assert_eq!(display, "Task started.");
7632 }
7633
7634 #[test]
7635 fn resolve_display_text_uses_response_text_for_final_turns() {
7636 let display = resolve_display_text("Final answer", "", false, false);
7637 assert_eq!(display, "Final answer");
7638 }
7639
7640 #[test]
7641 fn parse_tool_calls_extracts_single_call() {
7642 let response = r#"Let me check that.
7643<tool_call>
7644{"name": "shell", "arguments": {"command": "ls -la"}}
7645</tool_call>"#;
7646
7647 let (text, calls) = parse_tool_calls(response);
7648 assert_eq!(text, "Let me check that.");
7649 assert_eq!(calls.len(), 1);
7650 assert_eq!(calls[0].name, "shell");
7651 assert_eq!(
7652 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7653 "ls -la"
7654 );
7655 }
7656
7657 #[test]
7658 fn parse_tool_calls_extracts_multiple_calls() {
7659 let response = r#"<tool_call>
7660{"name": "file_read", "arguments": {"path": "a.txt"}}
7661</tool_call>
7662<tool_call>
7663{"name": "file_read", "arguments": {"path": "b.txt"}}
7664</tool_call>"#;
7665
7666 let (_, calls) = parse_tool_calls(response);
7667 assert_eq!(calls.len(), 2);
7668 assert_eq!(calls[0].name, "file_read");
7669 assert_eq!(calls[1].name, "file_read");
7670 }
7671
7672 #[test]
7673 fn parse_tool_calls_returns_text_only_when_no_calls() {
7674 let response = "Just a normal response with no tools.";
7675 let (text, calls) = parse_tool_calls(response);
7676 assert_eq!(text, "Just a normal response with no tools.");
7677 assert!(calls.is_empty());
7678 }
7679
7680 #[test]
7681 fn parse_tool_calls_handles_malformed_json() {
7682 let response = r#"<tool_call>
7683not valid json
7684</tool_call>
7685Some text after."#;
7686
7687 let (text, calls) = parse_tool_calls(response);
7688 assert!(calls.is_empty());
7689 assert!(text.contains("Some text after."));
7690 }
7691
7692 #[test]
7693 fn parse_tool_calls_text_before_and_after() {
7694 let response = r#"Before text.
7695<tool_call>
7696{"name": "shell", "arguments": {"command": "echo hi"}}
7697</tool_call>
7698After text."#;
7699
7700 let (text, calls) = parse_tool_calls(response);
7701 assert!(text.contains("Before text."));
7702 assert!(text.contains("After text."));
7703 assert_eq!(calls.len(), 1);
7704 }
7705
7706 #[test]
7707 fn parse_tool_calls_handles_openai_format() {
7708 let response = r#"{"content": "Let me check that for you.", "tool_calls": [{"type": "function", "function": {"name": "shell", "arguments": "{\"command\": \"ls -la\"}"}}]}"#;
7710
7711 let (text, calls) = parse_tool_calls(response);
7712 assert_eq!(text, "Let me check that for you.");
7713 assert_eq!(calls.len(), 1);
7714 assert_eq!(calls[0].name, "shell");
7715 assert_eq!(
7716 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7717 "ls -la"
7718 );
7719 }
7720
7721 #[test]
7722 fn parse_tool_calls_handles_openai_format_multiple_calls() {
7723 let response = r#"{"tool_calls": [{"type": "function", "function": {"name": "file_read", "arguments": "{\"path\": \"a.txt\"}"}}, {"type": "function", "function": {"name": "file_read", "arguments": "{\"path\": \"b.txt\"}"}}]}"#;
7724
7725 let (_, calls) = parse_tool_calls(response);
7726 assert_eq!(calls.len(), 2);
7727 assert_eq!(calls[0].name, "file_read");
7728 assert_eq!(calls[1].name, "file_read");
7729 }
7730
7731 #[test]
7732 fn parse_tool_calls_openai_format_without_content() {
7733 let response = r#"{"tool_calls": [{"type": "function", "function": {"name": "memory_recall", "arguments": "{}"}}]}"#;
7735
7736 let (text, calls) = parse_tool_calls(response);
7737 assert!(text.is_empty()); assert_eq!(calls.len(), 1);
7739 assert_eq!(calls[0].name, "memory_recall");
7740 }
7741
7742 #[test]
7743 fn parse_tool_calls_preserves_openai_tool_call_ids() {
7744 let response = r#"{"tool_calls":[{"id":"call_42","function":{"name":"shell","arguments":"{\"command\":\"pwd\"}"}}]}"#;
7745 let (_, calls) = parse_tool_calls(response);
7746 assert_eq!(calls.len(), 1);
7747 assert_eq!(calls[0].tool_call_id.as_deref(), Some("call_42"));
7748 }
7749
7750 #[test]
7751 fn parse_tool_calls_handles_markdown_json_inside_tool_call_tag() {
7752 let response = r#"<tool_call>
7753```json
7754{"name": "file_write", "arguments": {"path": "test.py", "content": "print('ok')"}}
7755```
7756</tool_call>"#;
7757
7758 let (text, calls) = parse_tool_calls(response);
7759 assert!(text.is_empty());
7760 assert_eq!(calls.len(), 1);
7761 assert_eq!(calls[0].name, "file_write");
7762 assert_eq!(
7763 calls[0].arguments.get("path").unwrap().as_str().unwrap(),
7764 "test.py"
7765 );
7766 }
7767
7768 #[test]
7769 fn parse_tool_calls_handles_noisy_tool_call_tag_body() {
7770 let response = r#"<tool_call>
7771I will now call the tool with this payload:
7772{"name": "shell", "arguments": {"command": "pwd"}}
7773</tool_call>"#;
7774
7775 let (text, calls) = parse_tool_calls(response);
7776 assert!(text.is_empty());
7777 assert_eq!(calls.len(), 1);
7778 assert_eq!(calls[0].name, "shell");
7779 assert_eq!(
7780 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7781 "pwd"
7782 );
7783 }
7784
7785 #[test]
7786 fn parse_tool_calls_handles_tool_call_inline_attributes_with_send_message_alias() {
7787 let response = r#"<tool_call>send_message channel="user_channel" message="Hello! How can I assist you today?"</tool_call>"#;
7788
7789 let (text, calls) = parse_tool_calls(response);
7790 assert!(text.is_empty());
7791 assert_eq!(calls.len(), 1);
7792 assert_eq!(calls[0].name, "message_send");
7793 assert_eq!(
7794 calls[0].arguments.get("channel").unwrap().as_str().unwrap(),
7795 "user_channel"
7796 );
7797 assert_eq!(
7798 calls[0].arguments.get("message").unwrap().as_str().unwrap(),
7799 "Hello! How can I assist you today?"
7800 );
7801 }
7802
7803 #[test]
7804 fn parse_tool_calls_handles_tool_call_function_style_arguments() {
7805 let response = r#"<tool_call>message_send(channel="general", message="test")</tool_call>"#;
7806
7807 let (text, calls) = parse_tool_calls(response);
7808 assert!(text.is_empty());
7809 assert_eq!(calls.len(), 1);
7810 assert_eq!(calls[0].name, "message_send");
7811 assert_eq!(
7812 calls[0].arguments.get("channel").unwrap().as_str().unwrap(),
7813 "general"
7814 );
7815 assert_eq!(
7816 calls[0].arguments.get("message").unwrap().as_str().unwrap(),
7817 "test"
7818 );
7819 }
7820
7821 #[test]
7822 fn parse_tool_calls_handles_xml_nested_tool_payload() {
7823 let response = r#"<tool_call>
7824<memory_recall>
7825<query>project roadmap</query>
7826</memory_recall>
7827</tool_call>"#;
7828
7829 let (text, calls) = parse_tool_calls(response);
7830 assert!(text.is_empty());
7831 assert_eq!(calls.len(), 1);
7832 assert_eq!(calls[0].name, "memory_recall");
7833 assert_eq!(
7834 calls[0].arguments.get("query").unwrap().as_str().unwrap(),
7835 "project roadmap"
7836 );
7837 }
7838
7839 #[test]
7840 fn parse_tool_calls_ignores_xml_thinking_wrapper() {
7841 let response = r#"<tool_call>
7842<thinking>Need to inspect memory first</thinking>
7843<memory_recall>
7844<query>recent deploy notes</query>
7845</memory_recall>
7846</tool_call>"#;
7847
7848 let (text, calls) = parse_tool_calls(response);
7849 assert!(text.is_empty());
7850 assert_eq!(calls.len(), 1);
7851 assert_eq!(calls[0].name, "memory_recall");
7852 assert_eq!(
7853 calls[0].arguments.get("query").unwrap().as_str().unwrap(),
7854 "recent deploy notes"
7855 );
7856 }
7857
7858 #[test]
7859 fn parse_tool_calls_handles_xml_with_json_arguments() {
7860 let response = r#"<tool_call>
7861<shell>{"command":"pwd"}</shell>
7862</tool_call>"#;
7863
7864 let (text, calls) = parse_tool_calls(response);
7865 assert!(text.is_empty());
7866 assert_eq!(calls.len(), 1);
7867 assert_eq!(calls[0].name, "shell");
7868 assert_eq!(
7869 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7870 "pwd"
7871 );
7872 }
7873
7874 #[test]
7875 fn parse_tool_calls_handles_markdown_tool_call_fence() {
7876 let response = r#"I'll check that.
7877```tool_call
7878{"name": "shell", "arguments": {"command": "pwd"}}
7879```
7880Done."#;
7881
7882 let (text, calls) = parse_tool_calls(response);
7883 assert_eq!(calls.len(), 1);
7884 assert_eq!(calls[0].name, "shell");
7885 assert_eq!(
7886 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7887 "pwd"
7888 );
7889 assert!(text.contains("I'll check that."));
7890 assert!(text.contains("Done."));
7891 assert!(!text.contains("```tool_call"));
7892 }
7893
7894 #[test]
7895 fn parse_tool_calls_handles_markdown_tool_call_hybrid_close_tag() {
7896 let response = r#"Preface
7897```tool-call
7898{"name": "shell", "arguments": {"command": "date"}}
7899</tool_call>
7900Tail"#;
7901
7902 let (text, calls) = parse_tool_calls(response);
7903 assert_eq!(calls.len(), 1);
7904 assert_eq!(calls[0].name, "shell");
7905 assert_eq!(
7906 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7907 "date"
7908 );
7909 assert!(text.contains("Preface"));
7910 assert!(text.contains("Tail"));
7911 assert!(!text.contains("```tool-call"));
7912 }
7913
7914 #[test]
7915 fn parse_tool_calls_handles_markdown_invoke_fence() {
7916 let response = r#"Checking.
7917```invoke
7918{"name": "shell", "arguments": {"command": "date"}}
7919```
7920Done."#;
7921
7922 let (text, calls) = parse_tool_calls(response);
7923 assert_eq!(calls.len(), 1);
7924 assert_eq!(calls[0].name, "shell");
7925 assert_eq!(
7926 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7927 "date"
7928 );
7929 assert!(text.contains("Checking."));
7930 assert!(text.contains("Done."));
7931 }
7932
7933 #[test]
7934 fn parse_tool_calls_handles_tool_name_fence_format() {
7935 let response = r#"I'll write a test file.
7937```tool file_write
7938{"path": "/home/user/test.txt", "content": "Hello world"}
7939```
7940Done."#;
7941
7942 let (text, calls) = parse_tool_calls(response);
7943 assert_eq!(calls.len(), 1);
7944 assert_eq!(calls[0].name, "file_write");
7945 assert_eq!(
7946 calls[0].arguments.get("path").unwrap().as_str().unwrap(),
7947 "/home/user/test.txt"
7948 );
7949 assert!(text.contains("I'll write a test file."));
7950 assert!(text.contains("Done."));
7951 }
7952
7953 #[test]
7954 fn parse_tool_calls_handles_tool_name_fence_shell() {
7955 let response = r#"```tool shell
7957{"command": "ls -la"}
7958```"#;
7959
7960 let (_text, calls) = parse_tool_calls(response);
7961 assert_eq!(calls.len(), 1);
7962 assert_eq!(calls[0].name, "shell");
7963 assert_eq!(
7964 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7965 "ls -la"
7966 );
7967 }
7968
7969 #[test]
7970 fn parse_tool_calls_handles_multiple_tool_name_fences() {
7971 let response = r#"First, I'll write a file.
7973```tool file_write
7974{"path": "/tmp/a.txt", "content": "A"}
7975```
7976Then read it.
7977```tool file_read
7978{"path": "/tmp/a.txt"}
7979```
7980Done."#;
7981
7982 let (text, calls) = parse_tool_calls(response);
7983 assert_eq!(calls.len(), 2);
7984 assert_eq!(calls[0].name, "file_write");
7985 assert_eq!(calls[1].name, "file_read");
7986 assert!(text.contains("First, I'll write a file."));
7987 assert!(text.contains("Then read it."));
7988 assert!(text.contains("Done."));
7989 }
7990
7991 #[test]
7992 fn parse_tool_calls_handles_toolcall_tag_alias() {
7993 let response = r#"<toolcall>
7994{"name": "shell", "arguments": {"command": "date"}}
7995</toolcall>"#;
7996
7997 let (text, calls) = parse_tool_calls(response);
7998 assert!(text.is_empty());
7999 assert_eq!(calls.len(), 1);
8000 assert_eq!(calls[0].name, "shell");
8001 assert_eq!(
8002 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8003 "date"
8004 );
8005 }
8006
8007 #[test]
8008 fn parse_tool_calls_handles_tool_dash_call_tag_alias() {
8009 let response = r#"<tool-call>
8010{"name": "shell", "arguments": {"command": "whoami"}}
8011</tool-call>"#;
8012
8013 let (text, calls) = parse_tool_calls(response);
8014 assert!(text.is_empty());
8015 assert_eq!(calls.len(), 1);
8016 assert_eq!(calls[0].name, "shell");
8017 assert_eq!(
8018 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8019 "whoami"
8020 );
8021 }
8022
8023 #[test]
8024 fn parse_tool_calls_handles_invoke_tag_alias() {
8025 let response = r#"<invoke>
8026{"name": "shell", "arguments": {"command": "uptime"}}
8027</invoke>"#;
8028
8029 let (text, calls) = parse_tool_calls(response);
8030 assert!(text.is_empty());
8031 assert_eq!(calls.len(), 1);
8032 assert_eq!(calls[0].name, "shell");
8033 assert_eq!(
8034 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8035 "uptime"
8036 );
8037 }
8038
8039 #[test]
8040 fn parse_tool_calls_handles_minimax_invoke_parameter_format() {
8041 let response = r#"<minimax:tool_call>
8042<invoke name="shell">
8043<parameter name="command">sqlite3 /tmp/test.db ".tables"</parameter>
8044</invoke>
8045</minimax:tool_call>"#;
8046
8047 let (text, calls) = parse_tool_calls(response);
8048 assert!(text.is_empty());
8049 assert_eq!(calls.len(), 1);
8050 assert_eq!(calls[0].name, "shell");
8051 assert_eq!(
8052 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8053 r#"sqlite3 /tmp/test.db ".tables""#
8054 );
8055 }
8056
8057 #[test]
8058 fn parse_tool_calls_handles_minimax_invoke_with_surrounding_text() {
8059 let response = r#"Preface
8060<minimax:tool_call>
8061<invoke name='http_request'>
8062<parameter name='url'>https://example.com</parameter>
8063<parameter name='method'>GET</parameter>
8064</invoke>
8065</minimax:tool_call>
8066Tail"#;
8067
8068 let (text, calls) = parse_tool_calls(response);
8069 assert!(text.contains("Preface"));
8070 assert!(text.contains("Tail"));
8071 assert_eq!(calls.len(), 1);
8072 assert_eq!(calls[0].name, "http_request");
8073 assert_eq!(
8074 calls[0].arguments.get("url").unwrap().as_str().unwrap(),
8075 "https://example.com"
8076 );
8077 assert_eq!(
8078 calls[0].arguments.get("method").unwrap().as_str().unwrap(),
8079 "GET"
8080 );
8081 }
8082
8083 #[test]
8084 fn parse_tool_calls_handles_minimax_toolcall_alias_and_cross_close_tag() {
8085 let response = r#"<tool_call>
8086{"name":"shell","arguments":{"command":"date"}}
8087</minimax:toolcall>"#;
8088
8089 let (text, calls) = parse_tool_calls(response);
8090 assert!(text.is_empty());
8091 assert_eq!(calls.len(), 1);
8092 assert_eq!(calls[0].name, "shell");
8093 assert_eq!(
8094 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8095 "date"
8096 );
8097 }
8098
8099 #[test]
8100 fn parse_tool_calls_handles_perl_style_tool_call_blocks() {
8101 let response = r#"TOOL_CALL
8102{tool => "shell", args => { --command "uname -a" }}}
8103/TOOL_CALL"#;
8104
8105 let calls = parse_perl_style_tool_calls(response);
8106 assert_eq!(calls.len(), 1);
8107 assert_eq!(calls[0].name, "shell");
8108 assert_eq!(
8109 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8110 "uname -a"
8111 );
8112 }
8113
8114 #[test]
8115 fn parse_tool_calls_handles_square_bracket_tool_call_blocks() {
8116 let response =
8117 r#"[TOOL_CALL]{tool => "shell", args => {--command "echo hello"}}[/TOOL_CALL]"#;
8118
8119 let calls = parse_perl_style_tool_calls(response);
8120 assert_eq!(calls.len(), 1);
8121 assert_eq!(calls[0].name, "shell");
8122 assert_eq!(
8123 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8124 "echo hello"
8125 );
8126 }
8127
8128 #[test]
8129 fn parse_tool_calls_handles_square_bracket_multiline() {
8130 let response = r#"[TOOL_CALL]
8131{tool => "file_read", args => {
8132 --path "/tmp/test.txt"
8133 --description "Read test file"
8134}}
8135[/TOOL_CALL]"#;
8136
8137 let calls = parse_perl_style_tool_calls(response);
8138 assert_eq!(calls.len(), 1);
8139 assert_eq!(calls[0].name, "file_read");
8140 assert_eq!(
8141 calls[0].arguments.get("path").unwrap().as_str().unwrap(),
8142 "/tmp/test.txt"
8143 );
8144 assert_eq!(
8145 calls[0]
8146 .arguments
8147 .get("description")
8148 .unwrap()
8149 .as_str()
8150 .unwrap(),
8151 "Read test file"
8152 );
8153 }
8154
8155 #[test]
8156 fn parse_tool_calls_recovers_unclosed_tool_call_with_json() {
8157 let response = r#"I will call the tool now.
8158<tool_call>
8159{"name": "shell", "arguments": {"command": "uptime -p"}}"#;
8160
8161 let (text, calls) = parse_tool_calls(response);
8162 assert!(text.contains("I will call the tool now."));
8163 assert_eq!(calls.len(), 1);
8164 assert_eq!(calls[0].name, "shell");
8165 assert_eq!(
8166 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8167 "uptime -p"
8168 );
8169 }
8170
8171 #[test]
8172 fn parse_tool_calls_recovers_mismatched_close_tag() {
8173 let response = r#"<tool_call>
8174{"name": "shell", "arguments": {"command": "uptime"}}
8175</arg_value>"#;
8176
8177 let (text, calls) = parse_tool_calls(response);
8178 assert!(text.is_empty());
8179 assert_eq!(calls.len(), 1);
8180 assert_eq!(calls[0].name, "shell");
8181 assert_eq!(
8182 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8183 "uptime"
8184 );
8185 }
8186
8187 #[test]
8188 fn parse_tool_calls_recovers_cross_alias_closing_tags() {
8189 let response = r#"<toolcall>
8190{"name": "shell", "arguments": {"command": "date"}}
8191</tool_call>"#;
8192
8193 let (text, calls) = parse_tool_calls(response);
8194 assert!(text.is_empty());
8195 assert_eq!(calls.len(), 1);
8196 assert_eq!(calls[0].name, "shell");
8197 }
8198
8199 #[test]
8200 fn parse_tool_calls_rejects_raw_tool_json_without_tags() {
8201 let response = r#"Sure, creating the file now.
8205{"name": "file_write", "arguments": {"path": "hello.py", "content": "print('hello')"}}"#;
8206
8207 let (text, calls) = parse_tool_calls(response);
8208 assert!(text.contains("Sure, creating the file now."));
8209 assert_eq!(
8210 calls.len(),
8211 0,
8212 "Raw JSON without wrappers should not be parsed"
8213 );
8214 }
8215
8216 #[test]
8217 fn build_tool_instructions_includes_all_tools() {
8218 use crate::security::SecurityPolicy;
8219 let security = Arc::new(SecurityPolicy::from_config(
8220 &crate::config::AutonomyConfig::default(),
8221 std::path::Path::new("/tmp"),
8222 ));
8223 let tools = tools::default_tools(security);
8224 let instructions = build_tool_instructions(&tools, None);
8225
8226 assert!(instructions.contains("## Tool Use Protocol"));
8227 assert!(instructions.contains("<tool_call>"));
8228 assert!(instructions.contains("shell"));
8229 assert!(instructions.contains("file_read"));
8230 assert!(instructions.contains("file_write"));
8231 }
8232
8233 #[test]
8234 fn tools_to_openai_format_produces_valid_schema() {
8235 use crate::security::SecurityPolicy;
8236 let security = Arc::new(SecurityPolicy::from_config(
8237 &crate::config::AutonomyConfig::default(),
8238 std::path::Path::new("/tmp"),
8239 ));
8240 let tools = tools::default_tools(security);
8241 let formatted = tools_to_openai_format(&tools);
8242
8243 assert!(!formatted.is_empty());
8244 for tool_json in &formatted {
8245 assert_eq!(tool_json["type"], "function");
8246 assert!(tool_json["function"]["name"].is_string());
8247 assert!(tool_json["function"]["description"].is_string());
8248 assert!(!tool_json["function"]["name"].as_str().unwrap().is_empty());
8249 }
8250 let names: Vec<&str> = formatted
8252 .iter()
8253 .filter_map(|t| t["function"]["name"].as_str())
8254 .collect();
8255 assert!(names.contains(&"shell"));
8256 assert!(names.contains(&"file_read"));
8257 }
8258
8259 #[test]
8260 fn trim_history_preserves_system_prompt() {
8261 let mut history = vec![ChatMessage::system("system prompt")];
8262 for i in 0..DEFAULT_MAX_HISTORY_MESSAGES + 20 {
8263 history.push(ChatMessage::user(format!("msg {i}")));
8264 }
8265 let original_len = history.len();
8266 assert!(original_len > DEFAULT_MAX_HISTORY_MESSAGES + 1);
8267
8268 trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
8269
8270 assert_eq!(history[0].role, "system");
8272 assert_eq!(history[0].content, "system prompt");
8273 assert_eq!(history.len(), DEFAULT_MAX_HISTORY_MESSAGES + 1); let last = &history[history.len() - 1];
8277 assert_eq!(
8278 last.content,
8279 format!("msg {}", DEFAULT_MAX_HISTORY_MESSAGES + 19)
8280 );
8281 }
8282
8283 #[test]
8284 fn trim_history_noop_when_within_limit() {
8285 let mut history = vec![
8286 ChatMessage::system("sys"),
8287 ChatMessage::user("hello"),
8288 ChatMessage::assistant("hi"),
8289 ];
8290 trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
8291 assert_eq!(history.len(), 3);
8292 }
8293
8294 #[test]
8295 fn autosave_memory_key_has_prefix_and_uniqueness() {
8296 let key1 = autosave_memory_key("user_msg");
8297 let key2 = autosave_memory_key("user_msg");
8298
8299 assert!(key1.starts_with("user_msg_"));
8300 assert!(key2.starts_with("user_msg_"));
8301 assert_ne!(key1, key2);
8302 }
8303
8304 #[test]
8309 fn parse_tool_calls_handles_empty_tool_result() {
8310 let response = r#"I'll run that command.
8312<tool_result name="shell">
8313
8314</tool_result>
8315Done."#;
8316 let (text, calls) = parse_tool_calls(response);
8317 assert!(text.contains("Done."));
8318 assert!(calls.is_empty());
8319 }
8320
8321 #[test]
8322 fn strip_tool_result_blocks_removes_single_block() {
8323 let input = r#"<tool_result name="memory_recall" status="ok">
8324{"matches":["hello"]}
8325</tool_result>
8326Here is my answer."#;
8327 assert_eq!(strip_tool_result_blocks(input), "Here is my answer.");
8328 }
8329
8330 #[test]
8331 fn strip_tool_result_blocks_removes_multiple_blocks() {
8332 let input = r#"<tool_result name="memory_recall" status="ok">
8333{"matches":[]}
8334</tool_result>
8335<tool_result name="shell" status="ok">
8336done
8337</tool_result>
8338Final answer."#;
8339 assert_eq!(strip_tool_result_blocks(input), "Final answer.");
8340 }
8341
8342 #[test]
8343 fn strip_tool_result_blocks_removes_prefix() {
8344 let input =
8345 "[Tool results]\n<tool_result name=\"shell\" status=\"ok\">\nok\n</tool_result>\nDone.";
8346 assert_eq!(strip_tool_result_blocks(input), "Done.");
8347 }
8348
8349 #[test]
8350 fn strip_tool_result_blocks_removes_thinking() {
8351 let input = "<thinking>\nLet me think...\n</thinking>\nHere is the answer.";
8352 assert_eq!(strip_tool_result_blocks(input), "Here is the answer.");
8353 }
8354
8355 #[test]
8356 fn strip_tool_result_blocks_removes_think_tags() {
8357 let input = "<think>\nLet me reason...\n</think>\nHere is the answer.";
8358 assert_eq!(strip_tool_result_blocks(input), "Here is the answer.");
8359 }
8360
8361 #[test]
8362 fn strip_think_tags_removes_single_block() {
8363 assert_eq!(strip_think_tags("<think>reasoning</think>Hello"), "Hello");
8364 }
8365
8366 #[test]
8367 fn strip_think_tags_removes_multiple_blocks() {
8368 assert_eq!(strip_think_tags("<think>a</think>X<think>b</think>Y"), "XY");
8369 }
8370
8371 #[test]
8372 fn strip_think_tags_handles_unclosed_block() {
8373 assert_eq!(strip_think_tags("visible<think>hidden"), "visible");
8374 }
8375
8376 #[test]
8377 fn strip_think_tags_preserves_text_without_tags() {
8378 assert_eq!(strip_think_tags("plain text"), "plain text");
8379 }
8380
8381 #[test]
8382 fn parse_tool_calls_strips_think_before_tool_call() {
8383 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>";
8386 let (text, calls) = parse_tool_calls(response);
8387 assert_eq!(
8388 calls.len(),
8389 1,
8390 "should parse tool call after stripping think tags"
8391 );
8392 assert_eq!(calls[0].name, "shell");
8393 assert_eq!(
8394 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8395 "ls"
8396 );
8397 assert!(text.is_empty(), "think content should not appear as text");
8398 }
8399
8400 #[test]
8401 fn parse_tool_calls_strips_think_only_returns_empty() {
8402 let response = "<think>Just thinking, no action needed</think>";
8405 let (text, calls) = parse_tool_calls(response);
8406 assert!(calls.is_empty());
8407 assert!(text.is_empty());
8408 }
8409
8410 #[test]
8411 fn parse_tool_calls_handles_qwen_think_with_multiple_tool_calls() {
8412 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>";
8413 let (_, calls) = parse_tool_calls(response);
8414 assert_eq!(calls.len(), 2);
8415 assert_eq!(
8416 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8417 "date"
8418 );
8419 assert_eq!(
8420 calls[1].arguments.get("command").unwrap().as_str().unwrap(),
8421 "pwd"
8422 );
8423 }
8424
8425 #[test]
8426 fn strip_tool_result_blocks_preserves_clean_text() {
8427 let input = "Hello, this is a normal response.";
8428 assert_eq!(strip_tool_result_blocks(input), input);
8429 }
8430
8431 #[test]
8432 fn strip_tool_result_blocks_returns_empty_for_only_tags() {
8433 let input = "<tool_result name=\"memory_recall\" status=\"ok\">\n{}\n</tool_result>";
8434 assert_eq!(strip_tool_result_blocks(input), "");
8435 }
8436
8437 #[test]
8438 fn parse_arguments_value_handles_null() {
8439 let value = serde_json::json!(null);
8441 let result = parse_arguments_value(Some(&value));
8442 assert!(result.is_null());
8443 }
8444
8445 #[test]
8446 fn parse_tool_calls_handles_empty_tool_calls_array() {
8447 let response = r#"{"content": "Hello", "tool_calls": []}"#;
8449 let (text, calls) = parse_tool_calls(response);
8450 assert!(text.contains("Hello"));
8452 assert!(calls.is_empty());
8453 }
8454
8455 #[test]
8456 fn detect_tool_call_parse_issue_flags_malformed_payloads() {
8457 let response =
8458 "<tool_call>{\"name\":\"shell\",\"arguments\":{\"command\":\"pwd\"}</tool_call>";
8459 let issue = detect_tool_call_parse_issue(response, &[]);
8460 assert!(
8461 issue.is_some(),
8462 "malformed tool payload should be flagged for diagnostics"
8463 );
8464 }
8465
8466 #[test]
8467 fn detect_tool_call_parse_issue_ignores_normal_text() {
8468 let issue = detect_tool_call_parse_issue("Thanks, done.", &[]);
8469 assert!(issue.is_none());
8470 }
8471
8472 #[test]
8473 fn parse_tool_calls_handles_whitespace_only_name() {
8474 let value = serde_json::json!({"function": {"name": " ", "arguments": {}}});
8476 let result = parse_tool_call_value(&value);
8477 assert!(result.is_none());
8478 }
8479
8480 #[test]
8481 fn parse_tool_calls_handles_empty_string_arguments() {
8482 let value = serde_json::json!({"name": "test", "arguments": ""});
8484 let result = parse_tool_call_value(&value);
8485 assert!(result.is_some());
8486 assert_eq!(result.unwrap().name, "test");
8487 }
8488
8489 #[test]
8494 fn trim_history_with_no_system_prompt() {
8495 let mut history = vec![];
8497 for i in 0..DEFAULT_MAX_HISTORY_MESSAGES + 20 {
8498 history.push(ChatMessage::user(format!("msg {i}")));
8499 }
8500 trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
8501 assert_eq!(history.len(), DEFAULT_MAX_HISTORY_MESSAGES);
8502 }
8503
8504 #[test]
8505 fn trim_history_preserves_role_ordering() {
8506 let mut history = vec![ChatMessage::system("system")];
8508 for i in 0..DEFAULT_MAX_HISTORY_MESSAGES + 10 {
8509 history.push(ChatMessage::user(format!("user {i}")));
8510 history.push(ChatMessage::assistant(format!("assistant {i}")));
8511 }
8512 trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
8513 assert_eq!(history[0].role, "system");
8514 assert_eq!(history[history.len() - 1].role, "assistant");
8515 }
8516
8517 #[test]
8518 fn trim_history_with_only_system_prompt() {
8519 let mut history = vec![ChatMessage::system("system prompt")];
8521 trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
8522 assert_eq!(history.len(), 1);
8523 }
8524
8525 #[test]
8530 fn parse_arguments_value_handles_invalid_json_string() {
8531 let value = serde_json::Value::String("not valid json".to_string());
8533 let result = parse_arguments_value(Some(&value));
8534 assert!(result.is_object());
8535 assert!(result.as_object().unwrap().is_empty());
8536 }
8537
8538 #[test]
8539 fn parse_arguments_value_handles_none() {
8540 let result = parse_arguments_value(None);
8542 assert!(result.is_object());
8543 assert!(result.as_object().unwrap().is_empty());
8544 }
8545
8546 #[test]
8551 fn extract_json_values_handles_empty_string() {
8552 let result = extract_json_values("");
8554 assert!(result.is_empty());
8555 }
8556
8557 #[test]
8558 fn extract_json_values_handles_whitespace_only() {
8559 let result = extract_json_values(" \n\t ");
8561 assert!(result.is_empty());
8562 }
8563
8564 #[test]
8565 fn extract_json_values_handles_multiple_objects() {
8566 let input = r#"{"a": 1}{"b": 2}{"c": 3}"#;
8568 let result = extract_json_values(input);
8569 assert_eq!(result.len(), 3);
8570 }
8571
8572 #[test]
8573 fn extract_json_values_handles_arrays() {
8574 let input = r#"[1, 2, 3]{"key": "value"}"#;
8576 let result = extract_json_values(input);
8577 assert_eq!(result.len(), 2);
8578 }
8579
8580 const _: () = {
8585 assert!(DEFAULT_MAX_TOOL_ITERATIONS > 0);
8586 assert!(DEFAULT_MAX_TOOL_ITERATIONS <= 100);
8587 assert!(DEFAULT_MAX_HISTORY_MESSAGES > 0);
8588 assert!(DEFAULT_MAX_HISTORY_MESSAGES <= 1000);
8589 };
8590
8591 #[test]
8592 fn constants_bounds_are_compile_time_checked() {
8593 }
8595
8596 #[test]
8601 fn parse_tool_call_value_handles_missing_name_field() {
8602 let value = serde_json::json!({"function": {"arguments": {}}});
8604 let result = parse_tool_call_value(&value);
8605 assert!(result.is_none());
8606 }
8607
8608 #[test]
8609 fn parse_tool_call_value_handles_top_level_name() {
8610 let value = serde_json::json!({"name": "test_tool", "arguments": {}});
8612 let result = parse_tool_call_value(&value);
8613 assert!(result.is_some());
8614 assert_eq!(result.unwrap().name, "test_tool");
8615 }
8616
8617 #[test]
8618 fn parse_tool_call_value_accepts_top_level_parameters_alias() {
8619 let value = serde_json::json!({
8620 "name": "schedule",
8621 "parameters": {"action": "create", "message": "test"}
8622 });
8623 let result = parse_tool_call_value(&value).expect("tool call should parse");
8624 assert_eq!(result.name, "schedule");
8625 assert_eq!(
8626 result.arguments.get("action").and_then(|v| v.as_str()),
8627 Some("create")
8628 );
8629 }
8630
8631 #[test]
8632 fn parse_tool_call_value_accepts_function_parameters_alias() {
8633 let value = serde_json::json!({
8634 "function": {
8635 "name": "shell",
8636 "parameters": {"command": "date"}
8637 }
8638 });
8639 let result = parse_tool_call_value(&value).expect("tool call should parse");
8640 assert_eq!(result.name, "shell");
8641 assert_eq!(
8642 result.arguments.get("command").and_then(|v| v.as_str()),
8643 Some("date")
8644 );
8645 }
8646
8647 #[test]
8648 fn parse_tool_call_value_preserves_tool_call_id_aliases() {
8649 let value = serde_json::json!({
8650 "call_id": "legacy_1",
8651 "function": {
8652 "name": "shell",
8653 "arguments": {"command": "date"}
8654 }
8655 });
8656 let result = parse_tool_call_value(&value).expect("tool call should parse");
8657 assert_eq!(result.tool_call_id.as_deref(), Some("legacy_1"));
8658 }
8659
8660 #[test]
8661 fn parse_tool_calls_from_json_value_handles_empty_array() {
8662 let value = serde_json::json!({"tool_calls": []});
8664 let result = parse_tool_calls_from_json_value(&value);
8665 assert!(result.is_empty());
8666 }
8667
8668 #[test]
8669 fn parse_tool_calls_from_json_value_handles_missing_tool_calls() {
8670 let value = serde_json::json!({"name": "test", "arguments": {}});
8672 let result = parse_tool_calls_from_json_value(&value);
8673 assert_eq!(result.len(), 1);
8674 }
8675
8676 #[test]
8677 fn parse_tool_calls_from_json_value_handles_top_level_array() {
8678 let value = serde_json::json!([
8680 {"name": "tool_a", "arguments": {}},
8681 {"name": "tool_b", "arguments": {}}
8682 ]);
8683 let result = parse_tool_calls_from_json_value(&value);
8684 assert_eq!(result.len(), 2);
8685 }
8686
8687 #[test]
8692 fn parse_glm_style_browser_open_url() {
8693 let response = "browser_open/url>https://example.com";
8694 let calls = parse_glm_style_tool_calls(response);
8695 assert_eq!(calls.len(), 1);
8696 assert_eq!(calls[0].0, "shell");
8697 assert!(calls[0].1["command"].as_str().unwrap().contains("curl"));
8698 assert!(
8699 calls[0].1["command"]
8700 .as_str()
8701 .unwrap()
8702 .contains("example.com")
8703 );
8704 }
8705
8706 #[test]
8707 fn parse_glm_style_shell_command() {
8708 let response = "shell/command>ls -la";
8709 let calls = parse_glm_style_tool_calls(response);
8710 assert_eq!(calls.len(), 1);
8711 assert_eq!(calls[0].0, "shell");
8712 assert_eq!(calls[0].1["command"], "ls -la");
8713 }
8714
8715 #[test]
8716 fn parse_glm_style_http_request() {
8717 let response = "http_request/url>https://api.example.com/data";
8718 let calls = parse_glm_style_tool_calls(response);
8719 assert_eq!(calls.len(), 1);
8720 assert_eq!(calls[0].0, "http_request");
8721 assert_eq!(calls[0].1["url"], "https://api.example.com/data");
8722 assert_eq!(calls[0].1["method"], "GET");
8723 }
8724
8725 #[test]
8726 fn parse_glm_style_ignores_plain_url() {
8727 let response = "https://example.com/api";
8730 let calls = parse_glm_style_tool_calls(response);
8731 assert!(
8732 calls.is_empty(),
8733 "plain URL must not be parsed as tool call"
8734 );
8735 }
8736
8737 #[test]
8738 fn parse_glm_style_json_args() {
8739 let response = r#"shell/{"command": "echo hello"}"#;
8740 let calls = parse_glm_style_tool_calls(response);
8741 assert_eq!(calls.len(), 1);
8742 assert_eq!(calls[0].0, "shell");
8743 assert_eq!(calls[0].1["command"], "echo hello");
8744 }
8745
8746 #[test]
8747 fn parse_glm_style_multiple_calls() {
8748 let response = r#"shell/command>ls
8749browser_open/url>https://example.com"#;
8750 let calls = parse_glm_style_tool_calls(response);
8751 assert_eq!(calls.len(), 2);
8752 }
8753
8754 #[test]
8755 fn parse_glm_style_tool_call_integration() {
8756 let response = "Checking...\nbrowser_open/url>https://example.com\nDone";
8758 let (text, calls) = parse_tool_calls(response);
8759 assert_eq!(calls.len(), 1);
8760 assert_eq!(calls[0].name, "shell");
8761 assert!(text.contains("Checking"));
8762 assert!(text.contains("Done"));
8763 }
8764
8765 #[test]
8766 fn parse_glm_style_rejects_non_http_url_param() {
8767 let response = "browser_open/url>javascript:alert(1)";
8768 let calls = parse_glm_style_tool_calls(response);
8769 assert!(calls.is_empty());
8770 }
8771
8772 #[test]
8773 fn parse_tool_calls_handles_unclosed_tool_call_tag() {
8774 let response = "<tool_call>{\"name\":\"shell\",\"arguments\":{\"command\":\"pwd\"}}\nDone";
8775 let (text, calls) = parse_tool_calls(response);
8776 assert_eq!(calls.len(), 1);
8777 assert_eq!(calls[0].name, "shell");
8778 assert_eq!(calls[0].arguments["command"], "pwd");
8779 assert_eq!(text, "Done");
8780 }
8781
8782 #[test]
8788 fn parse_tool_calls_empty_input_returns_empty() {
8789 let (text, calls) = parse_tool_calls("");
8790 assert!(calls.is_empty(), "empty input should produce no tool calls");
8791 assert!(text.is_empty(), "empty input should produce no text");
8792 }
8793
8794 #[test]
8795 fn parse_tool_calls_whitespace_only_returns_empty_calls() {
8796 let (text, calls) = parse_tool_calls(" \n\t ");
8797 assert!(calls.is_empty());
8798 assert!(text.is_empty() || text.trim().is_empty());
8799 }
8800
8801 #[test]
8802 fn parse_tool_calls_nested_xml_tags_handled() {
8803 let response = r#"<tool_call><tool_call>{"name":"echo","arguments":{"msg":"hi"}}</tool_call></tool_call>"#;
8805 let (_text, calls) = parse_tool_calls(response);
8806 assert!(
8808 !calls.is_empty(),
8809 "nested XML tags should still yield at least one tool call"
8810 );
8811 }
8812
8813 #[test]
8814 fn parse_tool_calls_truncated_json_no_panic() {
8815 let response = r#"<tool_call>{"name":"shell","arguments":{"command":"ls"</tool_call>"#;
8817 let (_text, _calls) = parse_tool_calls(response);
8818 }
8820
8821 #[test]
8822 fn parse_tool_calls_empty_json_object_in_tag() {
8823 let response = "<tool_call>{}</tool_call>";
8824 let (_text, calls) = parse_tool_calls(response);
8825 assert!(
8827 calls.is_empty(),
8828 "empty JSON object should not produce a tool call"
8829 );
8830 }
8831
8832 #[test]
8833 fn parse_tool_calls_closing_tag_only_returns_text() {
8834 let response = "Some text </tool_call> more text";
8835 let (text, calls) = parse_tool_calls(response);
8836 assert!(
8837 calls.is_empty(),
8838 "closing tag only should not produce calls"
8839 );
8840 assert!(
8841 !text.is_empty(),
8842 "text around orphaned closing tag should be preserved"
8843 );
8844 }
8845
8846 #[test]
8847 fn parse_tool_calls_very_large_arguments_no_panic() {
8848 let large_arg = "x".repeat(100_000);
8849 let response = format!(
8850 r#"<tool_call>{{"name":"echo","arguments":{{"message":"{}"}}}}</tool_call>"#,
8851 large_arg
8852 );
8853 let (_text, calls) = parse_tool_calls(&response);
8854 assert_eq!(calls.len(), 1, "large arguments should still parse");
8855 assert_eq!(calls[0].name, "echo");
8856 }
8857
8858 #[test]
8859 fn parse_tool_calls_special_characters_in_arguments() {
8860 let response = r#"<tool_call>{"name":"echo","arguments":{"message":"hello \"world\" <>&'\n\t"}}</tool_call>"#;
8861 let (_text, calls) = parse_tool_calls(response);
8862 assert_eq!(calls.len(), 1);
8863 assert_eq!(calls[0].name, "echo");
8864 }
8865
8866 #[test]
8867 fn parse_tool_calls_text_with_embedded_json_not_extracted() {
8868 let response = r#"Here is some data: {"name":"echo","arguments":{"message":"hi"}} end."#;
8870 let (_text, calls) = parse_tool_calls(response);
8871 assert!(
8872 calls.is_empty(),
8873 "raw JSON in text without tags should not be extracted"
8874 );
8875 }
8876
8877 #[test]
8878 fn parse_tool_calls_multiple_formats_mixed() {
8879 let response = r#"I'll help you with that.
8881
8882<tool_call>
8883{"name":"shell","arguments":{"command":"echo hello"}}
8884</tool_call>
8885
8886Let me check the result."#;
8887 let (text, calls) = parse_tool_calls(response);
8888 assert_eq!(
8889 calls.len(),
8890 1,
8891 "should extract one tool call from mixed content"
8892 );
8893 assert_eq!(calls[0].name, "shell");
8894 assert!(
8895 text.contains("help you"),
8896 "text before tool call should be preserved"
8897 );
8898 }
8899
8900 #[test]
8905 fn scrub_credentials_empty_input() {
8906 let result = scrub_credentials("");
8907 assert_eq!(result, "");
8908 }
8909
8910 #[test]
8911 fn scrub_credentials_no_sensitive_data() {
8912 let input = "normal text without any secrets";
8913 let result = scrub_credentials(input);
8914 assert_eq!(
8915 result, input,
8916 "non-sensitive text should pass through unchanged"
8917 );
8918 }
8919
8920 #[test]
8921 fn scrub_credentials_multibyte_chars_no_panic() {
8922 let input = "password=\"\u{4f60}\u{7684}WiFi\u{5bc6}\u{7801}ab\"";
8927 let result = scrub_credentials(input);
8928 assert!(
8929 result.contains("[REDACTED]"),
8930 "multi-byte quoted value should be redacted without panic, got: {result}"
8931 );
8932 }
8933
8934 #[test]
8935 fn scrub_credentials_short_values_not_redacted() {
8936 let input = r#"api_key="short""#;
8938 let result = scrub_credentials(input);
8939 assert_eq!(result, input, "short values should not be redacted");
8940 }
8941
8942 #[test]
8947 fn trim_history_empty_history() {
8948 let mut history: Vec<crate::providers::ChatMessage> = vec![];
8949 trim_history(&mut history, 10);
8950 assert!(history.is_empty());
8951 }
8952
8953 #[test]
8954 fn trim_history_system_only() {
8955 let mut history = vec![crate::providers::ChatMessage::system("system prompt")];
8956 trim_history(&mut history, 10);
8957 assert_eq!(history.len(), 1);
8958 assert_eq!(history[0].role, "system");
8959 }
8960
8961 #[test]
8962 fn trim_history_exactly_at_limit() {
8963 let mut history = vec![
8964 crate::providers::ChatMessage::system("system"),
8965 crate::providers::ChatMessage::user("msg 1"),
8966 crate::providers::ChatMessage::assistant("reply 1"),
8967 ];
8968 trim_history(&mut history, 2); assert_eq!(history.len(), 3, "should not trim when exactly at limit");
8970 }
8971
8972 #[test]
8973 fn trim_history_removes_oldest_non_system() {
8974 let mut history = vec![
8975 crate::providers::ChatMessage::system("system"),
8976 crate::providers::ChatMessage::user("old msg"),
8977 crate::providers::ChatMessage::assistant("old reply"),
8978 crate::providers::ChatMessage::user("new msg"),
8979 crate::providers::ChatMessage::assistant("new reply"),
8980 ];
8981 trim_history(&mut history, 2);
8982 assert_eq!(history.len(), 3); assert_eq!(history[0].role, "system");
8984 assert_eq!(history[1].content, "new msg");
8985 }
8986
8987 #[test]
8992 fn native_tools_system_prompt_contains_zero_xml() {
8993 use crate::channels::build_system_prompt_with_mode;
8994
8995 let tool_summaries: Vec<(&str, &str)> = vec![
8996 ("shell", "Execute shell commands"),
8997 ("file_read", "Read files"),
8998 ];
8999
9000 let system_prompt = build_system_prompt_with_mode(
9001 std::path::Path::new("/tmp"),
9002 "test-model",
9003 &tool_summaries,
9004 &[], None, None, true, crate::config::SkillsPromptInjectionMode::Full,
9009 crate::security::AutonomyLevel::default(),
9010 );
9011
9012 assert!(
9014 !system_prompt.contains("<tool_call>"),
9015 "Native prompt must not contain <tool_call>"
9016 );
9017 assert!(
9018 !system_prompt.contains("</tool_call>"),
9019 "Native prompt must not contain </tool_call>"
9020 );
9021 assert!(
9022 !system_prompt.contains("<tool_result>"),
9023 "Native prompt must not contain <tool_result>"
9024 );
9025 assert!(
9026 !system_prompt.contains("</tool_result>"),
9027 "Native prompt must not contain </tool_result>"
9028 );
9029 assert!(
9030 !system_prompt.contains("## Tool Use Protocol"),
9031 "Native prompt must not contain XML protocol header"
9032 );
9033
9034 assert!(
9036 system_prompt.contains("shell"),
9037 "Native prompt must list tool names"
9038 );
9039 assert!(
9040 system_prompt.contains("## Your Task"),
9041 "Native prompt should contain task instructions"
9042 );
9043 }
9044
9045 #[test]
9048 fn parse_tool_calls_cross_alias_close_tag_with_json() {
9049 let input = r#"<tool_call>{"name": "shell", "arguments": {"command": "ls"}}</invoke>"#;
9051 let (text, calls) = parse_tool_calls(input);
9052 assert_eq!(calls.len(), 1);
9053 assert_eq!(calls[0].name, "shell");
9054 assert_eq!(calls[0].arguments["command"], "ls");
9055 assert!(text.is_empty());
9056 }
9057
9058 #[test]
9059 fn parse_tool_calls_cross_alias_close_tag_with_glm_shortened() {
9060 let input = "<tool_call>shell>uname -a</invoke>";
9062 let (text, calls) = parse_tool_calls(input);
9063 assert_eq!(calls.len(), 1);
9064 assert_eq!(calls[0].name, "shell");
9065 assert_eq!(calls[0].arguments["command"], "uname -a");
9066 assert!(text.is_empty());
9067 }
9068
9069 #[test]
9070 fn parse_tool_calls_glm_shortened_body_in_matched_tags() {
9071 let input = "<tool_call>shell>pwd</tool_call>";
9073 let (text, calls) = parse_tool_calls(input);
9074 assert_eq!(calls.len(), 1);
9075 assert_eq!(calls[0].name, "shell");
9076 assert_eq!(calls[0].arguments["command"], "pwd");
9077 assert!(text.is_empty());
9078 }
9079
9080 #[test]
9081 fn parse_tool_calls_glm_yaml_style_in_tags() {
9082 let input = "<tool_call>shell>\ncommand: date\napproved: true</invoke>";
9084 let (text, calls) = parse_tool_calls(input);
9085 assert_eq!(calls.len(), 1);
9086 assert_eq!(calls[0].name, "shell");
9087 assert_eq!(calls[0].arguments["command"], "date");
9088 assert_eq!(calls[0].arguments["approved"], true);
9089 assert!(text.is_empty());
9090 }
9091
9092 #[test]
9093 fn parse_tool_calls_attribute_style_in_tags() {
9094 let input = r#"<tool_call>shell command="date" /></tool_call>"#;
9096 let (text, calls) = parse_tool_calls(input);
9097 assert_eq!(calls.len(), 1);
9098 assert_eq!(calls[0].name, "shell");
9099 assert_eq!(calls[0].arguments["command"], "date");
9100 assert!(text.is_empty());
9101 }
9102
9103 #[test]
9104 fn parse_tool_calls_file_read_shortened_in_cross_alias() {
9105 let input = r#"<tool_call>file_read path=".env" /></invoke>"#;
9107 let (text, calls) = parse_tool_calls(input);
9108 assert_eq!(calls.len(), 1);
9109 assert_eq!(calls[0].name, "file_read");
9110 assert_eq!(calls[0].arguments["path"], ".env");
9111 assert!(text.is_empty());
9112 }
9113
9114 #[test]
9115 fn parse_tool_calls_unclosed_glm_shortened_no_close_tag() {
9116 let input = "<tool_call>shell>ls -la";
9118 let (text, calls) = parse_tool_calls(input);
9119 assert_eq!(calls.len(), 1);
9120 assert_eq!(calls[0].name, "shell");
9121 assert_eq!(calls[0].arguments["command"], "ls -la");
9122 assert!(text.is_empty());
9123 }
9124
9125 #[test]
9126 fn parse_tool_calls_text_before_cross_alias() {
9127 let input = "Let me check that.\n<tool_call>shell>uname -a</invoke>\nDone.";
9129 let (text, calls) = parse_tool_calls(input);
9130 assert_eq!(calls.len(), 1);
9131 assert_eq!(calls[0].name, "shell");
9132 assert_eq!(calls[0].arguments["command"], "uname -a");
9133 assert!(text.contains("Let me check that."));
9134 assert!(text.contains("Done."));
9135 }
9136
9137 #[test]
9138 fn parse_glm_shortened_body_url_to_curl() {
9139 let call = parse_glm_shortened_body("shell>https://example.com/api").unwrap();
9141 assert_eq!(call.name, "shell");
9142 let cmd = call.arguments["command"].as_str().unwrap();
9143 assert!(cmd.contains("curl"));
9144 assert!(cmd.contains("example.com"));
9145 }
9146
9147 #[test]
9148 fn parse_glm_shortened_body_browser_open_maps_to_shell_command() {
9149 let call = parse_glm_shortened_body("browser_open>https://example.com").unwrap();
9152 assert_eq!(call.name, "shell");
9153 let cmd = call.arguments["command"].as_str().unwrap();
9154 assert!(cmd.contains("curl"));
9155 assert!(cmd.contains("example.com"));
9156 }
9157
9158 #[test]
9159 fn parse_glm_shortened_body_memory_recall() {
9160 let call = parse_glm_shortened_body("memory_recall>recent meetings").unwrap();
9162 assert_eq!(call.name, "memory_recall");
9163 assert_eq!(call.arguments["query"], "recent meetings");
9164 }
9165
9166 #[test]
9167 fn parse_glm_shortened_body_function_style_alias_maps_to_message_send() {
9168 let call =
9169 parse_glm_shortened_body(r#"sendmessage(channel="alerts", message="hi")"#).unwrap();
9170 assert_eq!(call.name, "message_send");
9171 assert_eq!(call.arguments["channel"], "alerts");
9172 assert_eq!(call.arguments["message"], "hi");
9173 }
9174
9175 #[test]
9176 fn map_tool_name_alias_direct_coverage() {
9177 assert_eq!(map_tool_name_alias("bash"), "shell");
9178 assert_eq!(map_tool_name_alias("filelist"), "file_list");
9179 assert_eq!(map_tool_name_alias("memorystore"), "memory_store");
9180 assert_eq!(map_tool_name_alias("memoryforget"), "memory_forget");
9181 assert_eq!(map_tool_name_alias("http"), "http_request");
9182 assert_eq!(
9183 map_tool_name_alias("totally_unknown_tool"),
9184 "totally_unknown_tool"
9185 );
9186 }
9187
9188 #[test]
9189 fn default_param_for_tool_coverage() {
9190 assert_eq!(default_param_for_tool("shell"), "command");
9191 assert_eq!(default_param_for_tool("bash"), "command");
9192 assert_eq!(default_param_for_tool("file_read"), "path");
9193 assert_eq!(default_param_for_tool("memory_recall"), "query");
9194 assert_eq!(default_param_for_tool("memory_store"), "content");
9195 assert_eq!(default_param_for_tool("web_search_tool"), "query");
9196 assert_eq!(default_param_for_tool("web_search"), "query");
9197 assert_eq!(default_param_for_tool("search"), "query");
9198 assert_eq!(default_param_for_tool("http_request"), "url");
9199 assert_eq!(default_param_for_tool("browser_open"), "url");
9200 assert_eq!(default_param_for_tool("unknown_tool"), "input");
9201 }
9202
9203 #[test]
9204 fn parse_glm_shortened_body_rejects_empty() {
9205 assert!(parse_glm_shortened_body("").is_none());
9206 assert!(parse_glm_shortened_body(" ").is_none());
9207 }
9208
9209 #[test]
9210 fn parse_glm_shortened_body_rejects_invalid_tool_name() {
9211 assert!(parse_glm_shortened_body("not-a-tool>value").is_none());
9213 assert!(parse_glm_shortened_body("tool name>value").is_none());
9214 }
9215
9216 #[test]
9221 fn build_native_assistant_history_includes_reasoning_content() {
9222 let calls = vec![ToolCall {
9223 id: "call_1".into(),
9224 name: "shell".into(),
9225 arguments: "{}".into(),
9226 }];
9227 let result = build_native_assistant_history("answer", &calls, Some("thinking step"));
9228 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
9229 assert_eq!(parsed["content"].as_str(), Some("answer"));
9230 assert_eq!(parsed["reasoning_content"].as_str(), Some("thinking step"));
9231 assert!(parsed["tool_calls"].is_array());
9232 }
9233
9234 #[test]
9235 fn build_native_assistant_history_omits_reasoning_content_when_none() {
9236 let calls = vec![ToolCall {
9237 id: "call_1".into(),
9238 name: "shell".into(),
9239 arguments: "{}".into(),
9240 }];
9241 let result = build_native_assistant_history("answer", &calls, None);
9242 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
9243 assert_eq!(parsed["content"].as_str(), Some("answer"));
9244 assert!(parsed.get("reasoning_content").is_none());
9245 }
9246
9247 #[test]
9248 fn build_native_assistant_history_from_parsed_calls_includes_reasoning_content() {
9249 let calls = vec![ParsedToolCall {
9250 name: "shell".into(),
9251 arguments: serde_json::json!({"command": "pwd"}),
9252 tool_call_id: Some("call_2".into()),
9253 }];
9254 let result = build_native_assistant_history_from_parsed_calls(
9255 "answer",
9256 &calls,
9257 Some("deep thought"),
9258 );
9259 assert!(result.is_some());
9260 let parsed: serde_json::Value = serde_json::from_str(result.as_deref().unwrap()).unwrap();
9261 assert_eq!(parsed["content"].as_str(), Some("answer"));
9262 assert_eq!(parsed["reasoning_content"].as_str(), Some("deep thought"));
9263 assert!(parsed["tool_calls"].is_array());
9264 }
9265
9266 #[test]
9267 fn build_native_assistant_history_from_parsed_calls_omits_reasoning_content_when_none() {
9268 let calls = vec![ParsedToolCall {
9269 name: "shell".into(),
9270 arguments: serde_json::json!({"command": "pwd"}),
9271 tool_call_id: Some("call_2".into()),
9272 }];
9273 let result = build_native_assistant_history_from_parsed_calls("answer", &calls, None);
9274 assert!(result.is_some());
9275 let parsed: serde_json::Value = serde_json::from_str(result.as_deref().unwrap()).unwrap();
9276 assert_eq!(parsed["content"].as_str(), Some("answer"));
9277 assert!(parsed.get("reasoning_content").is_none());
9278 }
9279
9280 #[test]
9283 fn glob_match_exact_no_wildcard() {
9284 assert!(glob_match("mcp_browser_navigate", "mcp_browser_navigate"));
9285 assert!(!glob_match("mcp_browser_navigate", "mcp_browser_click"));
9286 }
9287
9288 #[test]
9289 fn glob_match_prefix_wildcard() {
9290 assert!(glob_match("mcp_browser_*", "mcp_browser_navigate"));
9292 assert!(glob_match("mcp_browser_*", "mcp_browser_click"));
9293 assert!(!glob_match("mcp_browser_*", "mcp_filesystem_read"));
9294
9295 assert!(glob_match("*_read", "mcp_filesystem_read"));
9297 assert!(!glob_match("*_read", "mcp_filesystem_write"));
9298
9299 assert!(glob_match("mcp_*_navigate", "mcp_browser_navigate"));
9301 assert!(!glob_match("mcp_*_navigate", "mcp_browser_click"));
9302 }
9303
9304 #[test]
9305 fn glob_match_star_matches_everything() {
9306 assert!(glob_match("*", "anything_at_all"));
9307 assert!(glob_match("*", ""));
9308 }
9309
9310 fn make_spec(name: &str) -> crate::tools::ToolSpec {
9313 crate::tools::ToolSpec {
9314 name: name.to_string(),
9315 description: String::new(),
9316 parameters: serde_json::json!({}),
9317 }
9318 }
9319
9320 #[test]
9321 fn filter_tool_specs_no_groups_returns_all() {
9322 let specs = vec![
9323 make_spec("shell_exec"),
9324 make_spec("mcp_browser_navigate"),
9325 make_spec("mcp_filesystem_read"),
9326 ];
9327 let result = filter_tool_specs_for_turn(specs, &[], "hello");
9328 assert_eq!(result.len(), 3);
9329 }
9330
9331 #[test]
9332 fn filter_tool_specs_always_group_includes_matching_mcp_tool() {
9333 use crate::config::schema::{ToolFilterGroup, ToolFilterGroupMode};
9334
9335 let specs = vec![
9336 make_spec("shell_exec"),
9337 make_spec("mcp_browser_navigate"),
9338 make_spec("mcp_filesystem_read"),
9339 ];
9340 let groups = vec![ToolFilterGroup {
9341 mode: ToolFilterGroupMode::Always,
9342 tools: vec!["mcp_filesystem_*".into()],
9343 keywords: vec![],
9344 filter_builtins: false,
9345 }];
9346 let result = filter_tool_specs_for_turn(specs, &groups, "anything");
9347 let names: Vec<&str> = result.iter().map(|s| s.name.as_str()).collect();
9348 assert!(names.contains(&"shell_exec"));
9350 assert!(names.contains(&"mcp_filesystem_read"));
9351 assert!(!names.contains(&"mcp_browser_navigate"));
9352 }
9353
9354 #[test]
9355 fn filter_tool_specs_dynamic_group_included_on_keyword_match() {
9356 use crate::config::schema::{ToolFilterGroup, ToolFilterGroupMode};
9357
9358 let specs = vec![make_spec("shell_exec"), make_spec("mcp_browser_navigate")];
9359 let groups = vec![ToolFilterGroup {
9360 mode: ToolFilterGroupMode::Dynamic,
9361 tools: vec!["mcp_browser_*".into()],
9362 keywords: vec!["browse".into(), "website".into()],
9363 filter_builtins: false,
9364 }];
9365 let result = filter_tool_specs_for_turn(specs, &groups, "please browse this page");
9366 let names: Vec<&str> = result.iter().map(|s| s.name.as_str()).collect();
9367 assert!(names.contains(&"shell_exec"));
9368 assert!(names.contains(&"mcp_browser_navigate"));
9369 }
9370
9371 #[test]
9372 fn filter_tool_specs_dynamic_group_excluded_on_no_keyword_match() {
9373 use crate::config::schema::{ToolFilterGroup, ToolFilterGroupMode};
9374
9375 let specs = vec![make_spec("shell_exec"), make_spec("mcp_browser_navigate")];
9376 let groups = vec![ToolFilterGroup {
9377 mode: ToolFilterGroupMode::Dynamic,
9378 tools: vec!["mcp_browser_*".into()],
9379 keywords: vec!["browse".into(), "website".into()],
9380 filter_builtins: false,
9381 }];
9382 let result = filter_tool_specs_for_turn(specs, &groups, "read the file /etc/hosts");
9383 let names: Vec<&str> = result.iter().map(|s| s.name.as_str()).collect();
9384 assert!(names.contains(&"shell_exec"));
9385 assert!(!names.contains(&"mcp_browser_navigate"));
9386 }
9387
9388 #[test]
9389 fn filter_tool_specs_dynamic_keyword_match_is_case_insensitive() {
9390 use crate::config::schema::{ToolFilterGroup, ToolFilterGroupMode};
9391
9392 let specs = vec![make_spec("mcp_browser_navigate")];
9393 let groups = vec![ToolFilterGroup {
9394 mode: ToolFilterGroupMode::Dynamic,
9395 tools: vec!["mcp_browser_*".into()],
9396 keywords: vec!["Browse".into()],
9397 filter_builtins: false,
9398 }];
9399 let result = filter_tool_specs_for_turn(specs, &groups, "BROWSE the site");
9400 assert_eq!(result.len(), 1);
9401 }
9402
9403 #[test]
9406 fn estimate_history_tokens_empty() {
9407 assert_eq!(super::estimate_history_tokens(&[]), 0);
9408 }
9409
9410 #[test]
9411 fn estimate_history_tokens_single_message() {
9412 let history = vec![ChatMessage::user("hello world")]; let tokens = super::estimate_history_tokens(&history);
9414 assert_eq!(tokens, 7);
9416 }
9417
9418 #[test]
9419 fn estimate_history_tokens_multiple_messages() {
9420 let history = vec![
9421 ChatMessage::system("You are helpful."), ChatMessage::user("What is Rust?"), ChatMessage::assistant("A language."), ];
9425 let tokens = super::estimate_history_tokens(&history);
9426 assert_eq!(tokens, 23);
9427 }
9428
9429 #[tokio::test]
9430 async fn run_tool_call_loop_surfaces_tool_failure_reason_in_on_delta() {
9431 let provider = ScriptedProvider::from_text_responses(vec![
9432 r#"<tool_call>
9433{"name":"failing_shell","arguments":{"command":"rm -rf /"}}
9434</tool_call>"#,
9435 "I could not execute that command.",
9436 ]);
9437
9438 let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(FailingTool::new(
9439 "failing_shell",
9440 "Command not allowed by security policy: rm -rf /",
9441 ))];
9442
9443 let mut history = vec![
9444 ChatMessage::system("test-system"),
9445 ChatMessage::user("delete everything"),
9446 ];
9447 let observer = NoopObserver;
9448
9449 let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(64);
9450
9451 let result = run_tool_call_loop(
9452 &provider,
9453 &mut history,
9454 &tools_registry,
9455 &observer,
9456 "mock-provider",
9457 "mock-model",
9458 0.0,
9459 true,
9460 None,
9461 "telegram",
9462 None,
9463 &crate::config::MultimodalConfig::default(),
9464 4,
9465 None,
9466 Some(tx),
9467 None,
9468 &[],
9469 &[],
9470 None,
9471 None,
9472 &crate::config::PacingConfig::default(),
9473 0,
9474 0,
9475 None,
9476 )
9477 .await
9478 .expect("tool loop should complete");
9479
9480 let mut deltas = Vec::new();
9482 while let Ok(msg) = rx.try_recv() {
9483 deltas.push(msg);
9484 }
9485
9486 let all_deltas: String = deltas
9487 .iter()
9488 .filter_map(|d| match d {
9489 DraftEvent::Progress(t) | DraftEvent::Content(t) => Some(t.as_str()),
9490 DraftEvent::Clear => None,
9491 })
9492 .collect();
9493
9494 assert!(
9496 all_deltas.contains("Command not allowed by security policy"),
9497 "on_delta messages should include the tool failure reason, got: {all_deltas}"
9498 );
9499
9500 assert!(
9502 all_deltas.contains('\u{274c}'),
9503 "on_delta messages should include ❌ for failed tool calls, got: {all_deltas}"
9504 );
9505
9506 assert_eq!(result, "I could not execute that command.");
9507 }
9508
9509 #[test]
9512 fn filter_by_allowed_tools_none_passes_all() {
9513 let specs = vec![
9514 make_spec("shell"),
9515 make_spec("memory_store"),
9516 make_spec("file_read"),
9517 ];
9518 let result = filter_by_allowed_tools(specs, None);
9519 assert_eq!(result.len(), 3);
9520 }
9521
9522 #[test]
9523 fn filter_by_allowed_tools_some_restricts_to_listed() {
9524 let specs = vec![
9525 make_spec("shell"),
9526 make_spec("memory_store"),
9527 make_spec("file_read"),
9528 ];
9529 let allowed = vec!["shell".to_string(), "memory_store".to_string()];
9530 let result = filter_by_allowed_tools(specs, Some(&allowed));
9531 let names: Vec<&str> = result.iter().map(|s| s.name.as_str()).collect();
9532 assert_eq!(names.len(), 2);
9533 assert!(names.contains(&"shell"));
9534 assert!(names.contains(&"memory_store"));
9535 assert!(!names.contains(&"file_read"));
9536 }
9537
9538 #[test]
9539 fn filter_by_allowed_tools_unknown_names_silently_ignored() {
9540 let specs = vec![make_spec("shell"), make_spec("file_read")];
9541 let allowed = vec![
9542 "shell".to_string(),
9543 "nonexistent_tool".to_string(),
9544 "another_missing".to_string(),
9545 ];
9546 let result = filter_by_allowed_tools(specs, Some(&allowed));
9547 let names: Vec<&str> = result.iter().map(|s| s.name.as_str()).collect();
9548 assert_eq!(names.len(), 1);
9549 assert!(names.contains(&"shell"));
9550 }
9551
9552 #[test]
9553 fn filter_by_allowed_tools_empty_list_excludes_all() {
9554 let specs = vec![make_spec("shell"), make_spec("file_read")];
9555 let allowed: Vec<String> = vec![];
9556 let result = filter_by_allowed_tools(specs, Some(&allowed));
9557 assert!(result.is_empty());
9558 }
9559
9560 #[tokio::test]
9563 async fn cost_tracking_records_usage_when_scoped() {
9564 use super::{
9565 TOOL_LOOP_COST_TRACKING_CONTEXT, ToolLoopCostTrackingContext, run_tool_call_loop,
9566 };
9567 use crate::config::schema::ModelPricing;
9568 use crate::cost::CostTracker;
9569 use crate::observability::noop::NoopObserver;
9570 use std::collections::HashMap;
9571
9572 let provider = ScriptedProvider {
9573 responses: Arc::new(Mutex::new(VecDeque::from([ChatResponse {
9574 text: Some("done".to_string()),
9575 tool_calls: Vec::new(),
9576 usage: Some(crate::providers::traits::TokenUsage {
9577 input_tokens: Some(1_000),
9578 output_tokens: Some(200),
9579 cached_input_tokens: None,
9580 }),
9581 reasoning_content: None,
9582 }]))),
9583 capabilities: ProviderCapabilities::default(),
9584 };
9585 let observer = NoopObserver;
9586 let workspace = tempfile::TempDir::new().unwrap();
9587 let mut cost_config = crate::config::CostConfig {
9588 enabled: true,
9589 ..crate::config::CostConfig::default()
9590 };
9591 cost_config.prices = HashMap::from([(
9592 "mock-model".to_string(),
9593 ModelPricing {
9594 input: 3.0,
9595 output: 15.0,
9596 },
9597 )]);
9598 let tracker = Arc::new(CostTracker::new(cost_config.clone(), workspace.path()).unwrap());
9599 let ctx = ToolLoopCostTrackingContext::new(
9600 Arc::clone(&tracker),
9601 Arc::new(cost_config.prices.clone()),
9602 );
9603 let mut history = vec![ChatMessage::system("test"), ChatMessage::user("hello")];
9604
9605 let result = TOOL_LOOP_COST_TRACKING_CONTEXT
9606 .scope(
9607 Some(ctx),
9608 run_tool_call_loop(
9609 &provider,
9610 &mut history,
9611 &[],
9612 &observer,
9613 "mock-provider",
9614 "mock-model",
9615 0.0,
9616 true,
9617 None,
9618 "test",
9619 None,
9620 &crate::config::MultimodalConfig::default(),
9621 2,
9622 None,
9623 None,
9624 None,
9625 &[],
9626 &[],
9627 None,
9628 None,
9629 &crate::config::PacingConfig::default(),
9630 0,
9631 0,
9632 None,
9633 ),
9634 )
9635 .await
9636 .expect("tool loop should succeed");
9637
9638 assert_eq!(result, "done");
9639 let summary = tracker.get_summary().unwrap();
9640 assert_eq!(summary.request_count, 1);
9641 assert_eq!(summary.total_tokens, 1_200);
9642 assert!(summary.session_cost_usd > 0.0);
9643 }
9644
9645 #[tokio::test]
9646 async fn cost_tracking_enforces_budget() {
9647 use super::{
9648 TOOL_LOOP_COST_TRACKING_CONTEXT, ToolLoopCostTrackingContext, run_tool_call_loop,
9649 };
9650 use crate::config::schema::ModelPricing;
9651 use crate::cost::CostTracker;
9652 use crate::observability::noop::NoopObserver;
9653 use std::collections::HashMap;
9654
9655 let provider = ScriptedProvider::from_text_responses(vec!["should not reach this"]);
9656 let observer = NoopObserver;
9657 let workspace = tempfile::TempDir::new().unwrap();
9658 let cost_config = crate::config::CostConfig {
9659 enabled: true,
9660 daily_limit_usd: 0.001, ..crate::config::CostConfig::default()
9662 };
9663 let tracker = Arc::new(CostTracker::new(cost_config.clone(), workspace.path()).unwrap());
9664 tracker
9666 .record_usage(crate::cost::types::TokenUsage::new(
9667 "mock-model",
9668 100_000,
9669 50_000,
9670 1.0,
9671 1.0,
9672 ))
9673 .unwrap();
9674
9675 let ctx = ToolLoopCostTrackingContext::new(
9676 Arc::clone(&tracker),
9677 Arc::new(HashMap::from([(
9678 "mock-model".to_string(),
9679 ModelPricing {
9680 input: 1.0,
9681 output: 1.0,
9682 },
9683 )])),
9684 );
9685 let mut history = vec![ChatMessage::system("test"), ChatMessage::user("hello")];
9686
9687 let err = TOOL_LOOP_COST_TRACKING_CONTEXT
9688 .scope(
9689 Some(ctx),
9690 run_tool_call_loop(
9691 &provider,
9692 &mut history,
9693 &[],
9694 &observer,
9695 "mock-provider",
9696 "mock-model",
9697 0.0,
9698 true,
9699 None,
9700 "test",
9701 None,
9702 &crate::config::MultimodalConfig::default(),
9703 2,
9704 None,
9705 None,
9706 None,
9707 &[],
9708 &[],
9709 None,
9710 None,
9711 &crate::config::PacingConfig::default(),
9712 0,
9713 0,
9714 None,
9715 ),
9716 )
9717 .await
9718 .expect_err("should fail with budget exceeded");
9719
9720 assert!(
9721 err.to_string().contains("Budget exceeded"),
9722 "error should mention budget: {err}"
9723 );
9724 }
9725
9726 #[tokio::test]
9727 async fn cost_tracking_is_noop_without_scope() {
9728 use super::run_tool_call_loop;
9729 use crate::observability::noop::NoopObserver;
9730
9731 let provider = ScriptedProvider {
9733 responses: Arc::new(Mutex::new(VecDeque::from([ChatResponse {
9734 text: Some("ok".to_string()),
9735 tool_calls: Vec::new(),
9736 usage: Some(crate::providers::traits::TokenUsage {
9737 input_tokens: Some(500),
9738 output_tokens: Some(100),
9739 cached_input_tokens: None,
9740 }),
9741 reasoning_content: None,
9742 }]))),
9743 capabilities: ProviderCapabilities::default(),
9744 };
9745 let observer = NoopObserver;
9746 let mut history = vec![ChatMessage::system("test"), ChatMessage::user("hello")];
9747
9748 let result = run_tool_call_loop(
9749 &provider,
9750 &mut history,
9751 &[],
9752 &observer,
9753 "mock-provider",
9754 "mock-model",
9755 0.0,
9756 true,
9757 None,
9758 "test",
9759 None,
9760 &crate::config::MultimodalConfig::default(),
9761 2,
9762 None,
9763 None,
9764 None,
9765 &[],
9766 &[],
9767 None,
9768 None,
9769 &crate::config::PacingConfig::default(),
9770 0,
9771 0,
9772 None,
9773 )
9774 .await
9775 .expect("should succeed without cost scope");
9776
9777 assert_eq!(result, "ok");
9778 }
9779}