Skip to main content

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(&registry);
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}