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