phi_agent/agent/factory.rs
1use std::sync::Arc;
2
3use agent_base::{AgentResult, AgentRuntime, ReasoningEffort, RunOutcome, RuntimeEvent, SafetyConfig, SessionId};
4
5use agent_works::AgentBuilder;
6
7use crate::agent::builder::base_agent_builder;
8
9/// phi-agent configuration (tool-agnostic).
10///
11/// This config covers model and safety settings only. Tools are registered
12/// externally on [`agent_works::AgentBuilder`] — phi-agent itself never bundles tools
13/// beyond kernel tools (multi-agent, skills) which are opt-in via feature flags.
14#[derive(Clone, Default)]
15pub struct PhiAgentConfig {
16 /// Model name passed to the LLM provider (e.g. `"opus"`, `"gpt-4o"`).
17 pub model: String,
18 /// Enable extended thinking / chain-of-thought.
19 pub enable_thinking: bool,
20 /// Token budget for thinking (provider-dependent). `None` means use the
21 /// provider default.
22 pub thinking_budget: Option<u64>,
23 /// Reasoning intensity: Low / Medium / High / XHigh.
24 pub thinking_effort: ReasoningEffort,
25 /// Per-turn safety limits (max tool calls, max consecutive failures, etc.).
26 pub safety: SafetyConfig,
27 /// React-loop iteration cap for a single run (one user input).
28 /// `None` means use the builder default (200 in [`base_agent_builder`]).
29 pub max_turns: Option<u32>,
30}
31
32/// A built Agent instance.
33///
34/// Wraps [`AgentRuntime`] with common operations behind a simpler API.
35///
36/// ## Example
37///
38/// ```ignore
39/// let agent = PhiAgent::build(builder, config)?;
40/// let session = agent.create_session().await;
41/// agent.run_turn(session, "Hello!", |event| renderer.render(event)).await?;
42/// ```
43#[derive(Clone)]
44pub struct PhiAgent {
45 runtime: AgentRuntime,
46 /// The configuration this agent was built with.
47 pub config: PhiAgentConfig,
48 /// MCP hub for runtime server management. Only available with the `mcp` feature.
49 #[cfg(feature = "mcp")]
50 mcp_hub: Arc<tokio::sync::Mutex<Option<Arc<agent_works::mcp::EnhancedMcpHub>>>>,
51}
52
53impl PhiAgent {
54 /// Create a pre-configured AgentBuilder.
55 ///
56 /// Equivalent to `base_agent_builder(llm_client).system_prompt(system_prompt)`,
57 /// after which you register tools, middleware, and approval handlers,
58 /// then call [`Self::build`].
59 ///
60 /// # Example
61 ///
62 /// ```ignore
63 /// use phi_agent::PhiAgent;
64 /// use phi_agent::build_system_prompt;
65 /// use std::sync::Arc;
66 ///
67 /// let llm_client = Arc::new(phi_agent::OpenAiClient::new(
68 /// "sk-...".into(),
69 /// "gpt-4o".into(),
70 /// Some("https://api.openai.com/v1".into()),
71 /// ));
72 ///
73 /// let builder = PhiAgent::builder(llm_client, build_system_prompt());
74 /// ```
75 pub fn builder(llm_client: Arc<dyn agent_base::StreamClient>, 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 /// let llm_client = Arc::new(phi_agent::OpenAiClient::new(
88 /// "sk-...".into(),
89 /// "gpt-4o".into(),
90 /// Some("https://api.openai.com/v1".into()),
91 /// ));
92 /// let builder = base_agent_builder(llm_client).system_prompt(build_system_prompt());
93 ///
94 /// let config = PhiAgentConfig {
95 /// model: "gpt-4o".into(),
96 /// enable_thinking: true,
97 /// ..Default::default()
98 /// };
99 /// let agent = PhiAgent::build(builder, config)?;
100 /// # Ok::<(), Box<dyn std::error::Error>>(())
101 /// ```
102 pub fn build(builder: AgentBuilder, config: PhiAgentConfig) -> AgentResult<Self> {
103 let runtime = builder.build()?;
104 Ok(Self {
105 runtime,
106 config,
107 #[cfg(feature = "mcp")]
108 mcp_hub: Arc::new(tokio::sync::Mutex::new(None)),
109 })
110 }
111
112 /// Create an agent session.
113 ///
114 /// # Example
115 ///
116 /// ```ignore
117 /// # use phi_agent::{PhiAgent, PhiAgentConfig, base_agent_builder, build_system_prompt};
118 /// # use std::sync::Arc;
119 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
120 /// # let llm_client = Arc::new(phi_agent::OpenAiClient::new("sk-...".into(), "gpt-4o".into(), None));
121 /// # let builder = base_agent_builder(llm_client).system_prompt(build_system_prompt());
122 /// # let agent = PhiAgent::build(builder, PhiAgentConfig::default())?;
123 /// let session = agent.create_session().await;
124 /// println!("Session id: {}", session.id);
125 /// # Ok(())
126 /// # }
127 /// ```
128 pub async fn create_session(&self) -> SessionId {
129 self.runtime.create_session().await
130 }
131
132 /// Execute one turn.
133 ///
134 /// # Example
135 ///
136 /// ```ignore
137 /// # use phi_agent::{PhiAgent, PhiAgentConfig, base_agent_builder, build_system_prompt};
138 /// # use phi_agent::create_stdout_renderer;
139 /// # use std::sync::Arc;
140 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
141 /// # let llm_client = Arc::new(phi_agent::OpenAiClient::new("sk-...".into(), "gpt-4o".into(), None));
142 /// # let builder = base_agent_builder(llm_client).system_prompt(build_system_prompt());
143 /// # let agent = PhiAgent::build(builder, PhiAgentConfig::default())?;
144 /// let session = agent.create_session().await;
145 /// let renderer = create_stdout_renderer();
146 ///
147 /// let outcome = agent
148 /// .run_turn(session, "What is 2+2?", |event| renderer.render(event))
149 /// .await?;
150 /// println!("Turn completed: {:?}", outcome);
151 /// # Ok(())
152 /// # }
153 /// ```
154 pub async fn run_turn<F>(&self, session_id: SessionId, query: &str, on_event: F) -> AgentResult<RunOutcome>
155 where
156 F: FnMut(RuntimeEvent) -> AgentResult<()> + Send,
157 {
158 self.runtime.run_turn(session_id, query, on_event).await
159 }
160
161 /// Cancel the currently executing turn.
162 ///
163 /// # Example
164 ///
165 /// ```ignore
166 /// # use phi_agent::{PhiAgent, PhiAgentConfig, base_agent_builder, build_system_prompt};
167 /// # use std::sync::Arc;
168 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
169 /// # let llm_client = Arc::new(phi_agent::OpenAiClient::new("sk-...".into(), "gpt-4o".into(), None));
170 /// # let builder = base_agent_builder(llm_client).system_prompt(build_system_prompt());
171 /// # let agent = PhiAgent::build(builder, PhiAgentConfig::default())?;
172 /// let agent_clone = agent.clone();
173 /// let session = agent.create_session().await;
174 ///
175 /// // Run the turn in a separate task
176 /// let handle = tokio::spawn(async move {
177 /// agent_clone.run_turn(session, "count to 100", |_| Ok(())).await
178 /// });
179 ///
180 /// // Cancel after a short delay
181 /// tokio::time::sleep(std::time::Duration::from_millis(100)).await;
182 /// agent.cancel();
183 /// # Ok(())
184 /// # }
185 /// ```
186 pub fn cancel(&self) {
187 self.runtime.cancel();
188 }
189
190 /// Check whether the agent has been cancelled.
191 ///
192 /// # Example
193 ///
194 /// ```ignore
195 /// # use phi_agent::{PhiAgent, PhiAgentConfig, base_agent_builder, build_system_prompt};
196 /// # use std::sync::Arc;
197 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
198 /// # let llm_client = Arc::new(phi_agent::OpenAiClient::new("sk-...".into(), "gpt-4o".into(), None));
199 /// # let builder = base_agent_builder(llm_client).system_prompt(build_system_prompt());
200 /// # let agent = PhiAgent::build(builder, PhiAgentConfig::default())?;
201 /// assert!(!agent.is_cancelled());
202 /// agent.cancel();
203 /// assert!(agent.is_cancelled());
204 /// # Ok(())
205 /// # }
206 /// ```
207 pub fn is_cancelled(&self) -> bool {
208 self.runtime.is_cancelled()
209 }
210
211 /// Set the reasoning effort.
212 ///
213 /// # Example
214 ///
215 /// ```ignore
216 /// use phi_agent::ReasoningEffort;
217 /// # use phi_agent::{PhiAgent, PhiAgentConfig, base_agent_builder, build_system_prompt};
218 /// # use std::sync::Arc;
219 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
220 /// # let llm_client = Arc::new(phi_agent::OpenAiClient::new("sk-...".into(), "gpt-4o".into(), None));
221 /// # let builder = base_agent_builder(llm_client).system_prompt(build_system_prompt());
222 /// # let agent = PhiAgent::build(builder, PhiAgentConfig::default())?;
223 /// // Switch to high reasoning effort mid-conversation
224 /// agent.set_reasoning_effort(ReasoningEffort::High).await;
225 /// # Ok(())
226 /// # }
227 /// ```
228 pub async fn set_reasoning_effort(&self, effort: ReasoningEffort) {
229 self.runtime.set_reasoning_effort(effort).await;
230 }
231
232 /// Access the underlying runtime (for advanced use like hook registration).
233 ///
234 /// # Example
235 ///
236 /// ```ignore
237 /// # use phi_agent::{PhiAgent, PhiAgentConfig, base_agent_builder, build_system_prompt};
238 /// # use std::sync::Arc;
239 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
240 /// # let llm_client = Arc::new(phi_agent::OpenAiClient::new("sk-...".into(), "gpt-4o".into(), None));
241 /// # let builder = base_agent_builder(llm_client).system_prompt(build_system_prompt());
242 /// # let agent = PhiAgent::build(builder, PhiAgentConfig::default())?;
243 /// let runtime = agent.runtime();
244 /// let event_rx = runtime.subscribe_runtime_events();
245 /// println!("Subscribed to events, receiver lag: {}", event_rx.len());
246 /// # Ok(())
247 /// # }
248 /// ```
249 pub fn runtime(&self) -> &AgentRuntime {
250 &self.runtime
251 }
252
253 /// List all registered tools with their metadata, sorted by name.
254 ///
255 /// # Example
256 ///
257 /// ```ignore
258 /// # use phi_agent::{PhiAgent, PhiAgentConfig, base_agent_builder, build_system_prompt};
259 /// # use std::sync::Arc;
260 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
261 /// # let llm_client = Arc::new(phi_agent::OpenAiClient::new("sk-...".into(), "gpt-4o".into(), None));
262 /// # let builder = base_agent_builder(llm_client).system_prompt(build_system_prompt());
263 /// # let agent = PhiAgent::build(builder, PhiAgentConfig::default())?;
264 /// let tools = agent.list_tools().await;
265 /// for tool in &tools {
266 /// println!("{} - {} ({})", tool.name, tool.description, tool.origin);
267 /// }
268 /// println!("{} tools registered", tools.len());
269 /// # Ok(())
270 /// # }
271 /// ```
272 pub async fn list_tools(&self) -> Vec<agent_base::ToolMetadata> {
273 let tools = self.runtime.tools_mut();
274 let registry = tools.read().await;
275 registry.metadatas()
276 }
277}
278
279// ── MCP Runtime Management (Phase 1.2) ──
280
281#[cfg(feature = "mcp")]
282impl PhiAgent {
283 /// Get or lazily initialize the MCP hub.
284 async fn get_or_init_hub(&self) -> Arc<agent_works::mcp::EnhancedMcpHub> {
285 let mut guard = self.mcp_hub.lock().await;
286 if let Some(ref hub) = *guard {
287 return hub.clone();
288 }
289 let hub = Arc::new(agent_works::mcp::EnhancedMcpHub::new());
290 *guard = Some(hub.clone());
291 hub
292 }
293
294 /// Dynamically attach an MCP server at runtime.
295 ///
296 /// Adds the server config, connects, discovers tools, and registers them
297 /// into the agent's `ToolRegistry`. Tools are registered with the
298 /// `mcp.<server_name>.<tool_name>` naming convention.
299 ///
300 /// Returns an error if the server cannot be connected or tools cannot be
301 /// discovered. On failure, the server config is rolled back (removed from
302 /// the hub) so a partial entry is never left behind.
303 ///
304 /// # Example
305 ///
306 /// ```ignore
307 /// # use phi_agent::{PhiAgent, PhiAgentConfig, base_agent_builder, build_system_prompt};
308 /// # use phi_agent::McpServerConfig;
309 /// # use std::sync::Arc;
310 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
311 /// # let llm_client = Arc::new(phi_agent::OpenAiClient::new("sk-...".into(), "gpt-4o".into(), None));
312 /// # let builder = base_agent_builder(llm_client).system_prompt(build_system_prompt());
313 /// # let agent = PhiAgent::build(builder, PhiAgentConfig::default())?;
314 /// // Attach a filesystem MCP server at runtime
315 /// agent
316 /// .attach_mcp(McpServerConfig {
317 /// name: "filesystem".into(),
318 /// transport: phi_agent::McpTransport::Stdio {
319 /// command: "npx".into(),
320 /// args: vec!["-y".into(), "@modelcontextprotocol/server-filesystem".into(), "/tmp".into()],
321 /// env: Default::default(),
322 /// },
323 /// })
324 /// .await?;
325 /// # Ok(())
326 /// # }
327 /// ```
328 ///
329 /// # Performance note
330 ///
331 /// Currently calls `hub.register_all()` which re-registers all servers'
332 /// tools (O(total-servers)). For the common case this is fine because
333 /// re-registration is a no-op HashMap insert. A future optimization would
334 /// register only the newly attached server's tools.
335 pub async fn attach_mcp(&self, config: agent_works::mcp::McpServerConfig) -> AgentResult<()> {
336 let name = config.name.clone();
337 let hub = self.get_or_init_hub().await;
338
339 // Add server config and attempt connection
340 hub.add_server(config);
341 if let Err(e) = hub.connect_one(&name).await {
342 hub.remove_server(&name).await;
343 return Err(e);
344 }
345
346 // Discover tools; rollback on failure
347 let discovered = match hub.discover_all().await {
348 Ok(d) => d,
349 Err(e) => {
350 hub.remove_server(&name).await;
351 return Err(e);
352 },
353 };
354
355 // Register only the newly attached server's tools
356 let tools = self.runtime.tools_mut();
357 let mut registry = tools.write().await;
358 hub.register_server(&mut registry, &name).await;
359
360 // Inject framework dependencies (e.g. EventBus) into the new tools
361 self.runtime.inject_framework_deps(®istry);
362
363 let count: usize = discovered.iter().filter(|(n, _)| n == &name).map(|(_, t)| t.len()).sum();
364 if count == 0 {
365 tracing::warn!(
366 server_name = %name,
367 "attached MCP server but discovered zero tools — server may be misconfigured"
368 );
369 }
370 tracing::info!(server_name = %name, tool_count = count, "attached MCP server at runtime");
371 Ok(())
372 }
373
374 /// Dynamically detach an MCP server at runtime.
375 ///
376 /// Unregisters all tools belonging to this server from the agent's
377 /// `ToolRegistry`, disconnects the server, and removes its config
378 /// from the hub.
379 ///
380 /// This is a no-op if the server is not attached.
381 ///
382 /// # Example
383 ///
384 /// ```ignore
385 /// # use phi_agent::{PhiAgent, PhiAgentConfig, base_agent_builder, build_system_prompt};
386 /// # use phi_agent::McpServerConfig;
387 /// # use std::sync::Arc;
388 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
389 /// # let llm_client = Arc::new(phi_agent::OpenAiClient::new("sk-...".into(), "gpt-4o".into(), None));
390 /// # let builder = base_agent_builder(llm_client).system_prompt(build_system_prompt());
391 /// # let agent = PhiAgent::build(builder, PhiAgentConfig::default())?;
392 /// # agent.attach_mcp(McpServerConfig {
393 /// # name: "filesystem".into(),
394 /// # transport: phi_agent::McpTransport::Stdio {
395 /// # command: "npx".into(), args: vec!["-y".into(), "@modelcontextprotocol/server-filesystem".into(), "/tmp".into()],
396 /// # env: Default::default(),
397 /// # },
398 /// # }).await?;
399 /// // Detach the server — tools are unregistered, connection is closed
400 /// agent.detach_mcp("filesystem").await;
401 /// # Ok(())
402 /// # }
403 /// ```
404 ///
405 /// # Concurrency note
406 ///
407 /// There is a TOCTOU window between collecting tool names (read lock) and
408 /// removing them (write lock). If another thread re-attaches a server with
409 /// the same name during this window, its tools may be prematurely removed.
410 /// In practice this race is harmless: the new attach will re-register tools
411 /// on the next turn, and tool calls in flight will fail with a clear error
412 /// since `hub.remove_server` disconnects clients.
413 pub async fn detach_mcp(&self, name: &str) {
414 let hub = {
415 let guard = self.mcp_hub.lock().await;
416 match *guard {
417 Some(ref hub) => hub.clone(),
418 None => return,
419 }
420 };
421
422 // Collect tool names matching the mcp.<server>.<tool> prefix.
423 // NOTE: the "mcp.<server>.<tool>" naming convention is defined by
424 // agent_works::mcp::McpToolAdapter. If that convention changes, this
425 // prefix must be updated.
426 let mcp_prefix = format!("mcp.{}.", name);
427 let tool_names: Vec<String> = {
428 let tools = self.runtime.tools_mut();
429 let registry = tools.read().await;
430 registry.metadatas().iter().filter(|m| m.name.starts_with(&mcp_prefix)).map(|m| m.name.clone()).collect()
431 };
432
433 // Unregister tools from the runtime
434 if !tool_names.is_empty() {
435 let tools = self.runtime.tools_mut();
436 let mut registry = tools.write().await;
437 for tool_name in &tool_names {
438 registry.remove(tool_name);
439 }
440 }
441
442 // Remove the server from the hub (disconnects clients)
443 hub.remove_server(name).await;
444
445 tracing::info!(
446 server_name = %name,
447 tool_count = tool_names.len(),
448 "detached MCP server at runtime"
449 );
450 }
451
452 // ── MCP Server (Phase 4.1) ──
453
454 /// Convert this agent into an MCP server that external orchestrators can call.
455 ///
456 /// # Example
457 ///
458 /// ```ignore
459 /// # use phi_agent::{PhiAgent, PhiAgentConfig, base_agent_builder, build_system_prompt};
460 /// # use phi_agent::{McpServeConfig, McpServerTransport};
461 /// # use std::sync::Arc;
462 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
463 /// # let llm_client = Arc::new(phi_agent::OpenAiClient::new("sk-...".into(), "gpt-4o".into(), None));
464 /// # let builder = base_agent_builder(llm_client).system_prompt(build_system_prompt());
465 /// # let agent = PhiAgent::build(builder, PhiAgentConfig::default())?;
466 /// // Expose the agent as an MCP server via stdio
467 /// let mcp_server = agent.into_mcp_server(McpServeConfig {
468 /// transport: McpServerTransport::Stdio,
469 /// name: "phi-agent".into(),
470 /// version: "1.0.0".into(),
471 /// });
472 /// // External orchestrators (LangGraph, CrewAI, etc.) can now call
473 /// // the agent's tools through the MCP protocol
474 /// # Ok(())
475 /// # }
476 /// ```
477 pub fn into_mcp_server(&self, config: agent_works::mcp::McpServeConfig) -> agent_works::mcp::McpServer {
478 agent_works::mcp::McpServer::new(self.runtime.clone(), config)
479 }
480}