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}