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