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()
2554 && provider.supports_streaming()
2555 && (request_tools.is_none()
2556 || !provider.supports_native_tools()
2557 || provider.supports_streaming_tool_events());
2558 tracing::debug!(
2559 has_on_delta = on_delta.is_some(),
2560 supports_streaming = provider.supports_streaming(),
2561 should_consume_provider_stream,
2562 "Streaming decision for iteration {}",
2563 iteration + 1,
2564 );
2565 let mut streamed_live_deltas = false;
2566
2567 let chat_result = if should_consume_provider_stream {
2568 match consume_provider_streaming_response(
2569 active_provider,
2570 &prepared_messages.messages,
2571 request_tools,
2572 active_model,
2573 temperature,
2574 cancellation_token.as_ref(),
2575 on_delta.as_ref(),
2576 )
2577 .await
2578 {
2579 Ok(streamed) => {
2580 streamed_live_deltas = streamed.forwarded_live_deltas;
2581 Ok(crate::providers::ChatResponse {
2582 text: Some(streamed.response_text),
2583 tool_calls: streamed.tool_calls,
2584 usage: None,
2585 reasoning_content: None,
2586 })
2587 }
2588 Err(stream_err) => {
2589 tracing::warn!(
2590 provider = active_provider_name,
2591 model = active_model,
2592 iteration = iteration + 1,
2593 "provider streaming failed, falling back to non-streaming chat: {stream_err}"
2594 );
2595 runtime_trace::record_event(
2596 "llm_stream_fallback",
2597 Some(channel_name),
2598 Some(active_provider_name),
2599 Some(active_model),
2600 Some(&turn_id),
2601 Some(false),
2602 Some("provider stream failed; fallback to non-streaming chat"),
2603 serde_json::json!({
2604 "iteration": iteration + 1,
2605 "error": scrub_credentials(&stream_err.to_string()),
2606 }),
2607 );
2608 if let Some(ref tx) = on_delta {
2609 let _ = tx.send(DraftEvent::Clear).await;
2610 }
2611 {
2612 let chat_future = active_provider.chat(
2613 ChatRequest {
2614 messages: &prepared_messages.messages,
2615 tools: request_tools,
2616 },
2617 active_model,
2618 temperature,
2619 );
2620 if let Some(token) = cancellation_token.as_ref() {
2621 tokio::select! {
2622 () = token.cancelled() => Err(ToolLoopCancelled.into()),
2623 result = chat_future => result,
2624 }
2625 } else {
2626 chat_future.await
2627 }
2628 }
2629 }
2630 }
2631 } else {
2632 let chat_future = active_provider.chat(
2635 ChatRequest {
2636 messages: &prepared_messages.messages,
2637 tools: request_tools,
2638 },
2639 active_model,
2640 temperature,
2641 );
2642
2643 match pacing.step_timeout_secs {
2644 Some(step_secs) if step_secs > 0 => {
2645 let step_timeout = Duration::from_secs(step_secs);
2646 if let Some(token) = cancellation_token.as_ref() {
2647 tokio::select! {
2648 () = token.cancelled() => return Err(ToolLoopCancelled.into()),
2649 result = tokio::time::timeout(step_timeout, chat_future) => {
2650 match result {
2651 Ok(inner) => inner,
2652 Err(_) => anyhow::bail!(
2653 "LLM inference step timed out after {step_secs}s (step_timeout_secs)"
2654 ),
2655 }
2656 },
2657 }
2658 } else {
2659 match tokio::time::timeout(step_timeout, chat_future).await {
2660 Ok(inner) => inner,
2661 Err(_) => anyhow::bail!(
2662 "LLM inference step timed out after {step_secs}s (step_timeout_secs)"
2663 ),
2664 }
2665 }
2666 }
2667 _ => {
2668 if let Some(token) = cancellation_token.as_ref() {
2669 tokio::select! {
2670 () = token.cancelled() => return Err(ToolLoopCancelled.into()),
2671 result = chat_future => result,
2672 }
2673 } else {
2674 chat_future.await
2675 }
2676 }
2677 }
2678 };
2679
2680 let (
2681 response_text,
2682 parsed_text,
2683 tool_calls,
2684 assistant_history_content,
2685 native_tool_calls,
2686 _parse_issue_detected,
2687 response_streamed_live,
2688 ) = match chat_result {
2689 Ok(resp) => {
2690 let (resp_input_tokens, resp_output_tokens) = resp
2691 .usage
2692 .as_ref()
2693 .map(|u| (u.input_tokens, u.output_tokens))
2694 .unwrap_or((None, None));
2695
2696 observer.record_event(&ObserverEvent::LlmResponse {
2697 provider: provider_name.to_string(),
2698 model: model.to_string(),
2699 duration: llm_started_at.elapsed(),
2700 success: true,
2701 error_message: None,
2702 input_tokens: resp_input_tokens,
2703 output_tokens: resp_output_tokens,
2704 });
2705
2706 let usage_for_cost = resp
2711 .usage
2712 .clone()
2713 .unwrap_or_else(crate::providers::traits::TokenUsage::default);
2714 let _ = record_tool_loop_cost_usage(provider_name, model, &usage_for_cost);
2715
2716 let response_text = resp.text_or_empty().to_string();
2717 let mut calls: Vec<ParsedToolCall> = resp
2722 .tool_calls
2723 .iter()
2724 .map(|call| ParsedToolCall {
2725 name: call.name.clone(),
2726 arguments: serde_json::from_str::<serde_json::Value>(&call.arguments)
2727 .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new())),
2728 tool_call_id: Some(call.id.clone()),
2729 })
2730 .collect();
2731 let mut parsed_text = String::new();
2732
2733 if calls.is_empty() {
2734 let (fallback_text, fallback_calls) = parse_tool_calls(&response_text);
2735 if !fallback_text.is_empty() {
2736 parsed_text = fallback_text;
2737 }
2738 calls = fallback_calls;
2739 }
2740
2741 let parse_issue = detect_tool_call_parse_issue(&response_text, &calls);
2742 if let Some(ref issue) = parse_issue {
2743 runtime_trace::record_event(
2744 "tool_call_parse_issue",
2745 Some(channel_name),
2746 Some(provider_name),
2747 Some(model),
2748 Some(&turn_id),
2749 Some(false),
2750 Some(issue.as_str()),
2751 serde_json::json!({
2752 "iteration": iteration + 1,
2753 "response_excerpt": truncate_with_ellipsis(
2754 &scrub_credentials(&response_text),
2755 600
2756 ),
2757 }),
2758 );
2759 }
2760
2761 runtime_trace::record_event(
2762 "llm_response",
2763 Some(channel_name),
2764 Some(provider_name),
2765 Some(model),
2766 Some(&turn_id),
2767 Some(true),
2768 None,
2769 serde_json::json!({
2770 "iteration": iteration + 1,
2771 "duration_ms": llm_started_at.elapsed().as_millis(),
2772 "input_tokens": resp_input_tokens,
2773 "output_tokens": resp_output_tokens,
2774 "raw_response": scrub_credentials(&response_text),
2775 "native_tool_calls": resp.tool_calls.len(),
2776 "parsed_tool_calls": calls.len(),
2777 }),
2778 );
2779
2780 let reasoning_content = resp.reasoning_content.clone();
2783 let assistant_history_content = if resp.tool_calls.is_empty() {
2784 if use_native_tools {
2785 build_native_assistant_history_from_parsed_calls(
2786 &response_text,
2787 &calls,
2788 reasoning_content.as_deref(),
2789 )
2790 .unwrap_or_else(|| response_text.clone())
2791 } else {
2792 response_text.clone()
2793 }
2794 } else {
2795 build_native_assistant_history(
2796 &response_text,
2797 &resp.tool_calls,
2798 reasoning_content.as_deref(),
2799 )
2800 };
2801
2802 let native_calls = resp.tool_calls;
2803 (
2804 response_text,
2805 parsed_text,
2806 calls,
2807 assistant_history_content,
2808 native_calls,
2809 parse_issue.is_some(),
2810 streamed_live_deltas,
2811 )
2812 }
2813 Err(e) => {
2814 let safe_error = crate::providers::sanitize_api_error(&e.to_string());
2815 observer.record_event(&ObserverEvent::LlmResponse {
2816 provider: provider_name.to_string(),
2817 model: model.to_string(),
2818 duration: llm_started_at.elapsed(),
2819 success: false,
2820 error_message: Some(safe_error.clone()),
2821 input_tokens: None,
2822 output_tokens: None,
2823 });
2824 runtime_trace::record_event(
2825 "llm_response",
2826 Some(channel_name),
2827 Some(provider_name),
2828 Some(model),
2829 Some(&turn_id),
2830 Some(false),
2831 Some(&safe_error),
2832 serde_json::json!({
2833 "iteration": iteration + 1,
2834 "duration_ms": llm_started_at.elapsed().as_millis(),
2835 }),
2836 );
2837
2838 if crate::providers::reliable::is_context_window_exceeded(&e) {
2840 tracing::warn!(
2841 iteration = iteration + 1,
2842 "Context window exceeded, attempting in-loop recovery"
2843 );
2844
2845 let chars_saved = fast_trim_tool_results(history, 4);
2847 if chars_saved > 0 {
2848 tracing::info!(
2849 chars_saved,
2850 "Context recovery: trimmed old tool results, retrying"
2851 );
2852 continue;
2853 }
2854
2855 let dropped = emergency_history_trim(history, 4);
2857 if dropped > 0 {
2858 tracing::info!(dropped, "Context recovery: dropped old messages, retrying");
2859 continue;
2860 }
2861
2862 tracing::error!("Context overflow unrecoverable: no trimmable messages");
2864 }
2865
2866 return Err(e);
2867 }
2868 };
2869
2870 let display_text = if parsed_text.is_empty() {
2871 response_text.clone()
2872 } else {
2873 parsed_text
2874 };
2875
2876 if let Some(ref tx) = on_delta {
2878 let llm_secs = llm_started_at.elapsed().as_secs();
2879 if !tool_calls.is_empty() {
2880 let _ = tx
2881 .send(DraftEvent::Progress(format!(
2882 "\u{1f4ac} Got {} tool call(s) ({llm_secs}s)\n",
2883 tool_calls.len()
2884 )))
2885 .await;
2886 }
2887 }
2888
2889 if tool_calls.is_empty() {
2890 runtime_trace::record_event(
2891 "turn_final_response",
2892 Some(channel_name),
2893 Some(provider_name),
2894 Some(model),
2895 Some(&turn_id),
2896 Some(true),
2897 None,
2898 serde_json::json!({
2899 "iteration": iteration + 1,
2900 "text": scrub_credentials(&display_text),
2901 }),
2902 );
2903 if let Some(ref tx) = on_delta {
2907 let should_emit_post_hoc_chunks =
2908 !response_streamed_live || display_text != response_text;
2909 if !should_emit_post_hoc_chunks {
2910 history.push(ChatMessage::assistant(response_text.clone()));
2911 return Ok(display_text);
2912 }
2913 let _ = tx.send(DraftEvent::Clear).await;
2915 let mut chunk = String::new();
2918 for word in display_text.split_inclusive(char::is_whitespace) {
2919 if cancellation_token
2920 .as_ref()
2921 .is_some_and(CancellationToken::is_cancelled)
2922 {
2923 return Err(ToolLoopCancelled.into());
2924 }
2925 chunk.push_str(word);
2926 if chunk.len() >= STREAM_CHUNK_MIN_CHARS
2927 && tx
2928 .send(DraftEvent::Content(std::mem::take(&mut chunk)))
2929 .await
2930 .is_err()
2931 {
2932 break; }
2934 }
2935 if !chunk.is_empty() {
2936 let _ = tx.send(DraftEvent::Content(chunk)).await;
2937 }
2938 }
2939 history.push(ChatMessage::assistant(response_text.clone()));
2940 return Ok(display_text);
2941 }
2942
2943 if !display_text.is_empty() {
2946 if !native_tool_calls.is_empty() {
2947 if let Some(ref tx) = on_delta {
2948 let mut narration = display_text.clone();
2949 if !narration.ends_with('\n') {
2950 narration.push('\n');
2951 }
2952 let _ = tx.send(DraftEvent::Content(narration)).await;
2953 }
2954 }
2955 if !silent {
2956 print!("{display_text}");
2957 let _ = std::io::stdout().flush();
2958 }
2959 }
2960
2961 let mut tool_results = String::new();
2967 let mut individual_results: Vec<(Option<String>, String)> = Vec::new();
2968 let mut ordered_results: Vec<Option<(String, Option<String>, ToolExecutionOutcome)>> =
2969 (0..tool_calls.len()).map(|_| None).collect();
2970 let allow_parallel_execution = should_execute_tools_in_parallel(&tool_calls, approval);
2971 let mut executable_indices: Vec<usize> = Vec::new();
2972 let mut executable_calls: Vec<ParsedToolCall> = Vec::new();
2973
2974 for (idx, call) in tool_calls.iter().enumerate() {
2975 let mut tool_name = call.name.clone();
2977 let mut tool_args = call.arguments.clone();
2978 if let Some(hooks) = hooks {
2979 match hooks
2980 .run_before_tool_call(tool_name.clone(), tool_args.clone())
2981 .await
2982 {
2983 crate::hooks::HookResult::Cancel(reason) => {
2984 tracing::info!(tool = %call.name, %reason, "tool call cancelled by hook");
2985 let cancelled = format!("Cancelled by hook: {reason}");
2986 runtime_trace::record_event(
2987 "tool_call_result",
2988 Some(channel_name),
2989 Some(provider_name),
2990 Some(model),
2991 Some(&turn_id),
2992 Some(false),
2993 Some(&cancelled),
2994 serde_json::json!({
2995 "iteration": iteration + 1,
2996 "tool": call.name,
2997 "arguments": scrub_credentials(&tool_args.to_string()),
2998 }),
2999 );
3000 if let Some(ref tx) = on_delta {
3001 let _ = tx
3002 .send(DraftEvent::Progress(format!(
3003 "\u{274c} {}: {}\n",
3004 call.name,
3005 truncate_with_ellipsis(&scrub_credentials(&cancelled), 200)
3006 )))
3007 .await;
3008 }
3009 ordered_results[idx] = Some((
3010 call.name.clone(),
3011 call.tool_call_id.clone(),
3012 ToolExecutionOutcome {
3013 output: cancelled,
3014 success: false,
3015 error_reason: Some(scrub_credentials(&reason)),
3016 duration: Duration::ZERO,
3017 },
3018 ));
3019 continue;
3020 }
3021 crate::hooks::HookResult::Continue((name, args)) => {
3022 tool_name = name;
3023 tool_args = args;
3024 }
3025 }
3026 }
3027
3028 maybe_inject_channel_delivery_defaults(
3029 &tool_name,
3030 &mut tool_args,
3031 channel_name,
3032 channel_reply_target,
3033 );
3034
3035 if let Some(mgr) = approval {
3037 if mgr.needs_approval(&tool_name) {
3038 let request = ApprovalRequest {
3039 tool_name: tool_name.clone(),
3040 arguments: tool_args.clone(),
3041 };
3042
3043 let decision = if mgr.is_non_interactive() {
3047 ApprovalResponse::No
3048 } else {
3049 mgr.prompt_cli(&request)
3050 };
3051
3052 mgr.record_decision(&tool_name, &tool_args, decision, channel_name);
3053
3054 if decision == ApprovalResponse::No {
3055 let denied = "Denied by user.".to_string();
3056 runtime_trace::record_event(
3057 "tool_call_result",
3058 Some(channel_name),
3059 Some(provider_name),
3060 Some(model),
3061 Some(&turn_id),
3062 Some(false),
3063 Some(&denied),
3064 serde_json::json!({
3065 "iteration": iteration + 1,
3066 "tool": tool_name.clone(),
3067 "arguments": scrub_credentials(&tool_args.to_string()),
3068 }),
3069 );
3070 if let Some(ref tx) = on_delta {
3071 let _ = tx
3072 .send(DraftEvent::Progress(format!(
3073 "\u{274c} {}: {}\n",
3074 tool_name, denied
3075 )))
3076 .await;
3077 }
3078 ordered_results[idx] = Some((
3079 tool_name.clone(),
3080 call.tool_call_id.clone(),
3081 ToolExecutionOutcome {
3082 output: denied.clone(),
3083 success: false,
3084 error_reason: Some(denied),
3085 duration: Duration::ZERO,
3086 },
3087 ));
3088 continue;
3089 }
3090 }
3091 }
3092
3093 let signature = {
3094 let canonical_args = canonicalize_json_for_tool_signature(&tool_args);
3095 let args_json =
3096 serde_json::to_string(&canonical_args).unwrap_or_else(|_| "{}".to_string());
3097 (tool_name.trim().to_ascii_lowercase(), args_json)
3098 };
3099 let dedup_exempt = dedup_exempt_tools.iter().any(|e| e == &tool_name);
3100 if !dedup_exempt && !seen_tool_signatures.insert(signature) {
3101 let duplicate = format!(
3102 "Skipped duplicate tool call '{tool_name}' with identical arguments in this turn."
3103 );
3104 runtime_trace::record_event(
3105 "tool_call_result",
3106 Some(channel_name),
3107 Some(provider_name),
3108 Some(model),
3109 Some(&turn_id),
3110 Some(false),
3111 Some(&duplicate),
3112 serde_json::json!({
3113 "iteration": iteration + 1,
3114 "tool": tool_name.clone(),
3115 "arguments": scrub_credentials(&tool_args.to_string()),
3116 "deduplicated": true,
3117 }),
3118 );
3119 if let Some(ref tx) = on_delta {
3120 let _ = tx
3121 .send(DraftEvent::Progress(format!(
3122 "\u{274c} {}: {}\n",
3123 tool_name, duplicate
3124 )))
3125 .await;
3126 }
3127 ordered_results[idx] = Some((
3128 tool_name.clone(),
3129 call.tool_call_id.clone(),
3130 ToolExecutionOutcome {
3131 output: duplicate.clone(),
3132 success: false,
3133 error_reason: Some(duplicate),
3134 duration: Duration::ZERO,
3135 },
3136 ));
3137 continue;
3138 }
3139
3140 runtime_trace::record_event(
3141 "tool_call_start",
3142 Some(channel_name),
3143 Some(provider_name),
3144 Some(model),
3145 Some(&turn_id),
3146 None,
3147 None,
3148 serde_json::json!({
3149 "iteration": iteration + 1,
3150 "tool": tool_name.clone(),
3151 "arguments": scrub_credentials(&tool_args.to_string()),
3152 }),
3153 );
3154
3155 if let Some(ref tx) = on_delta {
3157 let progress = if let Some(suffix) = tool_name.strip_prefix("construct-operator__")
3158 {
3159 match suffix {
3161 "create_agent" => {
3162 let title = tool_args
3163 .get("title")
3164 .and_then(|v| v.as_str())
3165 .unwrap_or("agent");
3166 format!("\u{1f916} Spawning agent: {title}\n")
3167 }
3168 "wait_for_agent" => "\u{23f3} Waiting for agent to finish…\n".to_string(),
3169 "send_agent_prompt" => {
3170 "\u{1f4e8} Sending follow-up to agent…\n".to_string()
3171 }
3172 "get_agent_activity" => "\u{1f4cb} Collecting agent results…\n".to_string(),
3173 "get_agent_status" => "\u{1f50d} Checking agent status…\n".to_string(),
3174 "list_agents" => "\u{1f4cb} Listing active agents…\n".to_string(),
3175 "search_agent_pool" | "list_agent_templates" => {
3176 "\u{1f50d} Searching agent pool…\n".to_string()
3177 }
3178 "save_agent_template" => {
3179 let name = tool_args
3180 .get("name")
3181 .and_then(|v| v.as_str())
3182 .unwrap_or("template");
3183 format!("\u{1f4be} Saving agent template: {name}\n")
3184 }
3185 "list_teams" | "search_teams" => "\u{1f50d} Searching teams…\n".to_string(),
3186 "get_team" => "\u{1f4cb} Loading team details…\n".to_string(),
3187 "spawn_team" => "\u{1f680} Deploying team…\n".to_string(),
3188 "create_team" => {
3189 let name = tool_args
3190 .get("name")
3191 .and_then(|v| v.as_str())
3192 .unwrap_or("team");
3193 format!("\u{1f4be} Creating team: {name}\n")
3194 }
3195 "get_budget_status" => "\u{1f4b0} Checking budget…\n".to_string(),
3196 "save_plan" => "\u{1f4be} Saving execution plan…\n".to_string(),
3197 "recall_plans" => "\u{1f50d} Searching past plans…\n".to_string(),
3198 "create_goal" => {
3199 let name = tool_args
3200 .get("name")
3201 .and_then(|v| v.as_str())
3202 .unwrap_or("goal");
3203 format!("\u{1f3af} Creating goal: {name}\n")
3204 }
3205 "get_goals" => "\u{1f3af} Loading goals…\n".to_string(),
3206 "update_goal" => "\u{1f3af} Updating goal…\n".to_string(),
3207 "record_agent_outcome" => {
3208 "\u{1f4ca} Recording agent outcome…\n".to_string()
3209 }
3210 "get_agent_trust" => "\u{1f4ca} Checking trust scores…\n".to_string(),
3211 "publish_to_clawhub" => "\u{1f4e4} Publishing to ClawHub…\n".to_string(),
3212 "search_clawhub" => {
3213 "\u{1f50d} Searching ClawHub marketplace…\n".to_string()
3214 }
3215 "install_from_clawhub" => {
3216 "\u{1f4e5} Installing from ClawHub…\n".to_string()
3217 }
3218 "list_nodes" => "\u{1f310} Discovering connected nodes…\n".to_string(),
3219 "invoke_node" => "\u{1f4e1} Invoking node capability…\n".to_string(),
3220 "get_session_history" => "\u{1f4c3} Loading session history…\n".to_string(),
3221 "archive_session" => "\u{1f4e6} Archiving session…\n".to_string(),
3222 "capture_skill" => {
3223 let name = tool_args
3224 .get("name")
3225 .and_then(|v| v.as_str())
3226 .unwrap_or("skill");
3227 format!("\u{1f4da} Capturing skill: {name}\n")
3228 }
3229 _ => format!("\u{2699}\u{fe0f} Operator: {suffix}\n"),
3230 }
3231 } else {
3232 let hint = {
3233 let raw = match tool_name.as_str() {
3234 "shell" => tool_args.get("command").and_then(|v| v.as_str()),
3235 "file_read" | "file_write" => {
3236 tool_args.get("path").and_then(|v| v.as_str())
3237 }
3238 _ => tool_args
3239 .get("action")
3240 .and_then(|v| v.as_str())
3241 .or_else(|| tool_args.get("query").and_then(|v| v.as_str())),
3242 };
3243 match raw {
3244 Some(s) => truncate_with_ellipsis(s, 60),
3245 None => String::new(),
3246 }
3247 };
3248 if hint.is_empty() {
3249 format!("\u{23f3} {}\n", tool_name)
3250 } else {
3251 format!("\u{23f3} {}: {hint}\n", tool_name)
3252 }
3253 };
3254 tracing::debug!(tool = %tool_name, "Sending progress start to draft");
3255 let _ = tx.send(DraftEvent::Progress(progress)).await;
3256 }
3257
3258 executable_indices.push(idx);
3259 executable_calls.push(ParsedToolCall {
3260 name: tool_name,
3261 arguments: tool_args,
3262 tool_call_id: call.tool_call_id.clone(),
3263 });
3264 }
3265
3266 let executed_outcomes = if allow_parallel_execution && executable_calls.len() > 1 {
3267 execute_tools_parallel(
3268 &executable_calls,
3269 tools_registry,
3270 activated_tools,
3271 observer,
3272 cancellation_token.as_ref(),
3273 )
3274 .await?
3275 } else {
3276 execute_tools_sequential(
3277 &executable_calls,
3278 tools_registry,
3279 activated_tools,
3280 observer,
3281 cancellation_token.as_ref(),
3282 )
3283 .await?
3284 };
3285
3286 for ((idx, call), outcome) in executable_indices
3287 .iter()
3288 .zip(executable_calls.iter())
3289 .zip(executed_outcomes.into_iter())
3290 {
3291 runtime_trace::record_event(
3292 "tool_call_result",
3293 Some(channel_name),
3294 Some(provider_name),
3295 Some(model),
3296 Some(&turn_id),
3297 Some(outcome.success),
3298 outcome.error_reason.as_deref(),
3299 serde_json::json!({
3300 "iteration": iteration + 1,
3301 "tool": call.name.clone(),
3302 "duration_ms": outcome.duration.as_millis(),
3303 "output": scrub_credentials(&outcome.output),
3304 }),
3305 );
3306
3307 if let Some(hooks) = hooks {
3309 let tool_result_obj = crate::tools::ToolResult {
3310 success: outcome.success,
3311 output: outcome.output.clone(),
3312 error: None,
3313 };
3314 hooks
3315 .fire_after_tool_call(&call.name, &tool_result_obj, outcome.duration)
3316 .await;
3317 }
3318
3319 if let Some(ref tx) = on_delta {
3321 let secs = outcome.duration.as_secs();
3322 let progress_msg = if let Some(suffix) =
3323 call.name.strip_prefix("construct-operator__")
3324 {
3325 if outcome.success {
3327 match suffix {
3328 "create_agent" => format!("\u{2705} Agent spawned ({secs}s)\n"),
3329 "wait_for_agent" => format!("\u{2705} Agent finished ({secs}s)\n"),
3330 "get_agent_activity" => {
3331 format!("\u{2705} Results collected ({secs}s)\n")
3332 }
3333 "save_agent_template" => format!("\u{2705} Template saved ({secs}s)\n"),
3334 "send_agent_prompt" => format!("\u{2705} Follow-up sent ({secs}s)\n"),
3335 "search_agent_pool" | "list_agent_templates" => {
3336 format!("\u{2705} Pool search done ({secs}s)\n")
3337 }
3338 "list_teams" | "search_teams" => {
3339 format!("\u{2705} Team search done ({secs}s)\n")
3340 }
3341 "get_team" => format!("\u{2705} Team loaded ({secs}s)\n"),
3342 "spawn_team" => format!("\u{2705} Team deployed ({secs}s)\n"),
3343 "create_team" => format!("\u{2705} Team created ({secs}s)\n"),
3344 "get_budget_status" => format!("\u{2705} Budget checked ({secs}s)\n"),
3345 "save_plan" => format!("\u{2705} Plan saved ({secs}s)\n"),
3346 "recall_plans" => format!("\u{2705} Plans retrieved ({secs}s)\n"),
3347 "create_goal" => format!("\u{2705} Goal created ({secs}s)\n"),
3348 "get_goals" => format!("\u{2705} Goals loaded ({secs}s)\n"),
3349 "update_goal" => format!("\u{2705} Goal updated ({secs}s)\n"),
3350 "record_agent_outcome" => {
3351 format!("\u{2705} Outcome recorded ({secs}s)\n")
3352 }
3353 "get_agent_trust" => {
3354 format!("\u{2705} Trust scores loaded ({secs}s)\n")
3355 }
3356 "capture_skill" => format!("\u{2705} Skill captured ({secs}s)\n"),
3357 "publish_to_clawhub" => {
3358 format!("\u{2705} Published to ClawHub ({secs}s)\n")
3359 }
3360 "search_clawhub" => {
3361 format!("\u{2705} ClawHub search complete ({secs}s)\n")
3362 }
3363 "install_from_clawhub" => {
3364 format!("\u{2705} Installed from ClawHub ({secs}s)\n")
3365 }
3366 "list_nodes" => format!("\u{2705} Nodes discovered ({secs}s)\n"),
3367 "invoke_node" => {
3368 format!("\u{2705} Node invocation complete ({secs}s)\n")
3369 }
3370 "get_session_history" => {
3371 format!("\u{2705} Session history loaded ({secs}s)\n")
3372 }
3373 "archive_session" => format!("\u{2705} Session archived ({secs}s)\n"),
3374 _ => format!("\u{2705} {suffix} ({secs}s)\n"),
3375 }
3376 } else {
3377 let reason_hint = outcome.error_reason.as_deref().unwrap_or("failed");
3378 format!(
3379 "\u{274c} {suffix} ({secs}s): {}\n",
3380 truncate_with_ellipsis(reason_hint, 200)
3381 )
3382 }
3383 } else if outcome.success {
3384 format!("\u{2705} {} ({secs}s)\n", call.name)
3385 } else if let Some(ref reason) = outcome.error_reason {
3386 format!(
3387 "\u{274c} {} ({secs}s): {}\n",
3388 call.name,
3389 truncate_with_ellipsis(reason, 200)
3390 )
3391 } else {
3392 format!("\u{274c} {} ({secs}s)\n", call.name)
3393 };
3394 tracing::debug!(tool = %call.name, secs, "Sending progress complete to draft");
3395 let _ = tx.send(DraftEvent::Progress(progress_msg)).await;
3396 }
3397
3398 ordered_results[*idx] = Some((call.name.clone(), call.tool_call_id.clone(), outcome));
3399 }
3400
3401 let mut detection_relevant_output = String::new();
3404 for (result_index, (tool_name, tool_call_id, outcome)) in ordered_results
3407 .into_iter()
3408 .enumerate()
3409 .filter_map(|(i, opt)| opt.map(|v| (i, v)))
3410 {
3411 if !loop_ignore_tools.contains(tool_name.as_str()) {
3412 detection_relevant_output.push_str(&outcome.output);
3413
3414 let args = tool_calls
3416 .get(result_index)
3417 .map(|c| &c.arguments)
3418 .unwrap_or(&serde_json::Value::Null);
3419 let det_result = loop_detector.record(&tool_name, args, &outcome.output);
3420 match det_result {
3421 crate::agent::loop_detector::LoopDetectionResult::Ok => {}
3422 crate::agent::loop_detector::LoopDetectionResult::Warning(ref msg) => {
3423 tracing::warn!(tool = %tool_name, %msg, "loop detector warning");
3424 history.push(ChatMessage::system(format!("[Loop Detection] {msg}")));
3426 }
3427 crate::agent::loop_detector::LoopDetectionResult::Block(ref msg) => {
3428 tracing::warn!(tool = %tool_name, %msg, "loop detector blocked tool call");
3429 history.push(ChatMessage::system(format!(
3432 "[Loop Detection — BLOCKED] {msg}"
3433 )));
3434 }
3435 crate::agent::loop_detector::LoopDetectionResult::Break(msg) => {
3436 runtime_trace::record_event(
3437 "loop_detector_circuit_breaker",
3438 Some(channel_name),
3439 Some(provider_name),
3440 Some(model),
3441 Some(&turn_id),
3442 Some(false),
3443 Some(&msg),
3444 serde_json::json!({
3445 "iteration": iteration + 1,
3446 "tool": tool_name,
3447 }),
3448 );
3449 anyhow::bail!("Agent loop aborted by loop detector: {msg}");
3450 }
3451 }
3452 }
3453 let result_output = truncate_tool_result(&outcome.output, max_tool_result_chars);
3454 individual_results.push((tool_call_id, result_output.clone()));
3455 let _ = writeln!(
3456 tool_results,
3457 "<tool_result name=\"{}\">\n{}\n</tool_result>",
3458 tool_name, result_output
3459 );
3460 }
3461
3462 let loop_detection_active = match pacing.loop_detection_min_elapsed_secs {
3470 Some(min_secs) => loop_started_at.elapsed() >= Duration::from_secs(min_secs),
3471 None => false, };
3473
3474 if loop_detection_active && !detection_relevant_output.is_empty() {
3475 use std::hash::{Hash, Hasher};
3476 let mut hasher = std::collections::hash_map::DefaultHasher::new();
3477 detection_relevant_output.hash(&mut hasher);
3478 let current_hash = hasher.finish();
3479
3480 if last_tool_output_hash == Some(current_hash) {
3481 consecutive_identical_outputs += 1;
3482 } else {
3483 consecutive_identical_outputs = 0;
3484 last_tool_output_hash = Some(current_hash);
3485 }
3486
3487 if consecutive_identical_outputs >= 3 {
3489 runtime_trace::record_event(
3490 "tool_loop_identical_output_abort",
3491 Some(channel_name),
3492 Some(provider_name),
3493 Some(model),
3494 Some(&turn_id),
3495 Some(false),
3496 Some("identical tool output detected 3 consecutive times"),
3497 serde_json::json!({
3498 "iteration": iteration + 1,
3499 "consecutive_identical": consecutive_identical_outputs,
3500 }),
3501 );
3502 anyhow::bail!(
3503 "Agent loop aborted: identical tool output detected {} consecutive times",
3504 consecutive_identical_outputs
3505 );
3506 }
3507 }
3508
3509 history.push(ChatMessage::assistant(assistant_history_content));
3514 if native_tool_calls.is_empty() {
3515 let all_results_have_ids = use_native_tools
3516 && !individual_results.is_empty()
3517 && individual_results
3518 .iter()
3519 .all(|(tool_call_id, _)| tool_call_id.is_some());
3520 if all_results_have_ids {
3521 for (tool_call_id, result) in &individual_results {
3522 let tool_msg = serde_json::json!({
3523 "tool_call_id": tool_call_id,
3524 "content": result,
3525 });
3526 history.push(ChatMessage::tool(tool_msg.to_string()));
3527 }
3528 } else {
3529 history.push(ChatMessage::user(format!("[Tool results]\n{tool_results}")));
3530 }
3531 } else {
3532 for (native_call, (_, result)) in
3533 native_tool_calls.iter().zip(individual_results.iter())
3534 {
3535 let tool_msg = serde_json::json!({
3536 "tool_call_id": native_call.id,
3537 "content": result,
3538 });
3539 history.push(ChatMessage::tool(tool_msg.to_string()));
3540 }
3541 }
3542 }
3543
3544 runtime_trace::record_event(
3545 "tool_loop_exhausted",
3546 Some(channel_name),
3547 Some(provider_name),
3548 Some(model),
3549 Some(&turn_id),
3550 Some(false),
3551 Some("agent exceeded maximum tool iterations"),
3552 serde_json::json!({
3553 "max_iterations": max_iterations,
3554 }),
3555 );
3556
3557 tracing::warn!(
3559 max_iterations,
3560 "Max iterations reached, requesting final summary"
3561 );
3562 history.push(ChatMessage::user(
3563 "You have reached the maximum number of tool iterations. \
3564 Please provide your best answer based on the work completed so far. \
3565 Summarize what you accomplished and what remains to be done."
3566 .to_string(),
3567 ));
3568
3569 let summary_request = crate::providers::ChatRequest {
3570 messages: history,
3571 tools: None, };
3573 match provider.chat(summary_request, model, temperature).await {
3574 Ok(resp) => {
3575 let text = resp.text.unwrap_or_default();
3576 if text.is_empty() {
3577 anyhow::bail!("Agent exceeded maximum tool iterations ({max_iterations})")
3578 }
3579 Ok(text)
3580 }
3581 Err(e) => {
3582 tracing::warn!(error = %e, "Final summary LLM call failed, bailing");
3583 anyhow::bail!("Agent exceeded maximum tool iterations ({max_iterations})")
3584 }
3585 }
3586}
3587
3588pub(crate) fn build_tool_instructions(
3591 tools_registry: &[Box<dyn Tool>],
3592 tool_descriptions: Option<&ToolDescriptions>,
3593) -> String {
3594 let mut instructions = String::new();
3595 instructions.push_str("\n## Tool Use Protocol\n\n");
3596 instructions.push_str("To use a tool, wrap a JSON object in <tool_call></tool_call> tags:\n\n");
3597 instructions.push_str("```\n<tool_call>\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n```\n\n");
3598 instructions.push_str(
3599 "CRITICAL: Output actual <tool_call> tags—never describe steps or give examples.\n\n",
3600 );
3601 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");
3602 instructions.push_str("You may use multiple tool calls in a single response. ");
3603 instructions.push_str("After tool execution, results appear in <tool_result> tags. ");
3604 instructions
3605 .push_str("Continue reasoning with the results until you can give a final answer.\n\n");
3606 instructions.push_str("### Available Tools\n\n");
3607
3608 for tool in tools_registry {
3609 let desc = tool_descriptions
3610 .and_then(|td| td.get(tool.name()))
3611 .unwrap_or_else(|| tool.description());
3612 let _ = writeln!(
3613 instructions,
3614 "**{}**: {}\nParameters: `{}`\n",
3615 tool.name(),
3616 desc,
3617 tool.parameters_schema()
3618 );
3619 }
3620
3621 instructions
3622}
3623
3624#[allow(clippy::too_many_lines)]
3631pub async fn run(
3632 config: Config,
3633 message: Option<String>,
3634 provider_override: Option<String>,
3635 model_override: Option<String>,
3636 temperature: f64,
3637 peripheral_overrides: Vec<String>,
3638 interactive: bool,
3639 session_state_file: Option<PathBuf>,
3640 allowed_tools: Option<Vec<String>>,
3641) -> Result<String> {
3642 let base_observer = observability::create_observer(&config.observability);
3644 let observer: Arc<dyn Observer> = Arc::from(base_observer);
3645 let runtime: Arc<dyn runtime::RuntimeAdapter> =
3646 Arc::from(runtime::create_runtime(&config.runtime)?);
3647 let security = Arc::new(SecurityPolicy::from_config(
3648 &config.autonomy,
3649 &config.workspace_dir,
3650 ));
3651
3652 let mem: Arc<dyn Memory> = Arc::from(memory::create_memory_with_storage_and_routes(
3654 &config.memory,
3655 &config.embedding_routes,
3656 Some(&config.storage.provider.config),
3657 &config.workspace_dir,
3658 config.api_key.as_deref(),
3659 )?);
3660 tracing::info!(backend = mem.name(), "Memory initialized");
3661
3662 if !peripheral_overrides.is_empty() {
3664 tracing::info!(
3665 peripherals = ?peripheral_overrides,
3666 "Peripheral overrides from CLI (config boards take precedence)"
3667 );
3668 }
3669
3670 let (composio_key, composio_entity_id) = if config.composio.enabled {
3672 (
3673 config.composio.api_key.as_deref(),
3674 Some(config.composio.entity_id.as_str()),
3675 )
3676 } else {
3677 (None, None)
3678 };
3679 let (
3680 mut tools_registry,
3681 delegate_handle,
3682 _reaction_handle,
3683 _channel_map_handle,
3684 _ask_user_handle,
3685 _escalate_handle,
3686 ) = tools::all_tools_with_runtime(
3687 Arc::new(config.clone()),
3688 &security,
3689 runtime,
3690 mem.clone(),
3691 composio_key,
3692 composio_entity_id,
3693 &config.browser,
3694 &config.http_request,
3695 &config.web_fetch,
3696 &config.workspace_dir,
3697 &config.agents,
3698 config.api_key.as_deref(),
3699 &config,
3700 None,
3701 );
3702
3703 let peripheral_tools: Vec<Box<dyn Tool>> =
3704 crate::peripherals::create_peripheral_tools(&config.peripherals).await?;
3705 if !peripheral_tools.is_empty() {
3706 tracing::info!(count = peripheral_tools.len(), "Peripheral tools added");
3707 tools_registry.extend(peripheral_tools);
3708 }
3709
3710 if let Some(ref allow_list) = allowed_tools {
3715 tools_registry.retain(|t| allow_list.iter().any(|name| name == t.name()));
3716 tracing::info!(
3717 allowed = allow_list.len(),
3718 retained = tools_registry.len(),
3719 "Applied capability-based tool access filter"
3720 );
3721 }
3722
3723 let config = crate::agent::kumiho::inject_kumiho(config, false);
3727
3728 let config = crate::agent::operator::inject_operator(config, false);
3730
3731 let mut deferred_section = String::new();
3742 let mut activated_handle: Option<
3743 std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>,
3744 > = None;
3745 if config.mcp.enabled && !config.mcp.servers.is_empty() {
3746 tracing::info!(
3747 "Initializing MCP client — {} server(s) configured",
3748 config.mcp.servers.len()
3749 );
3750 match crate::tools::McpRegistry::connect_all(&config.mcp.servers).await {
3751 Ok(registry) => {
3752 let registry = std::sync::Arc::new(registry);
3753 if config.mcp.deferred_loading {
3754 let early_provider = provider_override
3763 .as_deref()
3764 .or(config.default_provider.as_deref())
3765 .unwrap_or("openrouter");
3766 let is_local_provider = early_provider == "ollama";
3767 let is_eager_tool = |name: &str| -> bool {
3768 if is_local_provider {
3769 crate::tools::mcp_deferred::is_local_model_eager_tool(name)
3770 } else {
3771 crate::tools::mcp_deferred::is_operator_seat_eager_tool(name)
3772 }
3773 };
3774
3775 let all_names = registry.tool_names();
3776 let mut eager_count = 0usize;
3777
3778 for name in &all_names {
3779 if is_eager_tool(name) {
3780 if let Some(def) = registry.get_tool_def(name).await {
3781 let wrapper: std::sync::Arc<dyn Tool> =
3782 std::sync::Arc::new(crate::tools::McpToolWrapper::new(
3783 name.clone(),
3784 def,
3785 std::sync::Arc::clone(®istry),
3786 ));
3787 if let Some(ref handle) = delegate_handle {
3788 handle.write().push(std::sync::Arc::clone(&wrapper));
3789 }
3790 tools_registry.push(Box::new(crate::tools::ArcToolRef(wrapper)));
3791 eager_count += 1;
3792 }
3793 }
3794 }
3795
3796 let deferred_set = crate::tools::DeferredMcpToolSet::from_registry_filtered(
3798 std::sync::Arc::clone(®istry),
3799 move |name: &str| {
3800 if is_local_provider {
3801 !crate::tools::mcp_deferred::is_local_model_eager_tool(name)
3802 } else {
3803 !crate::tools::mcp_deferred::is_operator_seat_eager_tool(name)
3804 }
3805 },
3806 )
3807 .await;
3808 tracing::info!(
3809 "MCP hybrid: {} eager tool(s), {} deferred stub(s) from {} server(s) (local_provider={})",
3810 eager_count,
3811 deferred_set.len(),
3812 registry.server_count(),
3813 is_local_provider,
3814 );
3815 deferred_section =
3816 crate::tools::mcp_deferred::build_deferred_tools_section(&deferred_set);
3817 let activated = std::sync::Arc::new(std::sync::Mutex::new(
3818 crate::tools::ActivatedToolSet::new(),
3819 ));
3820 activated_handle = Some(std::sync::Arc::clone(&activated));
3821 tools_registry.push(Box::new(crate::tools::ToolSearchTool::new(
3822 deferred_set,
3823 activated,
3824 )));
3825 } else {
3826 let names = registry.tool_names();
3828 let mut registered = 0usize;
3829 for name in names {
3830 if let Some(def) = registry.get_tool_def(&name).await {
3831 let wrapper: std::sync::Arc<dyn Tool> =
3832 std::sync::Arc::new(crate::tools::McpToolWrapper::new(
3833 name,
3834 def,
3835 std::sync::Arc::clone(®istry),
3836 ));
3837 if let Some(ref handle) = delegate_handle {
3838 handle.write().push(std::sync::Arc::clone(&wrapper));
3839 }
3840 tools_registry.push(Box::new(crate::tools::ArcToolRef(wrapper)));
3841 registered += 1;
3842 }
3843 }
3844 tracing::info!(
3845 "MCP: {} tool(s) registered from {} server(s)",
3846 registered,
3847 registry.server_count()
3848 );
3849 }
3850 }
3851 Err(e) => {
3852 tracing::error!("MCP registry failed to initialize: {e:#}");
3853 }
3854 }
3855 }
3856
3857 let mut provider_name = provider_override
3859 .as_deref()
3860 .or(config.default_provider.as_deref())
3861 .unwrap_or("openrouter")
3862 .to_string();
3863
3864 let mut model_name = model_override
3865 .as_deref()
3866 .or(config.default_model.as_deref())
3867 .unwrap_or("anthropic/claude-sonnet-4")
3868 .to_string();
3869
3870 let provider_runtime_options = providers::provider_runtime_options_from_config(&config);
3871
3872 let mut provider: Box<dyn Provider> = providers::create_routed_provider_with_options(
3873 &provider_name,
3874 config.api_key.as_deref(),
3875 config.api_url.as_deref(),
3876 &config.reliability,
3877 &config.model_routes,
3878 &model_name,
3879 &provider_runtime_options,
3880 )?;
3881
3882 let model_switch_callback = get_model_switch_state();
3883
3884 observer.record_event(&ObserverEvent::AgentStart {
3885 provider: provider_name.to_string(),
3886 model: model_name.to_string(),
3887 });
3888
3889 let hardware_rag: Option<crate::rag::HardwareRag> = config
3891 .peripherals
3892 .datasheet_dir
3893 .as_ref()
3894 .filter(|d| !d.trim().is_empty())
3895 .map(|dir| crate::rag::HardwareRag::load(&config.workspace_dir, dir.trim()))
3896 .and_then(Result::ok)
3897 .filter(|r: &crate::rag::HardwareRag| !r.is_empty());
3898 if let Some(ref rag) = hardware_rag {
3899 tracing::info!(chunks = rag.len(), "Hardware RAG loaded");
3900 }
3901
3902 let board_names: Vec<String> = config
3903 .peripherals
3904 .boards
3905 .iter()
3906 .map(|b| b.board.clone())
3907 .collect();
3908
3909 let i18n_locale = config
3911 .locale
3912 .as_deref()
3913 .filter(|s| !s.is_empty())
3914 .map(ToString::to_string)
3915 .unwrap_or_else(crate::i18n::detect_locale);
3916 let i18n_search_dirs = crate::i18n::default_search_dirs(&config.workspace_dir);
3917 let i18n_descs = crate::i18n::ToolDescriptions::load(&i18n_locale, &i18n_search_dirs);
3918
3919 let skills = crate::skills::load_skills_with_config(&config.workspace_dir, &config);
3921
3922 tools::register_skill_tools(&mut tools_registry, &skills, security.clone());
3925
3926 let mut tool_descs: Vec<(&str, &str)> = vec![
3927 (
3928 "shell",
3929 "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.",
3930 ),
3931 (
3932 "file_read",
3933 "Read file contents. Use when: inspecting project files, configs, logs. Don't use when: a targeted search is enough.",
3934 ),
3935 (
3936 "file_write",
3937 "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.",
3938 ),
3939 (
3940 "memory_store",
3941 "Save to memory. Use when: preserving durable preferences, decisions, key context. Don't use when: information is transient/noisy/sensitive without need.",
3942 ),
3943 (
3944 "memory_recall",
3945 "Search memory. Use when: retrieving prior decisions, user preferences, historical context. Don't use when: answer is already in current context.",
3946 ),
3947 (
3948 "memory_forget",
3949 "Delete a memory entry. Use when: memory is incorrect/stale or explicitly requested for removal. Don't use when: impact is uncertain.",
3950 ),
3951 ];
3952 if matches!(
3953 config.skills.prompt_injection_mode,
3954 crate::config::SkillsPromptInjectionMode::Compact
3955 ) {
3956 tool_descs.push((
3957 "read_skill",
3958 "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.",
3959 ));
3960 }
3961 tool_descs.push((
3962 "cron_add",
3963 "Create a cron job. Supports schedule kinds: cron, at, every; and job types: shell or agent.",
3964 ));
3965 tool_descs.push((
3966 "cron_list",
3967 "List all cron jobs with schedule, status, and metadata.",
3968 ));
3969 tool_descs.push(("cron_remove", "Remove a cron job by job_id."));
3970 tool_descs.push((
3971 "cron_update",
3972 "Patch a cron job (schedule, enabled, command/prompt, model, delivery, session_target).",
3973 ));
3974 tool_descs.push((
3975 "cron_run",
3976 "Force-run a cron job immediately and record a run history entry.",
3977 ));
3978 tool_descs.push(("cron_runs", "Show recent run history for a cron job."));
3979 tool_descs.push((
3980 "screenshot",
3981 "Capture a screenshot of the current screen. Returns file path and base64-encoded PNG. Use when: visual verification, UI inspection, debugging displays.",
3982 ));
3983 tool_descs.push((
3984 "image_info",
3985 "Read image file metadata (format, dimensions, size) and optionally base64-encode it. Use when: inspecting images, preparing visual data for analysis.",
3986 ));
3987 if config.browser.enabled {
3988 tool_descs.push((
3989 "browser_open",
3990 "Open approved HTTPS URLs in system browser (allowlist-only, no scraping)",
3991 ));
3992 }
3993 if config.composio.enabled {
3994 tool_descs.push((
3995 "composio",
3996 "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.",
3997 ));
3998 }
3999 tool_descs.push((
4000 "schedule",
4001 "Manage scheduled tasks (create/list/get/cancel/pause/resume). Supports recurring cron and one-shot delays.",
4002 ));
4003 tool_descs.push((
4004 "model_routing_config",
4005 "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'.",
4006 ));
4007 if !config.agents.is_empty() {
4008 tool_descs.push((
4009 "delegate",
4010 "Delegate a sub-task to a specialized agent. Use when: task needs different model/capability, or to parallelize work.",
4011 ));
4012 }
4013 if config.peripherals.enabled && !config.peripherals.boards.is_empty() {
4014 tool_descs.push((
4015 "gpio_read",
4016 "Read GPIO pin value (0 or 1) on connected hardware (STM32, Arduino). Use when: checking sensor/button state, LED status.",
4017 ));
4018 tool_descs.push((
4019 "gpio_write",
4020 "Set GPIO pin high (1) or low (0) on connected hardware. Use when: turning LED on/off, controlling actuators.",
4021 ));
4022 tool_descs.push((
4023 "arduino_upload",
4024 "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.",
4025 ));
4026 tool_descs.push((
4027 "hardware_memory_map",
4028 "Return flash and RAM address ranges for connected hardware. Use when: user asks for 'upper and lower memory addresses', 'memory map', or 'readable addresses'.",
4029 ));
4030 tool_descs.push((
4031 "hardware_board_info",
4032 "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'.",
4033 ));
4034 tool_descs.push((
4035 "hardware_memory_read",
4036 "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).",
4037 ));
4038 tool_descs.push((
4039 "hardware_capabilities",
4040 "Query connected hardware for reported GPIO pins and LED pin. Use when: user asks what pins are available.",
4041 ));
4042 }
4043 let bootstrap_max_chars = if config.agent.compact_context {
4044 Some(6000)
4045 } else {
4046 None
4047 };
4048 let native_tools = provider.supports_native_tools();
4049 let mut system_prompt = crate::channels::build_system_prompt_with_mode_and_autonomy(
4050 &config.workspace_dir,
4051 &model_name,
4052 &tool_descs,
4053 &skills,
4054 Some(&config.identity),
4055 bootstrap_max_chars,
4056 Some(&config.autonomy),
4057 native_tools,
4058 config.skills.prompt_injection_mode,
4059 config.agent.compact_context,
4060 config.agent.max_system_prompt_chars,
4061 );
4062
4063 if !native_tools {
4065 system_prompt.push_str(&build_tool_instructions(&tools_registry, Some(&i18n_descs)));
4066 }
4067
4068 if !deferred_section.is_empty() {
4070 system_prompt.push('\n');
4071 system_prompt.push_str(&deferred_section);
4072 }
4073
4074 crate::agent::kumiho::append_kumiho_bootstrap(&mut system_prompt, &config, false);
4076
4077 crate::agent::operator::append_operator_prompt(&mut system_prompt, &config, false, &model_name);
4079
4080 let approval_manager = if interactive {
4082 let trust_tracker = std::sync::Arc::new(parking_lot::Mutex::new(
4083 crate::trust::TrustTracker::new(config.trust.clone()),
4084 ));
4085 Some(ApprovalManager::from_config(&config.autonomy).with_trust_tracker(trust_tracker))
4086 } else {
4087 None
4088 };
4089 let channel_name = if interactive { "cli" } else { "daemon" };
4090 let memory_session_id = session_state_file.as_deref().and_then(|path| {
4091 let raw = path.to_string_lossy().trim().to_string();
4092 if raw.is_empty() {
4093 None
4094 } else {
4095 Some(format!("cli:{raw}"))
4096 }
4097 });
4098
4099 let start = Instant::now();
4101
4102 let mut final_output = String::new();
4103
4104 let base_system_prompt = system_prompt.clone();
4107
4108 if let Some(msg) = message {
4109 let (thinking_directive, effective_msg) =
4111 match crate::agent::thinking::parse_thinking_directive(&msg) {
4112 Some((level, remaining)) => {
4113 tracing::info!(thinking_level = ?level, "Thinking directive parsed from message");
4114 (Some(level), remaining)
4115 }
4116 None => (None, msg.clone()),
4117 };
4118 let thinking_level = crate::agent::thinking::resolve_thinking_level(
4119 thinking_directive,
4120 None,
4121 &config.agent.thinking,
4122 );
4123 let thinking_params = crate::agent::thinking::apply_thinking_level(thinking_level);
4124 let effective_temperature = crate::agent::thinking::clamp_temperature(
4125 temperature + thinking_params.temperature_adjustment,
4126 );
4127
4128 if let Some(ref prefix) = thinking_params.system_prompt_prefix {
4130 system_prompt = format!("{prefix}\n\n{system_prompt}");
4131 }
4132
4133 if config.memory.auto_save
4135 && effective_msg.chars().count() >= AUTOSAVE_MIN_MESSAGE_CHARS
4136 && !memory::should_skip_autosave_content(&effective_msg)
4137 {
4138 let user_key = autosave_memory_key("user_msg");
4139 let _ = mem
4140 .store(
4141 &user_key,
4142 &effective_msg,
4143 MemoryCategory::Conversation,
4144 memory_session_id.as_deref(),
4145 )
4146 .await;
4147 }
4148
4149 let mem_context = build_context(
4151 mem.as_ref(),
4152 &effective_msg,
4153 config.memory.min_relevance_score,
4154 memory_session_id.as_deref(),
4155 )
4156 .await;
4157 let rag_limit = if config.agent.compact_context { 2 } else { 5 };
4158 let hw_context = hardware_rag
4159 .as_ref()
4160 .map(|r| build_hardware_context(r, &effective_msg, &board_names, rag_limit))
4161 .unwrap_or_default();
4162 let context = format!("{mem_context}{hw_context}");
4163 let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z");
4164 let enriched = if context.is_empty() {
4165 format!("[{now}] {effective_msg}")
4166 } else {
4167 format!("{context}[{now}] {effective_msg}")
4168 };
4169
4170 let mut history = vec![
4171 ChatMessage::system(&system_prompt),
4172 ChatMessage::user(&enriched),
4173 ];
4174
4175 if config.agent.history_pruning.enabled {
4177 let _stats = crate::agent::history_pruner::prune_history(
4178 &mut history,
4179 &config.agent.history_pruning,
4180 );
4181 }
4182
4183 let excluded_tools = compute_excluded_mcp_tools(
4185 &tools_registry,
4186 &config.agent.tool_filter_groups,
4187 &effective_msg,
4188 );
4189
4190 #[allow(unused_assignments)]
4191 let mut response = String::new();
4192 loop {
4193 match run_tool_call_loop(
4194 provider.as_ref(),
4195 &mut history,
4196 &tools_registry,
4197 observer.as_ref(),
4198 &provider_name,
4199 &model_name,
4200 effective_temperature,
4201 false,
4202 approval_manager.as_ref(),
4203 channel_name,
4204 None,
4205 &config.multimodal,
4206 effective_max_tool_iterations(&config),
4207 None,
4208 None,
4209 None,
4210 &excluded_tools,
4211 &config.agent.tool_call_dedup_exempt,
4212 activated_handle.as_ref(),
4213 Some(model_switch_callback.clone()),
4214 &config.pacing,
4215 config.agent.max_tool_result_chars,
4216 config.agent.max_context_tokens,
4217 None, )
4219 .await
4220 {
4221 Ok(resp) => {
4222 response = resp;
4223 break;
4224 }
4225 Err(e) => {
4226 if let Some((new_provider, new_model)) = is_model_switch_requested(&e) {
4227 tracing::info!(
4228 "Model switch requested, switching from {} {} to {} {}",
4229 provider_name,
4230 model_name,
4231 new_provider,
4232 new_model
4233 );
4234
4235 provider = providers::create_routed_provider_with_options(
4236 &new_provider,
4237 config.api_key.as_deref(),
4238 config.api_url.as_deref(),
4239 &config.reliability,
4240 &config.model_routes,
4241 &new_model,
4242 &provider_runtime_options,
4243 )?;
4244
4245 provider_name = new_provider;
4246 model_name = new_model;
4247
4248 clear_model_switch_request();
4249
4250 observer.record_event(&ObserverEvent::AgentStart {
4251 provider: provider_name.to_string(),
4252 model: model_name.to_string(),
4253 });
4254
4255 continue;
4256 }
4257 return Err(e);
4258 }
4259 }
4260 }
4261
4262 #[cfg(feature = "skill-creation")]
4264 if config.skills.skill_creation.enabled {
4265 let tool_calls = crate::skills::creator::extract_tool_calls_from_history(&history);
4266 if tool_calls.len() >= 2 {
4267 let creator = crate::skills::creator::SkillCreator::new(
4268 config.workspace_dir.clone(),
4269 config.skills.skill_creation.clone(),
4270 );
4271 match creator.create_from_execution(&msg, &tool_calls, None).await {
4272 Ok(Some(slug)) => {
4273 tracing::info!(slug, "Auto-created skill from execution");
4274 }
4275 Ok(None) => {
4276 tracing::debug!("Skill creation skipped (duplicate or disabled)");
4277 }
4278 Err(e) => tracing::warn!("Skill creation failed: {e}"),
4279 }
4280 }
4281 }
4282 final_output = response.clone();
4283 println!("{response}");
4284 observer.record_event(&ObserverEvent::TurnComplete);
4285 } else {
4286 println!("🦀 Construct Interactive Mode");
4287 println!("Type /help for commands.\n");
4288 let cli = crate::channels::CliChannel::new();
4289
4290 let mut history = if let Some(path) = session_state_file.as_deref() {
4292 load_interactive_session_history(path, &system_prompt)?
4293 } else {
4294 vec![ChatMessage::system(&system_prompt)]
4295 };
4296
4297 loop {
4298 print!("> ");
4299 let _ = std::io::stdout().flush();
4300
4301 let mut raw = Vec::new();
4305 match std::io::BufRead::read_until(&mut std::io::stdin().lock(), b'\n', &mut raw) {
4306 Ok(0) => break,
4307 Ok(_) => {}
4308 Err(e) => {
4309 eprintln!("\nError reading input: {e}\n");
4310 break;
4311 }
4312 }
4313 let input = String::from_utf8_lossy(&raw).into_owned();
4314
4315 let user_input = input.trim().to_string();
4316 if user_input.is_empty() {
4317 continue;
4318 }
4319 match user_input.as_str() {
4320 "/quit" | "/exit" => break,
4321 "/help" => {
4322 println!("Available commands:");
4323 println!(" /help Show this help message");
4324 println!(" /clear /new Clear conversation history");
4325 println!(" /quit /exit Exit interactive mode");
4326 println!(
4327 " /think:<level> Set reasoning depth (off|minimal|low|medium|high|max)\n"
4328 );
4329 continue;
4330 }
4331 "/clear" | "/new" => {
4332 println!(
4333 "This will clear the current conversation and delete all session memory."
4334 );
4335 println!("Core memories (long-term facts/preferences) will be preserved.");
4336 print!("Continue? [y/N] ");
4337 let _ = std::io::stdout().flush();
4338
4339 let mut confirm_raw = Vec::new();
4340 if std::io::BufRead::read_until(
4341 &mut std::io::stdin().lock(),
4342 b'\n',
4343 &mut confirm_raw,
4344 )
4345 .is_err()
4346 {
4347 continue;
4348 }
4349 let confirm = String::from_utf8_lossy(&confirm_raw);
4350 if !matches!(confirm.trim().to_lowercase().as_str(), "y" | "yes") {
4351 println!("Cancelled.\n");
4352 continue;
4353 }
4354
4355 history.clear();
4356 history.push(ChatMessage::system(&system_prompt));
4357 let mut cleared = 0;
4359 for category in [MemoryCategory::Conversation, MemoryCategory::Daily] {
4360 let entries = mem.list(Some(&category), None).await.unwrap_or_default();
4361 for entry in entries {
4362 if mem.forget(&entry.key).await.unwrap_or(false) {
4363 cleared += 1;
4364 }
4365 }
4366 }
4367 if cleared > 0 {
4368 println!("Conversation cleared ({cleared} memory entries removed).\n");
4369 } else {
4370 println!("Conversation cleared.\n");
4371 }
4372 if let Some(path) = session_state_file.as_deref() {
4373 save_interactive_session_history(path, &history)?;
4374 }
4375 continue;
4376 }
4377 _ => {}
4378 }
4379
4380 let (thinking_directive, effective_input) =
4382 match crate::agent::thinking::parse_thinking_directive(&user_input) {
4383 Some((level, remaining)) => {
4384 tracing::info!(thinking_level = ?level, "Thinking directive parsed");
4385 (Some(level), remaining)
4386 }
4387 None => (None, user_input.clone()),
4388 };
4389 let thinking_level = crate::agent::thinking::resolve_thinking_level(
4390 thinking_directive,
4391 None,
4392 &config.agent.thinking,
4393 );
4394 let thinking_params = crate::agent::thinking::apply_thinking_level(thinking_level);
4395 let turn_temperature = crate::agent::thinking::clamp_temperature(
4396 temperature + thinking_params.temperature_adjustment,
4397 );
4398
4399 let turn_system_prompt;
4401 if let Some(ref prefix) = thinking_params.system_prompt_prefix {
4402 turn_system_prompt = format!("{prefix}\n\n{system_prompt}");
4403 if let Some(sys_msg) = history.first_mut() {
4405 if sys_msg.role == "system" {
4406 sys_msg.content = turn_system_prompt.clone();
4407 }
4408 }
4409 }
4410
4411 if config.memory.auto_save
4413 && effective_input.chars().count() >= AUTOSAVE_MIN_MESSAGE_CHARS
4414 && !memory::should_skip_autosave_content(&effective_input)
4415 {
4416 let user_key = autosave_memory_key("user_msg");
4417 let _ = mem
4418 .store(
4419 &user_key,
4420 &effective_input,
4421 MemoryCategory::Conversation,
4422 memory_session_id.as_deref(),
4423 )
4424 .await;
4425 }
4426
4427 let mem_context = build_context(
4429 mem.as_ref(),
4430 &effective_input,
4431 config.memory.min_relevance_score,
4432 memory_session_id.as_deref(),
4433 )
4434 .await;
4435 let rag_limit = if config.agent.compact_context { 2 } else { 5 };
4436 let hw_context = hardware_rag
4437 .as_ref()
4438 .map(|r| build_hardware_context(r, &effective_input, &board_names, rag_limit))
4439 .unwrap_or_default();
4440 let context = format!("{mem_context}{hw_context}");
4441 let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z");
4442 let enriched = if context.is_empty() {
4443 format!("[{now}] {effective_input}")
4444 } else {
4445 format!("{context}[{now}] {effective_input}")
4446 };
4447
4448 history.push(ChatMessage::user(&enriched));
4449
4450 let excluded_tools = compute_excluded_mcp_tools(
4452 &tools_registry,
4453 &config.agent.tool_filter_groups,
4454 &effective_input,
4455 );
4456
4457 let (delta_tx, mut delta_rx) = tokio::sync::mpsc::channel::<DraftEvent>(64);
4460 let content_was_streamed =
4461 std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
4462 let content_streamed_flag = content_was_streamed.clone();
4463 let is_tty = std::io::IsTerminal::is_terminal(&std::io::stderr());
4464
4465 let consumer_handle = tokio::spawn(async move {
4466 use std::io::Write;
4467 while let Some(event) = delta_rx.recv().await {
4468 match event {
4469 DraftEvent::Clear => {
4470 let _ = writeln!(std::io::stderr());
4471 }
4472 DraftEvent::Progress(text) => {
4473 if is_tty {
4474 let _ = write!(std::io::stderr(), "\x1b[2m{text}\x1b[0m");
4475 } else {
4476 let _ = write!(std::io::stderr(), "{text}");
4477 }
4478 let _ = std::io::stderr().flush();
4479 }
4480 DraftEvent::Content(text) => {
4481 content_streamed_flag.store(true, std::sync::atomic::Ordering::Relaxed);
4482 print!("{text}");
4483 let _ = std::io::stdout().flush();
4484 }
4485 }
4486 }
4487 });
4488
4489 let cancel_token = CancellationToken::new();
4491 let cancel_token_clone = cancel_token.clone();
4492 let ctrlc_handle = tokio::spawn(async move {
4493 if tokio::signal::ctrl_c().await.is_ok() {
4494 cancel_token_clone.cancel();
4495 }
4496 });
4497
4498 let response = loop {
4499 match run_tool_call_loop(
4500 provider.as_ref(),
4501 &mut history,
4502 &tools_registry,
4503 observer.as_ref(),
4504 &provider_name,
4505 &model_name,
4506 turn_temperature,
4507 true,
4508 approval_manager.as_ref(),
4509 channel_name,
4510 None,
4511 &config.multimodal,
4512 effective_max_tool_iterations(&config),
4513 Some(cancel_token.clone()),
4514 Some(delta_tx.clone()),
4515 None,
4516 &excluded_tools,
4517 &config.agent.tool_call_dedup_exempt,
4518 activated_handle.as_ref(),
4519 Some(model_switch_callback.clone()),
4520 &config.pacing,
4521 config.agent.max_tool_result_chars,
4522 config.agent.max_context_tokens,
4523 None, )
4525 .await
4526 {
4527 Ok(resp) => break resp,
4528 Err(e) => {
4529 if is_tool_loop_cancelled(&e) {
4530 eprintln!("\n\x1b[2m(cancelled)\x1b[0m");
4531 break String::new();
4532 }
4533 if let Some((new_provider, new_model)) = is_model_switch_requested(&e) {
4534 tracing::info!(
4535 "Model switch requested, switching from {} {} to {} {}",
4536 provider_name,
4537 model_name,
4538 new_provider,
4539 new_model
4540 );
4541
4542 provider = providers::create_routed_provider_with_options(
4543 &new_provider,
4544 config.api_key.as_deref(),
4545 config.api_url.as_deref(),
4546 &config.reliability,
4547 &config.model_routes,
4548 &new_model,
4549 &provider_runtime_options,
4550 )?;
4551
4552 provider_name = new_provider;
4553 model_name = new_model;
4554
4555 clear_model_switch_request();
4556
4557 observer.record_event(&ObserverEvent::AgentStart {
4558 provider: provider_name.to_string(),
4559 model: model_name.to_string(),
4560 });
4561
4562 continue;
4563 }
4564 if crate::providers::reliable::is_context_window_exceeded(&e) {
4566 tracing::warn!(
4567 "Context overflow in interactive loop, attempting recovery"
4568 );
4569 let mut compressor =
4570 crate::agent::context_compressor::ContextCompressor::new(
4571 config.agent.context_compression.clone(),
4572 config.agent.max_context_tokens,
4573 )
4574 .with_memory(mem.clone());
4575 let error_msg = format!("{e}");
4576 match compressor
4577 .compress_on_error(
4578 &mut history,
4579 provider.as_ref(),
4580 &model_name,
4581 &error_msg,
4582 )
4583 .await
4584 {
4585 Ok(true) => {
4586 tracing::info!(
4587 "Context recovered via compression, retrying turn"
4588 );
4589 continue;
4590 }
4591 Ok(false) => {
4592 tracing::warn!("Compression ran but couldn't reduce enough");
4593 }
4594 Err(compress_err) => {
4595 tracing::warn!(
4596 error = %compress_err,
4597 "Compression failed during recovery"
4598 );
4599 }
4600 }
4601 }
4602
4603 eprintln!("\nError: {e}\n");
4604 break String::new();
4605 }
4606 }
4607 };
4608
4609 ctrlc_handle.abort();
4611 drop(delta_tx);
4612 let _ = consumer_handle.await;
4613
4614 final_output = response.clone();
4615 if content_was_streamed.load(std::sync::atomic::Ordering::Relaxed) {
4616 println!();
4617 } else if let Err(e) = crate::channels::Channel::send(
4618 &cli,
4619 &crate::channels::traits::SendMessage::new(format!("\n{response}\n"), "user"),
4620 )
4621 .await
4622 {
4623 eprintln!("\nError sending CLI response: {e}\n");
4624 }
4625 observer.record_event(&ObserverEvent::TurnComplete);
4626
4627 {
4629 let compressor = crate::agent::context_compressor::ContextCompressor::new(
4630 config.agent.context_compression.clone(),
4631 config.agent.max_context_tokens,
4632 )
4633 .with_memory(mem.clone());
4634 match compressor
4635 .compress_if_needed(&mut history, provider.as_ref(), &model_name)
4636 .await
4637 {
4638 Ok(result) if result.compressed => {
4639 tracing::info!(
4640 passes = result.passes_used,
4641 before = result.tokens_before,
4642 after = result.tokens_after,
4643 "Context compression complete"
4644 );
4645 }
4646 Ok(_) => {} Err(e) => {
4648 tracing::warn!(
4649 error = %e,
4650 "Context compression failed, falling back to history trim"
4651 );
4652 trim_history(&mut history, config.agent.max_history_messages / 2);
4653 }
4654 }
4655 }
4656
4657 trim_history(&mut history, config.agent.max_history_messages);
4659
4660 if thinking_params.system_prompt_prefix.is_some() {
4662 if let Some(sys_msg) = history.first_mut() {
4663 if sys_msg.role == "system" {
4664 sys_msg.content.clone_from(&base_system_prompt);
4665 }
4666 }
4667 }
4668
4669 if let Some(path) = session_state_file.as_deref() {
4670 save_interactive_session_history(path, &history)?;
4671 }
4672 }
4673 }
4674
4675 let duration = start.elapsed();
4676 observer.record_event(&ObserverEvent::AgentEnd {
4677 provider: provider_name.to_string(),
4678 model: model_name.to_string(),
4679 duration,
4680 tokens_used: None,
4681 cost_usd: None,
4682 });
4683
4684 Ok(final_output)
4685}
4686
4687pub async fn process_message(
4690 config: Config,
4691 message: &str,
4692 session_id: Option<&str>,
4693) -> Result<String> {
4694 let observer: Arc<dyn Observer> =
4695 Arc::from(observability::create_observer(&config.observability));
4696 let runtime: Arc<dyn runtime::RuntimeAdapter> =
4697 Arc::from(runtime::create_runtime(&config.runtime)?);
4698 let security = Arc::new(SecurityPolicy::from_config(
4699 &config.autonomy,
4700 &config.workspace_dir,
4701 ));
4702 let approval_manager = ApprovalManager::for_non_interactive(&config.autonomy);
4703 let mem: Arc<dyn Memory> = Arc::from(memory::create_memory_with_storage_and_routes(
4704 &config.memory,
4705 &config.embedding_routes,
4706 Some(&config.storage.provider.config),
4707 &config.workspace_dir,
4708 config.api_key.as_deref(),
4709 )?);
4710
4711 let (composio_key, composio_entity_id) = if config.composio.enabled {
4712 (
4713 config.composio.api_key.as_deref(),
4714 Some(config.composio.entity_id.as_str()),
4715 )
4716 } else {
4717 (None, None)
4718 };
4719 let (
4720 mut tools_registry,
4721 delegate_handle_pm,
4722 _reaction_handle_pm,
4723 _channel_map_handle_pm,
4724 _ask_user_handle_pm,
4725 _escalate_handle_pm,
4726 ) = tools::all_tools_with_runtime(
4727 Arc::new(config.clone()),
4728 &security,
4729 runtime,
4730 mem.clone(),
4731 composio_key,
4732 composio_entity_id,
4733 &config.browser,
4734 &config.http_request,
4735 &config.web_fetch,
4736 &config.workspace_dir,
4737 &config.agents,
4738 config.api_key.as_deref(),
4739 &config,
4740 None,
4741 );
4742 let peripheral_tools: Vec<Box<dyn Tool>> =
4743 crate::peripherals::create_peripheral_tools(&config.peripherals).await?;
4744 tools_registry.extend(peripheral_tools);
4745
4746 let config = crate::agent::kumiho::inject_kumiho(config, false);
4748
4749 let config = crate::agent::operator::inject_operator(config, false);
4751
4752 let mut deferred_section = String::new();
4757 let mut activated_handle_pm: Option<
4758 std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>,
4759 > = None;
4760 if config.mcp.enabled && !config.mcp.servers.is_empty() {
4761 tracing::info!(
4762 "Initializing MCP client — {} server(s) configured",
4763 config.mcp.servers.len()
4764 );
4765 match crate::tools::McpRegistry::connect_all(&config.mcp.servers).await {
4766 Ok(registry) => {
4767 let registry = std::sync::Arc::new(registry);
4768 if config.mcp.deferred_loading {
4769 let operator_prefix =
4771 format!("{}__", crate::agent::operator::OPERATOR_SERVER_NAME);
4772 let all_names = registry.tool_names();
4773 let mut eager_count = 0usize;
4774
4775 let is_eager = |name: &str| -> bool {
4776 name.starts_with(&operator_prefix)
4777 || name == "kumiho-memory__kumiho_memory_engage"
4778 || name == "kumiho-memory__kumiho_memory_reflect"
4779 };
4780
4781 for name in &all_names {
4782 if is_eager(name) {
4783 if let Some(def) = registry.get_tool_def(name).await {
4784 let wrapper: std::sync::Arc<dyn Tool> =
4785 std::sync::Arc::new(crate::tools::McpToolWrapper::new(
4786 name.clone(),
4787 def,
4788 std::sync::Arc::clone(®istry),
4789 ));
4790 if let Some(ref handle) = delegate_handle_pm {
4791 handle.write().push(std::sync::Arc::clone(&wrapper));
4792 }
4793 tools_registry.push(Box::new(crate::tools::ArcToolRef(wrapper)));
4794 eager_count += 1;
4795 }
4796 }
4797 }
4798
4799 let operator_pfx = operator_prefix.clone();
4800 let deferred_set = crate::tools::DeferredMcpToolSet::from_registry_filtered(
4801 std::sync::Arc::clone(®istry),
4802 move |name: &str| {
4803 !(name.starts_with(&operator_pfx)
4804 || name == "kumiho-memory__kumiho_memory_engage"
4805 || name == "kumiho-memory__kumiho_memory_reflect")
4806 },
4807 )
4808 .await;
4809 tracing::info!(
4810 "MCP hybrid: {} eager tool(s) (operator + kumiho reflexes), {} deferred stub(s) from {} server(s)",
4811 eager_count,
4812 deferred_set.len(),
4813 registry.server_count()
4814 );
4815 deferred_section =
4816 crate::tools::mcp_deferred::build_deferred_tools_section(&deferred_set);
4817 let activated = std::sync::Arc::new(std::sync::Mutex::new(
4818 crate::tools::ActivatedToolSet::new(),
4819 ));
4820 activated_handle_pm = Some(std::sync::Arc::clone(&activated));
4821 tools_registry.push(Box::new(crate::tools::ToolSearchTool::new(
4822 deferred_set,
4823 activated,
4824 )));
4825 } else {
4826 let names = registry.tool_names();
4827 let mut registered = 0usize;
4828 for name in names {
4829 if let Some(def) = registry.get_tool_def(&name).await {
4830 let wrapper: std::sync::Arc<dyn Tool> =
4831 std::sync::Arc::new(crate::tools::McpToolWrapper::new(
4832 name,
4833 def,
4834 std::sync::Arc::clone(®istry),
4835 ));
4836 if let Some(ref handle) = delegate_handle_pm {
4837 handle.write().push(std::sync::Arc::clone(&wrapper));
4838 }
4839 tools_registry.push(Box::new(crate::tools::ArcToolRef(wrapper)));
4840 registered += 1;
4841 }
4842 }
4843 tracing::info!(
4844 "MCP: {} tool(s) registered from {} server(s)",
4845 registered,
4846 registry.server_count()
4847 );
4848 }
4849 }
4850 Err(e) => {
4851 tracing::error!("MCP registry failed to initialize: {e:#}");
4852 }
4853 }
4854 }
4855
4856 let provider_name = config.default_provider.as_deref().unwrap_or("openrouter");
4857 let model_name = config
4858 .default_model
4859 .clone()
4860 .unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into());
4861 let provider_runtime_options = providers::provider_runtime_options_from_config(&config);
4862 let provider: Box<dyn Provider> = providers::create_routed_provider_with_options(
4863 provider_name,
4864 config.api_key.as_deref(),
4865 config.api_url.as_deref(),
4866 &config.reliability,
4867 &config.model_routes,
4868 &model_name,
4869 &provider_runtime_options,
4870 )?;
4871
4872 let hardware_rag: Option<crate::rag::HardwareRag> = config
4873 .peripherals
4874 .datasheet_dir
4875 .as_ref()
4876 .filter(|d| !d.trim().is_empty())
4877 .map(|dir| crate::rag::HardwareRag::load(&config.workspace_dir, dir.trim()))
4878 .and_then(Result::ok)
4879 .filter(|r: &crate::rag::HardwareRag| !r.is_empty());
4880 let board_names: Vec<String> = config
4881 .peripherals
4882 .boards
4883 .iter()
4884 .map(|b| b.board.clone())
4885 .collect();
4886
4887 let i18n_locale = config
4889 .locale
4890 .as_deref()
4891 .filter(|s| !s.is_empty())
4892 .map(ToString::to_string)
4893 .unwrap_or_else(crate::i18n::detect_locale);
4894 let i18n_search_dirs = crate::i18n::default_search_dirs(&config.workspace_dir);
4895 let i18n_descs = crate::i18n::ToolDescriptions::load(&i18n_locale, &i18n_search_dirs);
4896
4897 let skills = crate::skills::load_skills_with_config(&config.workspace_dir, &config);
4898
4899 tools::register_skill_tools(&mut tools_registry, &skills, security.clone());
4901
4902 let mut tool_descs: Vec<(&str, &str)> = vec![
4903 ("shell", "Execute terminal commands."),
4904 ("file_read", "Read file contents."),
4905 ("file_write", "Write file contents."),
4906 ("memory_store", "Save to memory."),
4907 ("memory_recall", "Search memory."),
4908 ("memory_forget", "Delete a memory entry."),
4909 (
4910 "model_routing_config",
4911 "Configure default model, scenario routing, and delegate agents.",
4912 ),
4913 ("screenshot", "Capture a screenshot."),
4914 ("image_info", "Read image metadata."),
4915 ];
4916 if matches!(
4917 config.skills.prompt_injection_mode,
4918 crate::config::SkillsPromptInjectionMode::Compact
4919 ) {
4920 tool_descs.push((
4921 "read_skill",
4922 "Load the full source for an available skill by name.",
4923 ));
4924 }
4925 if config.browser.enabled {
4926 tool_descs.push(("browser_open", "Open approved URLs in browser."));
4927 }
4928 if config.composio.enabled {
4929 tool_descs.push(("composio", "Execute actions on 1000+ apps via Composio."));
4930 }
4931 if config.peripherals.enabled && !config.peripherals.boards.is_empty() {
4932 tool_descs.push(("gpio_read", "Read GPIO pin value on connected hardware."));
4933 tool_descs.push((
4934 "gpio_write",
4935 "Set GPIO pin high or low on connected hardware.",
4936 ));
4937 tool_descs.push((
4938 "arduino_upload",
4939 "Upload Arduino sketch. Use for 'make a heart', custom patterns. You write full .ino code; Construct uploads it.",
4940 ));
4941 tool_descs.push((
4942 "hardware_memory_map",
4943 "Return flash and RAM address ranges. Use when user asks for memory addresses or memory map.",
4944 ));
4945 tool_descs.push((
4946 "hardware_board_info",
4947 "Return full board info (chip, architecture, memory map). Use when user asks for board info, what board, connected hardware, or chip info.",
4948 ));
4949 tool_descs.push((
4950 "hardware_memory_read",
4951 "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.",
4952 ));
4953 tool_descs.push((
4954 "hardware_capabilities",
4955 "Query connected hardware for reported GPIO pins and LED pin. Use when user asks what pins are available.",
4956 ));
4957 }
4958
4959 if config.autonomy.level != AutonomyLevel::Full {
4962 let excluded = &config.autonomy.non_cli_excluded_tools;
4963 if !excluded.is_empty() {
4964 tool_descs.retain(|(name, _)| !excluded.iter().any(|ex| ex == name));
4965 }
4966 }
4967
4968 let bootstrap_max_chars = if config.agent.compact_context {
4969 Some(6000)
4970 } else {
4971 None
4972 };
4973 let native_tools = provider.supports_native_tools();
4974 let mut system_prompt = crate::channels::build_system_prompt_with_mode_and_autonomy(
4975 &config.workspace_dir,
4976 &model_name,
4977 &tool_descs,
4978 &skills,
4979 Some(&config.identity),
4980 bootstrap_max_chars,
4981 Some(&config.autonomy),
4982 native_tools,
4983 config.skills.prompt_injection_mode,
4984 config.agent.compact_context,
4985 config.agent.max_system_prompt_chars,
4986 );
4987 if !native_tools {
4988 system_prompt.push_str(&build_tool_instructions(&tools_registry, Some(&i18n_descs)));
4989 }
4990 if !deferred_section.is_empty() {
4991 system_prompt.push('\n');
4992 system_prompt.push_str(&deferred_section);
4993 }
4994
4995 crate::agent::kumiho::append_kumiho_bootstrap(&mut system_prompt, &config, false);
4997
4998 crate::agent::operator::append_operator_prompt(&mut system_prompt, &config, false, &model_name);
5000
5001 let (thinking_directive, effective_message) =
5003 match crate::agent::thinking::parse_thinking_directive(message) {
5004 Some((level, remaining)) => {
5005 tracing::info!(thinking_level = ?level, "Thinking directive parsed from message");
5006 (Some(level), remaining)
5007 }
5008 None => (None, message.to_string()),
5009 };
5010 let thinking_level = crate::agent::thinking::resolve_thinking_level(
5011 thinking_directive,
5012 None,
5013 &config.agent.thinking,
5014 );
5015 let thinking_params = crate::agent::thinking::apply_thinking_level(thinking_level);
5016 let effective_temperature = crate::agent::thinking::clamp_temperature(
5017 config.default_temperature + thinking_params.temperature_adjustment,
5018 );
5019
5020 if let Some(ref prefix) = thinking_params.system_prompt_prefix {
5022 system_prompt = format!("{prefix}\n\n{system_prompt}");
5023 }
5024
5025 let effective_msg_ref = effective_message.as_str();
5026 let mem_context = build_context(
5027 mem.as_ref(),
5028 effective_msg_ref,
5029 config.memory.min_relevance_score,
5030 session_id,
5031 )
5032 .await;
5033 let rag_limit = if config.agent.compact_context { 2 } else { 5 };
5034 let hw_context = hardware_rag
5035 .as_ref()
5036 .map(|r| build_hardware_context(r, effective_msg_ref, &board_names, rag_limit))
5037 .unwrap_or_default();
5038 let context = format!("{mem_context}{hw_context}");
5039 let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z");
5040 let enriched = if context.is_empty() {
5041 format!("[{now}] {effective_message}")
5042 } else {
5043 format!("{context}[{now}] {effective_message}")
5044 };
5045
5046 let mut history = vec![
5047 ChatMessage::system(&system_prompt),
5048 ChatMessage::user(&enriched),
5049 ];
5050 let mut excluded_tools = compute_excluded_mcp_tools(
5051 &tools_registry,
5052 &config.agent.tool_filter_groups,
5053 effective_msg_ref,
5054 );
5055 if config.autonomy.level != AutonomyLevel::Full {
5056 excluded_tools.extend(config.autonomy.non_cli_excluded_tools.iter().cloned());
5057 }
5058
5059 agent_turn(
5060 provider.as_ref(),
5061 &mut history,
5062 &tools_registry,
5063 observer.as_ref(),
5064 provider_name,
5065 &model_name,
5066 effective_temperature,
5067 true,
5068 "daemon",
5069 None,
5070 &config.multimodal,
5071 config.agent.max_tool_iterations,
5072 Some(&approval_manager),
5073 &excluded_tools,
5074 &config.agent.tool_call_dedup_exempt,
5075 activated_handle_pm.as_ref(),
5076 None,
5077 )
5078 .await
5079}
5080
5081#[cfg(test)]
5082mod tests {
5083 use super::{
5084 emergency_history_trim, estimate_history_tokens, fast_trim_tool_results,
5085 load_interactive_session_history, save_interactive_session_history, truncate_tool_result,
5086 };
5087 use crate::agent::history::{DEFAULT_MAX_HISTORY_MESSAGES, InteractiveSessionState};
5088 use crate::agent::tool_execution::execute_one_tool;
5089 use crate::providers::ChatMessage;
5090 use tempfile::tempdir;
5091
5092 #[test]
5095 fn truncate_tool_result_short_passthrough() {
5096 let output = "short output";
5097 assert_eq!(truncate_tool_result(output, 100), output);
5098 }
5099
5100 #[test]
5101 fn truncate_tool_result_exact_boundary() {
5102 let output = "a".repeat(100);
5103 assert_eq!(truncate_tool_result(&output, 100), output);
5104 }
5105
5106 #[test]
5107 fn truncate_tool_result_zero_disables() {
5108 let output = "a".repeat(200_000);
5109 assert_eq!(truncate_tool_result(&output, 0), output);
5110 }
5111
5112 #[test]
5113 fn truncate_tool_result_truncates_with_marker() {
5114 let output = "a".repeat(200);
5115 let result = truncate_tool_result(&output, 100);
5116 assert!(result.contains("[... "));
5117 assert!(result.contains("characters truncated ...]\n\n"));
5118 assert!(result.starts_with("aaa"));
5120 assert!(result.ends_with("aaa"));
5121 assert!(result.len() < output.len());
5123 }
5124
5125 #[test]
5126 fn truncate_tool_result_preserves_head_tail_ratio() {
5127 let output: String = (0u32..1000)
5128 .map(|i| char::from(b'a' + (i % 26) as u8))
5129 .collect();
5130 let result = truncate_tool_result(&output, 300);
5131 let marker_start = result.find("[... ").unwrap();
5134 let marker_end = result.find("characters truncated ...]\n\n").unwrap()
5135 + "characters truncated ...]\n\n".len();
5136 let head = &result[..marker_start - 2]; let tail = &result[marker_end..];
5138 assert!(
5139 head.len() >= 190 && head.len() <= 210,
5140 "head len={}",
5141 head.len()
5142 );
5143 assert!(
5144 tail.len() >= 90 && tail.len() <= 110,
5145 "tail len={}",
5146 tail.len()
5147 );
5148 }
5149
5150 #[test]
5151 fn truncate_tool_result_utf8_boundary_safety() {
5152 let output = "🦀".repeat(100); let result = truncate_tool_result(&output, 50);
5156 assert!(result.contains("[... "));
5157 let _ = result.len();
5159 }
5160
5161 #[test]
5162 fn truncate_tool_result_very_small_max() {
5163 let output = "abcdefghijklmnopqrstuvwxyz";
5164 let result = truncate_tool_result(output, 5);
5167 assert!(result.contains("[... "));
5168 assert!(result.starts_with("abc"));
5170 assert!(result.ends_with("yz"));
5171 }
5172
5173 #[test]
5176 fn fast_trim_protects_recent_messages() {
5177 let mut history = vec![
5178 ChatMessage::system("sys"),
5179 ChatMessage::tool("a".repeat(5000)),
5180 ChatMessage::tool("b".repeat(5000)),
5181 ChatMessage::user("recent user msg"),
5182 ChatMessage::tool("c".repeat(5000)), ];
5184 let saved = fast_trim_tool_results(&mut history, 2);
5186 assert!(saved > 0);
5187 assert!(history[1].content.len() <= 2100);
5189 assert!(history[2].content.len() <= 2100);
5190 assert_eq!(history[4].content.len(), 5000);
5192 }
5193
5194 #[test]
5195 fn fast_trim_skips_non_tool_messages() {
5196 let mut history = vec![
5197 ChatMessage::system("sys"),
5198 ChatMessage::user("a".repeat(5000)),
5199 ChatMessage::assistant("b".repeat(5000)),
5200 ];
5201 let saved = fast_trim_tool_results(&mut history, 0);
5202 assert_eq!(saved, 0);
5203 assert_eq!(history[1].content.len(), 5000);
5204 assert_eq!(history[2].content.len(), 5000);
5205 }
5206
5207 #[test]
5208 fn fast_trim_small_tool_results_unchanged() {
5209 let mut history = vec![
5210 ChatMessage::system("sys"),
5211 ChatMessage::tool("short result"),
5212 ];
5213 let saved = fast_trim_tool_results(&mut history, 0);
5214 assert_eq!(saved, 0);
5215 assert_eq!(history[1].content, "short result");
5216 }
5217
5218 #[test]
5221 fn emergency_trim_preserves_system() {
5222 let mut history = vec![
5223 ChatMessage::system("sys"),
5224 ChatMessage::user("msg1"),
5225 ChatMessage::assistant("resp1"),
5226 ChatMessage::user("msg2"),
5227 ChatMessage::assistant("resp2"),
5228 ChatMessage::user("msg3"),
5229 ];
5230 let dropped = emergency_history_trim(&mut history, 2);
5231 assert!(dropped > 0);
5232 assert_eq!(history[0].role, "system");
5234 assert_eq!(history[0].content, "sys");
5235 let len = history.len();
5237 assert_eq!(history[len - 1].content, "msg3");
5238 }
5239
5240 #[test]
5241 fn emergency_trim_preserves_recent() {
5242 let mut history = vec![
5243 ChatMessage::system("sys"),
5244 ChatMessage::user("old1"),
5245 ChatMessage::user("old2"),
5246 ChatMessage::user("recent1"),
5247 ChatMessage::user("recent2"),
5248 ];
5249 let dropped = emergency_history_trim(&mut history, 2);
5250 assert!(dropped > 0);
5251 let len = history.len();
5253 assert_eq!(history[len - 1].content, "recent2");
5254 assert_eq!(history[len - 2].content, "recent1");
5255 }
5256
5257 #[test]
5258 fn emergency_trim_nothing_to_drop() {
5259 let mut history = vec![
5260 ChatMessage::system("sys"),
5261 ChatMessage::user("only user msg"),
5262 ];
5263 let dropped = emergency_history_trim(&mut history, 1);
5266 assert_eq!(dropped, 0);
5267 }
5268
5269 #[test]
5272 fn estimate_tokens_empty_history() {
5273 let history: Vec<ChatMessage> = vec![];
5274 assert_eq!(estimate_history_tokens(&history), 0);
5275 }
5276
5277 #[test]
5278 fn estimate_tokens_single_message() {
5279 let msg = "a".repeat(40);
5281 let history = vec![ChatMessage::user(&msg)];
5282 let est = estimate_history_tokens(&history);
5283 assert_eq!(est, 14);
5284 }
5285
5286 #[test]
5287 fn estimate_tokens_multiple_messages() {
5288 let history = vec![
5289 ChatMessage::system("system prompt here"), ChatMessage::user("hello"), ChatMessage::assistant("world"), ];
5293 let est = estimate_history_tokens(&history);
5294 assert_eq!(est, 21);
5297 }
5298
5299 #[test]
5300 fn estimate_tokens_large_tool_result() {
5301 let big = "x".repeat(40_000);
5302 let history = vec![ChatMessage::tool(&big)];
5303 let est = estimate_history_tokens(&history);
5304 assert_eq!(est, 10_004);
5306 }
5307
5308 #[test]
5311 fn shared_budget_decrement_logic() {
5312 use std::sync::Arc;
5313 use std::sync::atomic::{AtomicUsize, Ordering};
5314
5315 let budget = Arc::new(AtomicUsize::new(3));
5316
5317 for i in 0..3 {
5319 let remaining = budget.load(Ordering::Relaxed);
5320 assert!(remaining > 0, "Budget should be >0 at iteration {i}");
5321 budget.fetch_sub(1, Ordering::Relaxed);
5322 }
5323
5324 assert_eq!(budget.load(Ordering::Relaxed), 0);
5326 }
5327
5328 #[test]
5329 fn shared_budget_none_has_no_effect() {
5330 let budget: Option<Arc<std::sync::atomic::AtomicUsize>> = None;
5332 assert!(budget.is_none());
5333 }
5334
5335 #[test]
5338 fn interactive_session_state_round_trips_history() {
5339 let dir = tempdir().unwrap();
5340 let path = dir.path().join("session.json");
5341 let history = vec![
5342 ChatMessage::system("system"),
5343 ChatMessage::user("hello"),
5344 ChatMessage::assistant("hi"),
5345 ];
5346
5347 save_interactive_session_history(&path, &history).unwrap();
5348 let restored = load_interactive_session_history(&path, "fallback").unwrap();
5349
5350 assert_eq!(restored.len(), 3);
5351 assert_eq!(restored[0].role, "system");
5352 assert_eq!(restored[1].content, "hello");
5353 assert_eq!(restored[2].content, "hi");
5354 }
5355
5356 #[test]
5357 fn interactive_session_state_adds_missing_system_prompt() {
5358 let dir = tempdir().unwrap();
5359 let path = dir.path().join("session.json");
5360 let payload = serde_json::to_string_pretty(&InteractiveSessionState {
5361 version: 1,
5362 history: vec![ChatMessage::user("orphan")],
5363 })
5364 .unwrap();
5365 std::fs::write(&path, payload).unwrap();
5366
5367 let restored = load_interactive_session_history(&path, "fallback system").unwrap();
5368
5369 assert_eq!(restored[0].role, "system");
5370 assert_eq!(restored[0].content, "fallback system");
5371 assert_eq!(restored[1].content, "orphan");
5372 }
5373
5374 use super::*;
5375 use async_trait::async_trait;
5376 use base64::{Engine as _, engine::general_purpose::STANDARD};
5377 use std::collections::VecDeque;
5378 use std::sync::atomic::{AtomicUsize, Ordering};
5379 use std::sync::{Arc, Mutex};
5380 use std::time::Duration;
5381
5382 #[test]
5383 fn scrub_credentials_redacts_bearer_token() {
5384 let input = "API_KEY=sk-1234567890abcdef; token: 1234567890; password=\"secret123456\"";
5385 let scrubbed = scrub_credentials(input);
5386 assert!(scrubbed.contains("API_KEY=sk-1*[REDACTED]"));
5387 assert!(scrubbed.contains("token: 1234*[REDACTED]"));
5388 assert!(scrubbed.contains("password=\"secr*[REDACTED]\""));
5389 assert!(!scrubbed.contains("abcdef"));
5390 assert!(!scrubbed.contains("secret123456"));
5391 }
5392
5393 #[test]
5394 fn scrub_credentials_redacts_json_api_key() {
5395 let input = r#"{"api_key": "sk-1234567890", "other": "public"}"#;
5396 let scrubbed = scrub_credentials(input);
5397 assert!(scrubbed.contains("\"api_key\": \"sk-1*[REDACTED]\""));
5398 assert!(scrubbed.contains("public"));
5399 }
5400
5401 #[tokio::test]
5402 async fn execute_one_tool_does_not_panic_on_utf8_boundary() {
5403 let call_arguments = (0..600)
5404 .map(|n| serde_json::json!({ "content": format!("{}:tail", "a".repeat(n)) }))
5405 .find(|args| {
5406 let raw = args.to_string();
5407 raw.len() > 300 && !raw.is_char_boundary(300)
5408 })
5409 .expect("should produce a sample whose byte index 300 is not a char boundary");
5410
5411 let observer = NoopObserver;
5412 let result =
5413 execute_one_tool("unknown_tool", call_arguments, &[], None, &observer, None).await;
5414 assert!(result.is_ok(), "execute_one_tool should not panic or error");
5415
5416 let outcome = result.unwrap();
5417 assert!(!outcome.success);
5418 assert!(outcome.output.contains("Unknown tool: unknown_tool"));
5419 }
5420
5421 #[tokio::test]
5422 async fn execute_one_tool_resolves_unique_activated_tool_suffix() {
5423 let observer = NoopObserver;
5424 let invocations = Arc::new(AtomicUsize::new(0));
5425 let activated = Arc::new(std::sync::Mutex::new(crate::tools::ActivatedToolSet::new()));
5426 let activated_tool: Arc<dyn Tool> = Arc::new(CountingTool::new(
5427 "docker-mcp__extract_text",
5428 Arc::clone(&invocations),
5429 ));
5430 activated
5431 .lock()
5432 .unwrap()
5433 .activate("docker-mcp__extract_text".into(), activated_tool);
5434
5435 let outcome = execute_one_tool(
5436 "extract_text",
5437 serde_json::json!({ "value": "ok" }),
5438 &[],
5439 Some(&activated),
5440 &observer,
5441 None,
5442 )
5443 .await
5444 .expect("suffix alias should execute the unique activated tool");
5445
5446 assert!(outcome.success);
5447 assert_eq!(outcome.output, "counted:ok");
5448 assert_eq!(invocations.load(Ordering::SeqCst), 1);
5449 }
5450
5451 use crate::observability::NoopObserver;
5452 use crate::providers::ChatResponse;
5453 use crate::providers::router::{Route, RouterProvider};
5454 use crate::providers::traits::{ProviderCapabilities, StreamChunk, StreamEvent, StreamOptions};
5455 use tempfile::TempDir;
5456
5457 struct NonVisionProvider {
5458 calls: Arc<AtomicUsize>,
5459 }
5460
5461 #[async_trait]
5462 impl Provider for NonVisionProvider {
5463 async fn chat_with_system(
5464 &self,
5465 _system_prompt: Option<&str>,
5466 _message: &str,
5467 _model: &str,
5468 _temperature: f64,
5469 ) -> anyhow::Result<String> {
5470 self.calls.fetch_add(1, Ordering::SeqCst);
5471 Ok("ok".to_string())
5472 }
5473 }
5474
5475 struct VisionProvider {
5476 calls: Arc<AtomicUsize>,
5477 }
5478
5479 #[async_trait]
5480 impl Provider for VisionProvider {
5481 fn capabilities(&self) -> ProviderCapabilities {
5482 ProviderCapabilities {
5483 native_tool_calling: false,
5484 vision: true,
5485 prompt_caching: false,
5486 }
5487 }
5488
5489 async fn chat_with_system(
5490 &self,
5491 _system_prompt: Option<&str>,
5492 _message: &str,
5493 _model: &str,
5494 _temperature: f64,
5495 ) -> anyhow::Result<String> {
5496 self.calls.fetch_add(1, Ordering::SeqCst);
5497 Ok("ok".to_string())
5498 }
5499
5500 async fn chat(
5501 &self,
5502 request: ChatRequest<'_>,
5503 _model: &str,
5504 _temperature: f64,
5505 ) -> anyhow::Result<ChatResponse> {
5506 self.calls.fetch_add(1, Ordering::SeqCst);
5507 let marker_count = crate::multimodal::count_image_markers(request.messages);
5508 if marker_count == 0 {
5509 anyhow::bail!("expected image markers in request messages");
5510 }
5511
5512 if request.tools.is_some() {
5513 anyhow::bail!("no tools should be attached for this test");
5514 }
5515
5516 Ok(ChatResponse {
5517 text: Some("vision-ok".to_string()),
5518 tool_calls: Vec::new(),
5519 usage: None,
5520 reasoning_content: None,
5521 })
5522 }
5523 }
5524
5525 struct ScriptedProvider {
5526 responses: Arc<Mutex<VecDeque<ChatResponse>>>,
5527 capabilities: ProviderCapabilities,
5528 }
5529
5530 impl ScriptedProvider {
5531 fn from_text_responses(responses: Vec<&str>) -> Self {
5532 let scripted = responses
5533 .into_iter()
5534 .map(|text| ChatResponse {
5535 text: Some(text.to_string()),
5536 tool_calls: Vec::new(),
5537 usage: None,
5538 reasoning_content: None,
5539 })
5540 .collect();
5541 Self {
5542 responses: Arc::new(Mutex::new(scripted)),
5543 capabilities: ProviderCapabilities::default(),
5544 }
5545 }
5546
5547 fn with_native_tool_support(mut self) -> Self {
5548 self.capabilities.native_tool_calling = true;
5549 self
5550 }
5551 }
5552
5553 #[async_trait]
5554 impl Provider for ScriptedProvider {
5555 fn capabilities(&self) -> ProviderCapabilities {
5556 self.capabilities.clone()
5557 }
5558
5559 async fn chat_with_system(
5560 &self,
5561 _system_prompt: Option<&str>,
5562 _message: &str,
5563 _model: &str,
5564 _temperature: f64,
5565 ) -> anyhow::Result<String> {
5566 anyhow::bail!("chat_with_system should not be used in scripted provider tests");
5567 }
5568
5569 async fn chat(
5570 &self,
5571 _request: ChatRequest<'_>,
5572 _model: &str,
5573 _temperature: f64,
5574 ) -> anyhow::Result<ChatResponse> {
5575 let mut responses = self
5576 .responses
5577 .lock()
5578 .expect("responses lock should be valid");
5579 responses
5580 .pop_front()
5581 .ok_or_else(|| anyhow::anyhow!("scripted provider exhausted responses"))
5582 }
5583 }
5584
5585 struct StreamingScriptedProvider {
5586 responses: Arc<Mutex<VecDeque<String>>>,
5587 stream_calls: Arc<AtomicUsize>,
5588 chat_calls: Arc<AtomicUsize>,
5589 }
5590
5591 impl StreamingScriptedProvider {
5592 fn from_text_responses(responses: Vec<&str>) -> Self {
5593 Self {
5594 responses: Arc::new(Mutex::new(
5595 responses.into_iter().map(ToString::to_string).collect(),
5596 )),
5597 stream_calls: Arc::new(AtomicUsize::new(0)),
5598 chat_calls: Arc::new(AtomicUsize::new(0)),
5599 }
5600 }
5601 }
5602
5603 #[async_trait]
5604 impl Provider for StreamingScriptedProvider {
5605 async fn chat_with_system(
5606 &self,
5607 _system_prompt: Option<&str>,
5608 _message: &str,
5609 _model: &str,
5610 _temperature: f64,
5611 ) -> anyhow::Result<String> {
5612 anyhow::bail!(
5613 "chat_with_system should not be used in streaming scripted provider tests"
5614 );
5615 }
5616
5617 async fn chat(
5618 &self,
5619 _request: ChatRequest<'_>,
5620 _model: &str,
5621 _temperature: f64,
5622 ) -> anyhow::Result<ChatResponse> {
5623 self.chat_calls.fetch_add(1, Ordering::SeqCst);
5624 anyhow::bail!("chat should not be called when streaming succeeds")
5625 }
5626
5627 fn supports_streaming(&self) -> bool {
5628 true
5629 }
5630
5631 fn stream_chat_with_history(
5632 &self,
5633 _messages: &[ChatMessage],
5634 _model: &str,
5635 _temperature: f64,
5636 options: StreamOptions,
5637 ) -> futures_util::stream::BoxStream<
5638 'static,
5639 crate::providers::traits::StreamResult<StreamChunk>,
5640 > {
5641 self.stream_calls.fetch_add(1, Ordering::SeqCst);
5642 if !options.enabled {
5643 return Box::pin(futures_util::stream::empty());
5644 }
5645
5646 let response = self
5647 .responses
5648 .lock()
5649 .expect("responses lock should be valid")
5650 .pop_front()
5651 .unwrap_or_default();
5652
5653 Box::pin(futures_util::stream::iter(vec![
5654 Ok(StreamChunk::delta(response)),
5655 Ok(StreamChunk::final_chunk()),
5656 ]))
5657 }
5658 }
5659
5660 enum NativeStreamTurn {
5661 ToolCall(ToolCall),
5662 Text(String),
5663 }
5664
5665 struct StreamingNativeToolEventProvider {
5666 turns: Arc<Mutex<VecDeque<NativeStreamTurn>>>,
5667 stream_calls: Arc<AtomicUsize>,
5668 stream_tool_requests: Arc<AtomicUsize>,
5669 chat_calls: Arc<AtomicUsize>,
5670 }
5671
5672 impl StreamingNativeToolEventProvider {
5673 fn with_turns(turns: Vec<NativeStreamTurn>) -> Self {
5674 Self {
5675 turns: Arc::new(Mutex::new(turns.into())),
5676 stream_calls: Arc::new(AtomicUsize::new(0)),
5677 stream_tool_requests: Arc::new(AtomicUsize::new(0)),
5678 chat_calls: Arc::new(AtomicUsize::new(0)),
5679 }
5680 }
5681 }
5682
5683 #[async_trait]
5684 impl Provider for StreamingNativeToolEventProvider {
5685 fn capabilities(&self) -> ProviderCapabilities {
5686 ProviderCapabilities {
5687 native_tool_calling: true,
5688 vision: false,
5689 prompt_caching: false,
5690 }
5691 }
5692
5693 async fn chat_with_system(
5694 &self,
5695 _system_prompt: Option<&str>,
5696 _message: &str,
5697 _model: &str,
5698 _temperature: f64,
5699 ) -> anyhow::Result<String> {
5700 anyhow::bail!(
5701 "chat_with_system should not be used in streaming native tool event provider tests"
5702 );
5703 }
5704
5705 async fn chat(
5706 &self,
5707 _request: ChatRequest<'_>,
5708 _model: &str,
5709 _temperature: f64,
5710 ) -> anyhow::Result<ChatResponse> {
5711 self.chat_calls.fetch_add(1, Ordering::SeqCst);
5712 anyhow::bail!("chat should not be called when native streaming events succeed")
5713 }
5714
5715 fn supports_streaming(&self) -> bool {
5716 true
5717 }
5718
5719 fn supports_streaming_tool_events(&self) -> bool {
5720 true
5721 }
5722
5723 fn stream_chat(
5724 &self,
5725 request: ChatRequest<'_>,
5726 _model: &str,
5727 _temperature: f64,
5728 options: StreamOptions,
5729 ) -> futures_util::stream::BoxStream<
5730 'static,
5731 crate::providers::traits::StreamResult<StreamEvent>,
5732 > {
5733 self.stream_calls.fetch_add(1, Ordering::SeqCst);
5734 if request.tools.is_some_and(|tools| !tools.is_empty()) {
5735 self.stream_tool_requests.fetch_add(1, Ordering::SeqCst);
5736 }
5737 if !options.enabled {
5738 return Box::pin(futures_util::stream::empty());
5739 }
5740
5741 let turn = self
5742 .turns
5743 .lock()
5744 .expect("turns lock should be valid")
5745 .pop_front()
5746 .expect("streaming turns should have scripted output");
5747 match turn {
5748 NativeStreamTurn::ToolCall(tool_call) => {
5749 Box::pin(futures_util::stream::iter(vec![
5750 Ok(StreamEvent::ToolCall(tool_call)),
5751 Ok(StreamEvent::Final),
5752 ]))
5753 }
5754 NativeStreamTurn::Text(text) => Box::pin(futures_util::stream::iter(vec![
5755 Ok(StreamEvent::TextDelta(StreamChunk::delta(text))),
5756 Ok(StreamEvent::Final),
5757 ])),
5758 }
5759 }
5760 }
5761
5762 struct RouteAwareStreamingProvider {
5763 response: String,
5764 stream_calls: Arc<AtomicUsize>,
5765 chat_calls: Arc<AtomicUsize>,
5766 last_model: Arc<Mutex<String>>,
5767 }
5768
5769 impl RouteAwareStreamingProvider {
5770 fn new(response: &str) -> Self {
5771 Self {
5772 response: response.to_string(),
5773 stream_calls: Arc::new(AtomicUsize::new(0)),
5774 chat_calls: Arc::new(AtomicUsize::new(0)),
5775 last_model: Arc::new(Mutex::new(String::new())),
5776 }
5777 }
5778 }
5779
5780 #[async_trait]
5781 impl Provider for RouteAwareStreamingProvider {
5782 async fn chat_with_system(
5783 &self,
5784 _system_prompt: Option<&str>,
5785 _message: &str,
5786 _model: &str,
5787 _temperature: f64,
5788 ) -> anyhow::Result<String> {
5789 anyhow::bail!("chat_with_system should not be used in route-aware stream tests");
5790 }
5791
5792 async fn chat(
5793 &self,
5794 _request: ChatRequest<'_>,
5795 _model: &str,
5796 _temperature: f64,
5797 ) -> anyhow::Result<ChatResponse> {
5798 self.chat_calls.fetch_add(1, Ordering::SeqCst);
5799 anyhow::bail!("chat should not be called when routed streaming succeeds")
5800 }
5801
5802 fn supports_streaming(&self) -> bool {
5803 true
5804 }
5805
5806 fn stream_chat_with_history(
5807 &self,
5808 _messages: &[ChatMessage],
5809 model: &str,
5810 _temperature: f64,
5811 options: StreamOptions,
5812 ) -> futures_util::stream::BoxStream<
5813 'static,
5814 crate::providers::traits::StreamResult<StreamChunk>,
5815 > {
5816 self.stream_calls.fetch_add(1, Ordering::SeqCst);
5817 *self
5818 .last_model
5819 .lock()
5820 .expect("last_model lock should be valid") = model.to_string();
5821 if !options.enabled {
5822 return Box::pin(futures_util::stream::empty());
5823 }
5824
5825 Box::pin(futures_util::stream::iter(vec![
5826 Ok(StreamChunk::delta(self.response.clone())),
5827 Ok(StreamChunk::final_chunk()),
5828 ]))
5829 }
5830 }
5831
5832 struct CountingTool {
5833 name: String,
5834 invocations: Arc<AtomicUsize>,
5835 }
5836
5837 impl CountingTool {
5838 fn new(name: &str, invocations: Arc<AtomicUsize>) -> Self {
5839 Self {
5840 name: name.to_string(),
5841 invocations,
5842 }
5843 }
5844 }
5845
5846 #[async_trait]
5847 impl Tool for CountingTool {
5848 fn name(&self) -> &str {
5849 &self.name
5850 }
5851
5852 fn description(&self) -> &str {
5853 "Counts executions for loop-stability tests"
5854 }
5855
5856 fn parameters_schema(&self) -> serde_json::Value {
5857 serde_json::json!({
5858 "type": "object",
5859 "properties": {
5860 "value": { "type": "string" }
5861 }
5862 })
5863 }
5864
5865 async fn execute(
5866 &self,
5867 args: serde_json::Value,
5868 ) -> anyhow::Result<crate::tools::ToolResult> {
5869 self.invocations.fetch_add(1, Ordering::SeqCst);
5870 let value = args
5871 .get("value")
5872 .and_then(serde_json::Value::as_str)
5873 .unwrap_or_default();
5874 Ok(crate::tools::ToolResult {
5875 success: true,
5876 output: format!("counted:{value}"),
5877 error: None,
5878 })
5879 }
5880 }
5881
5882 struct RecordingArgsTool {
5883 name: String,
5884 recorded_args: Arc<Mutex<Vec<serde_json::Value>>>,
5885 }
5886
5887 impl RecordingArgsTool {
5888 fn new(name: &str, recorded_args: Arc<Mutex<Vec<serde_json::Value>>>) -> Self {
5889 Self {
5890 name: name.to_string(),
5891 recorded_args,
5892 }
5893 }
5894 }
5895
5896 #[async_trait]
5897 impl Tool for RecordingArgsTool {
5898 fn name(&self) -> &str {
5899 &self.name
5900 }
5901
5902 fn description(&self) -> &str {
5903 "Records tool arguments for regression tests"
5904 }
5905
5906 fn parameters_schema(&self) -> serde_json::Value {
5907 serde_json::json!({
5908 "type": "object",
5909 "properties": {
5910 "prompt": { "type": "string" },
5911 "schedule": { "type": "object" },
5912 "delivery": { "type": "object" }
5913 }
5914 })
5915 }
5916
5917 async fn execute(
5918 &self,
5919 args: serde_json::Value,
5920 ) -> anyhow::Result<crate::tools::ToolResult> {
5921 self.recorded_args
5922 .lock()
5923 .expect("recorded args lock should be valid")
5924 .push(args.clone());
5925 Ok(crate::tools::ToolResult {
5926 success: true,
5927 output: args.to_string(),
5928 error: None,
5929 })
5930 }
5931 }
5932
5933 struct DelayTool {
5934 name: String,
5935 delay_ms: u64,
5936 active: Arc<AtomicUsize>,
5937 max_active: Arc<AtomicUsize>,
5938 }
5939
5940 impl DelayTool {
5941 fn new(
5942 name: &str,
5943 delay_ms: u64,
5944 active: Arc<AtomicUsize>,
5945 max_active: Arc<AtomicUsize>,
5946 ) -> Self {
5947 Self {
5948 name: name.to_string(),
5949 delay_ms,
5950 active,
5951 max_active,
5952 }
5953 }
5954 }
5955
5956 #[async_trait]
5957 impl Tool for DelayTool {
5958 fn name(&self) -> &str {
5959 &self.name
5960 }
5961
5962 fn description(&self) -> &str {
5963 "Delay tool for testing parallel tool execution"
5964 }
5965
5966 fn parameters_schema(&self) -> serde_json::Value {
5967 serde_json::json!({
5968 "type": "object",
5969 "properties": {
5970 "value": { "type": "string" }
5971 },
5972 "required": ["value"]
5973 })
5974 }
5975
5976 async fn execute(
5977 &self,
5978 args: serde_json::Value,
5979 ) -> anyhow::Result<crate::tools::ToolResult> {
5980 let now_active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
5981 self.max_active.fetch_max(now_active, Ordering::SeqCst);
5982
5983 tokio::time::sleep(Duration::from_millis(self.delay_ms)).await;
5984
5985 self.active.fetch_sub(1, Ordering::SeqCst);
5986
5987 let value = args
5988 .get("value")
5989 .and_then(serde_json::Value::as_str)
5990 .unwrap_or_default()
5991 .to_string();
5992
5993 Ok(crate::tools::ToolResult {
5994 success: true,
5995 output: format!("ok:{value}"),
5996 error: None,
5997 })
5998 }
5999 }
6000
6001 struct FailingTool {
6003 tool_name: String,
6004 error_reason: String,
6005 }
6006
6007 impl FailingTool {
6008 fn new(name: &str, error_reason: &str) -> Self {
6009 Self {
6010 tool_name: name.to_string(),
6011 error_reason: error_reason.to_string(),
6012 }
6013 }
6014 }
6015
6016 #[async_trait]
6017 impl Tool for FailingTool {
6018 fn name(&self) -> &str {
6019 &self.tool_name
6020 }
6021
6022 fn description(&self) -> &str {
6023 "A tool that always fails for testing failure surfacing"
6024 }
6025
6026 fn parameters_schema(&self) -> serde_json::Value {
6027 serde_json::json!({
6028 "type": "object",
6029 "properties": {
6030 "command": { "type": "string" }
6031 }
6032 })
6033 }
6034
6035 async fn execute(
6036 &self,
6037 _args: serde_json::Value,
6038 ) -> anyhow::Result<crate::tools::ToolResult> {
6039 Ok(crate::tools::ToolResult {
6040 success: false,
6041 output: String::new(),
6042 error: Some(self.error_reason.clone()),
6043 })
6044 }
6045 }
6046
6047 #[tokio::test]
6048 async fn run_tool_call_loop_returns_structured_error_for_non_vision_provider() {
6049 let calls = Arc::new(AtomicUsize::new(0));
6050 let provider = NonVisionProvider {
6051 calls: Arc::clone(&calls),
6052 };
6053
6054 let mut history = vec![ChatMessage::user(
6055 "please inspect [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
6056 )];
6057 let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6058 let observer = NoopObserver;
6059
6060 let err = run_tool_call_loop(
6061 &provider,
6062 &mut history,
6063 &tools_registry,
6064 &observer,
6065 "mock-provider",
6066 "mock-model",
6067 0.0,
6068 true,
6069 None,
6070 "cli",
6071 None,
6072 &crate::config::MultimodalConfig::default(),
6073 3,
6074 None,
6075 None,
6076 None,
6077 &[],
6078 &[],
6079 None,
6080 None,
6081 &crate::config::PacingConfig::default(),
6082 0,
6083 0,
6084 None,
6085 )
6086 .await
6087 .expect_err("provider without vision support should fail");
6088
6089 assert!(err.to_string().contains("provider_capability_error"));
6090 assert!(err.to_string().contains("capability=vision"));
6091 assert_eq!(calls.load(Ordering::SeqCst), 0);
6092 }
6093
6094 #[tokio::test]
6095 async fn run_tool_call_loop_rejects_oversized_image_payload() {
6096 let calls = Arc::new(AtomicUsize::new(0));
6097 let provider = VisionProvider {
6098 calls: Arc::clone(&calls),
6099 };
6100
6101 let oversized_payload = STANDARD.encode(vec![0_u8; (1024 * 1024) + 1]);
6102 let mut history = vec![ChatMessage::user(format!(
6103 "[IMAGE:data:image/png;base64,{oversized_payload}]"
6104 ))];
6105
6106 let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6107 let observer = NoopObserver;
6108 let multimodal = crate::config::MultimodalConfig {
6109 max_images: 4,
6110 max_image_size_mb: 1,
6111 allow_remote_fetch: false,
6112 ..Default::default()
6113 };
6114
6115 let err = run_tool_call_loop(
6116 &provider,
6117 &mut history,
6118 &tools_registry,
6119 &observer,
6120 "mock-provider",
6121 "mock-model",
6122 0.0,
6123 true,
6124 None,
6125 "cli",
6126 None,
6127 &multimodal,
6128 3,
6129 None,
6130 None,
6131 None,
6132 &[],
6133 &[],
6134 None,
6135 None,
6136 &crate::config::PacingConfig::default(),
6137 0,
6138 0,
6139 None,
6140 )
6141 .await
6142 .expect_err("oversized payload must fail");
6143
6144 assert!(
6145 err.to_string()
6146 .contains("multimodal image size limit exceeded")
6147 );
6148 assert_eq!(calls.load(Ordering::SeqCst), 0);
6149 }
6150
6151 #[tokio::test]
6152 async fn run_tool_call_loop_accepts_valid_multimodal_request_flow() {
6153 let calls = Arc::new(AtomicUsize::new(0));
6154 let provider = VisionProvider {
6155 calls: Arc::clone(&calls),
6156 };
6157
6158 let mut history = vec![ChatMessage::user(
6159 "Analyze this [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
6160 )];
6161 let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6162 let observer = NoopObserver;
6163
6164 let result = run_tool_call_loop(
6165 &provider,
6166 &mut history,
6167 &tools_registry,
6168 &observer,
6169 "mock-provider",
6170 "mock-model",
6171 0.0,
6172 true,
6173 None,
6174 "cli",
6175 None,
6176 &crate::config::MultimodalConfig::default(),
6177 3,
6178 None,
6179 None,
6180 None,
6181 &[],
6182 &[],
6183 None,
6184 None,
6185 &crate::config::PacingConfig::default(),
6186 0,
6187 0,
6188 None,
6189 )
6190 .await
6191 .expect("valid multimodal payload should pass");
6192
6193 assert_eq!(result, "vision-ok");
6194 assert_eq!(calls.load(Ordering::SeqCst), 1);
6195 }
6196
6197 #[tokio::test]
6200 async fn run_tool_call_loop_no_vision_provider_config_preserves_error() {
6201 let calls = Arc::new(AtomicUsize::new(0));
6202 let provider = NonVisionProvider {
6203 calls: Arc::clone(&calls),
6204 };
6205
6206 let mut history = vec![ChatMessage::user(
6207 "check [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
6208 )];
6209 let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6210 let observer = NoopObserver;
6211
6212 let err = run_tool_call_loop(
6213 &provider,
6214 &mut history,
6215 &tools_registry,
6216 &observer,
6217 "mock-provider",
6218 "mock-model",
6219 0.0,
6220 true,
6221 None,
6222 "cli",
6223 None,
6224 &crate::config::MultimodalConfig::default(),
6225 3,
6226 None,
6227 None,
6228 None,
6229 &[],
6230 &[],
6231 None,
6232 None,
6233 &crate::config::PacingConfig::default(),
6234 0,
6235 0,
6236 None,
6237 )
6238 .await
6239 .expect_err("should fail without vision_provider config");
6240
6241 assert!(err.to_string().contains("capability=vision"));
6242 assert_eq!(calls.load(Ordering::SeqCst), 0);
6243 }
6244
6245 #[tokio::test]
6249 async fn run_tool_call_loop_vision_provider_creation_failure() {
6250 let calls = Arc::new(AtomicUsize::new(0));
6251 let provider = NonVisionProvider {
6252 calls: Arc::clone(&calls),
6253 };
6254
6255 let mut history = vec![ChatMessage::user(
6256 "inspect [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
6257 )];
6258 let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6259 let observer = NoopObserver;
6260
6261 let multimodal = crate::config::MultimodalConfig {
6262 vision_provider: Some("nonexistent-provider-xyz".to_string()),
6263 vision_model: Some("some-model".to_string()),
6264 ..Default::default()
6265 };
6266
6267 let err = run_tool_call_loop(
6268 &provider,
6269 &mut history,
6270 &tools_registry,
6271 &observer,
6272 "mock-provider",
6273 "mock-model",
6274 0.0,
6275 true,
6276 None,
6277 "cli",
6278 None,
6279 &multimodal,
6280 3,
6281 None,
6282 None,
6283 None,
6284 &[],
6285 &[],
6286 None,
6287 None,
6288 &crate::config::PacingConfig::default(),
6289 0,
6290 0,
6291 None,
6292 )
6293 .await
6294 .expect_err("should fail when vision provider cannot be created");
6295
6296 assert!(
6297 err.to_string().contains("failed to create vision provider"),
6298 "expected creation failure error, got: {}",
6299 err
6300 );
6301 assert_eq!(calls.load(Ordering::SeqCst), 0);
6302 }
6303
6304 #[tokio::test]
6307 async fn run_tool_call_loop_no_images_uses_default_provider() {
6308 let provider = ScriptedProvider::from_text_responses(vec!["hello world"]);
6309
6310 let mut history = vec![ChatMessage::user("just text, no images".to_string())];
6311 let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6312 let observer = NoopObserver;
6313
6314 let multimodal = crate::config::MultimodalConfig {
6315 vision_provider: Some("nonexistent-provider-xyz".to_string()),
6316 vision_model: Some("some-model".to_string()),
6317 ..Default::default()
6318 };
6319
6320 let result = run_tool_call_loop(
6323 &provider,
6324 &mut history,
6325 &tools_registry,
6326 &observer,
6327 "scripted",
6328 "scripted-model",
6329 0.0,
6330 true,
6331 None,
6332 "cli",
6333 None,
6334 &multimodal,
6335 3,
6336 None,
6337 None,
6338 None,
6339 &[],
6340 &[],
6341 None,
6342 None,
6343 &crate::config::PacingConfig::default(),
6344 0,
6345 0,
6346 None,
6347 )
6348 .await
6349 .expect("text-only messages should succeed with default provider");
6350
6351 assert_eq!(result, "hello world");
6352 }
6353
6354 #[tokio::test]
6357 async fn run_tool_call_loop_vision_provider_without_model_falls_back() {
6358 let calls = Arc::new(AtomicUsize::new(0));
6359 let provider = NonVisionProvider {
6360 calls: Arc::clone(&calls),
6361 };
6362
6363 let mut history = vec![ChatMessage::user(
6364 "look [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
6365 )];
6366 let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6367 let observer = NoopObserver;
6368
6369 let multimodal = crate::config::MultimodalConfig {
6373 vision_provider: Some("nonexistent-provider-xyz".to_string()),
6374 vision_model: None,
6375 ..Default::default()
6376 };
6377
6378 let err = run_tool_call_loop(
6379 &provider,
6380 &mut history,
6381 &tools_registry,
6382 &observer,
6383 "mock-provider",
6384 "mock-model",
6385 0.0,
6386 true,
6387 None,
6388 "cli",
6389 None,
6390 &multimodal,
6391 3,
6392 None,
6393 None,
6394 None,
6395 &[],
6396 &[],
6397 None,
6398 None,
6399 &crate::config::PacingConfig::default(),
6400 0,
6401 0,
6402 None,
6403 )
6404 .await
6405 .expect_err("should fail due to nonexistent vision provider");
6406
6407 assert!(
6409 err.to_string().contains("failed to create vision provider"),
6410 "expected creation failure, got: {}",
6411 err
6412 );
6413 }
6414
6415 #[tokio::test]
6418 async fn run_tool_call_loop_empty_image_markers_use_default_provider() {
6419 let provider = ScriptedProvider::from_text_responses(vec!["handled"]);
6420
6421 let mut history = vec![ChatMessage::user(
6422 "empty marker [IMAGE:] should be ignored".to_string(),
6423 )];
6424 let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6425 let observer = NoopObserver;
6426
6427 let multimodal = crate::config::MultimodalConfig {
6428 vision_provider: Some("nonexistent-provider-xyz".to_string()),
6429 ..Default::default()
6430 };
6431
6432 let result = run_tool_call_loop(
6433 &provider,
6434 &mut history,
6435 &tools_registry,
6436 &observer,
6437 "scripted",
6438 "scripted-model",
6439 0.0,
6440 true,
6441 None,
6442 "cli",
6443 None,
6444 &multimodal,
6445 3,
6446 None,
6447 None,
6448 None,
6449 &[],
6450 &[],
6451 None,
6452 None,
6453 &crate::config::PacingConfig::default(),
6454 0,
6455 0,
6456 None,
6457 )
6458 .await
6459 .expect("empty image markers should not trigger vision routing");
6460
6461 assert_eq!(result, "handled");
6462 }
6463
6464 #[tokio::test]
6467 async fn run_tool_call_loop_multiple_images_trigger_vision_routing() {
6468 let calls = Arc::new(AtomicUsize::new(0));
6469 let provider = NonVisionProvider {
6470 calls: Arc::clone(&calls),
6471 };
6472
6473 let mut history = vec![ChatMessage::user(
6474 "two images [IMAGE:data:image/png;base64,aQ==] and [IMAGE:data:image/png;base64,bQ==]"
6475 .to_string(),
6476 )];
6477 let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
6478 let observer = NoopObserver;
6479
6480 let multimodal = crate::config::MultimodalConfig {
6481 vision_provider: Some("nonexistent-provider-xyz".to_string()),
6482 vision_model: Some("llava:7b".to_string()),
6483 ..Default::default()
6484 };
6485
6486 let err = run_tool_call_loop(
6487 &provider,
6488 &mut history,
6489 &tools_registry,
6490 &observer,
6491 "mock-provider",
6492 "mock-model",
6493 0.0,
6494 true,
6495 None,
6496 "cli",
6497 None,
6498 &multimodal,
6499 3,
6500 None,
6501 None,
6502 None,
6503 &[],
6504 &[],
6505 None,
6506 None,
6507 &crate::config::PacingConfig::default(),
6508 0,
6509 0,
6510 None,
6511 )
6512 .await
6513 .expect_err("should attempt vision provider creation for multiple images");
6514
6515 assert!(
6516 err.to_string().contains("failed to create vision provider"),
6517 "expected creation failure for multiple images, got: {}",
6518 err
6519 );
6520 }
6521
6522 #[test]
6523 fn should_execute_tools_in_parallel_returns_false_for_single_call() {
6524 let calls = vec![ParsedToolCall {
6525 name: "file_read".to_string(),
6526 arguments: serde_json::json!({"path": "a.txt"}),
6527 tool_call_id: None,
6528 }];
6529
6530 assert!(!should_execute_tools_in_parallel(&calls, None));
6531 }
6532
6533 #[test]
6534 fn should_execute_tools_in_parallel_returns_false_when_approval_is_required() {
6535 let calls = vec![
6536 ParsedToolCall {
6537 name: "shell".to_string(),
6538 arguments: serde_json::json!({"command": "pwd"}),
6539 tool_call_id: None,
6540 },
6541 ParsedToolCall {
6542 name: "http_request".to_string(),
6543 arguments: serde_json::json!({"url": "https://example.com"}),
6544 tool_call_id: None,
6545 },
6546 ];
6547 let approval_cfg = crate::config::AutonomyConfig::default();
6548 let approval_mgr = ApprovalManager::from_config(&approval_cfg);
6549
6550 assert!(!should_execute_tools_in_parallel(
6551 &calls,
6552 Some(&approval_mgr)
6553 ));
6554 }
6555
6556 #[test]
6557 fn should_execute_tools_in_parallel_returns_true_when_cli_has_no_interactive_approvals() {
6558 let calls = vec![
6559 ParsedToolCall {
6560 name: "shell".to_string(),
6561 arguments: serde_json::json!({"command": "pwd"}),
6562 tool_call_id: None,
6563 },
6564 ParsedToolCall {
6565 name: "http_request".to_string(),
6566 arguments: serde_json::json!({"url": "https://example.com"}),
6567 tool_call_id: None,
6568 },
6569 ];
6570 let approval_cfg = crate::config::AutonomyConfig {
6571 level: crate::security::AutonomyLevel::Full,
6572 ..crate::config::AutonomyConfig::default()
6573 };
6574 let approval_mgr = ApprovalManager::from_config(&approval_cfg);
6575
6576 assert!(should_execute_tools_in_parallel(
6577 &calls,
6578 Some(&approval_mgr)
6579 ));
6580 }
6581
6582 #[tokio::test]
6583 async fn run_tool_call_loop_executes_multiple_tools_with_ordered_results() {
6584 let provider = ScriptedProvider::from_text_responses(vec![
6585 r#"<tool_call>
6586{"name":"delay_a","arguments":{"value":"A"}}
6587</tool_call>
6588<tool_call>
6589{"name":"delay_b","arguments":{"value":"B"}}
6590</tool_call>"#,
6591 "done",
6592 ]);
6593
6594 let active = Arc::new(AtomicUsize::new(0));
6595 let max_active = Arc::new(AtomicUsize::new(0));
6596 let tools_registry: Vec<Box<dyn Tool>> = vec![
6597 Box::new(DelayTool::new(
6598 "delay_a",
6599 200,
6600 Arc::clone(&active),
6601 Arc::clone(&max_active),
6602 )),
6603 Box::new(DelayTool::new(
6604 "delay_b",
6605 200,
6606 Arc::clone(&active),
6607 Arc::clone(&max_active),
6608 )),
6609 ];
6610
6611 let approval_cfg = crate::config::AutonomyConfig {
6612 level: crate::security::AutonomyLevel::Full,
6613 ..crate::config::AutonomyConfig::default()
6614 };
6615 let approval_mgr = ApprovalManager::from_config(&approval_cfg);
6616
6617 let mut history = vec![
6618 ChatMessage::system("test-system"),
6619 ChatMessage::user("run tool calls"),
6620 ];
6621 let observer = NoopObserver;
6622
6623 let result = run_tool_call_loop(
6624 &provider,
6625 &mut history,
6626 &tools_registry,
6627 &observer,
6628 "mock-provider",
6629 "mock-model",
6630 0.0,
6631 true,
6632 Some(&approval_mgr),
6633 "telegram",
6634 None,
6635 &crate::config::MultimodalConfig::default(),
6636 4,
6637 None,
6638 None,
6639 None,
6640 &[],
6641 &[],
6642 None,
6643 None,
6644 &crate::config::PacingConfig::default(),
6645 0,
6646 0,
6647 None,
6648 )
6649 .await
6650 .expect("parallel execution should complete");
6651
6652 assert_eq!(result, "done");
6653 assert!(
6654 max_active.load(Ordering::SeqCst) >= 1,
6655 "tools should execute successfully"
6656 );
6657
6658 let tool_results_message = history
6659 .iter()
6660 .find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]"))
6661 .expect("tool results message should be present");
6662 let idx_a = tool_results_message
6663 .content
6664 .find("name=\"delay_a\"")
6665 .expect("delay_a result should be present");
6666 let idx_b = tool_results_message
6667 .content
6668 .find("name=\"delay_b\"")
6669 .expect("delay_b result should be present");
6670 assert!(
6671 idx_a < idx_b,
6672 "tool results should preserve input order for tool call mapping"
6673 );
6674 }
6675
6676 #[tokio::test]
6677 async fn run_tool_call_loop_injects_channel_delivery_defaults_for_cron_add() {
6678 let provider = ScriptedProvider::from_text_responses(vec![
6679 r#"<tool_call>
6680{"name":"cron_add","arguments":{"job_type":"agent","prompt":"remind me later","schedule":{"kind":"every","every_ms":60000}}}
6681</tool_call>"#,
6682 "done",
6683 ]);
6684
6685 let recorded_args = Arc::new(Mutex::new(Vec::new()));
6686 let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(RecordingArgsTool::new(
6687 "cron_add",
6688 Arc::clone(&recorded_args),
6689 ))];
6690
6691 let mut history = vec![
6692 ChatMessage::system("test-system"),
6693 ChatMessage::user("schedule a reminder"),
6694 ];
6695 let observer = NoopObserver;
6696
6697 let result = run_tool_call_loop(
6698 &provider,
6699 &mut history,
6700 &tools_registry,
6701 &observer,
6702 "mock-provider",
6703 "mock-model",
6704 0.0,
6705 true,
6706 None,
6707 "telegram",
6708 Some("chat-42"),
6709 &crate::config::MultimodalConfig::default(),
6710 4,
6711 None,
6712 None,
6713 None,
6714 &[],
6715 &[],
6716 None,
6717 None,
6718 &crate::config::PacingConfig::default(),
6719 0,
6720 0,
6721 None,
6722 )
6723 .await
6724 .expect("cron_add delivery defaults should be injected");
6725
6726 assert_eq!(result, "done");
6727
6728 let recorded = recorded_args
6729 .lock()
6730 .expect("recorded args lock should be valid");
6731 let delivery = recorded[0]["delivery"].clone();
6732 assert_eq!(
6733 delivery,
6734 serde_json::json!({
6735 "mode": "announce",
6736 "channel": "telegram",
6737 "to": "chat-42",
6738 })
6739 );
6740 }
6741
6742 #[tokio::test]
6743 async fn run_tool_call_loop_preserves_explicit_cron_delivery_none() {
6744 let provider = ScriptedProvider::from_text_responses(vec![
6745 r#"<tool_call>
6746{"name":"cron_add","arguments":{"job_type":"agent","prompt":"run silently","schedule":{"kind":"every","every_ms":60000},"delivery":{"mode":"none"}}}
6747</tool_call>"#,
6748 "done",
6749 ]);
6750
6751 let recorded_args = Arc::new(Mutex::new(Vec::new()));
6752 let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(RecordingArgsTool::new(
6753 "cron_add",
6754 Arc::clone(&recorded_args),
6755 ))];
6756
6757 let mut history = vec![
6758 ChatMessage::system("test-system"),
6759 ChatMessage::user("schedule a quiet cron job"),
6760 ];
6761 let observer = NoopObserver;
6762
6763 let result = run_tool_call_loop(
6764 &provider,
6765 &mut history,
6766 &tools_registry,
6767 &observer,
6768 "mock-provider",
6769 "mock-model",
6770 0.0,
6771 true,
6772 None,
6773 "telegram",
6774 Some("chat-42"),
6775 &crate::config::MultimodalConfig::default(),
6776 4,
6777 None,
6778 None,
6779 None,
6780 &[],
6781 &[],
6782 None,
6783 None,
6784 &crate::config::PacingConfig::default(),
6785 0,
6786 0,
6787 None,
6788 )
6789 .await
6790 .expect("explicit delivery mode should be preserved");
6791
6792 assert_eq!(result, "done");
6793
6794 let recorded = recorded_args
6795 .lock()
6796 .expect("recorded args lock should be valid");
6797 assert_eq!(recorded[0]["delivery"], serde_json::json!({"mode": "none"}));
6798 }
6799
6800 #[tokio::test]
6801 async fn run_tool_call_loop_deduplicates_repeated_tool_calls() {
6802 let provider = ScriptedProvider::from_text_responses(vec![
6803 r#"<tool_call>
6804{"name":"count_tool","arguments":{"value":"A"}}
6805</tool_call>
6806<tool_call>
6807{"name":"count_tool","arguments":{"value":"A"}}
6808</tool_call>"#,
6809 "done",
6810 ]);
6811
6812 let invocations = Arc::new(AtomicUsize::new(0));
6813 let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
6814 "count_tool",
6815 Arc::clone(&invocations),
6816 ))];
6817
6818 let mut history = vec![
6819 ChatMessage::system("test-system"),
6820 ChatMessage::user("run tool calls"),
6821 ];
6822 let observer = NoopObserver;
6823
6824 let result = run_tool_call_loop(
6825 &provider,
6826 &mut history,
6827 &tools_registry,
6828 &observer,
6829 "mock-provider",
6830 "mock-model",
6831 0.0,
6832 true,
6833 None,
6834 "cli",
6835 None,
6836 &crate::config::MultimodalConfig::default(),
6837 4,
6838 None,
6839 None,
6840 None,
6841 &[],
6842 &[],
6843 None,
6844 None,
6845 &crate::config::PacingConfig::default(),
6846 0,
6847 0,
6848 None,
6849 )
6850 .await
6851 .expect("loop should finish after deduplicating repeated calls");
6852
6853 assert_eq!(result, "done");
6854 assert_eq!(
6855 invocations.load(Ordering::SeqCst),
6856 1,
6857 "duplicate tool call with same args should not execute twice"
6858 );
6859
6860 let tool_results = history
6861 .iter()
6862 .find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]"))
6863 .expect("prompt-mode tool result payload should be present");
6864 assert!(tool_results.content.contains("counted:A"));
6865 assert!(tool_results.content.contains("Skipped duplicate tool call"));
6866 }
6867
6868 #[tokio::test]
6869 async fn run_tool_call_loop_allows_low_risk_shell_in_non_interactive_mode() {
6870 let provider = ScriptedProvider::from_text_responses(vec![
6871 r#"<tool_call>
6872{"name":"shell","arguments":{"command":"echo hello"}}
6873</tool_call>"#,
6874 "done",
6875 ]);
6876
6877 let tmp = TempDir::new().expect("temp dir");
6878 let security = Arc::new(crate::security::SecurityPolicy {
6879 autonomy: crate::security::AutonomyLevel::Supervised,
6880 workspace_dir: tmp.path().to_path_buf(),
6881 ..crate::security::SecurityPolicy::default()
6882 });
6883 let runtime: Arc<dyn crate::runtime::RuntimeAdapter> =
6884 Arc::new(crate::runtime::NativeRuntime::new());
6885 let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(
6886 crate::tools::shell::ShellTool::new(security, runtime),
6887 )];
6888
6889 let mut history = vec![
6890 ChatMessage::system("test-system"),
6891 ChatMessage::user("run shell"),
6892 ];
6893 let observer = NoopObserver;
6894 let approval_mgr =
6895 ApprovalManager::for_non_interactive(&crate::config::AutonomyConfig::default());
6896
6897 let result = run_tool_call_loop(
6898 &provider,
6899 &mut history,
6900 &tools_registry,
6901 &observer,
6902 "mock-provider",
6903 "mock-model",
6904 0.0,
6905 true,
6906 Some(&approval_mgr),
6907 "telegram",
6908 None,
6909 &crate::config::MultimodalConfig::default(),
6910 4,
6911 None,
6912 None,
6913 None,
6914 &[],
6915 &[],
6916 None,
6917 None,
6918 &crate::config::PacingConfig::default(),
6919 0,
6920 0,
6921 None,
6922 )
6923 .await
6924 .expect("non-interactive shell should succeed for low-risk command");
6925
6926 assert_eq!(result, "done");
6927
6928 let tool_results = history
6929 .iter()
6930 .find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]"))
6931 .expect("tool results message should be present");
6932 assert!(tool_results.content.contains("hello"));
6933 assert!(!tool_results.content.contains("Denied by user."));
6934 }
6935
6936 #[tokio::test]
6937 async fn run_tool_call_loop_dedup_exempt_allows_repeated_calls() {
6938 let provider = ScriptedProvider::from_text_responses(vec![
6939 r#"<tool_call>
6940{"name":"count_tool","arguments":{"value":"A"}}
6941</tool_call>
6942<tool_call>
6943{"name":"count_tool","arguments":{"value":"A"}}
6944</tool_call>"#,
6945 "done",
6946 ]);
6947
6948 let invocations = Arc::new(AtomicUsize::new(0));
6949 let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
6950 "count_tool",
6951 Arc::clone(&invocations),
6952 ))];
6953
6954 let mut history = vec![
6955 ChatMessage::system("test-system"),
6956 ChatMessage::user("run tool calls"),
6957 ];
6958 let observer = NoopObserver;
6959 let exempt = vec!["count_tool".to_string()];
6960
6961 let result = run_tool_call_loop(
6962 &provider,
6963 &mut history,
6964 &tools_registry,
6965 &observer,
6966 "mock-provider",
6967 "mock-model",
6968 0.0,
6969 true,
6970 None,
6971 "cli",
6972 None,
6973 &crate::config::MultimodalConfig::default(),
6974 4,
6975 None,
6976 None,
6977 None,
6978 &[],
6979 &exempt,
6980 None,
6981 None,
6982 &crate::config::PacingConfig::default(),
6983 0,
6984 0,
6985 None,
6986 )
6987 .await
6988 .expect("loop should finish with exempt tool executing twice");
6989
6990 assert_eq!(result, "done");
6991 assert_eq!(
6992 invocations.load(Ordering::SeqCst),
6993 2,
6994 "exempt tool should execute both duplicate calls"
6995 );
6996
6997 let tool_results = history
6998 .iter()
6999 .find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]"))
7000 .expect("prompt-mode tool result payload should be present");
7001 assert!(
7002 !tool_results.content.contains("Skipped duplicate tool call"),
7003 "exempt tool calls should not be suppressed"
7004 );
7005 }
7006
7007 #[tokio::test]
7008 async fn run_tool_call_loop_dedup_exempt_only_affects_listed_tools() {
7009 let provider = ScriptedProvider::from_text_responses(vec![
7010 r#"<tool_call>
7011{"name":"count_tool","arguments":{"value":"A"}}
7012</tool_call>
7013<tool_call>
7014{"name":"count_tool","arguments":{"value":"A"}}
7015</tool_call>
7016<tool_call>
7017{"name":"other_tool","arguments":{"value":"B"}}
7018</tool_call>
7019<tool_call>
7020{"name":"other_tool","arguments":{"value":"B"}}
7021</tool_call>"#,
7022 "done",
7023 ]);
7024
7025 let count_invocations = Arc::new(AtomicUsize::new(0));
7026 let other_invocations = Arc::new(AtomicUsize::new(0));
7027 let tools_registry: Vec<Box<dyn Tool>> = vec![
7028 Box::new(CountingTool::new(
7029 "count_tool",
7030 Arc::clone(&count_invocations),
7031 )),
7032 Box::new(CountingTool::new(
7033 "other_tool",
7034 Arc::clone(&other_invocations),
7035 )),
7036 ];
7037
7038 let mut history = vec![
7039 ChatMessage::system("test-system"),
7040 ChatMessage::user("run tool calls"),
7041 ];
7042 let observer = NoopObserver;
7043 let exempt = vec!["count_tool".to_string()];
7044
7045 let _result = run_tool_call_loop(
7046 &provider,
7047 &mut history,
7048 &tools_registry,
7049 &observer,
7050 "mock-provider",
7051 "mock-model",
7052 0.0,
7053 true,
7054 None,
7055 "cli",
7056 None,
7057 &crate::config::MultimodalConfig::default(),
7058 4,
7059 None,
7060 None,
7061 None,
7062 &[],
7063 &exempt,
7064 None,
7065 None,
7066 &crate::config::PacingConfig::default(),
7067 0,
7068 0,
7069 None,
7070 )
7071 .await
7072 .expect("loop should complete");
7073
7074 assert_eq!(
7075 count_invocations.load(Ordering::SeqCst),
7076 2,
7077 "exempt tool should execute both calls"
7078 );
7079 assert_eq!(
7080 other_invocations.load(Ordering::SeqCst),
7081 1,
7082 "non-exempt tool should still be deduped"
7083 );
7084 }
7085
7086 #[tokio::test]
7087 async fn run_tool_call_loop_native_mode_preserves_fallback_tool_call_ids() {
7088 let provider = ScriptedProvider::from_text_responses(vec![
7089 r#"{"content":"Need to call tool","tool_calls":[{"id":"call_abc","name":"count_tool","arguments":"{\"value\":\"X\"}"}]}"#,
7090 "done",
7091 ])
7092 .with_native_tool_support();
7093
7094 let invocations = Arc::new(AtomicUsize::new(0));
7095 let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
7096 "count_tool",
7097 Arc::clone(&invocations),
7098 ))];
7099
7100 let mut history = vec![
7101 ChatMessage::system("test-system"),
7102 ChatMessage::user("run tool calls"),
7103 ];
7104 let observer = NoopObserver;
7105
7106 let result = run_tool_call_loop(
7107 &provider,
7108 &mut history,
7109 &tools_registry,
7110 &observer,
7111 "mock-provider",
7112 "mock-model",
7113 0.0,
7114 true,
7115 None,
7116 "cli",
7117 None,
7118 &crate::config::MultimodalConfig::default(),
7119 4,
7120 None,
7121 None,
7122 None,
7123 &[],
7124 &[],
7125 None,
7126 None,
7127 &crate::config::PacingConfig::default(),
7128 0,
7129 0,
7130 None,
7131 )
7132 .await
7133 .expect("native fallback id flow should complete");
7134
7135 assert_eq!(result, "done");
7136 assert_eq!(invocations.load(Ordering::SeqCst), 1);
7137 assert!(
7138 history.iter().any(|msg| {
7139 msg.role == "tool" && msg.content.contains("\"tool_call_id\":\"call_abc\"")
7140 }),
7141 "tool result should preserve parsed fallback tool_call_id in native mode"
7142 );
7143 assert!(
7144 history
7145 .iter()
7146 .all(|msg| !(msg.role == "user" && msg.content.starts_with("[Tool results]"))),
7147 "native mode should use role=tool history instead of prompt fallback wrapper"
7148 );
7149 }
7150
7151 #[tokio::test]
7152 async fn run_tool_call_loop_relays_native_tool_call_text_via_on_delta() {
7153 let provider = ScriptedProvider {
7154 responses: Arc::new(Mutex::new(VecDeque::from(vec![
7155 ChatResponse {
7156 text: Some("Task started. Waiting 30 seconds before checking status.".into()),
7157 tool_calls: vec![ToolCall {
7158 id: "call_wait".into(),
7159 name: "count_tool".into(),
7160 arguments: r#"{"value":"A"}"#.into(),
7161 }],
7162 usage: None,
7163 reasoning_content: None,
7164 },
7165 ChatResponse {
7166 text: Some("Final answer".into()),
7167 tool_calls: Vec::new(),
7168 usage: None,
7169 reasoning_content: None,
7170 },
7171 ]))),
7172 capabilities: ProviderCapabilities {
7173 native_tool_calling: true,
7174 ..ProviderCapabilities::default()
7175 },
7176 };
7177
7178 let invocations = Arc::new(AtomicUsize::new(0));
7179 let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
7180 "count_tool",
7181 Arc::clone(&invocations),
7182 ))];
7183
7184 let mut history = vec![
7185 ChatMessage::system("test-system"),
7186 ChatMessage::user("run tool calls"),
7187 ];
7188 let observer = NoopObserver;
7189 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
7190
7191 let result = run_tool_call_loop(
7192 &provider,
7193 &mut history,
7194 &tools_registry,
7195 &observer,
7196 "mock-provider",
7197 "mock-model",
7198 0.0,
7199 true,
7200 None,
7201 "telegram",
7202 None,
7203 &crate::config::MultimodalConfig::default(),
7204 4,
7205 None,
7206 Some(tx),
7207 None,
7208 &[],
7209 &[],
7210 None,
7211 None,
7212 &crate::config::PacingConfig::default(),
7213 0,
7214 0,
7215 None,
7216 )
7217 .await
7218 .expect("native tool-call text should be relayed through on_delta");
7219
7220 let mut deltas: Vec<DraftEvent> = Vec::new();
7221 while let Some(delta) = rx.recv().await {
7222 deltas.push(delta);
7223 }
7224
7225 let explanation_idx = deltas
7226 .iter()
7227 .position(|delta| matches!(delta, DraftEvent::Content(t) if t == "Task started. Waiting 30 seconds before checking status.\n"))
7228 .expect("native assistant text should be relayed to on_delta");
7229 let clear_idx = deltas
7230 .iter()
7231 .position(|delta| matches!(delta, DraftEvent::Clear))
7232 .expect("final answer streaming should clear prior draft state");
7233
7234 assert!(
7235 deltas
7236 .iter()
7237 .any(|delta| matches!(delta, DraftEvent::Progress(t) if t.starts_with("\u{1f4ac} Got 1 tool call(s)"))),
7238 "tool-call progress line should still be relayed"
7239 );
7240 assert!(
7241 explanation_idx < clear_idx,
7242 "native assistant text should arrive before final-answer draft clearing"
7243 );
7244 assert_eq!(result, "Final answer");
7245 assert_eq!(invocations.load(Ordering::SeqCst), 1);
7246 }
7247
7248 #[tokio::test]
7249 async fn run_tool_call_loop_consumes_provider_stream_for_final_response() {
7250 let provider =
7251 StreamingScriptedProvider::from_text_responses(vec!["streamed final answer"]);
7252 let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
7253 let mut history = vec![
7254 ChatMessage::system("test-system"),
7255 ChatMessage::user("say hi"),
7256 ];
7257 let observer = NoopObserver;
7258 let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(32);
7259
7260 let result = run_tool_call_loop(
7261 &provider,
7262 &mut history,
7263 &tools_registry,
7264 &observer,
7265 "mock-provider",
7266 "mock-model",
7267 0.0,
7268 true,
7269 None,
7270 "telegram",
7271 None,
7272 &crate::config::MultimodalConfig::default(),
7273 4,
7274 None,
7275 Some(tx),
7276 None,
7277 &[],
7278 &[],
7279 None,
7280 None,
7281 &crate::config::PacingConfig::default(),
7282 0,
7283 0,
7284 None,
7285 )
7286 .await
7287 .expect("streaming provider should complete");
7288
7289 let mut visible_deltas = String::new();
7290 while let Some(delta) = rx.recv().await {
7291 match delta {
7292 DraftEvent::Clear => {
7293 visible_deltas.clear();
7294 }
7295 DraftEvent::Progress(_) => {}
7296 DraftEvent::Content(text) => {
7297 visible_deltas.push_str(&text);
7298 }
7299 }
7300 }
7301
7302 assert_eq!(result, "streamed final answer");
7303 assert_eq!(
7304 visible_deltas, "streamed final answer",
7305 "draft should receive upstream deltas once without post-hoc duplication"
7306 );
7307 assert_eq!(provider.stream_calls.load(Ordering::SeqCst), 1);
7308 assert_eq!(provider.chat_calls.load(Ordering::SeqCst), 0);
7309 }
7310
7311 #[tokio::test]
7312 async fn run_tool_call_loop_streaming_path_preserves_tool_loop_semantics() {
7313 let provider = StreamingScriptedProvider::from_text_responses(vec![
7314 r#"<tool_call>
7315{"name":"count_tool","arguments":{"value":"A"}}
7316</tool_call>"#,
7317 "done",
7318 ]);
7319 let invocations = Arc::new(AtomicUsize::new(0));
7320 let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
7321 "count_tool",
7322 Arc::clone(&invocations),
7323 ))];
7324 let mut history = vec![
7325 ChatMessage::system("test-system"),
7326 ChatMessage::user("run tool calls"),
7327 ];
7328 let observer = NoopObserver;
7329 let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(64);
7330
7331 let result = run_tool_call_loop(
7332 &provider,
7333 &mut history,
7334 &tools_registry,
7335 &observer,
7336 "mock-provider",
7337 "mock-model",
7338 0.0,
7339 true,
7340 None,
7341 "telegram",
7342 None,
7343 &crate::config::MultimodalConfig::default(),
7344 5,
7345 None,
7346 Some(tx),
7347 None,
7348 &[],
7349 &[],
7350 None,
7351 None,
7352 &crate::config::PacingConfig::default(),
7353 0,
7354 0,
7355 None,
7356 )
7357 .await
7358 .expect("streaming tool loop should execute tool and finish");
7359
7360 let mut visible_deltas = String::new();
7361 while let Some(delta) = rx.recv().await {
7362 match delta {
7363 DraftEvent::Clear => {
7364 visible_deltas.clear();
7365 }
7366 DraftEvent::Progress(_) => {}
7367 DraftEvent::Content(text) => {
7368 visible_deltas.push_str(&text);
7369 }
7370 }
7371 }
7372
7373 assert_eq!(result, "done");
7374 assert_eq!(invocations.load(Ordering::SeqCst), 1);
7375 assert_eq!(provider.stream_calls.load(Ordering::SeqCst), 2);
7376 assert_eq!(provider.chat_calls.load(Ordering::SeqCst), 0);
7377 assert_eq!(visible_deltas, "done");
7378 assert!(
7379 !visible_deltas.contains("<tool_call"),
7380 "draft text should not leak streamed tool payload markers"
7381 );
7382 }
7383
7384 #[tokio::test]
7385 async fn run_tool_call_loop_streams_native_tool_events_without_chat_fallback() {
7386 let provider = StreamingNativeToolEventProvider::with_turns(vec![
7387 NativeStreamTurn::ToolCall(ToolCall {
7388 id: "call_native_1".to_string(),
7389 name: "count_tool".to_string(),
7390 arguments: r#"{"value":"A"}"#.to_string(),
7391 }),
7392 NativeStreamTurn::Text("done".to_string()),
7393 ]);
7394 let invocations = Arc::new(AtomicUsize::new(0));
7395 let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
7396 "count_tool",
7397 Arc::clone(&invocations),
7398 ))];
7399 let mut history = vec![
7400 ChatMessage::system("test-system"),
7401 ChatMessage::user("run native tools"),
7402 ];
7403 let observer = NoopObserver;
7404 let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(64);
7405
7406 let result = run_tool_call_loop(
7407 &provider,
7408 &mut history,
7409 &tools_registry,
7410 &observer,
7411 "mock-provider",
7412 "mock-model",
7413 0.0,
7414 true,
7415 None,
7416 "telegram",
7417 None,
7418 &crate::config::MultimodalConfig::default(),
7419 5,
7420 None,
7421 Some(tx),
7422 None,
7423 &[],
7424 &[],
7425 None,
7426 None,
7427 &crate::config::PacingConfig::default(),
7428 0,
7429 0,
7430 None,
7431 )
7432 .await
7433 .expect("native streaming events should preserve tool loop semantics");
7434
7435 let mut visible_deltas = String::new();
7436 while let Some(delta) = rx.recv().await {
7437 match delta {
7438 DraftEvent::Clear => {
7439 visible_deltas.clear();
7440 }
7441 DraftEvent::Progress(_) => {}
7442 DraftEvent::Content(text) => {
7443 visible_deltas.push_str(&text);
7444 }
7445 }
7446 }
7447
7448 assert_eq!(result, "done");
7449 assert_eq!(invocations.load(Ordering::SeqCst), 1);
7450 assert_eq!(provider.stream_calls.load(Ordering::SeqCst), 2);
7451 assert_eq!(provider.stream_tool_requests.load(Ordering::SeqCst), 2);
7452 assert_eq!(provider.chat_calls.load(Ordering::SeqCst), 0);
7453 assert_eq!(visible_deltas, "done");
7454 }
7455
7456 #[tokio::test]
7457 async fn run_tool_call_loop_routed_streaming_uses_live_provider_deltas_once() {
7458 let default_provider = RouteAwareStreamingProvider::new("default answer");
7459 let default_stream_calls = Arc::clone(&default_provider.stream_calls);
7460 let default_chat_calls = Arc::clone(&default_provider.chat_calls);
7461
7462 let routed_provider = RouteAwareStreamingProvider::new("routed streamed answer");
7463 let routed_stream_calls = Arc::clone(&routed_provider.stream_calls);
7464 let routed_chat_calls = Arc::clone(&routed_provider.chat_calls);
7465 let routed_last_model = Arc::clone(&routed_provider.last_model);
7466
7467 let router = RouterProvider::new(
7468 vec![
7469 ("default".to_string(), Box::new(default_provider)),
7470 ("fast".to_string(), Box::new(routed_provider)),
7471 ],
7472 vec![(
7473 "fast".to_string(),
7474 Route {
7475 provider_name: "fast".to_string(),
7476 model: "routed-model".to_string(),
7477 },
7478 )],
7479 "default-model".to_string(),
7480 );
7481
7482 let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
7483 let mut history = vec![
7484 ChatMessage::system("test-system"),
7485 ChatMessage::user("say hi"),
7486 ];
7487 let observer = NoopObserver;
7488 let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(32);
7489
7490 let result = run_tool_call_loop(
7491 &router,
7492 &mut history,
7493 &tools_registry,
7494 &observer,
7495 "router",
7496 "hint:fast",
7497 0.0,
7498 true,
7499 None,
7500 "telegram",
7501 None,
7502 &crate::config::MultimodalConfig::default(),
7503 4,
7504 None,
7505 Some(tx),
7506 None,
7507 &[],
7508 &[],
7509 None,
7510 None,
7511 &crate::config::PacingConfig::default(),
7512 0,
7513 0,
7514 None,
7515 )
7516 .await
7517 .expect("routed streaming provider should complete");
7518
7519 let mut visible_deltas = String::new();
7520 while let Some(delta) = rx.recv().await {
7521 match delta {
7522 DraftEvent::Clear => {
7523 visible_deltas.clear();
7524 }
7525 DraftEvent::Progress(_) => {}
7526 DraftEvent::Content(text) => {
7527 visible_deltas.push_str(&text);
7528 }
7529 }
7530 }
7531
7532 assert_eq!(result, "routed streamed answer");
7533 assert_eq!(
7534 visible_deltas, "routed streamed answer",
7535 "routed draft should receive upstream deltas once without post-hoc duplication"
7536 );
7537 assert_eq!(default_stream_calls.load(Ordering::SeqCst), 0);
7538 assert_eq!(routed_stream_calls.load(Ordering::SeqCst), 1);
7539 assert_eq!(default_chat_calls.load(Ordering::SeqCst), 0);
7540 assert_eq!(routed_chat_calls.load(Ordering::SeqCst), 0);
7541 assert_eq!(
7542 routed_last_model
7543 .lock()
7544 .expect("routed_last_model lock should be valid")
7545 .as_str(),
7546 "routed-model"
7547 );
7548 }
7549
7550 #[test]
7551 fn agent_turn_executes_activated_tool_from_wrapper() {
7552 let runtime = tokio::runtime::Builder::new_current_thread()
7553 .enable_all()
7554 .build()
7555 .expect("test runtime should initialize");
7556
7557 runtime.block_on(async {
7558 let provider = ScriptedProvider::from_text_responses(vec![
7559 r#"<tool_call>
7560{"name":"pixel__get_api_health","arguments":{"value":"ok"}}
7561</tool_call>"#,
7562 "done",
7563 ]);
7564
7565 let invocations = Arc::new(AtomicUsize::new(0));
7566 let activated = Arc::new(std::sync::Mutex::new(crate::tools::ActivatedToolSet::new()));
7567 let activated_tool: Arc<dyn Tool> = Arc::new(CountingTool::new(
7568 "pixel__get_api_health",
7569 Arc::clone(&invocations),
7570 ));
7571 activated
7572 .lock()
7573 .unwrap()
7574 .activate("pixel__get_api_health".into(), activated_tool);
7575
7576 let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
7577 let mut history = vec![
7578 ChatMessage::system("test-system"),
7579 ChatMessage::user("use the activated MCP tool"),
7580 ];
7581 let observer = NoopObserver;
7582
7583 let result = agent_turn(
7584 &provider,
7585 &mut history,
7586 &tools_registry,
7587 &observer,
7588 "mock-provider",
7589 "mock-model",
7590 0.0,
7591 true,
7592 "daemon",
7593 None,
7594 &crate::config::MultimodalConfig::default(),
7595 4,
7596 None,
7597 &[],
7598 &[],
7599 Some(&activated),
7600 None,
7601 )
7602 .await
7603 .expect("wrapper path should execute activated tools");
7604
7605 assert_eq!(result, "done");
7606 assert_eq!(invocations.load(Ordering::SeqCst), 1);
7607 });
7608 }
7609
7610 #[test]
7611 fn resolve_display_text_hides_raw_payload_for_tool_only_turns() {
7612 let display = resolve_display_text(
7613 "<tool_call>{\"name\":\"memory_store\"}</tool_call>",
7614 "",
7615 true,
7616 false,
7617 );
7618 assert!(display.is_empty());
7619 }
7620
7621 #[test]
7622 fn resolve_display_text_keeps_plain_text_for_tool_turns() {
7623 let display = resolve_display_text(
7624 "<tool_call>{\"name\":\"shell\"}</tool_call>",
7625 "Let me check that.",
7626 true,
7627 false,
7628 );
7629 assert_eq!(display, "Let me check that.");
7630 }
7631
7632 #[test]
7633 fn resolve_display_text_uses_response_text_for_native_tool_turns() {
7634 let display = resolve_display_text("Task started.", "", true, true);
7635 assert_eq!(display, "Task started.");
7636 }
7637
7638 #[test]
7639 fn resolve_display_text_uses_response_text_for_final_turns() {
7640 let display = resolve_display_text("Final answer", "", false, false);
7641 assert_eq!(display, "Final answer");
7642 }
7643
7644 #[test]
7645 fn parse_tool_calls_extracts_single_call() {
7646 let response = r#"Let me check that.
7647<tool_call>
7648{"name": "shell", "arguments": {"command": "ls -la"}}
7649</tool_call>"#;
7650
7651 let (text, calls) = parse_tool_calls(response);
7652 assert_eq!(text, "Let me check that.");
7653 assert_eq!(calls.len(), 1);
7654 assert_eq!(calls[0].name, "shell");
7655 assert_eq!(
7656 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7657 "ls -la"
7658 );
7659 }
7660
7661 #[test]
7662 fn parse_tool_calls_extracts_multiple_calls() {
7663 let response = r#"<tool_call>
7664{"name": "file_read", "arguments": {"path": "a.txt"}}
7665</tool_call>
7666<tool_call>
7667{"name": "file_read", "arguments": {"path": "b.txt"}}
7668</tool_call>"#;
7669
7670 let (_, calls) = parse_tool_calls(response);
7671 assert_eq!(calls.len(), 2);
7672 assert_eq!(calls[0].name, "file_read");
7673 assert_eq!(calls[1].name, "file_read");
7674 }
7675
7676 #[test]
7677 fn parse_tool_calls_returns_text_only_when_no_calls() {
7678 let response = "Just a normal response with no tools.";
7679 let (text, calls) = parse_tool_calls(response);
7680 assert_eq!(text, "Just a normal response with no tools.");
7681 assert!(calls.is_empty());
7682 }
7683
7684 #[test]
7685 fn parse_tool_calls_handles_malformed_json() {
7686 let response = r#"<tool_call>
7687not valid json
7688</tool_call>
7689Some text after."#;
7690
7691 let (text, calls) = parse_tool_calls(response);
7692 assert!(calls.is_empty());
7693 assert!(text.contains("Some text after."));
7694 }
7695
7696 #[test]
7697 fn parse_tool_calls_text_before_and_after() {
7698 let response = r#"Before text.
7699<tool_call>
7700{"name": "shell", "arguments": {"command": "echo hi"}}
7701</tool_call>
7702After text."#;
7703
7704 let (text, calls) = parse_tool_calls(response);
7705 assert!(text.contains("Before text."));
7706 assert!(text.contains("After text."));
7707 assert_eq!(calls.len(), 1);
7708 }
7709
7710 #[test]
7711 fn parse_tool_calls_handles_openai_format() {
7712 let response = r#"{"content": "Let me check that for you.", "tool_calls": [{"type": "function", "function": {"name": "shell", "arguments": "{\"command\": \"ls -la\"}"}}]}"#;
7714
7715 let (text, calls) = parse_tool_calls(response);
7716 assert_eq!(text, "Let me check that for you.");
7717 assert_eq!(calls.len(), 1);
7718 assert_eq!(calls[0].name, "shell");
7719 assert_eq!(
7720 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7721 "ls -la"
7722 );
7723 }
7724
7725 #[test]
7726 fn parse_tool_calls_handles_openai_format_multiple_calls() {
7727 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\"}"}}]}"#;
7728
7729 let (_, calls) = parse_tool_calls(response);
7730 assert_eq!(calls.len(), 2);
7731 assert_eq!(calls[0].name, "file_read");
7732 assert_eq!(calls[1].name, "file_read");
7733 }
7734
7735 #[test]
7736 fn parse_tool_calls_openai_format_without_content() {
7737 let response = r#"{"tool_calls": [{"type": "function", "function": {"name": "memory_recall", "arguments": "{}"}}]}"#;
7739
7740 let (text, calls) = parse_tool_calls(response);
7741 assert!(text.is_empty()); assert_eq!(calls.len(), 1);
7743 assert_eq!(calls[0].name, "memory_recall");
7744 }
7745
7746 #[test]
7747 fn parse_tool_calls_preserves_openai_tool_call_ids() {
7748 let response = r#"{"tool_calls":[{"id":"call_42","function":{"name":"shell","arguments":"{\"command\":\"pwd\"}"}}]}"#;
7749 let (_, calls) = parse_tool_calls(response);
7750 assert_eq!(calls.len(), 1);
7751 assert_eq!(calls[0].tool_call_id.as_deref(), Some("call_42"));
7752 }
7753
7754 #[test]
7755 fn parse_tool_calls_handles_markdown_json_inside_tool_call_tag() {
7756 let response = r#"<tool_call>
7757```json
7758{"name": "file_write", "arguments": {"path": "test.py", "content": "print('ok')"}}
7759```
7760</tool_call>"#;
7761
7762 let (text, calls) = parse_tool_calls(response);
7763 assert!(text.is_empty());
7764 assert_eq!(calls.len(), 1);
7765 assert_eq!(calls[0].name, "file_write");
7766 assert_eq!(
7767 calls[0].arguments.get("path").unwrap().as_str().unwrap(),
7768 "test.py"
7769 );
7770 }
7771
7772 #[test]
7773 fn parse_tool_calls_handles_noisy_tool_call_tag_body() {
7774 let response = r#"<tool_call>
7775I will now call the tool with this payload:
7776{"name": "shell", "arguments": {"command": "pwd"}}
7777</tool_call>"#;
7778
7779 let (text, calls) = parse_tool_calls(response);
7780 assert!(text.is_empty());
7781 assert_eq!(calls.len(), 1);
7782 assert_eq!(calls[0].name, "shell");
7783 assert_eq!(
7784 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7785 "pwd"
7786 );
7787 }
7788
7789 #[test]
7790 fn parse_tool_calls_handles_tool_call_inline_attributes_with_send_message_alias() {
7791 let response = r#"<tool_call>send_message channel="user_channel" message="Hello! How can I assist you today?"</tool_call>"#;
7792
7793 let (text, calls) = parse_tool_calls(response);
7794 assert!(text.is_empty());
7795 assert_eq!(calls.len(), 1);
7796 assert_eq!(calls[0].name, "message_send");
7797 assert_eq!(
7798 calls[0].arguments.get("channel").unwrap().as_str().unwrap(),
7799 "user_channel"
7800 );
7801 assert_eq!(
7802 calls[0].arguments.get("message").unwrap().as_str().unwrap(),
7803 "Hello! How can I assist you today?"
7804 );
7805 }
7806
7807 #[test]
7808 fn parse_tool_calls_handles_tool_call_function_style_arguments() {
7809 let response = r#"<tool_call>message_send(channel="general", message="test")</tool_call>"#;
7810
7811 let (text, calls) = parse_tool_calls(response);
7812 assert!(text.is_empty());
7813 assert_eq!(calls.len(), 1);
7814 assert_eq!(calls[0].name, "message_send");
7815 assert_eq!(
7816 calls[0].arguments.get("channel").unwrap().as_str().unwrap(),
7817 "general"
7818 );
7819 assert_eq!(
7820 calls[0].arguments.get("message").unwrap().as_str().unwrap(),
7821 "test"
7822 );
7823 }
7824
7825 #[test]
7826 fn parse_tool_calls_handles_xml_nested_tool_payload() {
7827 let response = r#"<tool_call>
7828<memory_recall>
7829<query>project roadmap</query>
7830</memory_recall>
7831</tool_call>"#;
7832
7833 let (text, calls) = parse_tool_calls(response);
7834 assert!(text.is_empty());
7835 assert_eq!(calls.len(), 1);
7836 assert_eq!(calls[0].name, "memory_recall");
7837 assert_eq!(
7838 calls[0].arguments.get("query").unwrap().as_str().unwrap(),
7839 "project roadmap"
7840 );
7841 }
7842
7843 #[test]
7844 fn parse_tool_calls_ignores_xml_thinking_wrapper() {
7845 let response = r#"<tool_call>
7846<thinking>Need to inspect memory first</thinking>
7847<memory_recall>
7848<query>recent deploy notes</query>
7849</memory_recall>
7850</tool_call>"#;
7851
7852 let (text, calls) = parse_tool_calls(response);
7853 assert!(text.is_empty());
7854 assert_eq!(calls.len(), 1);
7855 assert_eq!(calls[0].name, "memory_recall");
7856 assert_eq!(
7857 calls[0].arguments.get("query").unwrap().as_str().unwrap(),
7858 "recent deploy notes"
7859 );
7860 }
7861
7862 #[test]
7863 fn parse_tool_calls_handles_xml_with_json_arguments() {
7864 let response = r#"<tool_call>
7865<shell>{"command":"pwd"}</shell>
7866</tool_call>"#;
7867
7868 let (text, calls) = parse_tool_calls(response);
7869 assert!(text.is_empty());
7870 assert_eq!(calls.len(), 1);
7871 assert_eq!(calls[0].name, "shell");
7872 assert_eq!(
7873 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7874 "pwd"
7875 );
7876 }
7877
7878 #[test]
7879 fn parse_tool_calls_handles_markdown_tool_call_fence() {
7880 let response = r#"I'll check that.
7881```tool_call
7882{"name": "shell", "arguments": {"command": "pwd"}}
7883```
7884Done."#;
7885
7886 let (text, calls) = parse_tool_calls(response);
7887 assert_eq!(calls.len(), 1);
7888 assert_eq!(calls[0].name, "shell");
7889 assert_eq!(
7890 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7891 "pwd"
7892 );
7893 assert!(text.contains("I'll check that."));
7894 assert!(text.contains("Done."));
7895 assert!(!text.contains("```tool_call"));
7896 }
7897
7898 #[test]
7899 fn parse_tool_calls_handles_markdown_tool_call_hybrid_close_tag() {
7900 let response = r#"Preface
7901```tool-call
7902{"name": "shell", "arguments": {"command": "date"}}
7903</tool_call>
7904Tail"#;
7905
7906 let (text, calls) = parse_tool_calls(response);
7907 assert_eq!(calls.len(), 1);
7908 assert_eq!(calls[0].name, "shell");
7909 assert_eq!(
7910 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7911 "date"
7912 );
7913 assert!(text.contains("Preface"));
7914 assert!(text.contains("Tail"));
7915 assert!(!text.contains("```tool-call"));
7916 }
7917
7918 #[test]
7919 fn parse_tool_calls_handles_markdown_invoke_fence() {
7920 let response = r#"Checking.
7921```invoke
7922{"name": "shell", "arguments": {"command": "date"}}
7923```
7924Done."#;
7925
7926 let (text, calls) = parse_tool_calls(response);
7927 assert_eq!(calls.len(), 1);
7928 assert_eq!(calls[0].name, "shell");
7929 assert_eq!(
7930 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7931 "date"
7932 );
7933 assert!(text.contains("Checking."));
7934 assert!(text.contains("Done."));
7935 }
7936
7937 #[test]
7938 fn parse_tool_calls_handles_tool_name_fence_format() {
7939 let response = r#"I'll write a test file.
7941```tool file_write
7942{"path": "/home/user/test.txt", "content": "Hello world"}
7943```
7944Done."#;
7945
7946 let (text, calls) = parse_tool_calls(response);
7947 assert_eq!(calls.len(), 1);
7948 assert_eq!(calls[0].name, "file_write");
7949 assert_eq!(
7950 calls[0].arguments.get("path").unwrap().as_str().unwrap(),
7951 "/home/user/test.txt"
7952 );
7953 assert!(text.contains("I'll write a test file."));
7954 assert!(text.contains("Done."));
7955 }
7956
7957 #[test]
7958 fn parse_tool_calls_handles_tool_name_fence_shell() {
7959 let response = r#"```tool shell
7961{"command": "ls -la"}
7962```"#;
7963
7964 let (_text, calls) = parse_tool_calls(response);
7965 assert_eq!(calls.len(), 1);
7966 assert_eq!(calls[0].name, "shell");
7967 assert_eq!(
7968 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
7969 "ls -la"
7970 );
7971 }
7972
7973 #[test]
7974 fn parse_tool_calls_handles_multiple_tool_name_fences() {
7975 let response = r#"First, I'll write a file.
7977```tool file_write
7978{"path": "/tmp/a.txt", "content": "A"}
7979```
7980Then read it.
7981```tool file_read
7982{"path": "/tmp/a.txt"}
7983```
7984Done."#;
7985
7986 let (text, calls) = parse_tool_calls(response);
7987 assert_eq!(calls.len(), 2);
7988 assert_eq!(calls[0].name, "file_write");
7989 assert_eq!(calls[1].name, "file_read");
7990 assert!(text.contains("First, I'll write a file."));
7991 assert!(text.contains("Then read it."));
7992 assert!(text.contains("Done."));
7993 }
7994
7995 #[test]
7996 fn parse_tool_calls_handles_toolcall_tag_alias() {
7997 let response = r#"<toolcall>
7998{"name": "shell", "arguments": {"command": "date"}}
7999</toolcall>"#;
8000
8001 let (text, calls) = parse_tool_calls(response);
8002 assert!(text.is_empty());
8003 assert_eq!(calls.len(), 1);
8004 assert_eq!(calls[0].name, "shell");
8005 assert_eq!(
8006 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8007 "date"
8008 );
8009 }
8010
8011 #[test]
8012 fn parse_tool_calls_handles_tool_dash_call_tag_alias() {
8013 let response = r#"<tool-call>
8014{"name": "shell", "arguments": {"command": "whoami"}}
8015</tool-call>"#;
8016
8017 let (text, calls) = parse_tool_calls(response);
8018 assert!(text.is_empty());
8019 assert_eq!(calls.len(), 1);
8020 assert_eq!(calls[0].name, "shell");
8021 assert_eq!(
8022 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8023 "whoami"
8024 );
8025 }
8026
8027 #[test]
8028 fn parse_tool_calls_handles_invoke_tag_alias() {
8029 let response = r#"<invoke>
8030{"name": "shell", "arguments": {"command": "uptime"}}
8031</invoke>"#;
8032
8033 let (text, calls) = parse_tool_calls(response);
8034 assert!(text.is_empty());
8035 assert_eq!(calls.len(), 1);
8036 assert_eq!(calls[0].name, "shell");
8037 assert_eq!(
8038 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8039 "uptime"
8040 );
8041 }
8042
8043 #[test]
8044 fn parse_tool_calls_handles_minimax_invoke_parameter_format() {
8045 let response = r#"<minimax:tool_call>
8046<invoke name="shell">
8047<parameter name="command">sqlite3 /tmp/test.db ".tables"</parameter>
8048</invoke>
8049</minimax:tool_call>"#;
8050
8051 let (text, calls) = parse_tool_calls(response);
8052 assert!(text.is_empty());
8053 assert_eq!(calls.len(), 1);
8054 assert_eq!(calls[0].name, "shell");
8055 assert_eq!(
8056 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8057 r#"sqlite3 /tmp/test.db ".tables""#
8058 );
8059 }
8060
8061 #[test]
8062 fn parse_tool_calls_handles_minimax_invoke_with_surrounding_text() {
8063 let response = r#"Preface
8064<minimax:tool_call>
8065<invoke name='http_request'>
8066<parameter name='url'>https://example.com</parameter>
8067<parameter name='method'>GET</parameter>
8068</invoke>
8069</minimax:tool_call>
8070Tail"#;
8071
8072 let (text, calls) = parse_tool_calls(response);
8073 assert!(text.contains("Preface"));
8074 assert!(text.contains("Tail"));
8075 assert_eq!(calls.len(), 1);
8076 assert_eq!(calls[0].name, "http_request");
8077 assert_eq!(
8078 calls[0].arguments.get("url").unwrap().as_str().unwrap(),
8079 "https://example.com"
8080 );
8081 assert_eq!(
8082 calls[0].arguments.get("method").unwrap().as_str().unwrap(),
8083 "GET"
8084 );
8085 }
8086
8087 #[test]
8088 fn parse_tool_calls_handles_minimax_toolcall_alias_and_cross_close_tag() {
8089 let response = r#"<tool_call>
8090{"name":"shell","arguments":{"command":"date"}}
8091</minimax:toolcall>"#;
8092
8093 let (text, calls) = parse_tool_calls(response);
8094 assert!(text.is_empty());
8095 assert_eq!(calls.len(), 1);
8096 assert_eq!(calls[0].name, "shell");
8097 assert_eq!(
8098 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8099 "date"
8100 );
8101 }
8102
8103 #[test]
8104 fn parse_tool_calls_handles_perl_style_tool_call_blocks() {
8105 let response = r#"TOOL_CALL
8106{tool => "shell", args => { --command "uname -a" }}}
8107/TOOL_CALL"#;
8108
8109 let calls = parse_perl_style_tool_calls(response);
8110 assert_eq!(calls.len(), 1);
8111 assert_eq!(calls[0].name, "shell");
8112 assert_eq!(
8113 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8114 "uname -a"
8115 );
8116 }
8117
8118 #[test]
8119 fn parse_tool_calls_handles_square_bracket_tool_call_blocks() {
8120 let response =
8121 r#"[TOOL_CALL]{tool => "shell", args => {--command "echo hello"}}[/TOOL_CALL]"#;
8122
8123 let calls = parse_perl_style_tool_calls(response);
8124 assert_eq!(calls.len(), 1);
8125 assert_eq!(calls[0].name, "shell");
8126 assert_eq!(
8127 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8128 "echo hello"
8129 );
8130 }
8131
8132 #[test]
8133 fn parse_tool_calls_handles_square_bracket_multiline() {
8134 let response = r#"[TOOL_CALL]
8135{tool => "file_read", args => {
8136 --path "/tmp/test.txt"
8137 --description "Read test file"
8138}}
8139[/TOOL_CALL]"#;
8140
8141 let calls = parse_perl_style_tool_calls(response);
8142 assert_eq!(calls.len(), 1);
8143 assert_eq!(calls[0].name, "file_read");
8144 assert_eq!(
8145 calls[0].arguments.get("path").unwrap().as_str().unwrap(),
8146 "/tmp/test.txt"
8147 );
8148 assert_eq!(
8149 calls[0]
8150 .arguments
8151 .get("description")
8152 .unwrap()
8153 .as_str()
8154 .unwrap(),
8155 "Read test file"
8156 );
8157 }
8158
8159 #[test]
8160 fn parse_tool_calls_recovers_unclosed_tool_call_with_json() {
8161 let response = r#"I will call the tool now.
8162<tool_call>
8163{"name": "shell", "arguments": {"command": "uptime -p"}}"#;
8164
8165 let (text, calls) = parse_tool_calls(response);
8166 assert!(text.contains("I will call the tool now."));
8167 assert_eq!(calls.len(), 1);
8168 assert_eq!(calls[0].name, "shell");
8169 assert_eq!(
8170 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8171 "uptime -p"
8172 );
8173 }
8174
8175 #[test]
8176 fn parse_tool_calls_recovers_mismatched_close_tag() {
8177 let response = r#"<tool_call>
8178{"name": "shell", "arguments": {"command": "uptime"}}
8179</arg_value>"#;
8180
8181 let (text, calls) = parse_tool_calls(response);
8182 assert!(text.is_empty());
8183 assert_eq!(calls.len(), 1);
8184 assert_eq!(calls[0].name, "shell");
8185 assert_eq!(
8186 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8187 "uptime"
8188 );
8189 }
8190
8191 #[test]
8192 fn parse_tool_calls_recovers_cross_alias_closing_tags() {
8193 let response = r#"<toolcall>
8194{"name": "shell", "arguments": {"command": "date"}}
8195</tool_call>"#;
8196
8197 let (text, calls) = parse_tool_calls(response);
8198 assert!(text.is_empty());
8199 assert_eq!(calls.len(), 1);
8200 assert_eq!(calls[0].name, "shell");
8201 }
8202
8203 #[test]
8204 fn parse_tool_calls_rejects_raw_tool_json_without_tags() {
8205 let response = r#"Sure, creating the file now.
8209{"name": "file_write", "arguments": {"path": "hello.py", "content": "print('hello')"}}"#;
8210
8211 let (text, calls) = parse_tool_calls(response);
8212 assert!(text.contains("Sure, creating the file now."));
8213 assert_eq!(
8214 calls.len(),
8215 0,
8216 "Raw JSON without wrappers should not be parsed"
8217 );
8218 }
8219
8220 #[test]
8221 fn build_tool_instructions_includes_all_tools() {
8222 use crate::security::SecurityPolicy;
8223 let security = Arc::new(SecurityPolicy::from_config(
8224 &crate::config::AutonomyConfig::default(),
8225 std::path::Path::new("/tmp"),
8226 ));
8227 let tools = tools::default_tools(security);
8228 let instructions = build_tool_instructions(&tools, None);
8229
8230 assert!(instructions.contains("## Tool Use Protocol"));
8231 assert!(instructions.contains("<tool_call>"));
8232 assert!(instructions.contains("shell"));
8233 assert!(instructions.contains("file_read"));
8234 assert!(instructions.contains("file_write"));
8235 }
8236
8237 #[test]
8238 fn tools_to_openai_format_produces_valid_schema() {
8239 use crate::security::SecurityPolicy;
8240 let security = Arc::new(SecurityPolicy::from_config(
8241 &crate::config::AutonomyConfig::default(),
8242 std::path::Path::new("/tmp"),
8243 ));
8244 let tools = tools::default_tools(security);
8245 let formatted = tools_to_openai_format(&tools);
8246
8247 assert!(!formatted.is_empty());
8248 for tool_json in &formatted {
8249 assert_eq!(tool_json["type"], "function");
8250 assert!(tool_json["function"]["name"].is_string());
8251 assert!(tool_json["function"]["description"].is_string());
8252 assert!(!tool_json["function"]["name"].as_str().unwrap().is_empty());
8253 }
8254 let names: Vec<&str> = formatted
8256 .iter()
8257 .filter_map(|t| t["function"]["name"].as_str())
8258 .collect();
8259 assert!(names.contains(&"shell"));
8260 assert!(names.contains(&"file_read"));
8261 }
8262
8263 #[test]
8264 fn trim_history_preserves_system_prompt() {
8265 let mut history = vec![ChatMessage::system("system prompt")];
8266 for i in 0..DEFAULT_MAX_HISTORY_MESSAGES + 20 {
8267 history.push(ChatMessage::user(format!("msg {i}")));
8268 }
8269 let original_len = history.len();
8270 assert!(original_len > DEFAULT_MAX_HISTORY_MESSAGES + 1);
8271
8272 trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
8273
8274 assert_eq!(history[0].role, "system");
8276 assert_eq!(history[0].content, "system prompt");
8277 assert_eq!(history.len(), DEFAULT_MAX_HISTORY_MESSAGES + 1); let last = &history[history.len() - 1];
8281 assert_eq!(
8282 last.content,
8283 format!("msg {}", DEFAULT_MAX_HISTORY_MESSAGES + 19)
8284 );
8285 }
8286
8287 #[test]
8288 fn trim_history_noop_when_within_limit() {
8289 let mut history = vec![
8290 ChatMessage::system("sys"),
8291 ChatMessage::user("hello"),
8292 ChatMessage::assistant("hi"),
8293 ];
8294 trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
8295 assert_eq!(history.len(), 3);
8296 }
8297
8298 #[test]
8299 fn autosave_memory_key_has_prefix_and_uniqueness() {
8300 let key1 = autosave_memory_key("user_msg");
8301 let key2 = autosave_memory_key("user_msg");
8302
8303 assert!(key1.starts_with("user_msg_"));
8304 assert!(key2.starts_with("user_msg_"));
8305 assert_ne!(key1, key2);
8306 }
8307
8308 #[test]
8313 fn parse_tool_calls_handles_empty_tool_result() {
8314 let response = r#"I'll run that command.
8316<tool_result name="shell">
8317
8318</tool_result>
8319Done."#;
8320 let (text, calls) = parse_tool_calls(response);
8321 assert!(text.contains("Done."));
8322 assert!(calls.is_empty());
8323 }
8324
8325 #[test]
8326 fn strip_tool_result_blocks_removes_single_block() {
8327 let input = r#"<tool_result name="memory_recall" status="ok">
8328{"matches":["hello"]}
8329</tool_result>
8330Here is my answer."#;
8331 assert_eq!(strip_tool_result_blocks(input), "Here is my answer.");
8332 }
8333
8334 #[test]
8335 fn strip_tool_result_blocks_removes_multiple_blocks() {
8336 let input = r#"<tool_result name="memory_recall" status="ok">
8337{"matches":[]}
8338</tool_result>
8339<tool_result name="shell" status="ok">
8340done
8341</tool_result>
8342Final answer."#;
8343 assert_eq!(strip_tool_result_blocks(input), "Final answer.");
8344 }
8345
8346 #[test]
8347 fn strip_tool_result_blocks_removes_prefix() {
8348 let input =
8349 "[Tool results]\n<tool_result name=\"shell\" status=\"ok\">\nok\n</tool_result>\nDone.";
8350 assert_eq!(strip_tool_result_blocks(input), "Done.");
8351 }
8352
8353 #[test]
8354 fn strip_tool_result_blocks_removes_thinking() {
8355 let input = "<thinking>\nLet me think...\n</thinking>\nHere is the answer.";
8356 assert_eq!(strip_tool_result_blocks(input), "Here is the answer.");
8357 }
8358
8359 #[test]
8360 fn strip_tool_result_blocks_removes_think_tags() {
8361 let input = "<think>\nLet me reason...\n</think>\nHere is the answer.";
8362 assert_eq!(strip_tool_result_blocks(input), "Here is the answer.");
8363 }
8364
8365 #[test]
8366 fn strip_think_tags_removes_single_block() {
8367 assert_eq!(strip_think_tags("<think>reasoning</think>Hello"), "Hello");
8368 }
8369
8370 #[test]
8371 fn strip_think_tags_removes_multiple_blocks() {
8372 assert_eq!(strip_think_tags("<think>a</think>X<think>b</think>Y"), "XY");
8373 }
8374
8375 #[test]
8376 fn strip_think_tags_handles_unclosed_block() {
8377 assert_eq!(strip_think_tags("visible<think>hidden"), "visible");
8378 }
8379
8380 #[test]
8381 fn strip_think_tags_preserves_text_without_tags() {
8382 assert_eq!(strip_think_tags("plain text"), "plain text");
8383 }
8384
8385 #[test]
8386 fn parse_tool_calls_strips_think_before_tool_call() {
8387 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>";
8390 let (text, calls) = parse_tool_calls(response);
8391 assert_eq!(
8392 calls.len(),
8393 1,
8394 "should parse tool call after stripping think tags"
8395 );
8396 assert_eq!(calls[0].name, "shell");
8397 assert_eq!(
8398 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8399 "ls"
8400 );
8401 assert!(text.is_empty(), "think content should not appear as text");
8402 }
8403
8404 #[test]
8405 fn parse_tool_calls_strips_think_only_returns_empty() {
8406 let response = "<think>Just thinking, no action needed</think>";
8409 let (text, calls) = parse_tool_calls(response);
8410 assert!(calls.is_empty());
8411 assert!(text.is_empty());
8412 }
8413
8414 #[test]
8415 fn parse_tool_calls_handles_qwen_think_with_multiple_tool_calls() {
8416 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>";
8417 let (_, calls) = parse_tool_calls(response);
8418 assert_eq!(calls.len(), 2);
8419 assert_eq!(
8420 calls[0].arguments.get("command").unwrap().as_str().unwrap(),
8421 "date"
8422 );
8423 assert_eq!(
8424 calls[1].arguments.get("command").unwrap().as_str().unwrap(),
8425 "pwd"
8426 );
8427 }
8428
8429 #[test]
8430 fn strip_tool_result_blocks_preserves_clean_text() {
8431 let input = "Hello, this is a normal response.";
8432 assert_eq!(strip_tool_result_blocks(input), input);
8433 }
8434
8435 #[test]
8436 fn strip_tool_result_blocks_returns_empty_for_only_tags() {
8437 let input = "<tool_result name=\"memory_recall\" status=\"ok\">\n{}\n</tool_result>";
8438 assert_eq!(strip_tool_result_blocks(input), "");
8439 }
8440
8441 #[test]
8442 fn parse_arguments_value_handles_null() {
8443 let value = serde_json::json!(null);
8445 let result = parse_arguments_value(Some(&value));
8446 assert!(result.is_null());
8447 }
8448
8449 #[test]
8450 fn parse_tool_calls_handles_empty_tool_calls_array() {
8451 let response = r#"{"content": "Hello", "tool_calls": []}"#;
8453 let (text, calls) = parse_tool_calls(response);
8454 assert!(text.contains("Hello"));
8456 assert!(calls.is_empty());
8457 }
8458
8459 #[test]
8460 fn detect_tool_call_parse_issue_flags_malformed_payloads() {
8461 let response =
8462 "<tool_call>{\"name\":\"shell\",\"arguments\":{\"command\":\"pwd\"}</tool_call>";
8463 let issue = detect_tool_call_parse_issue(response, &[]);
8464 assert!(
8465 issue.is_some(),
8466 "malformed tool payload should be flagged for diagnostics"
8467 );
8468 }
8469
8470 #[test]
8471 fn detect_tool_call_parse_issue_ignores_normal_text() {
8472 let issue = detect_tool_call_parse_issue("Thanks, done.", &[]);
8473 assert!(issue.is_none());
8474 }
8475
8476 #[test]
8477 fn parse_tool_calls_handles_whitespace_only_name() {
8478 let value = serde_json::json!({"function": {"name": " ", "arguments": {}}});
8480 let result = parse_tool_call_value(&value);
8481 assert!(result.is_none());
8482 }
8483
8484 #[test]
8485 fn parse_tool_calls_handles_empty_string_arguments() {
8486 let value = serde_json::json!({"name": "test", "arguments": ""});
8488 let result = parse_tool_call_value(&value);
8489 assert!(result.is_some());
8490 assert_eq!(result.unwrap().name, "test");
8491 }
8492
8493 #[test]
8498 fn trim_history_with_no_system_prompt() {
8499 let mut history = vec![];
8501 for i in 0..DEFAULT_MAX_HISTORY_MESSAGES + 20 {
8502 history.push(ChatMessage::user(format!("msg {i}")));
8503 }
8504 trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
8505 assert_eq!(history.len(), DEFAULT_MAX_HISTORY_MESSAGES);
8506 }
8507
8508 #[test]
8509 fn trim_history_preserves_role_ordering() {
8510 let mut history = vec![ChatMessage::system("system")];
8512 for i in 0..DEFAULT_MAX_HISTORY_MESSAGES + 10 {
8513 history.push(ChatMessage::user(format!("user {i}")));
8514 history.push(ChatMessage::assistant(format!("assistant {i}")));
8515 }
8516 trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
8517 assert_eq!(history[0].role, "system");
8518 assert_eq!(history[history.len() - 1].role, "assistant");
8519 }
8520
8521 #[test]
8522 fn trim_history_with_only_system_prompt() {
8523 let mut history = vec![ChatMessage::system("system prompt")];
8525 trim_history(&mut history, DEFAULT_MAX_HISTORY_MESSAGES);
8526 assert_eq!(history.len(), 1);
8527 }
8528
8529 #[test]
8534 fn parse_arguments_value_handles_invalid_json_string() {
8535 let value = serde_json::Value::String("not valid json".to_string());
8537 let result = parse_arguments_value(Some(&value));
8538 assert!(result.is_object());
8539 assert!(result.as_object().unwrap().is_empty());
8540 }
8541
8542 #[test]
8543 fn parse_arguments_value_handles_none() {
8544 let result = parse_arguments_value(None);
8546 assert!(result.is_object());
8547 assert!(result.as_object().unwrap().is_empty());
8548 }
8549
8550 #[test]
8555 fn extract_json_values_handles_empty_string() {
8556 let result = extract_json_values("");
8558 assert!(result.is_empty());
8559 }
8560
8561 #[test]
8562 fn extract_json_values_handles_whitespace_only() {
8563 let result = extract_json_values(" \n\t ");
8565 assert!(result.is_empty());
8566 }
8567
8568 #[test]
8569 fn extract_json_values_handles_multiple_objects() {
8570 let input = r#"{"a": 1}{"b": 2}{"c": 3}"#;
8572 let result = extract_json_values(input);
8573 assert_eq!(result.len(), 3);
8574 }
8575
8576 #[test]
8577 fn extract_json_values_handles_arrays() {
8578 let input = r#"[1, 2, 3]{"key": "value"}"#;
8580 let result = extract_json_values(input);
8581 assert_eq!(result.len(), 2);
8582 }
8583
8584 const _: () = {
8589 assert!(DEFAULT_MAX_TOOL_ITERATIONS > 0);
8590 assert!(DEFAULT_MAX_TOOL_ITERATIONS <= 100);
8591 assert!(DEFAULT_MAX_HISTORY_MESSAGES > 0);
8592 assert!(DEFAULT_MAX_HISTORY_MESSAGES <= 1000);
8593 };
8594
8595 #[test]
8596 fn constants_bounds_are_compile_time_checked() {
8597 }
8599
8600 #[test]
8605 fn parse_tool_call_value_handles_missing_name_field() {
8606 let value = serde_json::json!({"function": {"arguments": {}}});
8608 let result = parse_tool_call_value(&value);
8609 assert!(result.is_none());
8610 }
8611
8612 #[test]
8613 fn parse_tool_call_value_handles_top_level_name() {
8614 let value = serde_json::json!({"name": "test_tool", "arguments": {}});
8616 let result = parse_tool_call_value(&value);
8617 assert!(result.is_some());
8618 assert_eq!(result.unwrap().name, "test_tool");
8619 }
8620
8621 #[test]
8622 fn parse_tool_call_value_accepts_top_level_parameters_alias() {
8623 let value = serde_json::json!({
8624 "name": "schedule",
8625 "parameters": {"action": "create", "message": "test"}
8626 });
8627 let result = parse_tool_call_value(&value).expect("tool call should parse");
8628 assert_eq!(result.name, "schedule");
8629 assert_eq!(
8630 result.arguments.get("action").and_then(|v| v.as_str()),
8631 Some("create")
8632 );
8633 }
8634
8635 #[test]
8636 fn parse_tool_call_value_accepts_function_parameters_alias() {
8637 let value = serde_json::json!({
8638 "function": {
8639 "name": "shell",
8640 "parameters": {"command": "date"}
8641 }
8642 });
8643 let result = parse_tool_call_value(&value).expect("tool call should parse");
8644 assert_eq!(result.name, "shell");
8645 assert_eq!(
8646 result.arguments.get("command").and_then(|v| v.as_str()),
8647 Some("date")
8648 );
8649 }
8650
8651 #[test]
8652 fn parse_tool_call_value_preserves_tool_call_id_aliases() {
8653 let value = serde_json::json!({
8654 "call_id": "legacy_1",
8655 "function": {
8656 "name": "shell",
8657 "arguments": {"command": "date"}
8658 }
8659 });
8660 let result = parse_tool_call_value(&value).expect("tool call should parse");
8661 assert_eq!(result.tool_call_id.as_deref(), Some("legacy_1"));
8662 }
8663
8664 #[test]
8665 fn parse_tool_calls_from_json_value_handles_empty_array() {
8666 let value = serde_json::json!({"tool_calls": []});
8668 let result = parse_tool_calls_from_json_value(&value);
8669 assert!(result.is_empty());
8670 }
8671
8672 #[test]
8673 fn parse_tool_calls_from_json_value_handles_missing_tool_calls() {
8674 let value = serde_json::json!({"name": "test", "arguments": {}});
8676 let result = parse_tool_calls_from_json_value(&value);
8677 assert_eq!(result.len(), 1);
8678 }
8679
8680 #[test]
8681 fn parse_tool_calls_from_json_value_handles_top_level_array() {
8682 let value = serde_json::json!([
8684 {"name": "tool_a", "arguments": {}},
8685 {"name": "tool_b", "arguments": {}}
8686 ]);
8687 let result = parse_tool_calls_from_json_value(&value);
8688 assert_eq!(result.len(), 2);
8689 }
8690
8691 #[test]
8696 fn parse_glm_style_browser_open_url() {
8697 let response = "browser_open/url>https://example.com";
8698 let calls = parse_glm_style_tool_calls(response);
8699 assert_eq!(calls.len(), 1);
8700 assert_eq!(calls[0].0, "shell");
8701 assert!(calls[0].1["command"].as_str().unwrap().contains("curl"));
8702 assert!(
8703 calls[0].1["command"]
8704 .as_str()
8705 .unwrap()
8706 .contains("example.com")
8707 );
8708 }
8709
8710 #[test]
8711 fn parse_glm_style_shell_command() {
8712 let response = "shell/command>ls -la";
8713 let calls = parse_glm_style_tool_calls(response);
8714 assert_eq!(calls.len(), 1);
8715 assert_eq!(calls[0].0, "shell");
8716 assert_eq!(calls[0].1["command"], "ls -la");
8717 }
8718
8719 #[test]
8720 fn parse_glm_style_http_request() {
8721 let response = "http_request/url>https://api.example.com/data";
8722 let calls = parse_glm_style_tool_calls(response);
8723 assert_eq!(calls.len(), 1);
8724 assert_eq!(calls[0].0, "http_request");
8725 assert_eq!(calls[0].1["url"], "https://api.example.com/data");
8726 assert_eq!(calls[0].1["method"], "GET");
8727 }
8728
8729 #[test]
8730 fn parse_glm_style_ignores_plain_url() {
8731 let response = "https://example.com/api";
8734 let calls = parse_glm_style_tool_calls(response);
8735 assert!(
8736 calls.is_empty(),
8737 "plain URL must not be parsed as tool call"
8738 );
8739 }
8740
8741 #[test]
8742 fn parse_glm_style_json_args() {
8743 let response = r#"shell/{"command": "echo hello"}"#;
8744 let calls = parse_glm_style_tool_calls(response);
8745 assert_eq!(calls.len(), 1);
8746 assert_eq!(calls[0].0, "shell");
8747 assert_eq!(calls[0].1["command"], "echo hello");
8748 }
8749
8750 #[test]
8751 fn parse_glm_style_multiple_calls() {
8752 let response = r#"shell/command>ls
8753browser_open/url>https://example.com"#;
8754 let calls = parse_glm_style_tool_calls(response);
8755 assert_eq!(calls.len(), 2);
8756 }
8757
8758 #[test]
8759 fn parse_glm_style_tool_call_integration() {
8760 let response = "Checking...\nbrowser_open/url>https://example.com\nDone";
8762 let (text, calls) = parse_tool_calls(response);
8763 assert_eq!(calls.len(), 1);
8764 assert_eq!(calls[0].name, "shell");
8765 assert!(text.contains("Checking"));
8766 assert!(text.contains("Done"));
8767 }
8768
8769 #[test]
8770 fn parse_glm_style_rejects_non_http_url_param() {
8771 let response = "browser_open/url>javascript:alert(1)";
8772 let calls = parse_glm_style_tool_calls(response);
8773 assert!(calls.is_empty());
8774 }
8775
8776 #[test]
8777 fn parse_tool_calls_handles_unclosed_tool_call_tag() {
8778 let response = "<tool_call>{\"name\":\"shell\",\"arguments\":{\"command\":\"pwd\"}}\nDone";
8779 let (text, calls) = parse_tool_calls(response);
8780 assert_eq!(calls.len(), 1);
8781 assert_eq!(calls[0].name, "shell");
8782 assert_eq!(calls[0].arguments["command"], "pwd");
8783 assert_eq!(text, "Done");
8784 }
8785
8786 #[test]
8792 fn parse_tool_calls_empty_input_returns_empty() {
8793 let (text, calls) = parse_tool_calls("");
8794 assert!(calls.is_empty(), "empty input should produce no tool calls");
8795 assert!(text.is_empty(), "empty input should produce no text");
8796 }
8797
8798 #[test]
8799 fn parse_tool_calls_whitespace_only_returns_empty_calls() {
8800 let (text, calls) = parse_tool_calls(" \n\t ");
8801 assert!(calls.is_empty());
8802 assert!(text.is_empty() || text.trim().is_empty());
8803 }
8804
8805 #[test]
8806 fn parse_tool_calls_nested_xml_tags_handled() {
8807 let response = r#"<tool_call><tool_call>{"name":"echo","arguments":{"msg":"hi"}}</tool_call></tool_call>"#;
8809 let (_text, calls) = parse_tool_calls(response);
8810 assert!(
8812 !calls.is_empty(),
8813 "nested XML tags should still yield at least one tool call"
8814 );
8815 }
8816
8817 #[test]
8818 fn parse_tool_calls_truncated_json_no_panic() {
8819 let response = r#"<tool_call>{"name":"shell","arguments":{"command":"ls"</tool_call>"#;
8821 let (_text, _calls) = parse_tool_calls(response);
8822 }
8824
8825 #[test]
8826 fn parse_tool_calls_empty_json_object_in_tag() {
8827 let response = "<tool_call>{}</tool_call>";
8828 let (_text, calls) = parse_tool_calls(response);
8829 assert!(
8831 calls.is_empty(),
8832 "empty JSON object should not produce a tool call"
8833 );
8834 }
8835
8836 #[test]
8837 fn parse_tool_calls_closing_tag_only_returns_text() {
8838 let response = "Some text </tool_call> more text";
8839 let (text, calls) = parse_tool_calls(response);
8840 assert!(
8841 calls.is_empty(),
8842 "closing tag only should not produce calls"
8843 );
8844 assert!(
8845 !text.is_empty(),
8846 "text around orphaned closing tag should be preserved"
8847 );
8848 }
8849
8850 #[test]
8851 fn parse_tool_calls_very_large_arguments_no_panic() {
8852 let large_arg = "x".repeat(100_000);
8853 let response = format!(
8854 r#"<tool_call>{{"name":"echo","arguments":{{"message":"{}"}}}}</tool_call>"#,
8855 large_arg
8856 );
8857 let (_text, calls) = parse_tool_calls(&response);
8858 assert_eq!(calls.len(), 1, "large arguments should still parse");
8859 assert_eq!(calls[0].name, "echo");
8860 }
8861
8862 #[test]
8863 fn parse_tool_calls_special_characters_in_arguments() {
8864 let response = r#"<tool_call>{"name":"echo","arguments":{"message":"hello \"world\" <>&'\n\t"}}</tool_call>"#;
8865 let (_text, calls) = parse_tool_calls(response);
8866 assert_eq!(calls.len(), 1);
8867 assert_eq!(calls[0].name, "echo");
8868 }
8869
8870 #[test]
8871 fn parse_tool_calls_text_with_embedded_json_not_extracted() {
8872 let response = r#"Here is some data: {"name":"echo","arguments":{"message":"hi"}} end."#;
8874 let (_text, calls) = parse_tool_calls(response);
8875 assert!(
8876 calls.is_empty(),
8877 "raw JSON in text without tags should not be extracted"
8878 );
8879 }
8880
8881 #[test]
8882 fn parse_tool_calls_multiple_formats_mixed() {
8883 let response = r#"I'll help you with that.
8885
8886<tool_call>
8887{"name":"shell","arguments":{"command":"echo hello"}}
8888</tool_call>
8889
8890Let me check the result."#;
8891 let (text, calls) = parse_tool_calls(response);
8892 assert_eq!(
8893 calls.len(),
8894 1,
8895 "should extract one tool call from mixed content"
8896 );
8897 assert_eq!(calls[0].name, "shell");
8898 assert!(
8899 text.contains("help you"),
8900 "text before tool call should be preserved"
8901 );
8902 }
8903
8904 #[test]
8909 fn scrub_credentials_empty_input() {
8910 let result = scrub_credentials("");
8911 assert_eq!(result, "");
8912 }
8913
8914 #[test]
8915 fn scrub_credentials_no_sensitive_data() {
8916 let input = "normal text without any secrets";
8917 let result = scrub_credentials(input);
8918 assert_eq!(
8919 result, input,
8920 "non-sensitive text should pass through unchanged"
8921 );
8922 }
8923
8924 #[test]
8925 fn scrub_credentials_multibyte_chars_no_panic() {
8926 let input = "password=\"\u{4f60}\u{7684}WiFi\u{5bc6}\u{7801}ab\"";
8931 let result = scrub_credentials(input);
8932 assert!(
8933 result.contains("[REDACTED]"),
8934 "multi-byte quoted value should be redacted without panic, got: {result}"
8935 );
8936 }
8937
8938 #[test]
8939 fn scrub_credentials_short_values_not_redacted() {
8940 let input = r#"api_key="short""#;
8942 let result = scrub_credentials(input);
8943 assert_eq!(result, input, "short values should not be redacted");
8944 }
8945
8946 #[test]
8951 fn trim_history_empty_history() {
8952 let mut history: Vec<crate::providers::ChatMessage> = vec![];
8953 trim_history(&mut history, 10);
8954 assert!(history.is_empty());
8955 }
8956
8957 #[test]
8958 fn trim_history_system_only() {
8959 let mut history = vec![crate::providers::ChatMessage::system("system prompt")];
8960 trim_history(&mut history, 10);
8961 assert_eq!(history.len(), 1);
8962 assert_eq!(history[0].role, "system");
8963 }
8964
8965 #[test]
8966 fn trim_history_exactly_at_limit() {
8967 let mut history = vec![
8968 crate::providers::ChatMessage::system("system"),
8969 crate::providers::ChatMessage::user("msg 1"),
8970 crate::providers::ChatMessage::assistant("reply 1"),
8971 ];
8972 trim_history(&mut history, 2); assert_eq!(history.len(), 3, "should not trim when exactly at limit");
8974 }
8975
8976 #[test]
8977 fn trim_history_removes_oldest_non_system() {
8978 let mut history = vec![
8979 crate::providers::ChatMessage::system("system"),
8980 crate::providers::ChatMessage::user("old msg"),
8981 crate::providers::ChatMessage::assistant("old reply"),
8982 crate::providers::ChatMessage::user("new msg"),
8983 crate::providers::ChatMessage::assistant("new reply"),
8984 ];
8985 trim_history(&mut history, 2);
8986 assert_eq!(history.len(), 3); assert_eq!(history[0].role, "system");
8988 assert_eq!(history[1].content, "new msg");
8989 }
8990
8991 #[test]
8996 fn native_tools_system_prompt_contains_zero_xml() {
8997 use crate::channels::build_system_prompt_with_mode;
8998
8999 let tool_summaries: Vec<(&str, &str)> = vec![
9000 ("shell", "Execute shell commands"),
9001 ("file_read", "Read files"),
9002 ];
9003
9004 let system_prompt = build_system_prompt_with_mode(
9005 std::path::Path::new("/tmp"),
9006 "test-model",
9007 &tool_summaries,
9008 &[], None, None, true, crate::config::SkillsPromptInjectionMode::Full,
9013 crate::security::AutonomyLevel::default(),
9014 );
9015
9016 assert!(
9018 !system_prompt.contains("<tool_call>"),
9019 "Native prompt must not contain <tool_call>"
9020 );
9021 assert!(
9022 !system_prompt.contains("</tool_call>"),
9023 "Native prompt must not contain </tool_call>"
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_result>"),
9031 "Native prompt must not contain </tool_result>"
9032 );
9033 assert!(
9034 !system_prompt.contains("## Tool Use Protocol"),
9035 "Native prompt must not contain XML protocol header"
9036 );
9037
9038 assert!(
9040 system_prompt.contains("shell"),
9041 "Native prompt must list tool names"
9042 );
9043 assert!(
9044 system_prompt.contains("## Your Task"),
9045 "Native prompt should contain task instructions"
9046 );
9047 }
9048
9049 #[test]
9052 fn parse_tool_calls_cross_alias_close_tag_with_json() {
9053 let input = r#"<tool_call>{"name": "shell", "arguments": {"command": "ls"}}</invoke>"#;
9055 let (text, calls) = parse_tool_calls(input);
9056 assert_eq!(calls.len(), 1);
9057 assert_eq!(calls[0].name, "shell");
9058 assert_eq!(calls[0].arguments["command"], "ls");
9059 assert!(text.is_empty());
9060 }
9061
9062 #[test]
9063 fn parse_tool_calls_cross_alias_close_tag_with_glm_shortened() {
9064 let input = "<tool_call>shell>uname -a</invoke>";
9066 let (text, calls) = parse_tool_calls(input);
9067 assert_eq!(calls.len(), 1);
9068 assert_eq!(calls[0].name, "shell");
9069 assert_eq!(calls[0].arguments["command"], "uname -a");
9070 assert!(text.is_empty());
9071 }
9072
9073 #[test]
9074 fn parse_tool_calls_glm_shortened_body_in_matched_tags() {
9075 let input = "<tool_call>shell>pwd</tool_call>";
9077 let (text, calls) = parse_tool_calls(input);
9078 assert_eq!(calls.len(), 1);
9079 assert_eq!(calls[0].name, "shell");
9080 assert_eq!(calls[0].arguments["command"], "pwd");
9081 assert!(text.is_empty());
9082 }
9083
9084 #[test]
9085 fn parse_tool_calls_glm_yaml_style_in_tags() {
9086 let input = "<tool_call>shell>\ncommand: date\napproved: true</invoke>";
9088 let (text, calls) = parse_tool_calls(input);
9089 assert_eq!(calls.len(), 1);
9090 assert_eq!(calls[0].name, "shell");
9091 assert_eq!(calls[0].arguments["command"], "date");
9092 assert_eq!(calls[0].arguments["approved"], true);
9093 assert!(text.is_empty());
9094 }
9095
9096 #[test]
9097 fn parse_tool_calls_attribute_style_in_tags() {
9098 let input = r#"<tool_call>shell command="date" /></tool_call>"#;
9100 let (text, calls) = parse_tool_calls(input);
9101 assert_eq!(calls.len(), 1);
9102 assert_eq!(calls[0].name, "shell");
9103 assert_eq!(calls[0].arguments["command"], "date");
9104 assert!(text.is_empty());
9105 }
9106
9107 #[test]
9108 fn parse_tool_calls_file_read_shortened_in_cross_alias() {
9109 let input = r#"<tool_call>file_read path=".env" /></invoke>"#;
9111 let (text, calls) = parse_tool_calls(input);
9112 assert_eq!(calls.len(), 1);
9113 assert_eq!(calls[0].name, "file_read");
9114 assert_eq!(calls[0].arguments["path"], ".env");
9115 assert!(text.is_empty());
9116 }
9117
9118 #[test]
9119 fn parse_tool_calls_unclosed_glm_shortened_no_close_tag() {
9120 let input = "<tool_call>shell>ls -la";
9122 let (text, calls) = parse_tool_calls(input);
9123 assert_eq!(calls.len(), 1);
9124 assert_eq!(calls[0].name, "shell");
9125 assert_eq!(calls[0].arguments["command"], "ls -la");
9126 assert!(text.is_empty());
9127 }
9128
9129 #[test]
9130 fn parse_tool_calls_text_before_cross_alias() {
9131 let input = "Let me check that.\n<tool_call>shell>uname -a</invoke>\nDone.";
9133 let (text, calls) = parse_tool_calls(input);
9134 assert_eq!(calls.len(), 1);
9135 assert_eq!(calls[0].name, "shell");
9136 assert_eq!(calls[0].arguments["command"], "uname -a");
9137 assert!(text.contains("Let me check that."));
9138 assert!(text.contains("Done."));
9139 }
9140
9141 #[test]
9142 fn parse_glm_shortened_body_url_to_curl() {
9143 let call = parse_glm_shortened_body("shell>https://example.com/api").unwrap();
9145 assert_eq!(call.name, "shell");
9146 let cmd = call.arguments["command"].as_str().unwrap();
9147 assert!(cmd.contains("curl"));
9148 assert!(cmd.contains("example.com"));
9149 }
9150
9151 #[test]
9152 fn parse_glm_shortened_body_browser_open_maps_to_shell_command() {
9153 let call = parse_glm_shortened_body("browser_open>https://example.com").unwrap();
9156 assert_eq!(call.name, "shell");
9157 let cmd = call.arguments["command"].as_str().unwrap();
9158 assert!(cmd.contains("curl"));
9159 assert!(cmd.contains("example.com"));
9160 }
9161
9162 #[test]
9163 fn parse_glm_shortened_body_memory_recall() {
9164 let call = parse_glm_shortened_body("memory_recall>recent meetings").unwrap();
9166 assert_eq!(call.name, "memory_recall");
9167 assert_eq!(call.arguments["query"], "recent meetings");
9168 }
9169
9170 #[test]
9171 fn parse_glm_shortened_body_function_style_alias_maps_to_message_send() {
9172 let call =
9173 parse_glm_shortened_body(r#"sendmessage(channel="alerts", message="hi")"#).unwrap();
9174 assert_eq!(call.name, "message_send");
9175 assert_eq!(call.arguments["channel"], "alerts");
9176 assert_eq!(call.arguments["message"], "hi");
9177 }
9178
9179 #[test]
9180 fn map_tool_name_alias_direct_coverage() {
9181 assert_eq!(map_tool_name_alias("bash"), "shell");
9182 assert_eq!(map_tool_name_alias("filelist"), "file_list");
9183 assert_eq!(map_tool_name_alias("memorystore"), "memory_store");
9184 assert_eq!(map_tool_name_alias("memoryforget"), "memory_forget");
9185 assert_eq!(map_tool_name_alias("http"), "http_request");
9186 assert_eq!(
9187 map_tool_name_alias("totally_unknown_tool"),
9188 "totally_unknown_tool"
9189 );
9190 }
9191
9192 #[test]
9193 fn default_param_for_tool_coverage() {
9194 assert_eq!(default_param_for_tool("shell"), "command");
9195 assert_eq!(default_param_for_tool("bash"), "command");
9196 assert_eq!(default_param_for_tool("file_read"), "path");
9197 assert_eq!(default_param_for_tool("memory_recall"), "query");
9198 assert_eq!(default_param_for_tool("memory_store"), "content");
9199 assert_eq!(default_param_for_tool("web_search_tool"), "query");
9200 assert_eq!(default_param_for_tool("web_search"), "query");
9201 assert_eq!(default_param_for_tool("search"), "query");
9202 assert_eq!(default_param_for_tool("http_request"), "url");
9203 assert_eq!(default_param_for_tool("browser_open"), "url");
9204 assert_eq!(default_param_for_tool("unknown_tool"), "input");
9205 }
9206
9207 #[test]
9208 fn parse_glm_shortened_body_rejects_empty() {
9209 assert!(parse_glm_shortened_body("").is_none());
9210 assert!(parse_glm_shortened_body(" ").is_none());
9211 }
9212
9213 #[test]
9214 fn parse_glm_shortened_body_rejects_invalid_tool_name() {
9215 assert!(parse_glm_shortened_body("not-a-tool>value").is_none());
9217 assert!(parse_glm_shortened_body("tool name>value").is_none());
9218 }
9219
9220 #[test]
9225 fn build_native_assistant_history_includes_reasoning_content() {
9226 let calls = vec![ToolCall {
9227 id: "call_1".into(),
9228 name: "shell".into(),
9229 arguments: "{}".into(),
9230 }];
9231 let result = build_native_assistant_history("answer", &calls, Some("thinking step"));
9232 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
9233 assert_eq!(parsed["content"].as_str(), Some("answer"));
9234 assert_eq!(parsed["reasoning_content"].as_str(), Some("thinking step"));
9235 assert!(parsed["tool_calls"].is_array());
9236 }
9237
9238 #[test]
9239 fn build_native_assistant_history_omits_reasoning_content_when_none() {
9240 let calls = vec![ToolCall {
9241 id: "call_1".into(),
9242 name: "shell".into(),
9243 arguments: "{}".into(),
9244 }];
9245 let result = build_native_assistant_history("answer", &calls, None);
9246 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
9247 assert_eq!(parsed["content"].as_str(), Some("answer"));
9248 assert!(parsed.get("reasoning_content").is_none());
9249 }
9250
9251 #[test]
9252 fn build_native_assistant_history_from_parsed_calls_includes_reasoning_content() {
9253 let calls = vec![ParsedToolCall {
9254 name: "shell".into(),
9255 arguments: serde_json::json!({"command": "pwd"}),
9256 tool_call_id: Some("call_2".into()),
9257 }];
9258 let result = build_native_assistant_history_from_parsed_calls(
9259 "answer",
9260 &calls,
9261 Some("deep thought"),
9262 );
9263 assert!(result.is_some());
9264 let parsed: serde_json::Value = serde_json::from_str(result.as_deref().unwrap()).unwrap();
9265 assert_eq!(parsed["content"].as_str(), Some("answer"));
9266 assert_eq!(parsed["reasoning_content"].as_str(), Some("deep thought"));
9267 assert!(parsed["tool_calls"].is_array());
9268 }
9269
9270 #[test]
9271 fn build_native_assistant_history_from_parsed_calls_omits_reasoning_content_when_none() {
9272 let calls = vec![ParsedToolCall {
9273 name: "shell".into(),
9274 arguments: serde_json::json!({"command": "pwd"}),
9275 tool_call_id: Some("call_2".into()),
9276 }];
9277 let result = build_native_assistant_history_from_parsed_calls("answer", &calls, None);
9278 assert!(result.is_some());
9279 let parsed: serde_json::Value = serde_json::from_str(result.as_deref().unwrap()).unwrap();
9280 assert_eq!(parsed["content"].as_str(), Some("answer"));
9281 assert!(parsed.get("reasoning_content").is_none());
9282 }
9283
9284 #[test]
9287 fn glob_match_exact_no_wildcard() {
9288 assert!(glob_match("mcp_browser_navigate", "mcp_browser_navigate"));
9289 assert!(!glob_match("mcp_browser_navigate", "mcp_browser_click"));
9290 }
9291
9292 #[test]
9293 fn glob_match_prefix_wildcard() {
9294 assert!(glob_match("mcp_browser_*", "mcp_browser_navigate"));
9296 assert!(glob_match("mcp_browser_*", "mcp_browser_click"));
9297 assert!(!glob_match("mcp_browser_*", "mcp_filesystem_read"));
9298
9299 assert!(glob_match("*_read", "mcp_filesystem_read"));
9301 assert!(!glob_match("*_read", "mcp_filesystem_write"));
9302
9303 assert!(glob_match("mcp_*_navigate", "mcp_browser_navigate"));
9305 assert!(!glob_match("mcp_*_navigate", "mcp_browser_click"));
9306 }
9307
9308 #[test]
9309 fn glob_match_star_matches_everything() {
9310 assert!(glob_match("*", "anything_at_all"));
9311 assert!(glob_match("*", ""));
9312 }
9313
9314 fn make_spec(name: &str) -> crate::tools::ToolSpec {
9317 crate::tools::ToolSpec {
9318 name: name.to_string(),
9319 description: String::new(),
9320 parameters: serde_json::json!({}),
9321 }
9322 }
9323
9324 #[test]
9325 fn filter_tool_specs_no_groups_returns_all() {
9326 let specs = vec![
9327 make_spec("shell_exec"),
9328 make_spec("mcp_browser_navigate"),
9329 make_spec("mcp_filesystem_read"),
9330 ];
9331 let result = filter_tool_specs_for_turn(specs, &[], "hello");
9332 assert_eq!(result.len(), 3);
9333 }
9334
9335 #[test]
9336 fn filter_tool_specs_always_group_includes_matching_mcp_tool() {
9337 use crate::config::schema::{ToolFilterGroup, ToolFilterGroupMode};
9338
9339 let specs = vec![
9340 make_spec("shell_exec"),
9341 make_spec("mcp_browser_navigate"),
9342 make_spec("mcp_filesystem_read"),
9343 ];
9344 let groups = vec![ToolFilterGroup {
9345 mode: ToolFilterGroupMode::Always,
9346 tools: vec!["mcp_filesystem_*".into()],
9347 keywords: vec![],
9348 filter_builtins: false,
9349 }];
9350 let result = filter_tool_specs_for_turn(specs, &groups, "anything");
9351 let names: Vec<&str> = result.iter().map(|s| s.name.as_str()).collect();
9352 assert!(names.contains(&"shell_exec"));
9354 assert!(names.contains(&"mcp_filesystem_read"));
9355 assert!(!names.contains(&"mcp_browser_navigate"));
9356 }
9357
9358 #[test]
9359 fn filter_tool_specs_dynamic_group_included_on_keyword_match() {
9360 use crate::config::schema::{ToolFilterGroup, ToolFilterGroupMode};
9361
9362 let specs = vec![make_spec("shell_exec"), make_spec("mcp_browser_navigate")];
9363 let groups = vec![ToolFilterGroup {
9364 mode: ToolFilterGroupMode::Dynamic,
9365 tools: vec!["mcp_browser_*".into()],
9366 keywords: vec!["browse".into(), "website".into()],
9367 filter_builtins: false,
9368 }];
9369 let result = filter_tool_specs_for_turn(specs, &groups, "please browse this page");
9370 let names: Vec<&str> = result.iter().map(|s| s.name.as_str()).collect();
9371 assert!(names.contains(&"shell_exec"));
9372 assert!(names.contains(&"mcp_browser_navigate"));
9373 }
9374
9375 #[test]
9376 fn filter_tool_specs_dynamic_group_excluded_on_no_keyword_match() {
9377 use crate::config::schema::{ToolFilterGroup, ToolFilterGroupMode};
9378
9379 let specs = vec![make_spec("shell_exec"), make_spec("mcp_browser_navigate")];
9380 let groups = vec![ToolFilterGroup {
9381 mode: ToolFilterGroupMode::Dynamic,
9382 tools: vec!["mcp_browser_*".into()],
9383 keywords: vec!["browse".into(), "website".into()],
9384 filter_builtins: false,
9385 }];
9386 let result = filter_tool_specs_for_turn(specs, &groups, "read the file /etc/hosts");
9387 let names: Vec<&str> = result.iter().map(|s| s.name.as_str()).collect();
9388 assert!(names.contains(&"shell_exec"));
9389 assert!(!names.contains(&"mcp_browser_navigate"));
9390 }
9391
9392 #[test]
9393 fn filter_tool_specs_dynamic_keyword_match_is_case_insensitive() {
9394 use crate::config::schema::{ToolFilterGroup, ToolFilterGroupMode};
9395
9396 let specs = vec![make_spec("mcp_browser_navigate")];
9397 let groups = vec![ToolFilterGroup {
9398 mode: ToolFilterGroupMode::Dynamic,
9399 tools: vec!["mcp_browser_*".into()],
9400 keywords: vec!["Browse".into()],
9401 filter_builtins: false,
9402 }];
9403 let result = filter_tool_specs_for_turn(specs, &groups, "BROWSE the site");
9404 assert_eq!(result.len(), 1);
9405 }
9406
9407 #[test]
9410 fn estimate_history_tokens_empty() {
9411 assert_eq!(super::estimate_history_tokens(&[]), 0);
9412 }
9413
9414 #[test]
9415 fn estimate_history_tokens_single_message() {
9416 let history = vec![ChatMessage::user("hello world")]; let tokens = super::estimate_history_tokens(&history);
9418 assert_eq!(tokens, 7);
9420 }
9421
9422 #[test]
9423 fn estimate_history_tokens_multiple_messages() {
9424 let history = vec![
9425 ChatMessage::system("You are helpful."), ChatMessage::user("What is Rust?"), ChatMessage::assistant("A language."), ];
9429 let tokens = super::estimate_history_tokens(&history);
9430 assert_eq!(tokens, 23);
9431 }
9432
9433 #[tokio::test]
9434 async fn run_tool_call_loop_surfaces_tool_failure_reason_in_on_delta() {
9435 let provider = ScriptedProvider::from_text_responses(vec![
9436 r#"<tool_call>
9437{"name":"failing_shell","arguments":{"command":"rm -rf /"}}
9438</tool_call>"#,
9439 "I could not execute that command.",
9440 ]);
9441
9442 let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(FailingTool::new(
9443 "failing_shell",
9444 "Command not allowed by security policy: rm -rf /",
9445 ))];
9446
9447 let mut history = vec![
9448 ChatMessage::system("test-system"),
9449 ChatMessage::user("delete everything"),
9450 ];
9451 let observer = NoopObserver;
9452
9453 let (tx, mut rx) = tokio::sync::mpsc::channel::<DraftEvent>(64);
9454
9455 let result = run_tool_call_loop(
9456 &provider,
9457 &mut history,
9458 &tools_registry,
9459 &observer,
9460 "mock-provider",
9461 "mock-model",
9462 0.0,
9463 true,
9464 None,
9465 "telegram",
9466 None,
9467 &crate::config::MultimodalConfig::default(),
9468 4,
9469 None,
9470 Some(tx),
9471 None,
9472 &[],
9473 &[],
9474 None,
9475 None,
9476 &crate::config::PacingConfig::default(),
9477 0,
9478 0,
9479 None,
9480 )
9481 .await
9482 .expect("tool loop should complete");
9483
9484 let mut deltas = Vec::new();
9486 while let Ok(msg) = rx.try_recv() {
9487 deltas.push(msg);
9488 }
9489
9490 let all_deltas: String = deltas
9491 .iter()
9492 .filter_map(|d| match d {
9493 DraftEvent::Progress(t) | DraftEvent::Content(t) => Some(t.as_str()),
9494 DraftEvent::Clear => None,
9495 })
9496 .collect();
9497
9498 assert!(
9500 all_deltas.contains("Command not allowed by security policy"),
9501 "on_delta messages should include the tool failure reason, got: {all_deltas}"
9502 );
9503
9504 assert!(
9506 all_deltas.contains('\u{274c}'),
9507 "on_delta messages should include ❌ for failed tool calls, got: {all_deltas}"
9508 );
9509
9510 assert_eq!(result, "I could not execute that command.");
9511 }
9512
9513 #[test]
9516 fn filter_by_allowed_tools_none_passes_all() {
9517 let specs = vec![
9518 make_spec("shell"),
9519 make_spec("memory_store"),
9520 make_spec("file_read"),
9521 ];
9522 let result = filter_by_allowed_tools(specs, None);
9523 assert_eq!(result.len(), 3);
9524 }
9525
9526 #[test]
9527 fn filter_by_allowed_tools_some_restricts_to_listed() {
9528 let specs = vec![
9529 make_spec("shell"),
9530 make_spec("memory_store"),
9531 make_spec("file_read"),
9532 ];
9533 let allowed = vec!["shell".to_string(), "memory_store".to_string()];
9534 let result = filter_by_allowed_tools(specs, Some(&allowed));
9535 let names: Vec<&str> = result.iter().map(|s| s.name.as_str()).collect();
9536 assert_eq!(names.len(), 2);
9537 assert!(names.contains(&"shell"));
9538 assert!(names.contains(&"memory_store"));
9539 assert!(!names.contains(&"file_read"));
9540 }
9541
9542 #[test]
9543 fn filter_by_allowed_tools_unknown_names_silently_ignored() {
9544 let specs = vec![make_spec("shell"), make_spec("file_read")];
9545 let allowed = vec![
9546 "shell".to_string(),
9547 "nonexistent_tool".to_string(),
9548 "another_missing".to_string(),
9549 ];
9550 let result = filter_by_allowed_tools(specs, Some(&allowed));
9551 let names: Vec<&str> = result.iter().map(|s| s.name.as_str()).collect();
9552 assert_eq!(names.len(), 1);
9553 assert!(names.contains(&"shell"));
9554 }
9555
9556 #[test]
9557 fn filter_by_allowed_tools_empty_list_excludes_all() {
9558 let specs = vec![make_spec("shell"), make_spec("file_read")];
9559 let allowed: Vec<String> = vec![];
9560 let result = filter_by_allowed_tools(specs, Some(&allowed));
9561 assert!(result.is_empty());
9562 }
9563
9564 #[tokio::test]
9567 async fn cost_tracking_records_usage_when_scoped() {
9568 use super::{
9569 TOOL_LOOP_COST_TRACKING_CONTEXT, ToolLoopCostTrackingContext, run_tool_call_loop,
9570 };
9571 use crate::config::schema::ModelPricing;
9572 use crate::cost::CostTracker;
9573 use crate::observability::noop::NoopObserver;
9574 use std::collections::HashMap;
9575
9576 let provider = ScriptedProvider {
9577 responses: Arc::new(Mutex::new(VecDeque::from([ChatResponse {
9578 text: Some("done".to_string()),
9579 tool_calls: Vec::new(),
9580 usage: Some(crate::providers::traits::TokenUsage {
9581 input_tokens: Some(1_000),
9582 output_tokens: Some(200),
9583 cached_input_tokens: None,
9584 }),
9585 reasoning_content: None,
9586 }]))),
9587 capabilities: ProviderCapabilities::default(),
9588 };
9589 let observer = NoopObserver;
9590 let workspace = tempfile::TempDir::new().unwrap();
9591 let mut cost_config = crate::config::CostConfig {
9592 enabled: true,
9593 ..crate::config::CostConfig::default()
9594 };
9595 cost_config.prices = HashMap::from([(
9596 "mock-model".to_string(),
9597 ModelPricing {
9598 input: 3.0,
9599 output: 15.0,
9600 },
9601 )]);
9602 let tracker = Arc::new(CostTracker::new(cost_config.clone(), workspace.path()).unwrap());
9603 let ctx = ToolLoopCostTrackingContext::new(
9604 Arc::clone(&tracker),
9605 Arc::new(cost_config.prices.clone()),
9606 );
9607 let mut history = vec![ChatMessage::system("test"), ChatMessage::user("hello")];
9608
9609 let result = TOOL_LOOP_COST_TRACKING_CONTEXT
9610 .scope(
9611 Some(ctx),
9612 run_tool_call_loop(
9613 &provider,
9614 &mut history,
9615 &[],
9616 &observer,
9617 "mock-provider",
9618 "mock-model",
9619 0.0,
9620 true,
9621 None,
9622 "test",
9623 None,
9624 &crate::config::MultimodalConfig::default(),
9625 2,
9626 None,
9627 None,
9628 None,
9629 &[],
9630 &[],
9631 None,
9632 None,
9633 &crate::config::PacingConfig::default(),
9634 0,
9635 0,
9636 None,
9637 ),
9638 )
9639 .await
9640 .expect("tool loop should succeed");
9641
9642 assert_eq!(result, "done");
9643 let summary = tracker.get_summary().unwrap();
9644 assert_eq!(summary.request_count, 1);
9645 assert_eq!(summary.total_tokens, 1_200);
9646 assert!(summary.session_cost_usd > 0.0);
9647 }
9648
9649 #[tokio::test]
9650 async fn cost_tracking_enforces_budget() {
9651 use super::{
9652 TOOL_LOOP_COST_TRACKING_CONTEXT, ToolLoopCostTrackingContext, run_tool_call_loop,
9653 };
9654 use crate::config::schema::ModelPricing;
9655 use crate::cost::CostTracker;
9656 use crate::observability::noop::NoopObserver;
9657 use std::collections::HashMap;
9658
9659 let provider = ScriptedProvider::from_text_responses(vec!["should not reach this"]);
9660 let observer = NoopObserver;
9661 let workspace = tempfile::TempDir::new().unwrap();
9662 let cost_config = crate::config::CostConfig {
9663 enabled: true,
9664 daily_limit_usd: 0.001, ..crate::config::CostConfig::default()
9666 };
9667 let tracker = Arc::new(CostTracker::new(cost_config.clone(), workspace.path()).unwrap());
9668 tracker
9670 .record_usage(crate::cost::types::TokenUsage::new(
9671 "mock-model",
9672 100_000,
9673 50_000,
9674 1.0,
9675 1.0,
9676 ))
9677 .unwrap();
9678
9679 let ctx = ToolLoopCostTrackingContext::new(
9680 Arc::clone(&tracker),
9681 Arc::new(HashMap::from([(
9682 "mock-model".to_string(),
9683 ModelPricing {
9684 input: 1.0,
9685 output: 1.0,
9686 },
9687 )])),
9688 );
9689 let mut history = vec![ChatMessage::system("test"), ChatMessage::user("hello")];
9690
9691 let err = TOOL_LOOP_COST_TRACKING_CONTEXT
9692 .scope(
9693 Some(ctx),
9694 run_tool_call_loop(
9695 &provider,
9696 &mut history,
9697 &[],
9698 &observer,
9699 "mock-provider",
9700 "mock-model",
9701 0.0,
9702 true,
9703 None,
9704 "test",
9705 None,
9706 &crate::config::MultimodalConfig::default(),
9707 2,
9708 None,
9709 None,
9710 None,
9711 &[],
9712 &[],
9713 None,
9714 None,
9715 &crate::config::PacingConfig::default(),
9716 0,
9717 0,
9718 None,
9719 ),
9720 )
9721 .await
9722 .expect_err("should fail with budget exceeded");
9723
9724 assert!(
9725 err.to_string().contains("Budget exceeded"),
9726 "error should mention budget: {err}"
9727 );
9728 }
9729
9730 #[tokio::test]
9731 async fn cost_tracking_is_noop_without_scope() {
9732 use super::run_tool_call_loop;
9733 use crate::observability::noop::NoopObserver;
9734
9735 let provider = ScriptedProvider {
9737 responses: Arc::new(Mutex::new(VecDeque::from([ChatResponse {
9738 text: Some("ok".to_string()),
9739 tool_calls: Vec::new(),
9740 usage: Some(crate::providers::traits::TokenUsage {
9741 input_tokens: Some(500),
9742 output_tokens: Some(100),
9743 cached_input_tokens: None,
9744 }),
9745 reasoning_content: None,
9746 }]))),
9747 capabilities: ProviderCapabilities::default(),
9748 };
9749 let observer = NoopObserver;
9750 let mut history = vec![ChatMessage::system("test"), ChatMessage::user("hello")];
9751
9752 let result = run_tool_call_loop(
9753 &provider,
9754 &mut history,
9755 &[],
9756 &observer,
9757 "mock-provider",
9758 "mock-model",
9759 0.0,
9760 true,
9761 None,
9762 "test",
9763 None,
9764 &crate::config::MultimodalConfig::default(),
9765 2,
9766 None,
9767 None,
9768 None,
9769 &[],
9770 &[],
9771 None,
9772 None,
9773 &crate::config::PacingConfig::default(),
9774 0,
9775 0,
9776 None,
9777 )
9778 .await
9779 .expect("should succeed without cost scope");
9780
9781 assert_eq!(result, "ok");
9782 }
9783}