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}