Skip to main content

phi_agent/agent/
builder.rs

1//! General-purpose AgentBuilder factory — provides default configuration
2//! shared across consumers.
3//!
4//! Returns a pre-configured [`agent_works::AgentBuilder`]; callers then register
5//! tools and approval handlers on top.
6
7#[cfg(feature = "file")]
8use std::path::PathBuf;
9use std::sync::Arc;
10
11use agent_base::{ConsecutiveFailureRecovery, Language, ReasoningConfig, ReasoningEffort};
12
13use crate::agent::compression::SummarizingMiddleware;
14
15/// Returns an [`agent_works::AgentBuilder`] with sensible defaults:
16/// - English
17/// - Medium reasoning effort
18/// - Thinking enabled
19/// - Consecutive failure recovery (default 3 retries)
20/// - Session limits (50 sessions / 100 turns per session / 50k per-message cap)
21/// - Per-run react-loop cap (200 iterations for one user input)
22/// - LLM-based context compression for long tool-heavy conversations
23/// - File tools (read_file / write_file / list_files) — enabled by default
24/// - Skills injected into system prompt (not as tools — LLM uses read_file;
25///   enabled by default via `file` feature)
26/// - MCP protocol support — enabled by default
27/// - Telemetry + logging — enabled by default
28///
29/// Callers are responsible for: registering additional tools, setting the approval
30/// handler, setting the system prompt, then calling `.build()`.
31///
32/// Feature groups (opt-in):
33/// - `shell`: shell execution (`--features shell`)
34/// - `multi-agent`: multi-agent support (`--features multi-agent`)
35/// - `browser`: browser automation via CDP (`--features browser`)
36/// - `protocol` meta: MCP
37/// - `observability` meta: telemetry + logging
38/// - `app` meta: browser (NOT included in `full`)
39/// - `full`: file + shell + mcp + telemetry + logging (excludes browser and multi-agent)
40#[allow(unused_mut)]
41pub fn base_agent_builder(llm_client: Arc<dyn agent_base::StreamClient>) -> agent_works::AgentBuilder {
42    // Tool-output cap (default 4000 chars). Tune via PHI_MAX_TOOL_OUTPUT_CHARS for large
43    // outputs (HTML, base64 images, long lists). Truncated results carry an explicit
44    // "...(truncated)" suffix plus structured TruncationInfo from agent-base.
45    let max_tool_output_chars = match std::env::var("PHI_MAX_TOOL_OUTPUT_CHARS") {
46        Ok(value) => match value.trim().parse::<usize>() {
47            Ok(n) => n,
48            Err(_) => {
49                tracing::warn!(
50                    value = %value,
51                    "PHI_MAX_TOOL_OUTPUT_CHARS is not a valid integer; falling back to default 4000"
52                );
53                4000
54            },
55        },
56        Err(_) => 4000,
57    };
58
59    #[cfg(feature = "file")]
60    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
61
62    let mut builder = agent_works::AgentBuilder::new(llm_client.clone())
63        .language(Language::En)
64        .reasoning(ReasoningConfig { effort: Some(ReasoningEffort::Medium), ..Default::default() })
65        .enable_thought(true)
66        .enable_thinking(true)
67        .max_sessions(50)
68        .max_turns_per_session(100)
69        .execution_max_turns(200)
70        .max_message_tokens(50_000)
71        .max_tool_output_chars(max_tool_output_chars)
72        .error_recovery(Arc::new(ConsecutiveFailureRecovery::new(3)))
73        // Summarise the earlier part of long conversations so per-call LLM context
74        // stays bounded (see compression.rs). Override via the returned builder, or
75        // build your own AgentBuilder to opt out.
76        .middleware(SummarizingMiddleware::new(llm_client));
77
78    // ── File tools (opt-in via `file` feature) ──
79    #[cfg(feature = "file")]
80    {
81        use phi_kernel_tools::file::{ListFilesTool, ReadFileTool, WriteFileTool};
82        builder = builder
83            .register_tool_arc(Arc::new(ReadFileTool::new(cwd.clone())))
84            .register_tool_arc(Arc::new(WriteFileTool::new(cwd.clone())))
85            .register_tool_arc(Arc::new(ListFilesTool::new(cwd.clone())));
86    }
87
88    // ── Multi-agent (opt-in) ──
89    #[cfg(feature = "multi-agent")]
90    {
91        use agent_works::multi_agent::MultiAgentConfig;
92        builder =
93            builder.with_multi_agent(MultiAgentConfig::default()).with_multi_agent_tool_factory(Arc::new(|runtime| {
94                phi_kernel_tools::multi_agent::create_all_tools(runtime)
95            }));
96    }
97
98    // ── Skills: prompt-injection mode (uses read_file, no skill-specific tools) ──
99    #[cfg(feature = "file")]
100    {
101        use agent_works::skill::Skill;
102        use agent_works::skill::prompt_skill::PromptSkill;
103        let skill_dirs: Vec<PathBuf> = vec![
104            // User-level skills (low priority)
105            dirs_next().join(".phi").join("skills"),
106            // Project-level skills (high priority, loaded last to override)
107            PathBuf::from(".phi/skills"),
108        ];
109
110        for dir in &skill_dirs {
111            if dir.is_dir() {
112                match PromptSkill::scan_dir(dir) {
113                    Ok(skills) => {
114                        for skill in skills {
115                            tracing::debug!(
116                                name = skill.name(),
117                                dir = %dir.display(),
118                                "auto-loaded skill (prompt-injection mode)"
119                            );
120                            builder = builder.register_skill(skill);
121                        }
122                    },
123                    Err(e) => {
124                        tracing::warn!(dir = %dir.display(), error = %e, "failed to scan skills directory");
125                    },
126                }
127            }
128        }
129    }
130
131    builder
132}
133
134/// Resolve the user's home directory for `~/.phi/skills/`.
135#[cfg(feature = "file")]
136fn dirs_next() -> std::path::PathBuf {
137    std::env::var("HOME")
138        .or_else(|_| std::env::var("USERPROFILE"))
139        .map(PathBuf::from)
140        .unwrap_or_else(|_| PathBuf::from("."))
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use async_trait::async_trait;
147    use futures_core::Stream;
148    use std::pin::Pin;
149    use std::task::{Context, Poll};
150
151    struct StubClient;
152    struct EmptyStream;
153
154    impl Stream for EmptyStream {
155        type Item = agent_base::AgentResult<agent_base::StreamChunk>;
156        fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
157            Poll::Ready(None)
158        }
159    }
160
161    #[async_trait]
162    impl agent_base::LlmClient for StubClient {
163        async fn chat(
164            &self,
165            _messages: &[agent_base::ChatMessage],
166            _tools: &[serde_json::Value],
167            _reasoning: Option<&agent_base::ReasoningConfig>,
168            _response_format: Option<&agent_base::ResponseFormat>,
169        ) -> agent_base::AgentResult<serde_json::Value> {
170            Ok(serde_json::json!({"choices":[{"message":{"content":"stub"}}]}))
171        }
172        async fn chat_stream(
173            &self,
174            _messages: &[agent_base::ChatMessage],
175            _tools: &[serde_json::Value],
176            _reasoning: Option<&agent_base::ReasoningConfig>,
177            _response_format: Option<&agent_base::ResponseFormat>,
178        ) -> agent_base::AgentResult<Pin<Box<dyn Stream<Item = agent_base::AgentResult<agent_base::StreamChunk>> + Send>>>
179        {
180            Ok(Box::pin(EmptyStream))
181        }
182        fn capabilities(&self) -> agent_base::LlmCapabilities {
183            agent_base::LlmCapabilities {
184                supports_streaming: true,
185                supports_tools: true,
186                supports_vision: false,
187                supports_thinking: true,
188                max_context_tokens: Some(128_000),
189                max_output_tokens: Some(16_384),
190            }
191        }
192    }
193
194    #[test]
195    fn test_max_tool_output_chars_default() {
196        unsafe { std::env::remove_var("PHI_MAX_TOOL_OUTPUT_CHARS") };
197        let builder = base_agent_builder(agent_base::llm::adapt(Arc::new(StubClient)));
198        let _ = builder;
199    }
200
201    #[test]
202    fn test_max_tool_output_chars_custom() {
203        unsafe { std::env::set_var("PHI_MAX_TOOL_OUTPUT_CHARS", "8000") };
204        let builder = base_agent_builder(agent_base::llm::adapt(Arc::new(StubClient)));
205        let _ = builder;
206        unsafe { std::env::remove_var("PHI_MAX_TOOL_OUTPUT_CHARS") };
207    }
208
209    #[test]
210    fn test_max_tool_output_chars_invalid_fallback() {
211        unsafe { std::env::set_var("PHI_MAX_TOOL_OUTPUT_CHARS", "not-a-number") };
212        let builder = base_agent_builder(agent_base::llm::adapt(Arc::new(StubClient)));
213        let _ = builder;
214        unsafe { std::env::remove_var("PHI_MAX_TOOL_OUTPUT_CHARS") };
215    }
216
217    /// Verify that the default `base_agent_builder()` registers the 6 multi-agent tools.
218    #[cfg(feature = "multi-agent")]
219    #[tokio::test(flavor = "multi_thread")]
220    async fn test_base_agent_builder_registers_multi_agent_tools() {
221        let builder = base_agent_builder(agent_base::llm::adapt(Arc::new(StubClient))).system_prompt("test");
222
223        let runtime = builder.build().unwrap();
224
225        let tools = tokio::task::block_in_place(|| {
226            let tools = runtime.tools_mut();
227            let guard = tools.blocking_read();
228            guard.metadatas().into_iter().map(|m| m.name).collect::<Vec<String>>()
229        });
230
231        assert!(tools.contains(&"spawn_agent".to_string()), "expected spawn_agent tool");
232        assert!(tools.contains(&"send_message".to_string()), "expected send_message tool");
233        assert!(tools.contains(&"followup_task".to_string()), "expected followup_task tool");
234        assert!(tools.contains(&"wait_agent".to_string()), "expected wait_agent tool");
235        assert!(tools.contains(&"list_agents".to_string()), "expected list_agents tool");
236        assert!(tools.contains(&"close_agent".to_string()), "expected close_agent tool");
237    }
238
239    /// Verify that `.without_multi_agent()` on the returned builder removes the tools.
240    #[cfg(feature = "multi-agent")]
241    #[tokio::test(flavor = "multi_thread")]
242    async fn test_base_agent_builder_without_multi_agent() {
243        let builder = base_agent_builder(agent_base::llm::adapt(Arc::new(StubClient)))
244            .system_prompt("test")
245            .without_multi_agent();
246
247        let runtime = builder.build().unwrap();
248
249        let tools = tokio::task::block_in_place(|| {
250            let tools = runtime.tools_mut();
251            let guard = tools.blocking_read();
252            guard.metadatas().into_iter().map(|m| m.name).collect::<Vec<String>>()
253        });
254
255        assert!(!tools.contains(&"spawn_agent".to_string()), "spawn_agent should not be registered");
256        assert!(!tools.contains(&"list_agents".to_string()), "list_agents should not be registered");
257    }
258}