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}