1use crate::agent::dispatcher::{
2 NativeToolDispatcher, ParsedToolCall, ToolDispatcher, ToolExecutionResult, XmlToolDispatcher,
3};
4use crate::agent::memory_loader::{DefaultMemoryLoader, MemoryLoader};
5use crate::agent::prompt::{PromptContext, SystemPromptBuilder};
6use crate::config::Config;
7use crate::i18n::ToolDescriptions;
8use crate::memory::{self, Memory, MemoryCategory};
9use crate::observability::{self, Observer, ObserverEvent};
10use crate::providers::{self, ChatMessage, ChatRequest, ConversationMessage, Provider};
11use crate::runtime;
12use crate::security::SecurityPolicy;
13use crate::tools::{self, Tool, ToolSpec};
14use anyhow::Result;
15use chrono::{Datelike, Timelike};
16use std::collections::HashMap;
17use std::io::Write as IoWrite;
18use std::sync::Arc;
19use std::time::Instant;
20
21#[derive(Debug, Clone)]
26pub enum TurnEvent {
27 Chunk { delta: String },
29 Thinking { delta: String },
31 ToolCall {
33 name: String,
34 args: serde_json::Value,
35 },
36 ToolResult { name: String, output: String },
38 OperatorStatus { phase: String, detail: String },
40}
41
42pub struct Agent {
43 provider: Box<dyn Provider>,
44 provider_name: String,
47 tools: Vec<Box<dyn Tool>>,
48 tool_specs: Vec<ToolSpec>,
49 memory: Arc<dyn Memory>,
50 observer: Arc<dyn Observer>,
51 prompt_builder: SystemPromptBuilder,
52 tool_dispatcher: Box<dyn ToolDispatcher>,
53 memory_loader: Box<dyn MemoryLoader>,
54 config: crate::config::AgentConfig,
55 model_name: String,
56 temperature: f64,
57 workspace_dir: std::path::PathBuf,
58 identity_config: crate::config::IdentityConfig,
59 skills: Vec<crate::skills::Skill>,
60 skills_prompt_mode: crate::config::SkillsPromptInjectionMode,
61 auto_save: bool,
62 memory_session_id: Option<String>,
63 history: Vec<ConversationMessage>,
64 classification_config: crate::config::QueryClassificationConfig,
65 available_hints: Vec<String>,
66 route_model_by_hint: HashMap<String, String>,
67 allowed_tools: Option<Vec<String>>,
68 response_cache: Option<Arc<crate::memory::response_cache::ResponseCache>>,
69 tool_descriptions: Option<ToolDescriptions>,
70 security_summary: Option<String>,
73 autonomy_level: crate::security::AutonomyLevel,
75 activated_tools: Option<Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>>,
79 kumiho_enabled: bool,
82 operator_enabled: bool,
85 skill_effectiveness: Option<Arc<crate::skills::EffectivenessCache>>,
91}
92
93pub struct AgentBuilder {
94 provider: Option<Box<dyn Provider>>,
95 provider_name: Option<String>,
96 tools: Option<Vec<Box<dyn Tool>>>,
97 memory: Option<Arc<dyn Memory>>,
98 observer: Option<Arc<dyn Observer>>,
99 prompt_builder: Option<SystemPromptBuilder>,
100 tool_dispatcher: Option<Box<dyn ToolDispatcher>>,
101 memory_loader: Option<Box<dyn MemoryLoader>>,
102 config: Option<crate::config::AgentConfig>,
103 model_name: Option<String>,
104 temperature: Option<f64>,
105 workspace_dir: Option<std::path::PathBuf>,
106 identity_config: Option<crate::config::IdentityConfig>,
107 skills: Option<Vec<crate::skills::Skill>>,
108 skills_prompt_mode: Option<crate::config::SkillsPromptInjectionMode>,
109 auto_save: Option<bool>,
110 memory_session_id: Option<String>,
111 classification_config: Option<crate::config::QueryClassificationConfig>,
112 available_hints: Option<Vec<String>>,
113 route_model_by_hint: Option<HashMap<String, String>>,
114 allowed_tools: Option<Vec<String>>,
115 response_cache: Option<Arc<crate::memory::response_cache::ResponseCache>>,
116 tool_descriptions: Option<ToolDescriptions>,
117 security_summary: Option<String>,
118 autonomy_level: Option<crate::security::AutonomyLevel>,
119 activated_tools: Option<Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>>,
120 kumiho_enabled: bool,
121 operator_enabled: bool,
122 skill_effectiveness: Option<Arc<crate::skills::EffectivenessCache>>,
123}
124
125impl AgentBuilder {
126 pub fn new() -> Self {
127 Self {
128 provider: None,
129 provider_name: None,
130 tools: None,
131 memory: None,
132 observer: None,
133 prompt_builder: None,
134 tool_dispatcher: None,
135 memory_loader: None,
136 config: None,
137 model_name: None,
138 temperature: None,
139 workspace_dir: None,
140 identity_config: None,
141 skills: None,
142 skills_prompt_mode: None,
143 auto_save: None,
144 memory_session_id: None,
145 classification_config: None,
146 available_hints: None,
147 route_model_by_hint: None,
148 allowed_tools: None,
149 response_cache: None,
150 tool_descriptions: None,
151 security_summary: None,
152 autonomy_level: None,
153 activated_tools: None,
154 kumiho_enabled: false,
155 operator_enabled: false,
156 skill_effectiveness: None,
157 }
158 }
159
160 pub fn provider(mut self, provider: Box<dyn Provider>) -> Self {
161 self.provider = Some(provider);
162 self
163 }
164
165 pub fn provider_name(mut self, name: impl Into<String>) -> Self {
166 self.provider_name = Some(name.into());
167 self
168 }
169
170 pub fn tools(mut self, tools: Vec<Box<dyn Tool>>) -> Self {
171 self.tools = Some(tools);
172 self
173 }
174
175 pub fn memory(mut self, memory: Arc<dyn Memory>) -> Self {
176 self.memory = Some(memory);
177 self
178 }
179
180 pub fn observer(mut self, observer: Arc<dyn Observer>) -> Self {
181 self.observer = Some(observer);
182 self
183 }
184
185 pub fn prompt_builder(mut self, prompt_builder: SystemPromptBuilder) -> Self {
186 self.prompt_builder = Some(prompt_builder);
187 self
188 }
189
190 pub fn tool_dispatcher(mut self, tool_dispatcher: Box<dyn ToolDispatcher>) -> Self {
191 self.tool_dispatcher = Some(tool_dispatcher);
192 self
193 }
194
195 pub fn memory_loader(mut self, memory_loader: Box<dyn MemoryLoader>) -> Self {
196 self.memory_loader = Some(memory_loader);
197 self
198 }
199
200 pub fn config(mut self, config: crate::config::AgentConfig) -> Self {
201 self.config = Some(config);
202 self
203 }
204
205 pub fn model_name(mut self, model_name: String) -> Self {
206 self.model_name = Some(model_name);
207 self
208 }
209
210 pub fn temperature(mut self, temperature: f64) -> Self {
211 self.temperature = Some(temperature);
212 self
213 }
214
215 pub fn workspace_dir(mut self, workspace_dir: std::path::PathBuf) -> Self {
216 self.workspace_dir = Some(workspace_dir);
217 self
218 }
219
220 pub fn identity_config(mut self, identity_config: crate::config::IdentityConfig) -> Self {
221 self.identity_config = Some(identity_config);
222 self
223 }
224
225 pub fn skills(mut self, skills: Vec<crate::skills::Skill>) -> Self {
226 self.skills = Some(skills);
227 self
228 }
229
230 pub fn skills_prompt_mode(
231 mut self,
232 skills_prompt_mode: crate::config::SkillsPromptInjectionMode,
233 ) -> Self {
234 self.skills_prompt_mode = Some(skills_prompt_mode);
235 self
236 }
237
238 pub fn auto_save(mut self, auto_save: bool) -> Self {
239 self.auto_save = Some(auto_save);
240 self
241 }
242
243 pub fn memory_session_id(mut self, memory_session_id: Option<String>) -> Self {
244 self.memory_session_id = memory_session_id;
245 self
246 }
247
248 pub fn classification_config(
249 mut self,
250 classification_config: crate::config::QueryClassificationConfig,
251 ) -> Self {
252 self.classification_config = Some(classification_config);
253 self
254 }
255
256 pub fn available_hints(mut self, available_hints: Vec<String>) -> Self {
257 self.available_hints = Some(available_hints);
258 self
259 }
260
261 pub fn route_model_by_hint(mut self, route_model_by_hint: HashMap<String, String>) -> Self {
262 self.route_model_by_hint = Some(route_model_by_hint);
263 self
264 }
265
266 pub fn allowed_tools(mut self, allowed_tools: Option<Vec<String>>) -> Self {
267 self.allowed_tools = allowed_tools;
268 self
269 }
270
271 pub fn response_cache(
272 mut self,
273 cache: Option<Arc<crate::memory::response_cache::ResponseCache>>,
274 ) -> Self {
275 self.response_cache = cache;
276 self
277 }
278
279 pub fn tool_descriptions(mut self, tool_descriptions: Option<ToolDescriptions>) -> Self {
280 self.tool_descriptions = tool_descriptions;
281 self
282 }
283
284 pub fn security_summary(mut self, summary: Option<String>) -> Self {
285 self.security_summary = summary;
286 self
287 }
288
289 pub fn autonomy_level(mut self, level: crate::security::AutonomyLevel) -> Self {
290 self.autonomy_level = Some(level);
291 self
292 }
293
294 pub fn activated_tools(
295 mut self,
296 activated: Option<Arc<std::sync::Mutex<tools::ActivatedToolSet>>>,
297 ) -> Self {
298 self.activated_tools = activated;
299 self
300 }
301
302 pub fn kumiho_enabled(mut self, enabled: bool) -> Self {
303 self.kumiho_enabled = enabled;
304 self
305 }
306
307 pub fn operator_enabled(mut self, enabled: bool) -> Self {
308 self.operator_enabled = enabled;
309 self
310 }
311
312 pub fn skill_effectiveness(mut self, cache: Arc<crate::skills::EffectivenessCache>) -> Self {
319 self.skill_effectiveness = Some(cache);
320 self
321 }
322
323 pub fn build(self) -> Result<Agent> {
324 let mut tools = self
325 .tools
326 .ok_or_else(|| anyhow::anyhow!("tools are required"))?;
327 let allowed = self.allowed_tools.clone();
328 if let Some(ref allow_list) = allowed {
329 tools.retain(|t| allow_list.iter().any(|name| name == t.name()));
330 }
331 let tool_specs = tools.iter().map(|tool| tool.spec()).collect();
332
333 Ok(Agent {
334 provider: self
335 .provider
336 .ok_or_else(|| anyhow::anyhow!("provider is required"))?,
337 provider_name: self.provider_name.unwrap_or_else(|| "unknown".into()),
338 tools,
339 tool_specs,
340 memory: self
341 .memory
342 .ok_or_else(|| anyhow::anyhow!("memory is required"))?,
343 observer: self
344 .observer
345 .ok_or_else(|| anyhow::anyhow!("observer is required"))?,
346 prompt_builder: self
347 .prompt_builder
348 .unwrap_or_else(SystemPromptBuilder::with_defaults),
349 tool_dispatcher: self
350 .tool_dispatcher
351 .ok_or_else(|| anyhow::anyhow!("tool_dispatcher is required"))?,
352 memory_loader: self
353 .memory_loader
354 .unwrap_or_else(|| Box::new(DefaultMemoryLoader::default())),
355 config: self.config.unwrap_or_default(),
356 model_name: self
357 .model_name
358 .unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into()),
359 temperature: self.temperature.unwrap_or(0.7),
360 workspace_dir: self
361 .workspace_dir
362 .unwrap_or_else(|| std::path::PathBuf::from(".")),
363 identity_config: self.identity_config.unwrap_or_default(),
364 skills: self.skills.unwrap_or_default(),
365 skills_prompt_mode: self.skills_prompt_mode.unwrap_or_default(),
366 auto_save: self.auto_save.unwrap_or(false),
367 memory_session_id: self.memory_session_id,
368 history: Vec::new(),
369 classification_config: self.classification_config.unwrap_or_default(),
370 available_hints: self.available_hints.unwrap_or_default(),
371 route_model_by_hint: self.route_model_by_hint.unwrap_or_default(),
372 allowed_tools: allowed,
373 response_cache: self.response_cache,
374 tool_descriptions: self.tool_descriptions,
375 security_summary: self.security_summary,
376 autonomy_level: self
377 .autonomy_level
378 .unwrap_or(crate::security::AutonomyLevel::Supervised),
379 activated_tools: self.activated_tools,
380 kumiho_enabled: self.kumiho_enabled,
381 operator_enabled: self.operator_enabled,
382 skill_effectiveness: self.skill_effectiveness,
383 })
384 }
385}
386
387impl Agent {
388 pub fn builder() -> AgentBuilder {
389 AgentBuilder::new()
390 }
391
392 pub fn history(&self) -> &[ConversationMessage] {
393 &self.history
394 }
395
396 pub fn clear_history(&mut self) {
397 self.history.clear();
398 }
399
400 pub fn set_memory_session_id(&mut self, session_id: Option<String>) {
401 self.memory_session_id = session_id;
402 }
403
404 pub fn seed_history(&mut self, messages: &[ChatMessage]) {
410 if self.history.is_empty() {
411 if let Ok(sys) = self.build_system_prompt() {
412 self.history
413 .push(ConversationMessage::Chat(ChatMessage::system(sys)));
414 }
415 }
416 for msg in messages {
417 if msg.role != "system" {
418 self.history.push(ConversationMessage::Chat(msg.clone()));
419 }
420 }
421 }
422
423 pub async fn from_config(config: &Config) -> Result<Self> {
424 let config = crate::agent::kumiho::inject_kumiho(config.clone(), false);
428 let config = &crate::agent::operator::inject_operator(config, false);
429
430 let observer: Arc<dyn Observer> =
431 Arc::from(observability::create_observer(&config.observability));
432 let runtime: Arc<dyn runtime::RuntimeAdapter> =
433 Arc::from(runtime::create_runtime(&config.runtime)?);
434 let security = Arc::new(SecurityPolicy::from_config(
435 &config.autonomy,
436 &config.workspace_dir,
437 ));
438
439 let memory: Arc<dyn Memory> = Arc::from(memory::create_memory_with_storage_and_routes(
440 &config.memory,
441 &config.embedding_routes,
442 Some(&config.storage.provider.config),
443 &config.workspace_dir,
444 config.api_key.as_deref(),
445 )?);
446
447 let composio_key = if config.composio.enabled {
448 config.composio.api_key.as_deref()
449 } else {
450 None
451 };
452 let composio_entity_id = if config.composio.enabled {
453 Some(config.composio.entity_id.as_str())
454 } else {
455 None
456 };
457
458 let (
459 mut tools,
460 delegate_handle,
461 _reaction_handle,
462 _channel_map_handle,
463 _ask_user_handle,
464 _escalate_handle,
465 ) = tools::all_tools_with_runtime(
466 Arc::new(config.clone()),
467 &security,
468 runtime,
469 memory.clone(),
470 composio_key,
471 composio_entity_id,
472 &config.browser,
473 &config.http_request,
474 &config.web_fetch,
475 &config.workspace_dir,
476 &config.agents,
477 config.api_key.as_deref(),
478 config,
479 None,
480 );
481
482 let mut activated_tools: Option<Arc<std::sync::Mutex<tools::ActivatedToolSet>>> = None;
487 if config.mcp.enabled && !config.mcp.servers.is_empty() {
488 tracing::info!(
489 "Initializing MCP client — {} server(s) configured",
490 config.mcp.servers.len()
491 );
492 match tools::McpRegistry::connect_all(&config.mcp.servers).await {
493 Ok(registry) => {
494 let registry = std::sync::Arc::new(registry);
495 if config.mcp.deferred_loading {
496 let operator_prefix =
497 format!("{}__", crate::agent::operator::OPERATOR_SERVER_NAME);
498
499 let all_names = registry.tool_names();
502 let mut eager_count = 0usize;
503 for name in &all_names {
504 if name.starts_with(&operator_prefix) {
505 if let Some(def) = registry.get_tool_def(name).await {
506 let wrapper: std::sync::Arc<dyn tools::Tool> =
507 std::sync::Arc::new(tools::McpToolWrapper::new(
508 name.clone(),
509 def,
510 std::sync::Arc::clone(®istry),
511 ));
512 if let Some(ref handle) = delegate_handle {
513 handle.write().push(std::sync::Arc::clone(&wrapper));
514 }
515 tools.push(Box::new(tools::ArcToolRef(wrapper)));
516 eager_count += 1;
517 }
518 }
519 }
520
521 let deferred_set = tools::DeferredMcpToolSet::from_registry_filtered(
523 std::sync::Arc::clone(®istry),
524 |name| !name.starts_with(&operator_prefix),
525 )
526 .await;
527 tracing::info!(
528 "MCP hybrid: {} eager operator tool(s), {} deferred stub(s) from {} server(s)",
529 eager_count,
530 deferred_set.len(),
531 registry.server_count()
532 );
533 let activated =
534 Arc::new(std::sync::Mutex::new(tools::ActivatedToolSet::new()));
535 activated_tools = Some(Arc::clone(&activated));
536 tools.push(Box::new(tools::ToolSearchTool::new(
537 deferred_set,
538 activated,
539 )));
540 } else {
541 let names = registry.tool_names();
542 let mut registered = 0usize;
543 for name in names {
544 if let Some(def) = registry.get_tool_def(&name).await {
545 let wrapper: std::sync::Arc<dyn tools::Tool> =
546 std::sync::Arc::new(tools::McpToolWrapper::new(
547 name,
548 def,
549 std::sync::Arc::clone(®istry),
550 ));
551 if let Some(ref handle) = delegate_handle {
552 handle.write().push(std::sync::Arc::clone(&wrapper));
553 }
554 tools.push(Box::new(tools::ArcToolRef(wrapper)));
555 registered += 1;
556 }
557 }
558 tracing::info!(
559 "MCP: {} tool(s) registered from {} server(s)",
560 registered,
561 registry.server_count()
562 );
563 }
564 }
565 Err(e) => {
566 tracing::error!("MCP registry failed to initialize: {e:#}");
567 }
568 }
569 }
570
571 let provider_name = config.default_provider.as_deref().unwrap_or("openrouter");
572
573 let model_name = config
574 .default_model
575 .as_deref()
576 .unwrap_or("anthropic/claude-sonnet-4-20250514")
577 .to_string();
578
579 let provider_runtime_options = providers::provider_runtime_options_from_config(config);
580
581 let provider: Box<dyn Provider> = providers::create_routed_provider_with_options(
582 provider_name,
583 config.api_key.as_deref(),
584 config.api_url.as_deref(),
585 &config.reliability,
586 &config.model_routes,
587 &model_name,
588 &provider_runtime_options,
589 )?;
590
591 let dispatcher_choice = config.agent.tool_dispatcher.as_str();
592 let tool_dispatcher: Box<dyn ToolDispatcher> = match dispatcher_choice {
593 "native" => Box::new(NativeToolDispatcher),
594 "xml" => Box::new(XmlToolDispatcher),
595 _ if provider.supports_native_tools() => Box::new(NativeToolDispatcher),
596 _ => Box::new(XmlToolDispatcher),
597 };
598
599 let route_model_by_hint: HashMap<String, String> = config
600 .model_routes
601 .iter()
602 .map(|route| (route.hint.clone(), route.model.clone()))
603 .collect();
604 let available_hints: Vec<String> = route_model_by_hint.keys().cloned().collect();
605
606 let response_cache = if config.memory.response_cache_enabled {
607 crate::memory::response_cache::ResponseCache::with_hot_cache(
608 &config.workspace_dir,
609 config.memory.response_cache_ttl_minutes,
610 config.memory.response_cache_max_entries,
611 config.memory.response_cache_hot_entries,
612 )
613 .ok()
614 .map(Arc::new)
615 } else {
616 None
617 };
618
619 let mut agent_config = config.agent.clone();
621 agent_config.max_tool_iterations =
622 crate::agent::loop_::effective_max_tool_iterations(config);
623
624 Agent::builder()
625 .provider(provider)
626 .provider_name(provider_name.to_string())
627 .tools(tools)
628 .memory(memory)
629 .observer(observer)
630 .response_cache(response_cache)
631 .tool_dispatcher(tool_dispatcher)
632 .memory_loader(Box::new(DefaultMemoryLoader::new(
633 5,
634 config.memory.min_relevance_score,
635 )))
636 .prompt_builder(SystemPromptBuilder::with_defaults())
637 .config(agent_config)
638 .model_name(model_name)
639 .temperature(config.default_temperature)
640 .workspace_dir(config.workspace_dir.clone())
641 .classification_config(config.query_classification.clone())
642 .available_hints(available_hints)
643 .route_model_by_hint(route_model_by_hint)
644 .identity_config(config.identity.clone())
645 .skills(crate::skills::load_skills_with_config(
646 &config.workspace_dir,
647 config,
648 ))
649 .skills_prompt_mode(config.skills.prompt_injection_mode)
650 .auto_save(config.memory.auto_save)
651 .security_summary(Some(security.prompt_summary()))
652 .autonomy_level(config.autonomy.level)
653 .activated_tools(activated_tools)
654 .kumiho_enabled(config.kumiho.enabled)
655 .operator_enabled(config.operator.enabled)
656 .build()
657 }
658
659 fn trim_history(&mut self) {
660 let max = self.config.max_history_messages;
661 if self.history.len() <= max {
662 return;
663 }
664
665 let mut system_messages = Vec::new();
666 let mut other_messages = Vec::new();
667
668 for msg in self.history.drain(..) {
669 match &msg {
670 ConversationMessage::Chat(chat) if chat.role == "system" => {
671 system_messages.push(msg);
672 }
673 _ => other_messages.push(msg),
674 }
675 }
676
677 if other_messages.len() > max {
678 let drop_count = other_messages.len() - max;
679 other_messages.drain(0..drop_count);
680 }
681
682 self.history = system_messages;
683 self.history.extend(other_messages);
684 }
685
686 fn build_system_prompt(&self) -> Result<String> {
687 let instructions = self.tool_dispatcher.prompt_instructions(&self.tools);
688 let ctx = PromptContext {
689 workspace_dir: &self.workspace_dir,
690 model_name: &self.model_name,
691 tools: &self.tools,
692 skills: &self.skills,
693 skills_prompt_mode: self.skills_prompt_mode,
694 skill_effectiveness: self
695 .skill_effectiveness
696 .as_ref()
697 .map(|c| c.as_ref() as &dyn crate::skills::SkillEffectivenessProvider),
698 identity_config: Some(&self.identity_config),
699 dispatcher_instructions: &instructions,
700 tool_descriptions: self.tool_descriptions.as_ref(),
701 security_summary: self.security_summary.clone(),
702 autonomy_level: self.autonomy_level,
703 operator_enabled: self.operator_enabled,
704 kumiho_enabled: self.kumiho_enabled,
705 };
706 self.prompt_builder.build(&ctx)
707 }
708
709 async fn execute_tool_call(&self, call: &ParsedToolCall) -> ToolExecutionResult {
710 let start = Instant::now();
711
712 let result = if let Some(tool) = self.tools.iter().find(|t| t.name() == call.name) {
714 match tool.execute(call.arguments.clone()).await {
715 Ok(r) => {
716 self.observer.record_event(&ObserverEvent::ToolCall {
717 tool: call.name.clone(),
718 duration: start.elapsed(),
719 success: r.success,
720 });
721 if r.success {
722 r.output
723 } else {
724 format!("Error: {}", r.error.unwrap_or(r.output))
725 }
726 }
727 Err(e) => {
728 self.observer.record_event(&ObserverEvent::ToolCall {
729 tool: call.name.clone(),
730 duration: start.elapsed(),
731 success: false,
732 });
733 format!("Error executing {}: {e}", call.name)
734 }
735 }
736 } else if let Some(activated_arc) = self.activated_tools.as_ref() {
737 let activated_opt = activated_arc.lock().unwrap().get_resolved(&call.name);
739 if let Some(tool) = activated_opt {
740 match tool.execute(call.arguments.clone()).await {
741 Ok(r) => {
742 self.observer.record_event(&ObserverEvent::ToolCall {
743 tool: call.name.clone(),
744 duration: start.elapsed(),
745 success: r.success,
746 });
747 if r.success {
748 r.output
749 } else {
750 format!("Error: {}", r.error.unwrap_or(r.output))
751 }
752 }
753 Err(e) => {
754 self.observer.record_event(&ObserverEvent::ToolCall {
755 tool: call.name.clone(),
756 duration: start.elapsed(),
757 success: false,
758 });
759 format!("Error executing {}: {e}", call.name)
760 }
761 }
762 } else {
763 format!("Unknown tool: {}", call.name)
764 }
765 } else {
766 format!("Unknown tool: {}", call.name)
767 };
768
769 ToolExecutionResult {
770 name: call.name.clone(),
771 output: result,
772 success: true,
773 tool_call_id: call.tool_call_id.clone(),
774 }
775 }
776
777 async fn execute_tools(&self, calls: &[ParsedToolCall]) -> Vec<ToolExecutionResult> {
778 if !self.config.parallel_tools {
779 let mut results = Vec::with_capacity(calls.len());
780 for call in calls {
781 results.push(self.execute_tool_call(call).await);
782 }
783 return results;
784 }
785
786 let futs: Vec<_> = calls
787 .iter()
788 .map(|call| self.execute_tool_call(call))
789 .collect();
790 futures_util::future::join_all(futs).await
791 }
792
793 fn classify_model(&self, user_message: &str) -> String {
794 if let Some(decision) =
795 super::classifier::classify_with_decision(&self.classification_config, user_message)
796 {
797 if self.available_hints.contains(&decision.hint) {
798 let resolved_model = self
799 .route_model_by_hint
800 .get(&decision.hint)
801 .map(String::as_str)
802 .unwrap_or("unknown");
803 tracing::info!(
804 target: "query_classification",
805 hint = decision.hint.as_str(),
806 model = resolved_model,
807 rule_priority = decision.priority,
808 message_length = user_message.len(),
809 "Classified message route"
810 );
811 return format!("hint:{}", decision.hint);
812 }
813 }
814
815 if let Some(ref ac) = self.config.auto_classify {
817 let tier = super::eval::estimate_complexity(user_message);
818 if let Some(hint) = ac.hint_for(tier) {
819 if self.available_hints.contains(&hint.to_string()) {
820 tracing::info!(
821 target: "query_classification",
822 hint = hint,
823 complexity = ?tier,
824 message_length = user_message.len(),
825 "Auto-classified by complexity"
826 );
827 return format!("hint:{hint}");
828 }
829 }
830 }
831
832 self.model_name.clone()
833 }
834
835 pub async fn turn(&mut self, user_message: &str) -> Result<String> {
836 if self.history.is_empty() {
837 let system_prompt = self.build_system_prompt()?;
838 self.history
839 .push(ConversationMessage::Chat(ChatMessage::system(
840 system_prompt,
841 )));
842 }
843
844 let context = self
845 .memory_loader
846 .load_context(
847 self.memory.as_ref(),
848 user_message,
849 self.memory_session_id.as_deref(),
850 )
851 .await
852 .unwrap_or_default();
853
854 if self.auto_save {
855 let _ = self
856 .memory
857 .store(
858 "user_msg",
859 user_message,
860 MemoryCategory::Conversation,
861 self.memory_session_id.as_deref(),
862 )
863 .await;
864 }
865
866 let now = chrono::Local::now();
867 let (year, month, day) = (now.year(), now.month(), now.day());
868 let (hour, minute, second) = (now.hour(), now.minute(), now.second());
869 let tz = now.format("%Z");
870 let date_str =
871 format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02} {tz}");
872
873 let enriched = if context.is_empty() {
874 format!("[CURRENT DATE & TIME: {date_str}]\n\n{user_message}")
875 } else {
876 format!("[CURRENT DATE & TIME: {date_str}]\n\n{context}\n\n{user_message}")
877 };
878
879 self.history
880 .push(ConversationMessage::Chat(ChatMessage::user(enriched)));
881
882 let effective_model = self.classify_model(user_message);
883
884 for _ in 0..self.config.max_tool_iterations {
885 let messages = self.tool_dispatcher.to_provider_messages(&self.history);
886
887 let cache_key = if self.temperature == 0.0 {
889 self.response_cache.as_ref().map(|_| {
890 let last_user = messages
891 .iter()
892 .rfind(|m| m.role == "user")
893 .map(|m| m.content.as_str())
894 .unwrap_or("");
895 let system = messages
896 .iter()
897 .find(|m| m.role == "system")
898 .map(|m| m.content.as_str());
899 crate::memory::response_cache::ResponseCache::cache_key(
900 &effective_model,
901 system,
902 last_user,
903 )
904 })
905 } else {
906 None
907 };
908
909 if let (Some(cache), Some(key)) = (&self.response_cache, &cache_key) {
910 if let Ok(Some(cached)) = cache.get(key) {
911 self.observer.record_event(&ObserverEvent::CacheHit {
912 cache_type: "response".into(),
913 tokens_saved: 0,
914 });
915 self.history
916 .push(ConversationMessage::Chat(ChatMessage::assistant(
917 cached.clone(),
918 )));
919 self.trim_history();
920 return Ok(cached);
921 }
922 self.observer.record_event(&ObserverEvent::CacheMiss {
923 cache_type: "response".into(),
924 });
925 }
926
927 let mut iter_tool_specs: Vec<ToolSpec> = self.tools.iter().map(|t| t.spec()).collect();
930 if let Some(at) = self.activated_tools.as_ref() {
931 for spec in at.lock().unwrap().tool_specs() {
932 iter_tool_specs.push(spec);
933 }
934 }
935
936 let response = match self
937 .provider
938 .chat(
939 ChatRequest {
940 messages: &messages,
941 tools: if self.tool_dispatcher.should_send_tool_specs() {
942 Some(&iter_tool_specs)
943 } else {
944 None
945 },
946 },
947 &effective_model,
948 self.temperature,
949 )
950 .await
951 {
952 Ok(resp) => resp,
953 Err(err) => return Err(err),
954 };
955
956 let (text, calls) = self.tool_dispatcher.parse_response(&response);
957 if calls.is_empty() {
958 let final_text = if text.is_empty() {
959 response.text.unwrap_or_default()
960 } else {
961 text
962 };
963
964 if let (Some(cache), Some(key)) = (&self.response_cache, &cache_key) {
966 let token_count = response
967 .usage
968 .as_ref()
969 .and_then(|u| u.output_tokens)
970 .unwrap_or(0);
971 #[allow(clippy::cast_possible_truncation)]
972 let _ = cache.put(key, &effective_model, &final_text, token_count as u32);
973 }
974
975 self.history
976 .push(ConversationMessage::Chat(ChatMessage::assistant(
977 final_text.clone(),
978 )));
979 self.trim_history();
980
981 return Ok(final_text);
982 }
983
984 if !text.is_empty() {
985 self.history
986 .push(ConversationMessage::Chat(ChatMessage::assistant(
987 text.clone(),
988 )));
989 print!("{text}");
990 let _ = std::io::stdout().flush();
991 }
992
993 self.history.push(ConversationMessage::AssistantToolCalls {
994 text: response.text.clone(),
995 tool_calls: response.tool_calls.clone(),
996 reasoning_content: response.reasoning_content.clone(),
997 });
998
999 let results = self.execute_tools(&calls).await;
1000 let formatted = self.tool_dispatcher.format_results(&results);
1001 self.history.push(formatted);
1002 self.trim_history();
1003 }
1004
1005 anyhow::bail!(
1006 "Agent exceeded maximum tool iterations ({})",
1007 self.config.max_tool_iterations
1008 )
1009 }
1010
1011 pub async fn turn_streamed(
1020 &mut self,
1021 user_message: &str,
1022 event_tx: tokio::sync::mpsc::Sender<TurnEvent>,
1023 ) -> Result<String> {
1024 if self.history.is_empty() {
1026 let system_prompt = self.build_system_prompt()?;
1027 self.history
1028 .push(ConversationMessage::Chat(ChatMessage::system(
1029 system_prompt,
1030 )));
1031 }
1032
1033 let context = self
1034 .memory_loader
1035 .load_context(
1036 self.memory.as_ref(),
1037 user_message,
1038 self.memory_session_id.as_deref(),
1039 )
1040 .await
1041 .unwrap_or_default();
1042
1043 if self.auto_save {
1044 let _ = self
1045 .memory
1046 .store(
1047 "user_msg",
1048 user_message,
1049 MemoryCategory::Conversation,
1050 self.memory_session_id.as_deref(),
1051 )
1052 .await;
1053 }
1054
1055 let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z");
1056 let enriched = if context.is_empty() {
1057 format!("[{now}] {user_message}")
1058 } else {
1059 format!("{context}[{now}] {user_message}")
1060 };
1061
1062 self.history
1063 .push(ConversationMessage::Chat(ChatMessage::user(enriched)));
1064
1065 let effective_model = self.classify_model(user_message);
1066
1067 for _ in 0..self.config.max_tool_iterations {
1069 let messages = self.tool_dispatcher.to_provider_messages(&self.history);
1070
1071 let cache_key = if self.temperature == 0.0 {
1073 self.response_cache.as_ref().map(|_| {
1074 let last_user = messages
1075 .iter()
1076 .rfind(|m| m.role == "user")
1077 .map(|m| m.content.as_str())
1078 .unwrap_or("");
1079 let system = messages
1080 .iter()
1081 .find(|m| m.role == "system")
1082 .map(|m| m.content.as_str());
1083 crate::memory::response_cache::ResponseCache::cache_key(
1084 &effective_model,
1085 system,
1086 last_user,
1087 )
1088 })
1089 } else {
1090 None
1091 };
1092
1093 if let (Some(cache), Some(key)) = (&self.response_cache, &cache_key) {
1094 if let Ok(Some(cached)) = cache.get(key) {
1095 self.observer.record_event(&ObserverEvent::CacheHit {
1096 cache_type: "response".into(),
1097 tokens_saved: 0,
1098 });
1099 self.history
1100 .push(ConversationMessage::Chat(ChatMessage::assistant(
1101 cached.clone(),
1102 )));
1103 self.trim_history();
1104 return Ok(cached);
1105 }
1106 self.observer.record_event(&ObserverEvent::CacheMiss {
1107 cache_type: "response".into(),
1108 });
1109 }
1110
1111 use futures_util::StreamExt;
1115
1116 let mut iter_tool_specs: Vec<ToolSpec> = self.tools.iter().map(|t| t.spec()).collect();
1120 if let Some(at) = self.activated_tools.as_ref() {
1121 for spec in at.lock().unwrap().tool_specs() {
1122 iter_tool_specs.push(spec);
1123 }
1124 }
1125
1126 let stream_opts = crate::providers::traits::StreamOptions::new(true);
1127 let mut stream = self.provider.stream_chat(
1128 crate::providers::ChatRequest {
1129 messages: &messages,
1130 tools: if self.tool_dispatcher.should_send_tool_specs() {
1131 Some(&iter_tool_specs)
1132 } else {
1133 None
1134 },
1135 },
1136 &effective_model,
1137 self.temperature,
1138 stream_opts,
1139 );
1140
1141 let mut streamed_text = String::new();
1142 let mut streamed_tool_calls: Vec<crate::providers::traits::ToolCall> = Vec::new();
1143 let mut got_stream = false;
1144 let mut streamed_usage: Option<crate::providers::traits::TokenUsage> = None;
1145
1146 while let Some(item) = stream.next().await {
1147 match item {
1148 Ok(event) => match event {
1149 crate::providers::traits::StreamEvent::TextDelta(chunk) => {
1150 if let Some(reasoning) = chunk.reasoning {
1151 if !reasoning.is_empty() {
1152 let _ = event_tx
1153 .send(TurnEvent::Thinking { delta: reasoning })
1154 .await;
1155 }
1156 }
1157 if !chunk.delta.is_empty() {
1158 got_stream = true;
1159 streamed_text.push_str(&chunk.delta);
1160 let _ =
1161 event_tx.send(TurnEvent::Chunk { delta: chunk.delta }).await;
1162 }
1163 }
1164 crate::providers::traits::StreamEvent::ToolCall(tc) => {
1165 got_stream = true;
1166 let _ = event_tx
1167 .send(TurnEvent::ToolCall {
1168 name: tc.name.clone(),
1169 args: serde_json::from_str(&tc.arguments).unwrap_or_default(),
1170 })
1171 .await;
1172 streamed_tool_calls.push(tc);
1173 }
1174 crate::providers::traits::StreamEvent::PreExecutedToolCall {
1175 name,
1176 args,
1177 } => {
1178 let _ = event_tx
1179 .send(TurnEvent::ToolCall {
1180 name,
1181 args: serde_json::from_str(&args).unwrap_or_default(),
1182 })
1183 .await;
1184 }
1186 crate::providers::traits::StreamEvent::PreExecutedToolResult {
1187 name,
1188 output,
1189 } => {
1190 let _ = event_tx.send(TurnEvent::ToolResult { name, output }).await;
1191 }
1192 crate::providers::traits::StreamEvent::Usage(usage) => {
1193 let acc = streamed_usage.get_or_insert_with(Default::default);
1197 if let Some(v) = usage.input_tokens {
1198 acc.input_tokens = Some(v);
1199 }
1200 if let Some(v) = usage.output_tokens {
1201 acc.output_tokens = Some(v);
1202 }
1203 if let Some(v) = usage.cached_input_tokens {
1204 acc.cached_input_tokens = Some(v);
1205 }
1206 }
1207 crate::providers::traits::StreamEvent::Final => break,
1208 },
1209 Err(_) => break,
1210 }
1211 }
1212 drop(stream);
1214
1215 let response = if got_stream {
1218 let usage_for_cost = streamed_usage
1222 .clone()
1223 .unwrap_or_else(crate::providers::traits::TokenUsage::default);
1224 let _ = crate::agent::cost::record_tool_loop_cost_usage(
1225 &self.provider_name,
1226 &effective_model,
1227 &usage_for_cost,
1228 );
1229 crate::providers::ChatResponse {
1231 text: Some(streamed_text),
1232 tool_calls: streamed_tool_calls,
1233 usage: streamed_usage.take(),
1234 reasoning_content: None,
1235 }
1236 } else {
1237 let resp = match self
1239 .provider
1240 .chat(
1241 ChatRequest {
1242 messages: &messages,
1243 tools: if self.tool_dispatcher.should_send_tool_specs() {
1244 Some(&iter_tool_specs)
1245 } else {
1246 None
1247 },
1248 },
1249 &effective_model,
1250 self.temperature,
1251 )
1252 .await
1253 {
1254 Ok(resp) => resp,
1255 Err(err) => return Err(err),
1256 };
1257 let usage_for_cost = resp
1260 .usage
1261 .clone()
1262 .unwrap_or_else(crate::providers::traits::TokenUsage::default);
1263 let _ = crate::agent::cost::record_tool_loop_cost_usage(
1264 &self.provider_name,
1265 &effective_model,
1266 &usage_for_cost,
1267 );
1268 resp
1269 };
1270
1271 let (text, calls) = self.tool_dispatcher.parse_response(&response);
1272 if calls.is_empty() {
1273 let final_text = if text.is_empty() {
1274 response.text.unwrap_or_default()
1275 } else {
1276 text
1277 };
1278
1279 if let (Some(cache), Some(key)) = (&self.response_cache, &cache_key) {
1281 let token_count = response
1282 .usage
1283 .as_ref()
1284 .and_then(|u| u.output_tokens)
1285 .unwrap_or(0);
1286 #[allow(clippy::cast_possible_truncation)]
1287 let _ = cache.put(key, &effective_model, &final_text, token_count as u32);
1288 }
1289
1290 if !got_stream && !final_text.is_empty() {
1292 let _ = event_tx
1293 .send(TurnEvent::Chunk {
1294 delta: final_text.clone(),
1295 })
1296 .await;
1297 }
1298
1299 self.history
1300 .push(ConversationMessage::Chat(ChatMessage::assistant(
1301 final_text.clone(),
1302 )));
1303 self.trim_history();
1304
1305 return Ok(final_text);
1306 }
1307
1308 if !text.is_empty() {
1310 self.history
1311 .push(ConversationMessage::Chat(ChatMessage::assistant(
1312 text.clone(),
1313 )));
1314 }
1315
1316 self.history.push(ConversationMessage::AssistantToolCalls {
1317 text: response.text.clone(),
1318 tool_calls: response.tool_calls.clone(),
1319 reasoning_content: response.reasoning_content.clone(),
1320 });
1321
1322 for call in &calls {
1324 if let Some(status) = operator_status_for_tool_call(&call.name, &call.arguments) {
1326 let _ = event_tx
1327 .send(TurnEvent::OperatorStatus {
1328 phase: status.0,
1329 detail: status.1,
1330 })
1331 .await;
1332 }
1333 let _ = event_tx
1334 .send(TurnEvent::ToolCall {
1335 name: call.name.clone(),
1336 args: call.arguments.clone(),
1337 })
1338 .await;
1339 }
1340
1341 let results = self.execute_tools(&calls).await;
1342
1343 for result in &results {
1345 if let Some(status) = operator_status_for_tool_result(&result.name, &result.output)
1346 {
1347 let _ = event_tx
1348 .send(TurnEvent::OperatorStatus {
1349 phase: status.0,
1350 detail: status.1,
1351 })
1352 .await;
1353 }
1354 let _ = event_tx
1355 .send(TurnEvent::ToolResult {
1356 name: result.name.clone(),
1357 output: result.output.clone(),
1358 })
1359 .await;
1360 }
1361
1362 let formatted = self.tool_dispatcher.format_results(&results);
1363 self.history.push(formatted);
1364 self.trim_history();
1365 }
1366
1367 anyhow::bail!(
1368 "Agent exceeded maximum tool iterations ({})",
1369 self.config.max_tool_iterations
1370 )
1371 }
1372
1373 pub async fn run_single(&mut self, message: &str) -> Result<String> {
1374 self.turn(message).await
1375 }
1376
1377 pub async fn run_interactive(&mut self) -> Result<()> {
1378 println!("🦀 Construct Interactive Mode");
1379 println!("Type /quit to exit.\n");
1380
1381 let (tx, mut rx) = tokio::sync::mpsc::channel(32);
1382 let cli = crate::channels::CliChannel::new();
1383
1384 let listen_handle = tokio::spawn(async move {
1385 let _ = crate::channels::Channel::listen(&cli, tx).await;
1386 });
1387
1388 while let Some(msg) = rx.recv().await {
1389 let response = match self.turn(&msg.content).await {
1390 Ok(resp) => resp,
1391 Err(e) => {
1392 eprintln!("\nError: {e}\n");
1393 continue;
1394 }
1395 };
1396 println!("\n{response}\n");
1397 }
1398
1399 listen_handle.abort();
1400 Ok(())
1401 }
1402}
1403
1404fn operator_status_for_tool_call(
1408 tool_name: &str,
1409 args: &serde_json::Value,
1410) -> Option<(String, String)> {
1411 let suffix = tool_name.strip_prefix("construct-operator__")?;
1412 match suffix {
1413 "create_agent" => {
1414 let title = args
1415 .get("title")
1416 .and_then(|v| v.as_str())
1417 .unwrap_or("agent");
1418 Some(("spawning".into(), format!("Spawning agent: {title}")))
1419 }
1420 "wait_for_agent" => {
1421 let id = args
1422 .get("agent_id")
1423 .and_then(|v| v.as_str())
1424 .unwrap_or("unknown");
1425 Some(("waiting".into(), format!("Waiting for agent {id}…")))
1426 }
1427 "send_agent_prompt" => {
1428 let id = args
1429 .get("agent_id")
1430 .and_then(|v| v.as_str())
1431 .unwrap_or("unknown");
1432 Some((
1433 "delegating".into(),
1434 format!("Sending follow-up to agent {id}"),
1435 ))
1436 }
1437 "get_agent_activity" => {
1438 let id = args
1439 .get("agent_id")
1440 .and_then(|v| v.as_str())
1441 .unwrap_or("unknown");
1442 Some((
1443 "collecting".into(),
1444 format!("Collecting results from agent {id}"),
1445 ))
1446 }
1447 "get_agent_status" => Some(("checking".into(), "Checking agent status…".into())),
1448 "list_agents" => Some(("listing".into(), "Listing active agents…".into())),
1449 "search_agent_pool" | "list_agent_templates" => {
1450 Some(("searching".into(), "Searching agent pool…".into()))
1451 }
1452 "save_agent_template" => {
1453 let name = args
1454 .get("name")
1455 .and_then(|v| v.as_str())
1456 .unwrap_or("template");
1457 Some(("saving".into(), format!("Saving agent template: {name}")))
1458 }
1459 "list_teams" => Some(("searching".into(), "Listing agent teams…".into())),
1460 "get_team" => Some(("searching".into(), "Loading team details…".into())),
1461 "spawn_team" => {
1462 let task = args
1463 .get("task")
1464 .and_then(|v| v.as_str())
1465 .map(|t| {
1466 if t.chars().count() > 60 {
1467 let end = t.char_indices().nth(60).map_or(t.len(), |(i, _)| i);
1468 &t[..end]
1469 } else {
1470 t
1471 }
1472 })
1473 .unwrap_or("task");
1474 Some(("spawning".into(), format!("Deploying team for: {task}…")))
1475 }
1476 "create_team" => {
1477 let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("team");
1478 Some(("saving".into(), format!("Creating team: {name}")))
1479 }
1480 "search_teams" => Some(("searching".into(), "Searching for teams…".into())),
1481 "get_budget_status" => Some(("checking".into(), "Checking budget status…".into())),
1482 "save_plan" => Some(("saving".into(), "Saving execution plan…".into())),
1483 "recall_plans" => Some(("searching".into(), "Searching past plans…".into())),
1484 "create_goal" => {
1485 let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("goal");
1486 Some(("saving".into(), format!("Creating goal: {name}")))
1487 }
1488 "get_goals" => Some(("searching".into(), "Loading goals…".into())),
1489 "update_goal" => Some(("saving".into(), "Updating goal…".into())),
1490 "record_agent_outcome" => Some(("saving".into(), "Recording agent outcome…".into())),
1491 "get_agent_trust" => Some(("searching".into(), "Checking agent trust scores…".into())),
1492 "publish_to_clawhub" => Some(("saving".into(), "Publishing to ClawHub…".into())),
1493 "search_clawhub" => Some(("searching".into(), "Searching ClawHub marketplace…".into())),
1494 "install_from_clawhub" => Some(("saving".into(), "Installing from ClawHub…".into())),
1495 "list_nodes" => Some(("searching".into(), "Discovering connected nodes…".into())),
1496 "invoke_node" => Some(("working".into(), "Invoking node capability…".into())),
1497 "get_session_history" => Some(("searching".into(), "Loading session history…".into())),
1498 "archive_session" => Some(("saving".into(), "Archiving session…".into())),
1499 "capture_skill" => {
1500 let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("skill");
1501 Some(("saving".into(), format!("Capturing skill: {name}")))
1502 }
1503 _ => Some(("working".into(), format!("Operator: {suffix}"))),
1504 }
1505}
1506
1507fn operator_parse_agent_result(output: &str) -> (String, String) {
1513 if let Ok(json) = serde_json::from_str::<serde_json::Value>(output) {
1514 let status = json.get("status").and_then(|v| v.as_str()).unwrap_or("");
1516 match status {
1517 "error" | "failed" | "backend_unreachable" => {
1518 let error_msg = json
1519 .get("error")
1520 .and_then(|v| v.as_str())
1521 .or_else(|| json.get("hint").and_then(|v| v.as_str()))
1522 .unwrap_or("Agent finished with errors");
1523 return ("failed".into(), error_msg.to_string());
1524 }
1525 "permission_blocked" => {
1526 let hint = json
1527 .get("hint")
1528 .and_then(|v| v.as_str())
1529 .unwrap_or("Agent blocked on permissions");
1530 return ("blocked".into(), hint.to_string());
1531 }
1532 "running" => {
1533 return ("running".into(), "Agent still running".into());
1534 }
1535 _ => {}
1536 }
1537
1538 let error_count = json
1540 .get("error_count")
1541 .and_then(|v| v.as_u64())
1542 .unwrap_or(0);
1543 if error_count > 0 {
1544 let detail = format!("Agent completed with {} error(s)", error_count);
1545 return ("failed".into(), detail);
1546 }
1547
1548 if let Some(err) = json.get("error").and_then(|v| v.as_str()) {
1550 return ("failed".into(), err.to_string());
1551 }
1552
1553 ("completed".into(), "Agent finished successfully".into())
1554 } else {
1555 if output.contains("\"status\":\"error\"") || output.contains("\"status\": \"error\"") {
1558 ("failed".into(), "Agent finished with errors".into())
1559 } else {
1560 ("completed".into(), "Agent finished successfully".into())
1561 }
1562 }
1563}
1564
1565fn operator_status_for_tool_result(tool_name: &str, output: &str) -> Option<(String, String)> {
1569 let suffix = tool_name.strip_prefix("construct-operator__")?;
1570 match suffix {
1571 "create_agent" => Some(("spawned".into(), "Agent created successfully".into())),
1572 "wait_for_agent" => Some(operator_parse_agent_result(output)),
1573 "get_agent_activity" => Some(("collected".into(), "Results collected".into())),
1574 "send_agent_prompt" => Some(("completed".into(), "Follow-up sent".into())),
1575 "list_agents" => Some(("completed".into(), "Agent list retrieved".into())),
1576 "search_agent_pool" | "list_agent_templates" => {
1577 Some(("completed".into(), "Pool search complete".into()))
1578 }
1579 "save_agent_template" => Some(("completed".into(), "Template saved".into())),
1580 "list_teams" | "search_teams" => Some(("completed".into(), "Team search complete".into())),
1581 "get_team" => Some(("completed".into(), "Team details loaded".into())),
1582 "spawn_team" => Some(operator_parse_agent_result(output)),
1583 "create_team" => Some(("completed".into(), "Team created".into())),
1584 "get_budget_status" => Some(("completed".into(), "Budget status retrieved".into())),
1585 "save_plan" => Some(("completed".into(), "Plan saved".into())),
1586 "recall_plans" => Some(("completed".into(), "Past plans retrieved".into())),
1587 "create_goal" => Some(("completed".into(), "Goal created".into())),
1588 "get_goals" => Some(("completed".into(), "Goals loaded".into())),
1589 "update_goal" => Some(("completed".into(), "Goal updated".into())),
1590 "record_agent_outcome" => Some(("completed".into(), "Outcome recorded".into())),
1591 "get_agent_trust" => Some(("completed".into(), "Trust scores retrieved".into())),
1592 "capture_skill" => Some(("completed".into(), "Skill captured".into())),
1593 "publish_to_clawhub" => Some(("completed".into(), "Published to ClawHub".into())),
1594 "search_clawhub" => Some(("completed".into(), "ClawHub search complete".into())),
1595 "install_from_clawhub" => Some(("completed".into(), "Installed from ClawHub".into())),
1596 "list_nodes" => Some(("completed".into(), "Nodes discovered".into())),
1597 "invoke_node" => Some(("completed".into(), "Node invocation complete".into())),
1598 "get_session_history" => Some(("completed".into(), "Session history loaded".into())),
1599 "archive_session" => Some(("completed".into(), "Session archived".into())),
1600 _ => None,
1601 }
1602}
1603
1604pub async fn run(
1605 config: Config,
1606 message: Option<String>,
1607 provider_override: Option<String>,
1608 model_override: Option<String>,
1609 temperature: f64,
1610) -> Result<()> {
1611 let start = Instant::now();
1612
1613 let mut effective_config = config;
1614 if let Some(p) = provider_override {
1615 effective_config.default_provider = Some(p);
1616 }
1617 if let Some(m) = model_override {
1618 effective_config.default_model = Some(m);
1619 }
1620 effective_config.default_temperature = temperature;
1621
1622 let mut agent = Agent::from_config(&effective_config).await?;
1623
1624 let provider_name = effective_config
1625 .default_provider
1626 .as_deref()
1627 .unwrap_or("openrouter")
1628 .to_string();
1629 let model_name = effective_config
1630 .default_model
1631 .as_deref()
1632 .unwrap_or("anthropic/claude-sonnet-4-20250514")
1633 .to_string();
1634
1635 agent.observer.record_event(&ObserverEvent::AgentStart {
1636 provider: provider_name.clone(),
1637 model: model_name.clone(),
1638 });
1639
1640 if let Some(msg) = message {
1641 let response = agent.run_single(&msg).await?;
1642 println!("{response}");
1643 } else {
1644 agent.run_interactive().await?;
1645 }
1646
1647 agent.observer.record_event(&ObserverEvent::AgentEnd {
1648 provider: provider_name,
1649 model: model_name,
1650 duration: start.elapsed(),
1651 tokens_used: None,
1652 cost_usd: None,
1653 });
1654
1655 Ok(())
1656}
1657
1658#[cfg(test)]
1659mod tests {
1660 use super::*;
1661 use async_trait::async_trait;
1662 use parking_lot::Mutex;
1663 use std::collections::HashMap;
1664
1665 struct MockProvider {
1666 responses: Mutex<Vec<crate::providers::ChatResponse>>,
1667 }
1668
1669 #[async_trait]
1670 impl Provider for MockProvider {
1671 async fn chat_with_system(
1672 &self,
1673 _system_prompt: Option<&str>,
1674 _message: &str,
1675 _model: &str,
1676 _temperature: f64,
1677 ) -> Result<String> {
1678 Ok("ok".into())
1679 }
1680
1681 async fn chat(
1682 &self,
1683 _request: ChatRequest<'_>,
1684 _model: &str,
1685 _temperature: f64,
1686 ) -> Result<crate::providers::ChatResponse> {
1687 let mut guard = self.responses.lock();
1688 if guard.is_empty() {
1689 return Ok(crate::providers::ChatResponse {
1690 text: Some("done".into()),
1691 tool_calls: vec![],
1692 usage: None,
1693 reasoning_content: None,
1694 });
1695 }
1696 Ok(guard.remove(0))
1697 }
1698 }
1699
1700 struct ModelCaptureProvider {
1701 responses: Mutex<Vec<crate::providers::ChatResponse>>,
1702 seen_models: Arc<Mutex<Vec<String>>>,
1703 }
1704
1705 #[async_trait]
1706 impl Provider for ModelCaptureProvider {
1707 async fn chat_with_system(
1708 &self,
1709 _system_prompt: Option<&str>,
1710 _message: &str,
1711 _model: &str,
1712 _temperature: f64,
1713 ) -> Result<String> {
1714 Ok("ok".into())
1715 }
1716
1717 async fn chat(
1718 &self,
1719 _request: ChatRequest<'_>,
1720 model: &str,
1721 _temperature: f64,
1722 ) -> Result<crate::providers::ChatResponse> {
1723 self.seen_models.lock().push(model.to_string());
1724 let mut guard = self.responses.lock();
1725 if guard.is_empty() {
1726 return Ok(crate::providers::ChatResponse {
1727 text: Some("done".into()),
1728 tool_calls: vec![],
1729 usage: None,
1730 reasoning_content: None,
1731 });
1732 }
1733 Ok(guard.remove(0))
1734 }
1735 }
1736
1737 struct MockTool;
1738
1739 #[async_trait]
1740 impl Tool for MockTool {
1741 fn name(&self) -> &str {
1742 "echo"
1743 }
1744
1745 fn description(&self) -> &str {
1746 "echo"
1747 }
1748
1749 fn parameters_schema(&self) -> serde_json::Value {
1750 serde_json::json!({"type": "object"})
1751 }
1752
1753 async fn execute(&self, _args: serde_json::Value) -> Result<crate::tools::ToolResult> {
1754 Ok(crate::tools::ToolResult {
1755 success: true,
1756 output: "tool-out".into(),
1757 error: None,
1758 })
1759 }
1760 }
1761
1762 #[tokio::test]
1763 async fn turn_without_tools_returns_text() {
1764 let provider = Box::new(MockProvider {
1765 responses: Mutex::new(vec![crate::providers::ChatResponse {
1766 text: Some("hello".into()),
1767 tool_calls: vec![],
1768 usage: None,
1769 reasoning_content: None,
1770 }]),
1771 });
1772
1773 let memory_cfg = crate::config::MemoryConfig {
1774 backend: "none".into(),
1775 ..crate::config::MemoryConfig::default()
1776 };
1777 let mem: Arc<dyn Memory> = Arc::from(
1778 crate::memory::create_memory(&memory_cfg, std::path::Path::new("/tmp"), None)
1779 .expect("memory creation should succeed with valid config"),
1780 );
1781
1782 let observer: Arc<dyn Observer> = Arc::from(crate::observability::NoopObserver {});
1783 let mut agent = Agent::builder()
1784 .provider(provider)
1785 .tools(vec![Box::new(MockTool)])
1786 .memory(mem)
1787 .observer(observer)
1788 .tool_dispatcher(Box::new(XmlToolDispatcher))
1789 .workspace_dir(std::path::PathBuf::from("/tmp"))
1790 .build()
1791 .expect("agent builder should succeed with valid config");
1792
1793 let response = agent.turn("hi").await.unwrap();
1794 assert_eq!(response, "hello");
1795 }
1796
1797 #[tokio::test]
1798 async fn turn_with_native_dispatcher_handles_tool_results_variant() {
1799 let provider = Box::new(MockProvider {
1800 responses: Mutex::new(vec![
1801 crate::providers::ChatResponse {
1802 text: Some(String::new()),
1803 tool_calls: vec![crate::providers::ToolCall {
1804 id: "tc1".into(),
1805 name: "echo".into(),
1806 arguments: "{}".into(),
1807 }],
1808 usage: None,
1809 reasoning_content: None,
1810 },
1811 crate::providers::ChatResponse {
1812 text: Some("done".into()),
1813 tool_calls: vec![],
1814 usage: None,
1815 reasoning_content: None,
1816 },
1817 ]),
1818 });
1819
1820 let memory_cfg = crate::config::MemoryConfig {
1821 backend: "none".into(),
1822 ..crate::config::MemoryConfig::default()
1823 };
1824 let mem: Arc<dyn Memory> = Arc::from(
1825 crate::memory::create_memory(&memory_cfg, std::path::Path::new("/tmp"), None)
1826 .expect("memory creation should succeed with valid config"),
1827 );
1828
1829 let observer: Arc<dyn Observer> = Arc::from(crate::observability::NoopObserver {});
1830 let mut agent = Agent::builder()
1831 .provider(provider)
1832 .tools(vec![Box::new(MockTool)])
1833 .memory(mem)
1834 .observer(observer)
1835 .tool_dispatcher(Box::new(NativeToolDispatcher))
1836 .workspace_dir(std::path::PathBuf::from("/tmp"))
1837 .build()
1838 .expect("agent builder should succeed with valid config");
1839
1840 let response = agent.turn("hi").await.unwrap();
1841 assert_eq!(response, "done");
1842 assert!(
1843 agent
1844 .history()
1845 .iter()
1846 .any(|msg| matches!(msg, ConversationMessage::ToolResults(_)))
1847 );
1848 }
1849
1850 #[tokio::test]
1851 async fn turn_routes_with_hint_when_query_classification_matches() {
1852 let seen_models = Arc::new(Mutex::new(Vec::new()));
1853 let provider = Box::new(ModelCaptureProvider {
1854 responses: Mutex::new(vec![crate::providers::ChatResponse {
1855 text: Some("classified".into()),
1856 tool_calls: vec![],
1857 usage: None,
1858 reasoning_content: None,
1859 }]),
1860 seen_models: seen_models.clone(),
1861 });
1862
1863 let memory_cfg = crate::config::MemoryConfig {
1864 backend: "none".into(),
1865 ..crate::config::MemoryConfig::default()
1866 };
1867 let mem: Arc<dyn Memory> = Arc::from(
1868 crate::memory::create_memory(&memory_cfg, std::path::Path::new("/tmp"), None)
1869 .expect("memory creation should succeed with valid config"),
1870 );
1871
1872 let observer: Arc<dyn Observer> = Arc::from(crate::observability::NoopObserver {});
1873 let mut route_model_by_hint = HashMap::new();
1874 route_model_by_hint.insert("fast".to_string(), "anthropic/claude-haiku-4-5".to_string());
1875 let mut agent = Agent::builder()
1876 .provider(provider)
1877 .tools(vec![Box::new(MockTool)])
1878 .memory(mem)
1879 .observer(observer)
1880 .tool_dispatcher(Box::new(NativeToolDispatcher))
1881 .workspace_dir(std::path::PathBuf::from("/tmp"))
1882 .classification_config(crate::config::QueryClassificationConfig {
1883 enabled: true,
1884 rules: vec![crate::config::ClassificationRule {
1885 hint: "fast".to_string(),
1886 keywords: vec!["quick".to_string()],
1887 patterns: vec![],
1888 min_length: None,
1889 max_length: None,
1890 priority: 10,
1891 }],
1892 })
1893 .available_hints(vec!["fast".to_string()])
1894 .route_model_by_hint(route_model_by_hint)
1895 .build()
1896 .expect("agent builder should succeed with valid config");
1897
1898 let response = agent.turn("quick summary please").await.unwrap();
1899 assert_eq!(response, "classified");
1900 let seen = seen_models.lock();
1901 assert_eq!(seen.as_slice(), &["hint:fast".to_string()]);
1902 }
1903
1904 #[tokio::test]
1905 async fn from_config_passes_extra_headers_to_custom_provider() {
1906 use axum::{Json, Router, http::HeaderMap, routing::post};
1907 use tempfile::TempDir;
1908 use tokio::net::TcpListener;
1909
1910 let captured_headers: Arc<std::sync::Mutex<Option<HashMap<String, String>>>> =
1911 Arc::new(std::sync::Mutex::new(None));
1912 let captured_headers_clone = captured_headers.clone();
1913
1914 let app = Router::new().route(
1915 "/chat/completions",
1916 post(
1917 move |headers: HeaderMap, Json(_body): Json<serde_json::Value>| {
1918 let captured_headers = captured_headers_clone.clone();
1919 async move {
1920 let collected = headers
1921 .iter()
1922 .filter_map(|(name, value)| {
1923 value
1924 .to_str()
1925 .ok()
1926 .map(|value| (name.as_str().to_string(), value.to_string()))
1927 })
1928 .collect();
1929 *captured_headers.lock().unwrap() = Some(collected);
1930 Json(serde_json::json!({
1931 "choices": [{
1932 "message": {
1933 "content": "hello from mock"
1934 }
1935 }]
1936 }))
1937 }
1938 },
1939 ),
1940 );
1941
1942 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1943 let addr = listener.local_addr().unwrap();
1944 let server_handle = tokio::spawn(async move {
1945 axum::serve(listener, app).await.unwrap();
1946 });
1947
1948 let tmp = TempDir::new().expect("temp dir");
1949 let workspace_dir = tmp.path().join("workspace");
1950 std::fs::create_dir_all(&workspace_dir).unwrap();
1951
1952 let mut config = crate::config::Config::default();
1953 config.workspace_dir = workspace_dir;
1954 config.config_path = tmp.path().join("config.toml");
1955 config.api_key = Some("test-key".to_string());
1956 config.default_provider = Some(format!("custom:http://{addr}"));
1957 config.default_model = Some("test-model".to_string());
1958 config.memory.backend = "none".to_string();
1959 config.memory.auto_save = false;
1960 config.extra_headers.insert(
1961 "User-Agent".to_string(),
1962 "construct-web-test/1.0".to_string(),
1963 );
1964 config
1965 .extra_headers
1966 .insert("X-Title".to_string(), "construct-web".to_string());
1967
1968 let mut agent = Agent::from_config(&config)
1969 .await
1970 .expect("agent from config");
1971 let response = agent.turn("hello").await.expect("agent turn");
1972
1973 assert_eq!(response, "hello from mock");
1974
1975 let headers = captured_headers
1976 .lock()
1977 .unwrap()
1978 .clone()
1979 .expect("captured headers");
1980 assert_eq!(
1981 headers.get("user-agent").map(String::as_str),
1982 Some("construct-web-test/1.0")
1983 );
1984 assert_eq!(
1985 headers.get("x-title").map(String::as_str),
1986 Some("construct-web")
1987 );
1988
1989 server_handle.abort();
1990 }
1991
1992 #[test]
1993 fn builder_allowed_tools_none_keeps_all_tools() {
1994 let provider = Box::new(MockProvider {
1995 responses: Mutex::new(vec![]),
1996 });
1997
1998 let memory_cfg = crate::config::MemoryConfig {
1999 backend: "none".into(),
2000 ..crate::config::MemoryConfig::default()
2001 };
2002 let mem: Arc<dyn Memory> = Arc::from(
2003 crate::memory::create_memory(&memory_cfg, std::path::Path::new("/tmp"), None)
2004 .expect("memory creation should succeed with valid config"),
2005 );
2006
2007 let observer: Arc<dyn Observer> = Arc::from(crate::observability::NoopObserver {});
2008 let agent = Agent::builder()
2009 .provider(provider)
2010 .tools(vec![Box::new(MockTool)])
2011 .memory(mem)
2012 .observer(observer)
2013 .tool_dispatcher(Box::new(NativeToolDispatcher))
2014 .workspace_dir(std::path::PathBuf::from("/tmp"))
2015 .allowed_tools(None)
2016 .build()
2017 .expect("agent builder should succeed with valid config");
2018
2019 assert_eq!(agent.tool_specs.len(), 1);
2020 assert_eq!(agent.tool_specs[0].name, "echo");
2021 }
2022
2023 #[test]
2024 fn builder_allowed_tools_some_filters_tools() {
2025 let provider = Box::new(MockProvider {
2026 responses: Mutex::new(vec![]),
2027 });
2028
2029 let memory_cfg = crate::config::MemoryConfig {
2030 backend: "none".into(),
2031 ..crate::config::MemoryConfig::default()
2032 };
2033 let mem: Arc<dyn Memory> = Arc::from(
2034 crate::memory::create_memory(&memory_cfg, std::path::Path::new("/tmp"), None)
2035 .expect("memory creation should succeed with valid config"),
2036 );
2037
2038 let observer: Arc<dyn Observer> = Arc::from(crate::observability::NoopObserver {});
2039 let agent = Agent::builder()
2040 .provider(provider)
2041 .tools(vec![Box::new(MockTool)])
2042 .memory(mem)
2043 .observer(observer)
2044 .tool_dispatcher(Box::new(NativeToolDispatcher))
2045 .workspace_dir(std::path::PathBuf::from("/tmp"))
2046 .allowed_tools(Some(vec!["nonexistent".to_string()]))
2047 .build()
2048 .expect("agent builder should succeed with valid config");
2049
2050 assert!(
2051 agent.tool_specs.is_empty(),
2052 "No tools should match a non-existent allowlist entry"
2053 );
2054 }
2055
2056 #[test]
2057 fn seed_history_prepends_system_and_skips_system_from_seed() {
2058 let provider = Box::new(MockProvider {
2059 responses: Mutex::new(vec![]),
2060 });
2061
2062 let memory_cfg = crate::config::MemoryConfig {
2063 backend: "none".into(),
2064 ..crate::config::MemoryConfig::default()
2065 };
2066 let mem: Arc<dyn Memory> = Arc::from(
2067 crate::memory::create_memory(&memory_cfg, std::path::Path::new("/tmp"), None)
2068 .expect("memory creation should succeed with valid config"),
2069 );
2070
2071 let observer: Arc<dyn Observer> = Arc::from(crate::observability::NoopObserver {});
2072 let mut agent = Agent::builder()
2073 .provider(provider)
2074 .tools(vec![Box::new(MockTool)])
2075 .memory(mem)
2076 .observer(observer)
2077 .tool_dispatcher(Box::new(NativeToolDispatcher))
2078 .workspace_dir(std::path::PathBuf::from("/tmp"))
2079 .build()
2080 .expect("agent builder should succeed with valid config");
2081
2082 let seed = vec![
2083 ChatMessage::system("old system prompt"),
2084 ChatMessage::user("hello"),
2085 ChatMessage::assistant("hi there"),
2086 ];
2087 agent.seed_history(&seed);
2088
2089 let history = agent.history();
2090 assert!(matches!(&history[0], ConversationMessage::Chat(m) if m.role == "system"));
2092 assert!(
2094 matches!(&history[1], ConversationMessage::Chat(m) if m.role == "user" && m.content == "hello")
2095 );
2096 assert!(
2097 matches!(&history[2], ConversationMessage::Chat(m) if m.role == "assistant" && m.content == "hi there")
2098 );
2099 assert_eq!(history.len(), 3);
2100 }
2101
2102 struct StreamToolCaptureProvider {
2105 tools_received: Arc<Mutex<Vec<bool>>>,
2106 call_count: Arc<Mutex<usize>>,
2107 }
2108
2109 #[async_trait]
2110 impl Provider for StreamToolCaptureProvider {
2111 async fn chat_with_system(
2112 &self,
2113 _system_prompt: Option<&str>,
2114 _message: &str,
2115 _model: &str,
2116 _temperature: f64,
2117 ) -> Result<String> {
2118 Ok("ok".into())
2119 }
2120
2121 async fn chat(
2122 &self,
2123 request: ChatRequest<'_>,
2124 _model: &str,
2125 _temperature: f64,
2126 ) -> Result<crate::providers::ChatResponse> {
2127 self.tools_received.lock().push(request.tools.is_some());
2128 let mut count = self.call_count.lock();
2129 *count += 1;
2130 if *count == 1 {
2131 Ok(crate::providers::ChatResponse {
2132 text: Some(String::new()),
2133 tool_calls: vec![crate::providers::ToolCall {
2134 id: "tc_stream_1".into(),
2135 name: "echo".into(),
2136 arguments: "{}".into(),
2137 }],
2138 usage: None,
2139 reasoning_content: None,
2140 })
2141 } else {
2142 Ok(crate::providers::ChatResponse {
2143 text: Some("stream-done".into()),
2144 tool_calls: vec![],
2145 usage: None,
2146 reasoning_content: None,
2147 })
2148 }
2149 }
2150
2151 fn supports_native_tools(&self) -> bool {
2152 true
2153 }
2154
2155 fn stream_chat(
2156 &self,
2157 request: ChatRequest<'_>,
2158 _model: &str,
2159 _temperature: f64,
2160 _options: crate::providers::traits::StreamOptions,
2161 ) -> futures_util::stream::BoxStream<
2162 'static,
2163 crate::providers::traits::StreamResult<crate::providers::traits::StreamEvent>,
2164 > {
2165 use futures_util::stream::{self, StreamExt};
2166 self.tools_received.lock().push(request.tools.is_some());
2167 let mut count = self.call_count.lock();
2168 *count += 1;
2169 if *count == 1 {
2170 let tc =
2171 crate::providers::traits::StreamEvent::ToolCall(crate::providers::ToolCall {
2172 id: "tc_stream_1".into(),
2173 name: "echo".into(),
2174 arguments: "{}".into(),
2175 });
2176 stream::iter(vec![
2177 Ok(tc),
2178 Ok(crate::providers::traits::StreamEvent::Final),
2179 ])
2180 .boxed()
2181 } else {
2182 let chunk = crate::providers::traits::StreamEvent::TextDelta(
2183 crate::providers::traits::StreamChunk {
2184 delta: "stream-done".into(),
2185 is_final: false,
2186 reasoning: None,
2187 token_count: 0,
2188 },
2189 );
2190 stream::iter(vec![
2191 Ok(chunk),
2192 Ok(crate::providers::traits::StreamEvent::Final),
2193 ])
2194 .boxed()
2195 }
2196 }
2197 }
2198
2199 #[tokio::test]
2200 async fn turn_streamed_passes_tool_specs_to_provider() {
2201 let tools_received = Arc::new(Mutex::new(Vec::new()));
2202 let provider = Box::new(StreamToolCaptureProvider {
2203 tools_received: tools_received.clone(),
2204 call_count: Arc::new(Mutex::new(0)),
2205 });
2206
2207 let memory_cfg = crate::config::MemoryConfig {
2208 backend: "none".into(),
2209 ..crate::config::MemoryConfig::default()
2210 };
2211 let mem: Arc<dyn Memory> = Arc::from(
2212 crate::memory::create_memory(&memory_cfg, std::path::Path::new("/tmp"), None)
2213 .expect("memory creation should succeed with valid config"),
2214 );
2215
2216 let observer: Arc<dyn Observer> = Arc::from(crate::observability::NoopObserver {});
2217 let mut agent = Agent::builder()
2218 .provider(provider)
2219 .tools(vec![Box::new(MockTool)])
2220 .memory(mem)
2221 .observer(observer)
2222 .tool_dispatcher(Box::new(NativeToolDispatcher))
2223 .workspace_dir(std::path::PathBuf::from("/tmp"))
2224 .build()
2225 .expect("agent builder should succeed with valid config");
2226
2227 let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<TurnEvent>(64);
2228 let response = agent
2229 .turn_streamed("use the echo tool", event_tx)
2230 .await
2231 .unwrap();
2232 assert_eq!(response, "stream-done");
2233
2234 let received = tools_received.lock();
2236 assert!(
2237 received.len() >= 2,
2238 "Expected at least 2 stream_chat calls, got {}",
2239 received.len()
2240 );
2241 assert!(
2242 received[0],
2243 "First stream_chat call should have received tool specs"
2244 );
2245 assert!(
2246 received[1],
2247 "Second stream_chat call should have received tool specs"
2248 );
2249
2250 let mut events = Vec::new();
2252 while let Ok(ev) = event_rx.try_recv() {
2253 events.push(ev);
2254 }
2255 let has_tool_call = events
2256 .iter()
2257 .any(|e| matches!(e, TurnEvent::ToolCall { name, .. } if name == "echo"));
2258 let has_tool_result = events
2259 .iter()
2260 .any(|e| matches!(e, TurnEvent::ToolResult { name, .. } if name == "echo"));
2261 assert!(
2262 has_tool_call,
2263 "Should have emitted a ToolCall event for 'echo'"
2264 );
2265 assert!(
2266 has_tool_result,
2267 "Should have emitted a ToolResult event for 'echo'"
2268 );
2269 }
2270}