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 / edit_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::{EditFileTool, 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(EditFileTool::new(cwd.clone())))
86            .register_tool_arc(Arc::new(ListFilesTool::new(cwd.clone())));
87    }
88
89    // ── Multi-agent (opt-in) ──
90    #[cfg(feature = "multi-agent")]
91    {
92        use agent_works::multi_agent::MultiAgentConfig;
93        builder =
94            builder.with_multi_agent(MultiAgentConfig::default()).with_multi_agent_tool_factory(Arc::new(|runtime| {
95                phi_kernel_tools::multi_agent::create_all_tools(runtime)
96            }));
97    }
98
99    // ── Skills: prompt-injection mode (uses read_file, no skill-specific tools) ──
100    #[cfg(feature = "file")]
101    {
102        use agent_works::skill::Skill;
103        use agent_works::skill::prompt_skill::PromptSkill;
104        let skill_dirs: Vec<PathBuf> = vec![
105            // User-level skills (low priority)
106            dirs_next().join(".phi").join("skills"),
107            // Project-level skills (high priority, loaded last to override)
108            PathBuf::from(".phi/skills"),
109        ];
110
111        for dir in &skill_dirs {
112            if dir.is_dir() {
113                match PromptSkill::scan_dir(dir) {
114                    Ok(skills) => {
115                        for skill in skills {
116                            tracing::debug!(
117                                name = skill.name(),
118                                dir = %dir.display(),
119                                "auto-loaded skill (prompt-injection mode)"
120                            );
121                            builder = builder.register_skill(skill);
122                        }
123                    },
124                    Err(e) => {
125                        tracing::warn!(dir = %dir.display(), error = %e, "failed to scan skills directory");
126                    },
127                }
128            }
129        }
130    }
131
132    builder
133}
134
135/// Resolve the user's home directory for `~/.phi/skills/`.
136#[cfg(feature = "file")]
137fn dirs_next() -> std::path::PathBuf {
138    std::env::var("HOME")
139        .or_else(|_| std::env::var("USERPROFILE"))
140        .map(PathBuf::from)
141        .unwrap_or_else(|_| PathBuf::from("."))
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use async_trait::async_trait;
148    use futures_core::Stream;
149    use std::pin::Pin;
150    use std::task::{Context, Poll};
151
152    struct StubClient;
153    struct EmptyStream;
154
155    impl Stream for EmptyStream {
156        type Item = agent_base::AgentResult<agent_base::StreamChunk>;
157        fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
158            Poll::Ready(None)
159        }
160    }
161
162    #[async_trait]
163    impl agent_base::LlmClient for StubClient {
164        async fn chat(
165            &self,
166            _messages: &[agent_base::ChatMessage],
167            _tools: &[serde_json::Value],
168            _reasoning: Option<&agent_base::ReasoningConfig>,
169            _response_format: Option<&agent_base::ResponseFormat>,
170        ) -> agent_base::AgentResult<serde_json::Value> {
171            Ok(serde_json::json!({"choices":[{"message":{"content":"stub"}}]}))
172        }
173        async fn chat_stream(
174            &self,
175            _messages: &[agent_base::ChatMessage],
176            _tools: &[serde_json::Value],
177            _reasoning: Option<&agent_base::ReasoningConfig>,
178            _response_format: Option<&agent_base::ResponseFormat>,
179        ) -> agent_base::AgentResult<Pin<Box<dyn Stream<Item = agent_base::AgentResult<agent_base::StreamChunk>> + Send>>>
180        {
181            Ok(Box::pin(EmptyStream))
182        }
183        fn capabilities(&self) -> agent_base::LlmCapabilities {
184            agent_base::LlmCapabilities {
185                supports_streaming: true,
186                supports_tools: true,
187                supports_vision: false,
188                supports_thinking: true,
189                max_context_tokens: Some(128_000),
190                max_output_tokens: Some(16_384),
191            }
192        }
193    }
194
195    #[test]
196    fn test_max_tool_output_chars_default() {
197        unsafe { std::env::remove_var("PHI_MAX_TOOL_OUTPUT_CHARS") };
198        let builder = base_agent_builder(agent_base::llm::adapt(Arc::new(StubClient)));
199        let _ = builder;
200    }
201
202    #[test]
203    fn test_max_tool_output_chars_custom() {
204        unsafe { std::env::set_var("PHI_MAX_TOOL_OUTPUT_CHARS", "8000") };
205        let builder = base_agent_builder(agent_base::llm::adapt(Arc::new(StubClient)));
206        let _ = builder;
207        unsafe { std::env::remove_var("PHI_MAX_TOOL_OUTPUT_CHARS") };
208    }
209
210    #[test]
211    fn test_max_tool_output_chars_invalid_fallback() {
212        unsafe { std::env::set_var("PHI_MAX_TOOL_OUTPUT_CHARS", "not-a-number") };
213        let builder = base_agent_builder(agent_base::llm::adapt(Arc::new(StubClient)));
214        let _ = builder;
215        unsafe { std::env::remove_var("PHI_MAX_TOOL_OUTPUT_CHARS") };
216    }
217
218    /// Verify that the default `base_agent_builder()` registers the 6 multi-agent tools.
219    #[cfg(feature = "multi-agent")]
220    #[tokio::test(flavor = "multi_thread")]
221    async fn test_base_agent_builder_registers_multi_agent_tools() {
222        let builder = base_agent_builder(agent_base::llm::adapt(Arc::new(StubClient))).system_prompt("test");
223
224        let runtime = builder.build().unwrap();
225
226        let tools = tokio::task::block_in_place(|| {
227            let tools = runtime.tools_mut();
228            let guard = tools.blocking_read();
229            guard.metadatas().into_iter().map(|m| m.name).collect::<Vec<String>>()
230        });
231
232        assert!(tools.contains(&"spawn_agent".to_string()), "expected spawn_agent tool");
233        assert!(tools.contains(&"send_message".to_string()), "expected send_message tool");
234        assert!(tools.contains(&"followup_task".to_string()), "expected followup_task tool");
235        assert!(tools.contains(&"wait_agent".to_string()), "expected wait_agent tool");
236        assert!(tools.contains(&"list_agents".to_string()), "expected list_agents tool");
237        assert!(tools.contains(&"close_agent".to_string()), "expected close_agent tool");
238    }
239
240    /// Verify that `.without_multi_agent()` on the returned builder removes the tools.
241    #[cfg(feature = "multi-agent")]
242    #[tokio::test(flavor = "multi_thread")]
243    async fn test_base_agent_builder_without_multi_agent() {
244        let builder = base_agent_builder(agent_base::llm::adapt(Arc::new(StubClient)))
245            .system_prompt("test")
246            .without_multi_agent();
247
248        let runtime = builder.build().unwrap();
249
250        let tools = tokio::task::block_in_place(|| {
251            let tools = runtime.tools_mut();
252            let guard = tools.blocking_read();
253            guard.metadatas().into_iter().map(|m| m.name).collect::<Vec<String>>()
254        });
255
256        assert!(!tools.contains(&"spawn_agent".to_string()), "spawn_agent should not be registered");
257        assert!(!tools.contains(&"list_agents".to_string()), "list_agents should not be registered");
258    }
259}