Skip to main content

phi_agent/agent/
factory.rs

1use std::path::Path;
2use std::sync::Arc;
3
4use agent_base::{
5    AgentError, AgentResult, AgentRuntime, ChatMessage, ReasoningEffort, RunOutcome, RuntimeEvent, SafetyConfig,
6    SessionId,
7};
8
9use agent_works::AgentBuilder;
10
11use crate::agent::builder::base_agent_builder;
12
13/// phi-agent configuration (tool-agnostic).
14///
15/// This config covers model and safety settings only. Tools are registered
16/// externally on [`agent_works::AgentBuilder`] — phi-agent itself never bundles tools
17/// beyond kernel tools (multi-agent, skills) which are opt-in via feature flags.
18#[derive(Clone, Default)]
19pub struct PhiAgentConfig {
20    /// Model name passed to the LLM provider (e.g. `"opus"`, `"gpt-4o"`).
21    pub model: String,
22    /// Enable extended thinking / chain-of-thought.
23    pub enable_thinking: bool,
24    /// Token budget for thinking (provider-dependent). `None` means use the
25    /// provider default.
26    pub thinking_budget: Option<u64>,
27    /// Reasoning intensity: Low / Medium / High / XHigh.
28    pub thinking_effort: ReasoningEffort,
29    /// Per-turn safety limits (max tool calls, max consecutive failures, etc.).
30    pub safety: SafetyConfig,
31    /// React-loop iteration cap for a single run (one user input).
32    /// `None` means use the builder default (200 in [`base_agent_builder`]).
33    pub max_turns: Option<u32>,
34}
35
36/// A built Agent instance.
37///
38/// Wraps [`AgentRuntime`] with common operations behind a simpler API.
39///
40/// ## Example
41///
42/// ```ignore
43/// let agent = PhiAgent::build(builder, config)?;
44/// let session = agent.create_session().await;
45/// agent.run_turn(session, "Hello!", |event| renderer.render(event)).await?;
46/// ```
47#[derive(Clone)]
48pub struct PhiAgent {
49    runtime: AgentRuntime,
50    /// The configuration this agent was built with.
51    pub config: PhiAgentConfig,
52    /// MCP hub for runtime server management. Only available with the `mcp` feature.
53    #[cfg(feature = "mcp")]
54    mcp_hub: Arc<tokio::sync::Mutex<Option<Arc<agent_works::mcp::EnhancedMcpHub>>>>,
55    /// Multi-agent runtime (if multi-agent is enabled).
56    #[cfg(feature = "multi-agent")]
57    ma_runtime: Option<Arc<agent_works::multi_agent::MultiAgentRuntime>>,
58}
59
60impl PhiAgent {
61    /// Create a pre-configured AgentBuilder.
62    ///
63    /// Equivalent to `base_agent_builder(llm_client).system_prompt(system_prompt)`,
64    /// after which you register tools, middleware, and approval handlers,
65    /// then call [`Self::build`].
66    ///
67    /// # Example
68    ///
69    /// ```ignore
70    /// use phi_agent::PhiAgent;
71    /// use phi_agent::build_system_prompt;
72    /// use std::sync::Arc;
73    ///
74    /// // Create your LLM provider (e.g., via llm_unified::create_provider)
75    /// let llm_client: Arc<dyn agent_base::llm_trait::LlmProvider> = todo!();
76    ///
77    /// let builder = PhiAgent::builder(llm_client, build_system_prompt());
78    /// ```
79    pub fn builder(llm_client: Arc<dyn agent_base::llm_trait::LlmProvider>, system_prompt: String) -> AgentBuilder {
80        base_agent_builder(llm_client).system_prompt(system_prompt)
81    }
82
83    /// Build from an AgentBuilder.
84    ///
85    /// # Example
86    ///
87    /// ```ignore
88    /// use phi_agent::{PhiAgent, PhiAgentConfig, base_agent_builder, build_system_prompt};
89    /// use std::sync::Arc;
90    ///
91    /// // Create your LLM provider (e.g., via llm_unified::create_provider)
92    /// let llm_client: Arc<dyn agent_base::llm_trait::LlmProvider> = todo!();
93    /// let builder = base_agent_builder(llm_client).system_prompt(build_system_prompt());
94    ///
95    /// let config = PhiAgentConfig {
96    ///     model: "gpt-4o".into(),
97    ///     enable_thinking: true,
98    ///     ..Default::default()
99    /// };
100    /// let agent = PhiAgent::build(builder, config)?;
101    /// # Ok::<(), Box<dyn std::error::Error>>(())
102    /// ```
103    pub fn build(builder: AgentBuilder, config: PhiAgentConfig) -> AgentResult<Self> {
104        let (runtime, ma_runtime) = builder.build_with_ma()?;
105        // Consumed by the struct below under the multi-agent feature; the
106        // discard keeps the default-features build clippy-clean.
107        #[cfg(not(feature = "multi-agent"))]
108        let _ = ma_runtime;
109        Ok(Self {
110            runtime,
111            config,
112            #[cfg(feature = "mcp")]
113            mcp_hub: Arc::new(tokio::sync::Mutex::new(None)),
114            #[cfg(feature = "multi-agent")]
115            ma_runtime,
116        })
117    }
118
119    /// Get the multi-agent runtime (if multi-agent is enabled).
120    ///
121    /// Returns `None` if multi-agent support was not configured during build.
122    #[cfg(feature = "multi-agent")]
123    pub fn multi_agent_runtime(&self) -> Option<&Arc<agent_works::multi_agent::MultiAgentRuntime>> {
124        self.ma_runtime.as_ref()
125    }
126
127    /// Create an agent session.
128    ///
129    /// # Example
130    ///
131    /// ```ignore
132    /// # use phi_agent::{PhiAgent, PhiAgentConfig, base_agent_builder, build_system_prompt};
133    /// # use std::sync::Arc;
134    /// # 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    /// println!("Session id: {}", session.id);
140    /// # Ok(())
141    /// # }
142    /// ```
143    pub async fn create_session(&self) -> SessionId {
144        self.runtime.create_session().await
145    }
146
147    /// Execute one turn.
148    ///
149    /// # Example
150    ///
151    /// ```ignore
152    /// # use phi_agent::{PhiAgent, PhiAgentConfig, base_agent_builder, build_system_prompt};
153    /// # use phi_agent::create_stdout_renderer;
154    /// # use std::sync::Arc;
155    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
156    /// # let llm_client: Arc<dyn agent_base::llm_trait::LlmProvider> = todo!("create your LLM provider");
157    /// # let builder = base_agent_builder(llm_client).system_prompt(build_system_prompt());
158    /// # let agent = PhiAgent::build(builder, PhiAgentConfig::default())?;
159    /// let session = agent.create_session().await;
160    /// let renderer = create_stdout_renderer();
161    ///
162    /// let outcome = agent
163    ///     .run_turn(session, "What is 2+2?", |event| renderer.render(event))
164    ///     .await?;
165    /// println!("Turn completed: {:?}", outcome);
166    /// # Ok(())
167    /// # }
168    /// ```
169    pub async fn run_turn<F>(&self, session_id: SessionId, query: &str, on_event: F) -> AgentResult<RunOutcome>
170    where
171        F: FnMut(RuntimeEvent) -> AgentResult<()> + Send + 'static,
172    {
173        self.runtime.run_turn(session_id, query, on_event).await
174    }
175
176    /// Like `run_turn`, but the query is pushed as an **ephemeral** user
177    /// message: the LLM sees it for this turn only, then turn-end cleanup
178    /// removes it from memory and persistence. Used for skill-body
179    /// injection — history keeps only the original command.
180    pub async fn run_turn_ephemeral_input<F>(
181        &self,
182        session_id: SessionId,
183        query: &str,
184        on_event: F,
185    ) -> AgentResult<RunOutcome>
186    where
187        F: FnMut(RuntimeEvent) -> AgentResult<()> + Send + 'static,
188    {
189        self.runtime.run_turn_ephemeral_input(session_id, query, on_event).await
190    }
191
192    /// The pristine build-time system prompt (async — safe inside a runtime).
193    /// Hosts that bake session state into the prompt (e.g. skill activation)
194    /// capture this once and append to it, never recompose.
195    pub async fn system_prompt(&self) -> Option<String> {
196        self.runtime.system_prompt().await
197    }
198
199    /// Replace the session's system prompt (the first non-ephemeral System
200    /// message). phimint uses this to re-bake the prompt when a session-scope
201    /// skill is activated — the body joins an "Active Skills" section.
202    pub async fn set_system_prompt(&self, session_id: &SessionId, prompt: impl Into<String>) -> AgentResult<()> {
203        self.runtime.set_system_prompt(session_id, prompt).await
204    }
205
206    /// Cancel the currently executing turn.
207    ///
208    /// # Example
209    ///
210    /// ```ignore
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    /// let agent_clone = agent.clone();
218    /// let session = agent.create_session().await;
219    ///
220    /// // Run the turn in a separate task
221    /// let handle = tokio::spawn(async move {
222    ///     agent_clone.run_turn(session, "count to 100", |_| Ok(())).await
223    /// });
224    ///
225    /// // Cancel after a short delay
226    /// tokio::time::sleep(std::time::Duration::from_millis(100)).await;
227    /// agent.cancel();
228    /// # Ok(())
229    /// # }
230    /// ```
231    pub fn cancel(&self) {
232        self.runtime.cancel();
233    }
234
235    /// Check whether the agent has been cancelled.
236    ///
237    /// # Example
238    ///
239    /// ```ignore
240    /// # use phi_agent::{PhiAgent, PhiAgentConfig, base_agent_builder, build_system_prompt};
241    /// # use std::sync::Arc;
242    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
243    /// # let llm_client: Arc<dyn agent_base::llm_trait::LlmProvider> = todo!("create your LLM provider");
244    /// # let builder = base_agent_builder(llm_client).system_prompt(build_system_prompt());
245    /// # let agent = PhiAgent::build(builder, PhiAgentConfig::default())?;
246    /// assert!(!agent.is_cancelled());
247    /// agent.cancel();
248    /// assert!(agent.is_cancelled());
249    /// # Ok(())
250    /// # }
251    /// ```
252    pub fn is_cancelled(&self) -> bool {
253        self.runtime.is_cancelled()
254    }
255
256    /// Set the reasoning effort.
257    ///
258    /// # Example
259    ///
260    /// ```ignore
261    /// use phi_agent::ReasoningEffort;
262    /// # use phi_agent::{PhiAgent, PhiAgentConfig, base_agent_builder, build_system_prompt};
263    /// # use std::sync::Arc;
264    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
265    /// # let llm_client: Arc<dyn agent_base::llm_trait::LlmProvider> = todo!("create your LLM provider");
266    /// # let builder = base_agent_builder(llm_client).system_prompt(build_system_prompt());
267    /// # let agent = PhiAgent::build(builder, PhiAgentConfig::default())?;
268    /// // Switch to high reasoning effort mid-conversation
269    /// agent.set_reasoning_effort(ReasoningEffort::High).await;
270    /// # Ok(())
271    /// # }
272    /// ```
273    pub async fn set_reasoning_effort(&self, effort: ReasoningEffort) {
274        self.runtime.set_reasoning_effort(effort).await;
275    }
276
277    /// Access the underlying runtime (for advanced use like hook registration).
278    ///
279    /// # Example
280    ///
281    /// ```ignore
282    /// # use phi_agent::{PhiAgent, PhiAgentConfig, base_agent_builder, build_system_prompt};
283    /// # use std::sync::Arc;
284    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
285    /// # let llm_client: Arc<dyn agent_base::llm_trait::LlmProvider> = todo!("create your LLM provider");
286    /// # let builder = base_agent_builder(llm_client).system_prompt(build_system_prompt());
287    /// # let agent = PhiAgent::build(builder, PhiAgentConfig::default())?;
288    /// let runtime = agent.runtime();
289    /// let event_rx = runtime.subscribe_runtime_events();
290    /// println!("Subscribed to events, receiver lag: {}", event_rx.len());
291    /// # Ok(())
292    /// # }
293    /// ```
294    pub fn runtime(&self) -> &AgentRuntime {
295        &self.runtime
296    }
297
298    /// List all registered tools with their metadata, sorted by name.
299    ///
300    /// # Example
301    ///
302    /// ```ignore
303    /// # use phi_agent::{PhiAgent, PhiAgentConfig, base_agent_builder, build_system_prompt};
304    /// # use std::sync::Arc;
305    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
306    /// # let llm_client: Arc<dyn agent_base::llm_trait::LlmProvider> = todo!("create your LLM provider");
307    /// # let builder = base_agent_builder(llm_client).system_prompt(build_system_prompt());
308    /// # let agent = PhiAgent::build(builder, PhiAgentConfig::default())?;
309    /// let tools = agent.list_tools().await;
310    /// for tool in &tools {
311    ///     println!("{} - {} ({})", tool.name, tool.description, tool.origin);
312    /// }
313    /// println!("{} tools registered", tools.len());
314    /// # Ok(())
315    /// # }
316    /// ```
317    pub async fn list_tools(&self) -> Vec<agent_base::ToolMetadata> {
318        let tools = self.runtime.tools_mut();
319        let registry = tools.read().await;
320        registry.metadatas()
321    }
322
323    /// Create a new session and inject historical messages for resume.
324    ///
325    /// Creates a fresh session (which receives a fresh System prompt), then
326    /// replaces the chat messages with `[fresh_system] + messages`.  The
327    /// caller must ensure `messages` contains **no** `System` messages — use
328    /// [`crate::session::load_session_messages`] which filters them out automatically.
329    ///
330    /// # Errors
331    /// Returns an error if `messages` is empty / System-only, or if the
332    /// combined sequence fails `validate_message_sequence` (e.g. dangling
333    /// tool calls).  Again, [`crate::session::load_session_messages`] sanitizes all of this.
334    pub async fn resume_session(&self, messages: Vec<ChatMessage>) -> AgentResult<SessionId> {
335        let session_id = self.create_session().await;
336        self.runtime()
337            .with_session_mut(&session_id, |s| {
338                // Keep the fresh system prompt that create_session wrote.
339                let fresh_system = s.chat_messages().first().cloned();
340                let mut merged = Vec::new();
341                if let Some(sys) = fresh_system {
342                    merged.push(sys);
343                }
344                merged.extend(messages);
345                s.set_chat_messages(merged).map_err(AgentError::config_error)
346            })
347            .await??;
348        Ok(session_id)
349    }
350
351    /// One-step session switch: resolve → load messages → resume.
352    ///
353    /// Given a session directory (from the picker), resolves the session context,
354    /// loads historical messages, and creates a new agent session with those
355    /// messages. Returns the new session ID, the loaded messages (for transcript
356    /// replay), and the resolved context.
357    pub async fn switch_to_session(
358        &self,
359        picked_session_dir: &Path,
360        base_dir: &Path,
361    ) -> AgentResult<(SessionId, Vec<ChatMessage>, crate::session::SessionContext)> {
362        let picked_id = picked_session_dir.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
363        let ctx = crate::session::resolve_session(Some(&picked_id), base_dir)?;
364        let messages = crate::session::load_session_messages(&ctx.session_dir)
365            .map_err(|e| AgentError::config_error(e.to_string()))?;
366        let new_id = self.resume_session(messages.clone()).await?;
367        Ok((new_id, messages, ctx))
368    }
369}
370
371// ── MCP Runtime Management (Phase 1.2) ──
372
373#[cfg(feature = "mcp")]
374impl PhiAgent {
375    /// Get or lazily initialize the MCP hub.
376    async fn get_or_init_hub(&self) -> Arc<agent_works::mcp::EnhancedMcpHub> {
377        let mut guard = self.mcp_hub.lock().await;
378        if let Some(ref hub) = *guard {
379            return hub.clone();
380        }
381        let hub = Arc::new(agent_works::mcp::EnhancedMcpHub::new());
382        *guard = Some(hub.clone());
383        hub
384    }
385
386    /// Dynamically attach an MCP server at runtime.
387    ///
388    /// Adds the server config, connects, discovers tools, and registers them
389    /// into the agent's `ToolRegistry`. Tools are registered with the
390    /// `mcp.<server_name>.<tool_name>` naming convention.
391    ///
392    /// Returns an error if the server cannot be connected or tools cannot be
393    /// discovered. On failure, the server config is rolled back (removed from
394    /// the hub) so a partial entry is never left behind.
395    ///
396    /// # Example
397    ///
398    /// ```ignore
399    /// # use phi_agent::{PhiAgent, PhiAgentConfig, base_agent_builder, build_system_prompt};
400    /// # use phi_agent::McpServerConfig;
401    /// # use std::sync::Arc;
402    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
403    /// # let llm_client: Arc<dyn agent_base::llm_trait::LlmProvider> = todo!("create your LLM provider");
404    /// # let builder = base_agent_builder(llm_client).system_prompt(build_system_prompt());
405    /// # let agent = PhiAgent::build(builder, PhiAgentConfig::default())?;
406    /// // Attach a filesystem MCP server at runtime
407    /// agent
408    ///     .attach_mcp(McpServerConfig {
409    ///         name: "filesystem".into(),
410    ///         transport: phi_agent::McpTransport::Stdio {
411    ///             command: "npx".into(),
412    ///             args: vec!["-y".into(), "@modelcontextprotocol/server-filesystem".into(), "/tmp".into()],
413    ///             env: Default::default(),
414    ///         },
415    ///     })
416    ///     .await?;
417    /// # Ok(())
418    /// # }
419    /// ```
420    ///
421    /// # Performance note
422    ///
423    /// Currently calls `hub.register_all()` which re-registers all servers'
424    /// tools (O(total-servers)). For the common case this is fine because
425    /// re-registration is a no-op HashMap insert. A future optimization would
426    /// register only the newly attached server's tools.
427    pub async fn attach_mcp(&self, config: agent_works::mcp::McpServerConfig) -> AgentResult<()> {
428        let name = config.name.clone();
429        let hub = self.get_or_init_hub().await;
430
431        // Add server config and attempt connection
432        hub.add_server(config);
433        if let Err(e) = hub.connect_one(&name).await {
434            hub.remove_server(&name).await;
435            return Err(e);
436        }
437
438        // Discover tools; rollback on failure
439        let discovered = match hub.discover_all().await {
440            Ok(d) => d,
441            Err(e) => {
442                hub.remove_server(&name).await;
443                return Err(e);
444            },
445        };
446
447        // Register only the newly attached server's tools
448        let tools = self.runtime.tools_mut();
449        let mut registry = tools.write().await;
450        hub.register_server(&mut registry, &name).await;
451
452        let count: usize = discovered.iter().filter(|(n, _)| n == &name).map(|(_, t)| t.len()).sum();
453        if count == 0 {
454            tracing::warn!(
455                server_name = %name,
456                "attached MCP server but discovered zero tools — server may be misconfigured"
457            );
458        }
459        tracing::info!(server_name = %name, tool_count = count, "attached MCP server at runtime");
460        Ok(())
461    }
462
463    /// Dynamically detach an MCP server at runtime.
464    ///
465    /// Unregisters all tools belonging to this server from the agent's
466    /// `ToolRegistry`, disconnects the server, and removes its config
467    /// from the hub.
468    ///
469    /// This is a no-op if the server is not attached.
470    ///
471    /// # Example
472    ///
473    /// ```ignore
474    /// # use phi_agent::{PhiAgent, PhiAgentConfig, base_agent_builder, build_system_prompt};
475    /// # use phi_agent::McpServerConfig;
476    /// # use std::sync::Arc;
477    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
478    /// # let llm_client: Arc<dyn agent_base::llm_trait::LlmProvider> = todo!("create your LLM provider");
479    /// # let builder = base_agent_builder(llm_client).system_prompt(build_system_prompt());
480    /// # let agent = PhiAgent::build(builder, PhiAgentConfig::default())?;
481    /// # agent.attach_mcp(McpServerConfig {
482    /// #     name: "filesystem".into(),
483    /// #     transport: phi_agent::McpTransport::Stdio {
484    /// #         command: "npx".into(), args: vec!["-y".into(), "@modelcontextprotocol/server-filesystem".into(), "/tmp".into()],
485    /// #         env: Default::default(),
486    /// #     },
487    /// # }).await?;
488    /// // Detach the server — tools are unregistered, connection is closed
489    /// agent.detach_mcp("filesystem").await;
490    /// # Ok(())
491    /// # }
492    /// ```
493    ///
494    /// # Concurrency note
495    ///
496    /// There is a TOCTOU window between collecting tool names (read lock) and
497    /// removing them (write lock). If another thread re-attaches a server with
498    /// the same name during this window, its tools may be prematurely removed.
499    /// In practice this race is harmless: the new attach will re-register tools
500    /// on the next turn, and tool calls in flight will fail with a clear error
501    /// since `hub.remove_server` disconnects clients.
502    pub async fn detach_mcp(&self, name: &str) {
503        let hub = {
504            let guard = self.mcp_hub.lock().await;
505            match *guard {
506                Some(ref hub) => hub.clone(),
507                None => return,
508            }
509        };
510
511        // Collect tool names matching the mcp.<server>.<tool> prefix.
512        // NOTE: the "mcp.<server>.<tool>" naming convention is defined by
513        // agent_works::mcp::McpToolAdapter. If that convention changes, this
514        // prefix must be updated.
515        let mcp_prefix = format!("mcp.{}.", name);
516        let tool_names: Vec<String> = {
517            let tools = self.runtime.tools_mut();
518            let registry = tools.read().await;
519            registry.metadatas().iter().filter(|m| m.name.starts_with(&mcp_prefix)).map(|m| m.name.clone()).collect()
520        };
521
522        // Unregister tools from the runtime
523        if !tool_names.is_empty() {
524            let tools = self.runtime.tools_mut();
525            let mut registry = tools.write().await;
526            for tool_name in &tool_names {
527                registry.remove(tool_name);
528            }
529        }
530
531        // Remove the server from the hub (disconnects clients)
532        hub.remove_server(name).await;
533
534        tracing::info!(
535            server_name = %name,
536            tool_count = tool_names.len(),
537            "detached MCP server at runtime"
538        );
539    }
540
541    // ── MCP Server (Phase 4.1) ──
542
543    /// Convert this agent into an MCP server that external orchestrators can call.
544    ///
545    /// # Example
546    ///
547    /// ```ignore
548    /// # use phi_agent::{PhiAgent, PhiAgentConfig, base_agent_builder, build_system_prompt};
549    /// # use phi_agent::{McpServeConfig, McpServerTransport};
550    /// # use std::sync::Arc;
551    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
552    /// # let llm_client: Arc<dyn agent_base::llm_trait::LlmProvider> = todo!("create your LLM provider");
553    /// # let builder = base_agent_builder(llm_client).system_prompt(build_system_prompt());
554    /// # let agent = PhiAgent::build(builder, PhiAgentConfig::default())?;
555    /// // Expose the agent as an MCP server via stdio
556    /// let mcp_server = agent.into_mcp_server(McpServeConfig {
557    ///     transport: McpServerTransport::Stdio,
558    ///     name: "phi-agent".into(),
559    ///     version: "1.0.0".into(),
560    /// });
561    /// // External orchestrators (LangGraph, CrewAI, etc.) can now call
562    /// // the agent's tools through the MCP protocol
563    /// # Ok(())
564    /// # }
565    /// ```
566    pub fn into_mcp_server(&self, config: agent_works::mcp::McpServeConfig) -> agent_works::mcp::McpServer {
567        agent_works::mcp::McpServer::new(self.runtime.clone(), config)
568    }
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574    use crate::prompt::build_system_prompt;
575    use async_trait::async_trait;
576    use futures_core::Stream;
577    use std::pin::Pin;
578    use std::task::{Context, Poll};
579
580    struct StubClient;
581
582    /// Yields one `Text` chunk, one `Stop` chunk, then ends — a minimal valid
583    /// LLM response that lets the react loop complete a turn.
584    struct StopStream {
585        state: u8,
586    }
587
588    impl Stream for StopStream {
589        type Item = Result<agent_base::StreamChunk, agent_base::llm_trait::LlmError>;
590
591        fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
592            match self.state {
593                0 => {
594                    self.state = 1;
595                    Poll::Ready(Some(Ok(agent_base::StreamChunk::Text("hello".to_string()))))
596                },
597                1 => {
598                    self.state = 2;
599                    Poll::Ready(Some(Ok(agent_base::StreamChunk::Stop { finish_reason: Some("stop".to_string()) })))
600                },
601                _ => Poll::Ready(None),
602            }
603        }
604    }
605
606    #[async_trait]
607    impl agent_base::llm_trait::LlmProvider for StubClient {
608        async fn stream(
609            &self,
610            _request: agent_base::llm_trait::ChatRequest,
611        ) -> Result<agent_base::llm_trait::ChatStream, agent_base::llm_trait::LlmError> {
612            Ok(agent_base::llm_trait::ChatStream::new(Box::pin(StopStream { state: 0 })))
613        }
614        async fn chat(
615            &self,
616            _request: agent_base::llm_trait::ChatRequest,
617        ) -> Result<agent_base::llm_trait::ChatResponse, agent_base::llm_trait::LlmError> {
618            Ok(agent_base::llm_trait::ChatResponse {
619                content: "stub".to_string(),
620                reasoning_content: None,
621                tool_calls: vec![],
622                usage: agent_base::UsageInfo::default(),
623                finish_reason: agent_base::llm_trait::response::FinishReason::Stop,
624                raw: None,
625                thinking_signature: None,
626            })
627        }
628        fn capabilities(&self) -> agent_base::llm_trait::Capabilities {
629            agent_base::llm_trait::Capabilities::default()
630        }
631        fn info(&self) -> agent_base::llm_trait::ProviderInfo {
632            agent_base::llm_trait::ProviderInfo { name: "stub".to_string(), model: "stub".to_string(), version: None }
633        }
634    }
635
636    fn client() -> Arc<dyn agent_base::llm_trait::LlmProvider> {
637        Arc::new(StubClient)
638    }
639
640    fn build_agent() -> PhiAgent {
641        let builder = PhiAgent::builder(client(), build_system_prompt());
642        PhiAgent::build(builder, PhiAgentConfig::default()).unwrap()
643    }
644
645    #[test]
646    fn test_phi_agent_config_default() {
647        let cfg = PhiAgentConfig::default();
648        assert!(cfg.model.is_empty());
649        assert!(!cfg.enable_thinking);
650        assert!(cfg.thinking_budget.is_none());
651        assert!(cfg.max_turns.is_none());
652    }
653
654    #[tokio::test(flavor = "multi_thread")]
655    async fn test_builder_and_build() {
656        let builder = PhiAgent::builder(client(), "custom prompt".to_string());
657        let agent = PhiAgent::build(builder, PhiAgentConfig::default()).unwrap();
658        let _ = agent.runtime();
659        assert!(agent.config.model.is_empty());
660    }
661
662    #[tokio::test(flavor = "multi_thread")]
663    async fn test_delegate_methods() {
664        let agent = build_agent();
665
666        let session = agent.create_session().await;
667        agent.set_reasoning_effort(agent_base::ReasoningEffort::High).await;
668
669        let tools = agent.list_tools().await;
670        let _ = tools;
671
672        // A turn with the stub client's Text+Stop stream should complete.
673        let outcome = agent.run_turn(session, "hi", |_| Ok(())).await;
674        assert!(outcome.is_ok());
675
676        assert!(!agent.is_cancelled());
677        agent.cancel();
678        assert!(agent.is_cancelled());
679    }
680
681    #[cfg(feature = "mcp")]
682    #[tokio::test(flavor = "multi_thread")]
683    async fn test_into_mcp_server() {
684        let agent = build_agent();
685        let server = agent.into_mcp_server(agent_works::mcp::McpServeConfig::default());
686        let _ = server;
687    }
688
689    #[cfg(feature = "mcp")]
690    #[tokio::test(flavor = "multi_thread")]
691    async fn test_attach_mcp_connection_failure_rolls_back() {
692        let agent = build_agent();
693        // A Stdio transport with a nonexistent command fails at `McpClient::new`
694        // (process spawn), which surfaces as an attach error and rolls back.
695        let config = agent_works::mcp::McpServerConfig {
696            name: "bogus".to_string(),
697            transport: agent_works::mcp::McpTransport::Stdio {
698                command: "definitely-not-a-real-command-xyz".to_string(),
699                args: vec![],
700            },
701            auto_reconnect: false,
702        };
703        // Spawn failure → attach fails and rolls the server back.
704        assert!(agent.attach_mcp(config).await.is_err());
705    }
706
707    #[cfg(feature = "mcp")]
708    #[tokio::test(flavor = "multi_thread")]
709    async fn test_detach_mcp_noop_when_not_attached() {
710        let agent = build_agent();
711        // No hub initialized → detach is a no-op.
712        agent.detach_mcp("never-attached").await;
713    }
714
715    // ── Phase 2: resume_session tests ──
716
717    #[tokio::test(flavor = "multi_thread")]
718    async fn test_resume_session_preserves_fresh_system_prompt() {
719        let agent = build_agent();
720
721        // Simulate a conversation: User + Assistant.
722        let historical = vec![
723            ChatMessage::User { content: "what is 2+2?".to_string(), images: vec![], ephemeral: false },
724            ChatMessage::Assistant {
725                content: Some("4".to_string()),
726                reasoning_content: None,
727                thinking_signature: None,
728                tool_calls: None,
729            },
730        ];
731
732        let session_id = agent.resume_session(historical).await.unwrap();
733
734        // The session should contain: [System(fresh), User, Assistant].
735        let msgs = agent.runtime().get_messages(&session_id).await.unwrap();
736        assert!(msgs.len() >= 3, "expected at least System + User + Assistant, got {}", msgs.len());
737
738        // First message must be the fresh System prompt.
739        assert!(
740            matches!(&msgs[0], ChatMessage::System { content, .. } if !content.is_empty()),
741            "first message must be a non-empty System prompt, got {:?}",
742            msgs[0]
743        );
744
745        // The historical messages follow.
746        assert!(matches!(&msgs[1], ChatMessage::User { content, .. } if content == "what is 2+2?"));
747        assert!(matches!(&msgs[2], ChatMessage::Assistant { content, .. } if content.as_deref() == Some("4")));
748    }
749
750    #[tokio::test(flavor = "multi_thread")]
751    async fn test_resume_session_rejects_empty_messages() {
752        let agent = build_agent();
753
754        // Empty messages → validate_message_sequence fails (no sendable message).
755        let err = agent.resume_session(vec![]).await.unwrap_err();
756        assert!(
757            matches!(err, agent_base::AgentError::ConfigError(_)),
758            "expected ConfigError for empty messages, got {:?}",
759            err
760        );
761    }
762
763    #[tokio::test(flavor = "multi_thread")]
764    async fn test_resume_session_roundtrip_with_persist() {
765        use crate::session::{load_session_messages, persist_window_messages};
766
767        let agent = build_agent();
768
769        // Persist a conversation to disk, then load and resume.
770        let tmp = tempfile::TempDir::new().unwrap();
771        let messages = vec![
772            ChatMessage::User { content: "hello".to_string(), images: vec![], ephemeral: false },
773            ChatMessage::Assistant {
774                content: Some("hi!".to_string()),
775                reasoning_content: Some("thinking...".to_string()),
776                thinking_signature: Some("sig".to_string()),
777                tool_calls: None,
778            },
779        ];
780        persist_window_messages(tmp.path(), &messages).unwrap();
781
782        let loaded = load_session_messages(tmp.path()).unwrap();
783        let session_id = agent.resume_session(loaded).await.unwrap();
784
785        let msgs = agent.runtime().get_messages(&session_id).await.unwrap();
786        // System + User + Assistant
787        assert!(msgs.len() >= 3);
788
789        // System is fresh (not the persisted one — there was none).
790        assert!(matches!(&msgs[0], ChatMessage::System { .. }));
791
792        // Assistant reasoning_content was stripped during load.
793        if let ChatMessage::Assistant { reasoning_content, thinking_signature, .. } = &msgs[2] {
794            assert!(reasoning_content.is_none());
795            assert!(thinking_signature.is_none());
796        } else {
797            panic!("expected Assistant at index 2");
798        }
799    }
800}