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}