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