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 pub fn builder(llm_client: Arc<dyn agent_base::LlmClient>, system_prompt: String) -> AgentBuilder {
60 base_agent_builder(llm_client).system_prompt(system_prompt)
61 }
62
63 /// Build from an AgentBuilder.
64 pub fn build(builder: AgentBuilder, config: PhiAgentConfig) -> AgentResult<Self> {
65 let runtime = builder.build()?;
66 Ok(Self {
67 runtime,
68 config,
69 #[cfg(feature = "mcp")]
70 mcp_hub: Arc::new(tokio::sync::Mutex::new(None)),
71 })
72 }
73
74 /// Create an agent session.
75 pub async fn create_session(&self) -> SessionId {
76 self.runtime.create_session().await
77 }
78
79 /// Execute one turn.
80 pub async fn run_turn<F>(&self, session_id: SessionId, query: &str, on_event: F) -> AgentResult<RunOutcome>
81 where
82 F: FnMut(RuntimeEvent) -> AgentResult<()> + Send,
83 {
84 self.runtime.run_turn(session_id, query, on_event).await
85 }
86
87 /// Cancel the currently executing turn.
88 pub fn cancel(&self) {
89 self.runtime.cancel();
90 }
91
92 /// Check whether the agent has been cancelled.
93 pub fn is_cancelled(&self) -> bool {
94 self.runtime.is_cancelled()
95 }
96
97 /// Set the reasoning effort.
98 pub async fn set_reasoning_effort(&self, effort: ReasoningEffort) {
99 self.runtime.set_reasoning_effort(effort).await;
100 }
101
102 /// Access the underlying runtime (for advanced use like hook registration).
103 pub fn runtime(&self) -> &AgentRuntime {
104 &self.runtime
105 }
106
107 /// List all registered tools with their metadata, sorted by name.
108 pub async fn list_tools(&self) -> Vec<agent_base::ToolMetadata> {
109 let tools = self.runtime.tools_mut();
110 let registry = tools.read().await;
111 registry.metadatas()
112 }
113}
114
115// ── MCP Runtime Management (Phase 1.2) ──
116
117#[cfg(feature = "mcp")]
118impl PhiAgent {
119 /// Get or lazily initialize the MCP hub.
120 async fn get_or_init_hub(&self) -> Arc<agent_works::mcp::EnhancedMcpHub> {
121 let mut guard = self.mcp_hub.lock().await;
122 if let Some(ref hub) = *guard {
123 return hub.clone();
124 }
125 let hub = Arc::new(agent_works::mcp::EnhancedMcpHub::new());
126 *guard = Some(hub.clone());
127 hub
128 }
129
130 /// Dynamically attach an MCP server at runtime.
131 ///
132 /// Adds the server config, connects, discovers tools, and registers them
133 /// into the agent's [`ToolRegistry`]. Tools are registered with the
134 /// `mcp.<server_name>.<tool_name>` naming convention.
135 ///
136 /// Returns an error if the server cannot be connected or tools cannot be
137 /// discovered. On failure, the server config is rolled back (removed from
138 /// the hub) so a partial entry is never left behind.
139 ///
140 /// # Performance note
141 ///
142 /// Currently calls `hub.register_all()` which re-registers all servers'
143 /// tools (O(total-servers)). For the common case this is fine because
144 /// re-registration is a no-op HashMap insert. A future optimization would
145 /// register only the newly attached server's tools.
146 pub async fn attach_mcp(&self, config: agent_works::mcp::McpServerConfig) -> AgentResult<()> {
147 let name = config.name.clone();
148 let hub = self.get_or_init_hub().await;
149
150 // Add server config and attempt connection
151 hub.add_server(config);
152 if let Err(e) = hub.connect_one(&name).await {
153 hub.remove_server(&name).await;
154 return Err(e);
155 }
156
157 // Discover tools; rollback on failure
158 let discovered = match hub.discover_all().await {
159 Ok(d) => d,
160 Err(e) => {
161 hub.remove_server(&name).await;
162 return Err(e);
163 },
164 };
165
166 // Register only the newly attached server's tools
167 let tools = self.runtime.tools_mut();
168 let mut registry = tools.write().await;
169 hub.register_server(&mut registry, &name).await;
170
171 // Inject framework dependencies (e.g. EventBus) into the new tools
172 self.runtime.inject_framework_deps(®istry);
173
174 let count: usize = discovered.iter().filter(|(n, _)| n == &name).map(|(_, t)| t.len()).sum();
175 if count == 0 {
176 tracing::warn!(
177 server_name = %name,
178 "attached MCP server but discovered zero tools — server may be misconfigured"
179 );
180 }
181 tracing::info!(server_name = %name, tool_count = count, "attached MCP server at runtime");
182 Ok(())
183 }
184
185 /// Dynamically detach an MCP server at runtime.
186 ///
187 /// Unregisters all tools belonging to this server from the agent's
188 /// [`ToolRegistry`], disconnects the server, and removes its config
189 /// from the hub.
190 ///
191 /// This is a no-op if the server is not attached.
192 ///
193 /// # Concurrency note
194 ///
195 /// There is a TOCTOU window between collecting tool names (read lock) and
196 /// removing them (write lock). If another thread re-attaches a server with
197 /// the same name during this window, its tools may be prematurely removed.
198 /// In practice this race is harmless: the new attach will re-register tools
199 /// on the next turn, and tool calls in flight will fail with a clear error
200 /// since `hub.remove_server` disconnects clients.
201 pub async fn detach_mcp(&self, name: &str) {
202 let hub = {
203 let guard = self.mcp_hub.lock().await;
204 match *guard {
205 Some(ref hub) => hub.clone(),
206 None => return,
207 }
208 };
209
210 // Collect tool names matching the mcp.<server>.<tool> prefix.
211 // NOTE: the "mcp.<server>.<tool>" naming convention is defined by
212 // agent_works::mcp::McpToolAdapter. If that convention changes, this
213 // prefix must be updated.
214 let mcp_prefix = format!("mcp.{}.", name);
215 let tool_names: Vec<String> = {
216 let tools = self.runtime.tools_mut();
217 let registry = tools.read().await;
218 registry.metadatas().iter().filter(|m| m.name.starts_with(&mcp_prefix)).map(|m| m.name.clone()).collect()
219 };
220
221 // Unregister tools from the runtime
222 if !tool_names.is_empty() {
223 let tools = self.runtime.tools_mut();
224 let mut registry = tools.write().await;
225 for tool_name in &tool_names {
226 registry.remove(tool_name);
227 }
228 }
229
230 // Remove the server from the hub (disconnects clients)
231 hub.remove_server(name).await;
232
233 tracing::info!(
234 server_name = %name,
235 tool_count = tool_names.len(),
236 "detached MCP server at runtime"
237 );
238 }
239
240 // ── MCP Server (Phase 4.1) ──
241
242 /// Convert this agent into an MCP server that external orchestrators can call.
243 #[cfg(feature = "mcp")]
244 pub fn into_mcp_server(&self, config: agent_works::mcp::McpServeConfig) -> agent_works::mcp::McpServer {
245 agent_works::mcp::McpServer::new(self.runtime.clone(), config)
246 }
247}