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