Skip to main content

construct/agent/
agent.rs

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/// Events emitted during a streamed agent turn.
22///
23/// Consumers receive these through a `tokio::sync::mpsc::Sender<TurnEvent>`
24/// passed to [`Agent::turn_streamed`].
25#[derive(Debug, Clone)]
26pub enum TurnEvent {
27    /// A text chunk from the LLM response (may arrive many times).
28    Chunk { delta: String },
29    /// A reasoning/thinking chunk from a thinking model (may arrive many times).
30    Thinking { delta: String },
31    /// The agent is invoking a tool.
32    ToolCall {
33        name: String,
34        args: serde_json::Value,
35    },
36    /// A tool has returned a result.
37    ToolResult { name: String, output: String },
38    /// A operator orchestration status update (spawning/waiting/collecting).
39    OperatorStatus { phase: String, detail: String },
40}
41
42pub struct Agent {
43    provider: Box<dyn Provider>,
44    /// Logical provider name (e.g. "anthropic", "openrouter") used for cost
45    /// tracker pricing lookup and observer event attribution.
46    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    /// Pre-rendered security policy summary injected into the system prompt
71    /// so the LLM knows the concrete constraints before making tool calls.
72    security_summary: Option<String>,
73    /// Autonomy level from config; controls safety prompt instructions.
74    autonomy_level: crate::security::AutonomyLevel,
75    /// Activated MCP tools for deferred loading mode.
76    /// When MCP deferred loading is enabled, tools are activated via `tool_search`
77    /// and stored here for lookup during tool execution.
78    activated_tools: Option<Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>>,
79    /// Whether Kumiho memory is enabled — used to append the session-bootstrap
80    /// prompt to the system prompt so the agent knows how to use Kumiho MCP tools.
81    kumiho_enabled: bool,
82    /// Whether Operator orchestration is enabled — used to append the operator
83    /// prompt so the agent knows how to delegate to sub-agents.
84    operator_enabled: bool,
85    /// Optional process-wide cache of skill effectiveness scores.  When
86    /// present, the prompt builder reranks skills by recency-weighted
87    /// success rate so high-performing skills appear first in the
88    /// `<available_skills>` block.  Built once at daemon startup and
89    /// shared across all agent constructions.
90    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    /// Attach a process-wide [`EffectivenessCache`] so the prompt builder
313    /// can rerank skills by recency-weighted success rate.  Pass the same
314    /// `Arc` to every agent the daemon spawns — the cache is intended to
315    /// be shared.
316    ///
317    /// [`EffectivenessCache`]: crate::skills::EffectivenessCache
318    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    /// Hydrate the agent with prior chat messages (e.g. from a session backend).
405    ///
406    /// Ensures a system prompt is prepended if history is empty, then appends all
407    /// non-system messages from the seed. System messages in the seed are skipped
408    /// to avoid duplicating the system prompt.
409    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        // Inject Kumiho memory MCP server and Operator orchestration MCP server
425        // so dashboard/WebSocket agents also get persistent memory and multi-agent
426        // tools.  Both inject functions are idempotent.
427        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        // ── Wire MCP tools (non-fatal) ─────────────────────────────
483        // Replicates the same MCP initialization logic used in the CLI
484        // and webhook paths (loop_.rs) so that the WebSocket/daemon UI
485        // path also has access to MCP tools.
486        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                        // Eagerly load operator tools so they are always
500                        // available without a tool_search round-trip.
501                        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(&registry),
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                        // Defer everything else (kumiho-memory, etc.)
522                        let deferred_set = tools::DeferredMcpToolSet::from_registry_filtered(
523                            std::sync::Arc::clone(&registry),
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(&registry),
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        // Use operator-aware iteration limit when operator is active.
620        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        // First try to find tool in static registry, then in activated MCP tools.
713        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            // Try to find in activated MCP tools.
738            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        // Fallback: auto-classify by complexity when no rule matched.
816        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            // Response cache: check before LLM call (only for deterministic, text-only prompts)
888            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            // Rebuild tool_specs per iteration so newly activated deferred
928            // tools (via tool_search) appear in subsequent LLM calls.
929            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                // Store in response cache (text-only, no tool calls)
965                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    /// Execute a single agent turn while streaming intermediate events.
1012    ///
1013    /// Behaves identically to [`turn`](Self::turn) but forwards [`TurnEvent`]s
1014    /// through the provided channel so callers (e.g. the WebSocket gateway)
1015    /// can relay incremental updates to clients.
1016    ///
1017    /// The returned `String` is the final, complete assistant response — the
1018    /// same value that `turn` would return.
1019    pub async fn turn_streamed(
1020        &mut self,
1021        user_message: &str,
1022        event_tx: tokio::sync::mpsc::Sender<TurnEvent>,
1023    ) -> Result<String> {
1024        // ── Preamble (identical to turn) ───────────────────────────────
1025        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        // ── Turn loop ──────────────────────────────────────────────────
1068        for _ in 0..self.config.max_tool_iterations {
1069            let messages = self.tool_dispatcher.to_provider_messages(&self.history);
1070
1071            // Response cache check (same as turn)
1072            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            // ── Streaming LLM call ────────────────────────────────────
1112            // Try streaming first; if the provider returns content we
1113            // forward deltas.  Otherwise fall back to non-streaming chat.
1114            use futures_util::StreamExt;
1115
1116            // Rebuild tool_specs each iteration so newly activated deferred
1117            // tools (via tool_search) appear in the next LLM call.  Matches
1118            // run_tool_call_loop's pattern in loop_.rs.
1119            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                            // NOT pushed to streamed_tool_calls — already executed by proxy
1185                        }
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                            // Merge into accumulator — providers may emit multiple
1194                            // Usage events (Anthropic sends partial input on
1195                            // message_start, final output on message_delta).
1196                            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 the stream so we release the borrow on provider.
1213            drop(stream);
1214
1215            // If streaming produced text, use it as the response and
1216            // check for tool calls via the dispatcher.
1217            let response = if got_stream {
1218                // Record cost via the task-local tracker when the provider
1219                // reported usage mid-stream. No-op when the tracker context
1220                // isn't scoped (tests, CLI without cost config).
1221                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                // Build a synthetic ChatResponse from streamed text
1230                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                // Fall back to non-streaming chat
1238                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                // Record cost — always, even when the provider omits usage — so
1258                // request_count on the cost page reflects every turn.
1259                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                // Store in response cache
1280                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 we didn't stream, send the full response as a single chunk
1291                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            // ── Tool calls ─────────────────────────────────────────────
1309            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            // Notify about each tool call (with operator status for orchestration tools)
1323            for call in &calls {
1324                // Emit operator status for construct-operator tools
1325                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            // Notify about each tool result (with operator status for completion)
1344            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
1404/// Map a operator MCP tool call to a human-friendly status message.
1405///
1406/// Returns `Some((phase, detail))` for `construct-operator__*` tools, `None` otherwise.
1407fn 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
1507/// Parse a operator agent/team JSON result into a (phase, detail) pair.
1508///
1509/// Checks the actual `status` and `error_count` fields in the JSON response
1510/// rather than naive string matching, which would false-positive on field names
1511/// like `"error_count": 0`.
1512fn operator_parse_agent_result(output: &str) -> (String, String) {
1513    if let Ok(json) = serde_json::from_str::<serde_json::Value>(output) {
1514        // Check explicit status field first
1515        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        // Check error_count for actual errors (not just the field existing)
1539        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        // Check for top-level "error" string field (non-status responses like spawn failures)
1549        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        // Fallback: couldn't parse JSON — use conservative heuristic
1556        // Only match "error" as a JSON value pattern, not as a field name
1557        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
1565/// Map a operator MCP tool result to a human-friendly status message.
1566///
1567/// Returns `Some((phase, detail))` for `construct-operator__*` tools, `None` otherwise.
1568fn 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        // First message should be a freshly built system prompt (not the seed one)
2091        assert!(matches!(&history[0], ConversationMessage::Chat(m) if m.role == "system"));
2092        // System message from seed should be skipped, so next is user
2093        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    /// Mock provider that captures whether tool specs were passed to `stream_chat`
2103    /// and returns a tool call followed by a text response through the stream.
2104    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        // Verify tools were passed in both stream_chat calls
2235        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        // Collect events and verify tool call + tool result were emitted
2251        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}