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