Skip to main content

phi_agent/agent/
factory.rs

1use std::sync::Arc;
2
3use agent_base::{AgentResult, AgentRuntime, ReasoningEffort, RunOutcome, RuntimeEvent, SafetyConfig, SessionId};
4
5use agent_works::AgentBuilder;
6
7use crate::agent::builder::base_agent_builder;
8
9/// phi-agent configuration (tool-agnostic).
10///
11/// This config covers model and safety settings only. Tools are registered
12/// externally on [`agent_works::AgentBuilder`] — phi-agent itself never bundles tools
13/// beyond kernel tools (multi-agent, skills) which are opt-in via feature flags.
14#[derive(Clone, Default)]
15pub struct PhiAgentConfig {
16    /// Model name passed to the LLM provider (e.g. `"opus"`, `"gpt-4o"`).
17    pub model: String,
18    /// Enable extended thinking / chain-of-thought.
19    pub enable_thinking: bool,
20    /// Token budget for thinking (provider-dependent). `None` means use the
21    /// provider default.
22    pub thinking_budget: Option<u64>,
23    /// Reasoning intensity: Low / Medium / High / XHigh.
24    pub thinking_effort: ReasoningEffort,
25    /// Per-turn safety limits (max tool calls, max consecutive failures, etc.).
26    pub safety: SafetyConfig,
27    /// React-loop iteration cap for a single run (one user input).
28    /// `None` means use the builder default (200 in [`base_agent_builder`]).
29    pub max_turns: Option<u32>,
30}
31
32/// A built Agent instance.
33///
34/// Wraps [`AgentRuntime`] with common operations behind a simpler API.
35///
36/// ## Example
37///
38/// ```ignore
39/// let agent = PhiAgent::build(builder, config)?;
40/// let session = agent.create_session().await;
41/// agent.run_turn(session, "Hello!", |event| renderer.render(event)).await?;
42/// ```
43#[derive(Clone)]
44pub struct PhiAgent {
45    runtime: AgentRuntime,
46    /// The configuration this agent was built with.
47    pub config: PhiAgentConfig,
48    /// MCP hub for runtime server management. Only available with the `mcp` feature.
49    #[cfg(feature = "mcp")]
50    mcp_hub: Arc<tokio::sync::Mutex<Option<Arc<agent_works::mcp::EnhancedMcpHub>>>>,
51}
52
53impl PhiAgent {
54    /// Create a pre-configured AgentBuilder.
55    ///
56    /// Equivalent to `base_agent_builder(llm_client).system_prompt(system_prompt)`,
57    /// after which you register tools, middleware, and approval handlers,
58    /// then call [`Self::build`].
59    ///
60    /// # Example
61    ///
62    /// ```ignore
63    /// use phi_agent::PhiAgent;
64    /// use phi_agent::build_system_prompt;
65    /// use std::sync::Arc;
66    ///
67    /// // Create your LLM provider (e.g., via llm_unified::create_provider)
68    /// let llm_client: Arc<dyn agent_base::llm_trait::LlmProvider> = todo!();
69    ///
70    /// let builder = PhiAgent::builder(llm_client, build_system_prompt());
71    /// ```
72    pub fn builder(llm_client: Arc<dyn agent_base::llm_trait::LlmProvider>, system_prompt: String) -> AgentBuilder {
73        base_agent_builder(llm_client).system_prompt(system_prompt)
74    }
75
76    /// Build from an AgentBuilder.
77    ///
78    /// # Example
79    ///
80    /// ```ignore
81    /// use phi_agent::{PhiAgent, PhiAgentConfig, base_agent_builder, build_system_prompt};
82    /// use std::sync::Arc;
83    ///
84    /// // Create your LLM provider (e.g., via llm_unified::create_provider)
85    /// let llm_client: Arc<dyn agent_base::llm_trait::LlmProvider> = todo!();
86    /// let builder = base_agent_builder(llm_client).system_prompt(build_system_prompt());
87    ///
88    /// let config = PhiAgentConfig {
89    ///     model: "gpt-4o".into(),
90    ///     enable_thinking: true,
91    ///     ..Default::default()
92    /// };
93    /// let agent = PhiAgent::build(builder, config)?;
94    /// # Ok::<(), Box<dyn std::error::Error>>(())
95    /// ```
96    pub fn build(builder: AgentBuilder, config: PhiAgentConfig) -> AgentResult<Self> {
97        let runtime = builder.build()?;
98        Ok(Self {
99            runtime,
100            config,
101            #[cfg(feature = "mcp")]
102            mcp_hub: Arc::new(tokio::sync::Mutex::new(None)),
103        })
104    }
105
106    /// Create an agent session.
107    ///
108    /// # Example
109    ///
110    /// ```ignore
111    /// # use phi_agent::{PhiAgent, PhiAgentConfig, base_agent_builder, build_system_prompt};
112    /// # use std::sync::Arc;
113    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
114    /// # let llm_client: Arc<dyn agent_base::llm_trait::LlmProvider> = todo!("create your LLM provider");
115    /// # let builder = base_agent_builder(llm_client).system_prompt(build_system_prompt());
116    /// # let agent = PhiAgent::build(builder, PhiAgentConfig::default())?;
117    /// let session = agent.create_session().await;
118    /// println!("Session id: {}", session.id);
119    /// # Ok(())
120    /// # }
121    /// ```
122    pub async fn create_session(&self) -> SessionId {
123        self.runtime.create_session().await
124    }
125
126    /// Execute one turn.
127    ///
128    /// # Example
129    ///
130    /// ```ignore
131    /// # use phi_agent::{PhiAgent, PhiAgentConfig, base_agent_builder, build_system_prompt};
132    /// # use phi_agent::create_stdout_renderer;
133    /// # use std::sync::Arc;
134    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
135    /// # let llm_client: Arc<dyn agent_base::llm_trait::LlmProvider> = todo!("create your LLM provider");
136    /// # let builder = base_agent_builder(llm_client).system_prompt(build_system_prompt());
137    /// # let agent = PhiAgent::build(builder, PhiAgentConfig::default())?;
138    /// let session = agent.create_session().await;
139    /// let renderer = create_stdout_renderer();
140    ///
141    /// let outcome = agent
142    ///     .run_turn(session, "What is 2+2?", |event| renderer.render(event))
143    ///     .await?;
144    /// println!("Turn completed: {:?}", outcome);
145    /// # Ok(())
146    /// # }
147    /// ```
148    pub async fn run_turn<F>(&self, session_id: SessionId, query: &str, on_event: F) -> AgentResult<RunOutcome>
149    where
150        F: FnMut(RuntimeEvent) -> AgentResult<()> + Send + 'static,
151    {
152        self.runtime.run_turn(session_id, query, on_event).await
153    }
154
155    /// Cancel the currently executing turn.
156    ///
157    /// # Example
158    ///
159    /// ```ignore
160    /// # use phi_agent::{PhiAgent, PhiAgentConfig, base_agent_builder, build_system_prompt};
161    /// # use std::sync::Arc;
162    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
163    /// # let llm_client: Arc<dyn agent_base::llm_trait::LlmProvider> = todo!("create your LLM provider");
164    /// # let builder = base_agent_builder(llm_client).system_prompt(build_system_prompt());
165    /// # let agent = PhiAgent::build(builder, PhiAgentConfig::default())?;
166    /// let agent_clone = agent.clone();
167    /// let session = agent.create_session().await;
168    ///
169    /// // Run the turn in a separate task
170    /// let handle = tokio::spawn(async move {
171    ///     agent_clone.run_turn(session, "count to 100", |_| Ok(())).await
172    /// });
173    ///
174    /// // Cancel after a short delay
175    /// tokio::time::sleep(std::time::Duration::from_millis(100)).await;
176    /// agent.cancel();
177    /// # Ok(())
178    /// # }
179    /// ```
180    pub fn cancel(&self) {
181        self.runtime.cancel();
182    }
183
184    /// Check whether the agent has been cancelled.
185    ///
186    /// # Example
187    ///
188    /// ```ignore
189    /// # use phi_agent::{PhiAgent, PhiAgentConfig, base_agent_builder, build_system_prompt};
190    /// # use std::sync::Arc;
191    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
192    /// # let llm_client: Arc<dyn agent_base::llm_trait::LlmProvider> = todo!("create your LLM provider");
193    /// # let builder = base_agent_builder(llm_client).system_prompt(build_system_prompt());
194    /// # let agent = PhiAgent::build(builder, PhiAgentConfig::default())?;
195    /// assert!(!agent.is_cancelled());
196    /// agent.cancel();
197    /// assert!(agent.is_cancelled());
198    /// # Ok(())
199    /// # }
200    /// ```
201    pub fn is_cancelled(&self) -> bool {
202        self.runtime.is_cancelled()
203    }
204
205    /// Set the reasoning effort.
206    ///
207    /// # Example
208    ///
209    /// ```ignore
210    /// use phi_agent::ReasoningEffort;
211    /// # use phi_agent::{PhiAgent, PhiAgentConfig, base_agent_builder, build_system_prompt};
212    /// # use std::sync::Arc;
213    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
214    /// # let llm_client: Arc<dyn agent_base::llm_trait::LlmProvider> = todo!("create your LLM provider");
215    /// # let builder = base_agent_builder(llm_client).system_prompt(build_system_prompt());
216    /// # let agent = PhiAgent::build(builder, PhiAgentConfig::default())?;
217    /// // Switch to high reasoning effort mid-conversation
218    /// agent.set_reasoning_effort(ReasoningEffort::High).await;
219    /// # Ok(())
220    /// # }
221    /// ```
222    pub async fn set_reasoning_effort(&self, effort: ReasoningEffort) {
223        self.runtime.set_reasoning_effort(effort).await;
224    }
225
226    /// Access the underlying runtime (for advanced use like hook registration).
227    ///
228    /// # Example
229    ///
230    /// ```ignore
231    /// # use phi_agent::{PhiAgent, PhiAgentConfig, base_agent_builder, build_system_prompt};
232    /// # use std::sync::Arc;
233    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
234    /// # let llm_client: Arc<dyn agent_base::llm_trait::LlmProvider> = todo!("create your LLM provider");
235    /// # let builder = base_agent_builder(llm_client).system_prompt(build_system_prompt());
236    /// # let agent = PhiAgent::build(builder, PhiAgentConfig::default())?;
237    /// let runtime = agent.runtime();
238    /// let event_rx = runtime.subscribe_runtime_events();
239    /// println!("Subscribed to events, receiver lag: {}", event_rx.len());
240    /// # Ok(())
241    /// # }
242    /// ```
243    pub fn runtime(&self) -> &AgentRuntime {
244        &self.runtime
245    }
246
247    /// List all registered tools with their metadata, sorted by name.
248    ///
249    /// # Example
250    ///
251    /// ```ignore
252    /// # use phi_agent::{PhiAgent, PhiAgentConfig, base_agent_builder, build_system_prompt};
253    /// # use std::sync::Arc;
254    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
255    /// # let llm_client: Arc<dyn agent_base::llm_trait::LlmProvider> = todo!("create your LLM provider");
256    /// # let builder = base_agent_builder(llm_client).system_prompt(build_system_prompt());
257    /// # let agent = PhiAgent::build(builder, PhiAgentConfig::default())?;
258    /// let tools = agent.list_tools().await;
259    /// for tool in &tools {
260    ///     println!("{} - {} ({})", tool.name, tool.description, tool.origin);
261    /// }
262    /// println!("{} tools registered", tools.len());
263    /// # Ok(())
264    /// # }
265    /// ```
266    pub async fn list_tools(&self) -> Vec<agent_base::ToolMetadata> {
267        let tools = self.runtime.tools_mut();
268        let registry = tools.read().await;
269        registry.metadatas()
270    }
271}
272
273// ── MCP Runtime Management (Phase 1.2) ──
274
275#[cfg(feature = "mcp")]
276impl PhiAgent {
277    /// Get or lazily initialize the MCP hub.
278    async fn get_or_init_hub(&self) -> Arc<agent_works::mcp::EnhancedMcpHub> {
279        let mut guard = self.mcp_hub.lock().await;
280        if let Some(ref hub) = *guard {
281            return hub.clone();
282        }
283        let hub = Arc::new(agent_works::mcp::EnhancedMcpHub::new());
284        *guard = Some(hub.clone());
285        hub
286    }
287
288    /// Dynamically attach an MCP server at runtime.
289    ///
290    /// Adds the server config, connects, discovers tools, and registers them
291    /// into the agent's `ToolRegistry`. Tools are registered with the
292    /// `mcp.<server_name>.<tool_name>` naming convention.
293    ///
294    /// Returns an error if the server cannot be connected or tools cannot be
295    /// discovered. On failure, the server config is rolled back (removed from
296    /// the hub) so a partial entry is never left behind.
297    ///
298    /// # Example
299    ///
300    /// ```ignore
301    /// # use phi_agent::{PhiAgent, PhiAgentConfig, base_agent_builder, build_system_prompt};
302    /// # use phi_agent::McpServerConfig;
303    /// # use std::sync::Arc;
304    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
305    /// # let llm_client: Arc<dyn agent_base::llm_trait::LlmProvider> = todo!("create your LLM provider");
306    /// # let builder = base_agent_builder(llm_client).system_prompt(build_system_prompt());
307    /// # let agent = PhiAgent::build(builder, PhiAgentConfig::default())?;
308    /// // Attach a filesystem MCP server at runtime
309    /// agent
310    ///     .attach_mcp(McpServerConfig {
311    ///         name: "filesystem".into(),
312    ///         transport: phi_agent::McpTransport::Stdio {
313    ///             command: "npx".into(),
314    ///             args: vec!["-y".into(), "@modelcontextprotocol/server-filesystem".into(), "/tmp".into()],
315    ///             env: Default::default(),
316    ///         },
317    ///     })
318    ///     .await?;
319    /// # Ok(())
320    /// # }
321    /// ```
322    ///
323    /// # Performance note
324    ///
325    /// Currently calls `hub.register_all()` which re-registers all servers'
326    /// tools (O(total-servers)). For the common case this is fine because
327    /// re-registration is a no-op HashMap insert. A future optimization would
328    /// register only the newly attached server's tools.
329    pub async fn attach_mcp(&self, config: agent_works::mcp::McpServerConfig) -> AgentResult<()> {
330        let name = config.name.clone();
331        let hub = self.get_or_init_hub().await;
332
333        // Add server config and attempt connection
334        hub.add_server(config);
335        if let Err(e) = hub.connect_one(&name).await {
336            hub.remove_server(&name).await;
337            return Err(e);
338        }
339
340        // Discover tools; rollback on failure
341        let discovered = match hub.discover_all().await {
342            Ok(d) => d,
343            Err(e) => {
344                hub.remove_server(&name).await;
345                return Err(e);
346            },
347        };
348
349        // Register only the newly attached server's tools
350        let tools = self.runtime.tools_mut();
351        let mut registry = tools.write().await;
352        hub.register_server(&mut registry, &name).await;
353
354        let count: usize = discovered.iter().filter(|(n, _)| n == &name).map(|(_, t)| t.len()).sum();
355        if count == 0 {
356            tracing::warn!(
357                server_name = %name,
358                "attached MCP server but discovered zero tools — server may be misconfigured"
359            );
360        }
361        tracing::info!(server_name = %name, tool_count = count, "attached MCP server at runtime");
362        Ok(())
363    }
364
365    /// Dynamically detach an MCP server at runtime.
366    ///
367    /// Unregisters all tools belonging to this server from the agent's
368    /// `ToolRegistry`, disconnects the server, and removes its config
369    /// from the hub.
370    ///
371    /// This is a no-op if the server is not attached.
372    ///
373    /// # Example
374    ///
375    /// ```ignore
376    /// # use phi_agent::{PhiAgent, PhiAgentConfig, base_agent_builder, build_system_prompt};
377    /// # use phi_agent::McpServerConfig;
378    /// # use std::sync::Arc;
379    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
380    /// # let llm_client: Arc<dyn agent_base::llm_trait::LlmProvider> = todo!("create your LLM provider");
381    /// # let builder = base_agent_builder(llm_client).system_prompt(build_system_prompt());
382    /// # let agent = PhiAgent::build(builder, PhiAgentConfig::default())?;
383    /// # agent.attach_mcp(McpServerConfig {
384    /// #     name: "filesystem".into(),
385    /// #     transport: phi_agent::McpTransport::Stdio {
386    /// #         command: "npx".into(), args: vec!["-y".into(), "@modelcontextprotocol/server-filesystem".into(), "/tmp".into()],
387    /// #         env: Default::default(),
388    /// #     },
389    /// # }).await?;
390    /// // Detach the server — tools are unregistered, connection is closed
391    /// agent.detach_mcp("filesystem").await;
392    /// # Ok(())
393    /// # }
394    /// ```
395    ///
396    /// # Concurrency note
397    ///
398    /// There is a TOCTOU window between collecting tool names (read lock) and
399    /// removing them (write lock). If another thread re-attaches a server with
400    /// the same name during this window, its tools may be prematurely removed.
401    /// In practice this race is harmless: the new attach will re-register tools
402    /// on the next turn, and tool calls in flight will fail with a clear error
403    /// since `hub.remove_server` disconnects clients.
404    pub async fn detach_mcp(&self, name: &str) {
405        let hub = {
406            let guard = self.mcp_hub.lock().await;
407            match *guard {
408                Some(ref hub) => hub.clone(),
409                None => return,
410            }
411        };
412
413        // Collect tool names matching the mcp.<server>.<tool> prefix.
414        // NOTE: the "mcp.<server>.<tool>" naming convention is defined by
415        // agent_works::mcp::McpToolAdapter. If that convention changes, this
416        // prefix must be updated.
417        let mcp_prefix = format!("mcp.{}.", name);
418        let tool_names: Vec<String> = {
419            let tools = self.runtime.tools_mut();
420            let registry = tools.read().await;
421            registry.metadatas().iter().filter(|m| m.name.starts_with(&mcp_prefix)).map(|m| m.name.clone()).collect()
422        };
423
424        // Unregister tools from the runtime
425        if !tool_names.is_empty() {
426            let tools = self.runtime.tools_mut();
427            let mut registry = tools.write().await;
428            for tool_name in &tool_names {
429                registry.remove(tool_name);
430            }
431        }
432
433        // Remove the server from the hub (disconnects clients)
434        hub.remove_server(name).await;
435
436        tracing::info!(
437            server_name = %name,
438            tool_count = tool_names.len(),
439            "detached MCP server at runtime"
440        );
441    }
442
443    // ── MCP Server (Phase 4.1) ──
444
445    /// Convert this agent into an MCP server that external orchestrators can call.
446    ///
447    /// # Example
448    ///
449    /// ```ignore
450    /// # use phi_agent::{PhiAgent, PhiAgentConfig, base_agent_builder, build_system_prompt};
451    /// # use phi_agent::{McpServeConfig, McpServerTransport};
452    /// # use std::sync::Arc;
453    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
454    /// # let llm_client: Arc<dyn agent_base::llm_trait::LlmProvider> = todo!("create your LLM provider");
455    /// # let builder = base_agent_builder(llm_client).system_prompt(build_system_prompt());
456    /// # let agent = PhiAgent::build(builder, PhiAgentConfig::default())?;
457    /// // Expose the agent as an MCP server via stdio
458    /// let mcp_server = agent.into_mcp_server(McpServeConfig {
459    ///     transport: McpServerTransport::Stdio,
460    ///     name: "phi-agent".into(),
461    ///     version: "1.0.0".into(),
462    /// });
463    /// // External orchestrators (LangGraph, CrewAI, etc.) can now call
464    /// // the agent's tools through the MCP protocol
465    /// # Ok(())
466    /// # }
467    /// ```
468    pub fn into_mcp_server(&self, config: agent_works::mcp::McpServeConfig) -> agent_works::mcp::McpServer {
469        agent_works::mcp::McpServer::new(self.runtime.clone(), config)
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use crate::prompt::build_system_prompt;
477    use async_trait::async_trait;
478    use futures_core::Stream;
479    use std::pin::Pin;
480    use std::task::{Context, Poll};
481
482    struct StubClient;
483
484    /// Yields one `Text` chunk, one `Stop` chunk, then ends — a minimal valid
485    /// LLM response that lets the react loop complete a turn.
486    struct StopStream {
487        state: u8,
488    }
489
490    impl Stream for StopStream {
491        type Item = Result<agent_base::StreamChunk, agent_base::llm_trait::LlmError>;
492
493        fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
494            match self.state {
495                0 => {
496                    self.state = 1;
497                    Poll::Ready(Some(Ok(agent_base::StreamChunk::Text("hello".to_string()))))
498                },
499                1 => {
500                    self.state = 2;
501                    Poll::Ready(Some(Ok(agent_base::StreamChunk::Stop { finish_reason: Some("stop".to_string()) })))
502                },
503                _ => Poll::Ready(None),
504            }
505        }
506    }
507
508    #[async_trait]
509    impl agent_base::llm_trait::LlmProvider for StubClient {
510        async fn stream(
511            &self,
512            _request: agent_base::llm_trait::ChatRequest,
513        ) -> Result<agent_base::llm_trait::ChatStream, agent_base::llm_trait::LlmError> {
514            Ok(agent_base::llm_trait::ChatStream::new(Box::pin(StopStream { state: 0 })))
515        }
516        async fn chat(
517            &self,
518            _request: agent_base::llm_trait::ChatRequest,
519        ) -> Result<agent_base::llm_trait::ChatResponse, agent_base::llm_trait::LlmError> {
520            Ok(agent_base::llm_trait::ChatResponse {
521                content: "stub".to_string(),
522                reasoning_content: None,
523                tool_calls: vec![],
524                usage: agent_base::UsageInfo::default(),
525                finish_reason: agent_base::llm_trait::response::FinishReason::Stop,
526                raw: None,
527                thinking_signature: None,
528            })
529        }
530        fn capabilities(&self) -> agent_base::llm_trait::Capabilities {
531            agent_base::llm_trait::Capabilities::default()
532        }
533        fn info(&self) -> agent_base::llm_trait::ProviderInfo {
534            agent_base::llm_trait::ProviderInfo { name: "stub".to_string(), model: "stub".to_string(), version: None }
535        }
536    }
537
538    fn client() -> Arc<dyn agent_base::llm_trait::LlmProvider> {
539        Arc::new(StubClient)
540    }
541
542    fn build_agent() -> PhiAgent {
543        let builder = PhiAgent::builder(client(), build_system_prompt());
544        PhiAgent::build(builder, PhiAgentConfig::default()).unwrap()
545    }
546
547    #[test]
548    fn test_phi_agent_config_default() {
549        let cfg = PhiAgentConfig::default();
550        assert!(cfg.model.is_empty());
551        assert!(!cfg.enable_thinking);
552        assert!(cfg.thinking_budget.is_none());
553        assert!(cfg.max_turns.is_none());
554    }
555
556    #[tokio::test(flavor = "multi_thread")]
557    async fn test_builder_and_build() {
558        let builder = PhiAgent::builder(client(), "custom prompt".to_string());
559        let agent = PhiAgent::build(builder, PhiAgentConfig::default()).unwrap();
560        let _ = agent.runtime();
561        assert!(agent.config.model.is_empty());
562    }
563
564    #[tokio::test(flavor = "multi_thread")]
565    async fn test_delegate_methods() {
566        let agent = build_agent();
567
568        let session = agent.create_session().await;
569        agent.set_reasoning_effort(agent_base::ReasoningEffort::High).await;
570
571        let tools = agent.list_tools().await;
572        let _ = tools;
573
574        // A turn with the stub client's Text+Stop stream should complete.
575        let outcome = agent.run_turn(session, "hi", |_| Ok(())).await;
576        assert!(outcome.is_ok());
577
578        assert!(!agent.is_cancelled());
579        agent.cancel();
580        assert!(agent.is_cancelled());
581    }
582
583    #[cfg(feature = "mcp")]
584    #[tokio::test(flavor = "multi_thread")]
585    async fn test_into_mcp_server() {
586        let agent = build_agent();
587        let server = agent.into_mcp_server(agent_works::mcp::McpServeConfig::default());
588        let _ = server;
589    }
590
591    #[cfg(feature = "mcp")]
592    #[tokio::test(flavor = "multi_thread")]
593    async fn test_attach_mcp_connection_failure_rolls_back() {
594        let agent = build_agent();
595        // A Stdio transport with a nonexistent command fails at `McpClient::new`
596        // (process spawn), which surfaces as an attach error and rolls back.
597        let config = agent_works::mcp::McpServerConfig {
598            name: "bogus".to_string(),
599            transport: agent_works::mcp::McpTransport::Stdio {
600                command: "definitely-not-a-real-command-xyz".to_string(),
601                args: vec![],
602            },
603            auto_reconnect: false,
604        };
605        // Spawn failure → attach fails and rolls the server back.
606        assert!(agent.attach_mcp(config).await.is_err());
607    }
608
609    #[cfg(feature = "mcp")]
610    #[tokio::test(flavor = "multi_thread")]
611    async fn test_detach_mcp_noop_when_not_attached() {
612        let agent = build_agent();
613        // No hub initialized → detach is a no-op.
614        agent.detach_mcp("never-attached").await;
615    }
616}