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(any(feature = "file", feature = "skill"))]
8use std::path::PathBuf;
9use std::sync::Arc;
10
11use agent_base::{ConsecutiveFailureRecovery, Language, ReasoningConfig, ReasoningEffort, UpdatePlanTool};
12
13#[cfg(feature = "compression")]
14use agent_works::compression::{CompressionConfig, CompressionMiddleware, ContextCompactor};
15
16/// Module-level compactor handle, set once by `base_agent_builder_with_excludes`
17/// when the `compression` feature is enabled.  Shared cache with the middleware.
18///
19/// **Limitation**: This is a process-global static, so it only works correctly
20/// for single-agent processes (the normal phi-agent use case).  Multi-agent
21/// processes that run multiple independent agents in the same process would
22/// need a per-agent compactor instead.
23#[cfg(feature = "compression")]
24static COMPACTOR: std::sync::Mutex<Option<ContextCompactor>> = std::sync::Mutex::new(None);
25
26/// Clear the compression summary cache.
27///
28/// Called by the `/compact` REPL command.  The next turn will re-summarise
29/// (if compression triggers).  No-op when `compression` is disabled.
30pub fn clear_compression_cache() {
31    #[cfg(feature = "compression")]
32    {
33        if let Ok(guard) = COMPACTOR.lock()
34            && let Some(ref compactor) = *guard
35        {
36            compactor.clear_cache();
37            tracing::info!("compression cache cleared");
38            return;
39        }
40        tracing::warn!("clear_compression_cache: no compactor registered");
41    }
42}
43
44/// Run `compact_session` on the current session: read → compress → write back.
45///
46/// - `Ok(Some(true))` — compression applied, session rewritten.
47/// - `Ok(Some(false))` — session is below the threshold, no-op.
48/// - `Ok(None)` — compression not available (feature disabled or no compactor registered).
49/// - `Err(...)` — actual error (write-back failure, concurrent modification, etc.).
50///
51/// When `emit_fn` is provided, lifecycle events
52/// (`CompressionEvent::Preparing/Started/Progress/Completed`) with
53/// `CompressionTrigger::Manual` are emitted through it.
54pub async fn run_compact_session(
55    runtime: &agent_base::AgentRuntime,
56    session_id: &agent_base::SessionId,
57    emit_fn: Option<std::sync::Arc<dyn Fn(agent_base::UserEvent) + Send + Sync>>,
58) -> agent_base::AgentResult<Option<bool>> {
59    #[cfg(feature = "compression")]
60    {
61        // Clone the handle so we can drop the lock before the async call.
62        let handle = {
63            let guard = COMPACTOR
64                .lock()
65                .map_err(|e| agent_base::AgentError::internal(format!("COMPACTOR lock poisoned: {e}")))?;
66            guard.as_ref().map(|c| c.clone_handle())
67        };
68        if let Some(compactor) = handle {
69            return compactor.compact_session(runtime, session_id, emit_fn).await.map(Some);
70        }
71        tracing::warn!("run_compact_session: no compactor registered");
72        Ok(None)
73    }
74    #[cfg(not(feature = "compression"))]
75    {
76        let _ = (runtime, session_id, emit_fn);
77        Ok(None)
78    }
79}
80
81/// Returns an [`agent_works::AgentBuilder`] with sensible defaults:
82/// - English
83/// - Medium reasoning effort
84/// - Thinking enabled
85/// - Consecutive failure recovery (default 3 retries)
86/// - Session limits (50 sessions / 100 turns per session / 50k per-message cap)
87/// - Per-run react-loop cap (200 iterations for one user input)
88/// - LLM-based context compression for long tool-heavy conversations
89///   (uses `CompressionMiddleware` from agent-works when `compression` feature is enabled)
90/// - File tools (read_file / write_file / edit_file / list_files) — enabled by default
91/// - Plan checklist (update_plan) — display-only progress tracking, enabled by default
92/// - Skills injected into system prompt (not as tools — LLM uses read_file;
93///   enabled by default via `file` feature)
94/// - MCP protocol support — enabled by default
95/// - Telemetry + logging — enabled by default
96///
97/// Callers are responsible for: registering additional tools, setting the approval
98/// handler, setting the system prompt, then calling `.build()`.
99///
100/// Feature groups (opt-in):
101/// - `shell`: shell execution (`--features shell`)
102/// - `multi-agent`: multi-agent support (`--features multi-agent`)
103/// - `browser`: browser automation via CDP (`--features browser`)
104/// - `protocol` meta: MCP
105/// - `observability` meta: telemetry + logging
106/// - `app` meta: browser (NOT included in `full`)
107/// - `full`: file + shell + mcp + telemetry + logging (excludes browser and multi-agent)
108#[allow(unused_mut)]
109pub fn base_agent_builder(llm_client: Arc<dyn agent_base::llm_trait::LlmProvider>) -> agent_works::AgentBuilder {
110    base_agent_builder_with_excludes(llm_client, Vec::new())
111}
112
113/// Like [`base_agent_builder`], but lets the consumer inject a list of entry
114/// names for [`phi_kernel_tools::file::ListFilesTool`] to skip (e.g. a coding agent passing
115/// `["target", "node_modules"]`). The framework stays domain-agnostic: it
116/// defaults to no excludes; the consumer decides what counts as noise.
117#[allow(unused_mut)]
118pub fn base_agent_builder_with_excludes(
119    llm_client: Arc<dyn agent_base::llm_trait::LlmProvider>,
120    file_excludes: Vec<String>,
121) -> agent_works::AgentBuilder {
122    base_agent_builder_with_options(llm_client, file_excludes, None)
123}
124
125/// Like [`base_agent_builder_with_excludes`], but also lets the consumer
126/// override the [`CompressionConfig`] used by the context-compression
127/// middleware.  Pass `None` to use the defaults.
128#[allow(unused_mut)]
129pub fn base_agent_builder_with_options(
130    llm_client: Arc<dyn agent_base::llm_trait::LlmProvider>,
131    file_excludes: Vec<String>,
132    compression_config: Option<CompressionConfig>,
133) -> agent_works::AgentBuilder {
134    base_agent_builder_with_options_inner(llm_client, file_excludes, compression_config, false)
135}
136
137/// Like [`base_agent_builder_with_options`], but skips registering the
138/// compression middleware entirely. Use when a custom context compactor
139/// (e.g. token-budget window rotation) replaces LLM-based summarization.
140#[allow(unused_mut)]
141pub fn base_agent_builder_no_compression(
142    llm_client: Arc<dyn agent_base::llm_trait::LlmProvider>,
143    file_excludes: Vec<String>,
144) -> agent_works::AgentBuilder {
145    base_agent_builder_with_options_inner(llm_client, file_excludes, None, true)
146}
147
148#[allow(unused_mut)]
149fn base_agent_builder_with_options_inner(
150    llm_client: Arc<dyn agent_base::llm_trait::LlmProvider>,
151    file_excludes: Vec<String>,
152    compression_config: Option<CompressionConfig>,
153    skip_compression: bool,
154) -> agent_works::AgentBuilder {
155    // Tool-output cap (default 4000 chars). Tune via PHI_MAX_TOOL_OUTPUT_CHARS for large
156    // outputs (HTML, base64 images, long lists). The engine REJECTS output that exceeds
157    // this cap (design §6.5) rather than silently truncating. Tools that can bound
158    // themselves — e.g. read_file — self-truncate to the per-call budget exposed via
159    // `ToolContext::max_output_chars` and emit a "...(truncated)" continuation hint.
160    let max_tool_output_chars = match std::env::var("PHI_MAX_TOOL_OUTPUT_CHARS") {
161        Ok(value) => match value.trim().parse::<usize>() {
162            Ok(n) => n,
163            Err(_) => {
164                tracing::warn!(
165                    value = %value,
166                    "PHI_MAX_TOOL_OUTPUT_CHARS is not a valid integer; falling back to default 4000"
167                );
168                4000
169            },
170        },
171        Err(_) => 4000,
172    };
173
174    #[cfg(feature = "file")]
175    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
176
177    let mut builder = agent_works::AgentBuilder::new(llm_client.clone())
178        .language(Language::En)
179        .reasoning(ReasoningConfig { effort: Some(ReasoningEffort::Medium), ..Default::default() })
180        .enable_thought(true)
181        .enable_thinking(true)
182        .max_sessions(50)
183        .max_turns_per_session(100)
184        .execution_max_turns(200)
185        // Session 20260904_c6559510: the fan-in batch injection (all child
186        // reports in one message) hit this valve and was SILENTLY popped —
187        // the parent turned without any reports and hallucinated a synthesis.
188        // 50k was sized for single user inputs, not multi-report batches.
189        // Injection senders still cap their own payloads; this valve is the
190        // last-resort guard, not the size manager.
191        .max_message_tokens(120_000)
192        .max_tool_output_chars(max_tool_output_chars)
193        .error_recovery(Arc::new(ConsecutiveFailureRecovery::new(3)));
194
195    // Context compression: use CompressionMiddleware from agent-works
196    // (hybrid retention + stable-prefix cache + handoff summary).
197    // Skipped when `skip_compression` is true (token-budget path uses
198    // its own compactor instead).
199    #[cfg(feature = "compression")]
200    if !skip_compression {
201        let compactor = ContextCompactor::new(llm_client.clone(), compression_config.unwrap_or_default());
202        // Store a cloned handle (shared cache) for the /compact command.
203        let handle = compactor.clone_handle();
204        if let Ok(mut guard) = COMPACTOR.lock() {
205            *guard = Some(handle);
206        }
207        builder = builder.middleware(CompressionMiddleware::from_compactor(compactor));
208    }
209
210    // ── File tools (opt-in via `file` feature) ──
211    #[cfg(feature = "file")]
212    {
213        use phi_kernel_tools::file::{EditFileTool, ListFilesTool, ReadFileTool, WriteFileTool};
214        builder = builder
215            .register_tool_arc(Arc::new(ReadFileTool::new(cwd.clone())))
216            .register_tool_arc(Arc::new(WriteFileTool::new(cwd.clone())))
217            .register_tool_arc(Arc::new(EditFileTool::new(cwd.clone())))
218            .register_tool_arc(Arc::new(ListFilesTool::with_excludes(cwd.clone(), file_excludes)));
219    }
220
221    // ── Plan checklist (display-only progress tracking) ──
222    // `build_system_prompt` instructs the model to call `update_plan` for complex tasks,
223    // so the base builder registers it unconditionally — otherwise the prompt references
224    // a tool the agent doesn't have (the `init`/`serve` entry points used to hit this).
225    builder = builder.register_tool_arc(Arc::new(UpdatePlanTool::new()));
226
227    // ── Multi-agent (opt-in) ──
228    #[cfg(feature = "multi-agent")]
229    {
230        use agent_works::multi_agent::MultiAgentConfig;
231        // Children share this process's cwd (phimint chdir's to the workspace
232        // at startup), so it is the base their relative paths resolve against.
233        // Injected as a fact into each child's system prompt — session
234        // 20260904_3eeb5610: a child silently analyzed the wrong directory
235        // because nothing told it where its relative paths land.
236        let ma_cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
237        builder = builder.with_multi_agent(MultiAgentConfig::default()).with_multi_agent_tool_factory(Arc::new(
238            move |runtime| phi_kernel_tools::multi_agent::create_all_tools(runtime, ma_cwd.clone()),
239        ));
240    }
241
242    // ── Skills: prompt-injection mode (uses read_file, no skill-specific tools) ──
243    #[cfg(feature = "skill")]
244    {
245        use agent_works::skill::Skill;
246        use agent_works::skill::prompt_skill::PromptSkill;
247        let skill_dirs: Vec<PathBuf> = vec![
248            // User-level skills (low priority)
249            dirs_next().join(".claude").join("skills"),
250            // Project-level skills (high priority, loaded last to override)
251            PathBuf::from(".claude/skills"),
252        ];
253
254        for dir in &skill_dirs {
255            if dir.is_dir() {
256                match PromptSkill::scan_dir(dir) {
257                    Ok(skills) => {
258                        for skill in skills {
259                            tracing::debug!(
260                                name = skill.name(),
261                                dir = %dir.display(),
262                                "auto-loaded skill (prompt-injection mode)"
263                            );
264                            builder = builder.register_skill(skill);
265                        }
266                    },
267                    Err(e) => {
268                        tracing::warn!(dir = %dir.display(), error = %e, "failed to scan skills directory");
269                    },
270                }
271            }
272        }
273    }
274
275    builder
276}
277
278/// Resolve the user's home directory for `~/.claude/skills/`.
279#[cfg(feature = "skill")]
280fn dirs_next() -> std::path::PathBuf {
281    std::env::var("HOME")
282        .or_else(|_| std::env::var("USERPROFILE"))
283        .map(PathBuf::from)
284        .unwrap_or_else(|_| PathBuf::from("."))
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use async_trait::async_trait;
291    use futures_core::Stream;
292    use std::pin::Pin;
293    use std::task::{Context, Poll};
294
295    struct StubClient;
296    struct EmptyStream;
297
298    impl Stream for EmptyStream {
299        type Item = Result<agent_base::StreamChunk, agent_base::llm_trait::LlmError>;
300        fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
301            Poll::Ready(None)
302        }
303    }
304
305    #[async_trait]
306    impl agent_base::llm_trait::LlmProvider for StubClient {
307        async fn stream(
308            &self,
309            _request: agent_base::llm_trait::ChatRequest,
310        ) -> Result<agent_base::llm_trait::ChatStream, agent_base::llm_trait::LlmError> {
311            Ok(agent_base::llm_trait::ChatStream::new(Box::pin(EmptyStream)))
312        }
313        async fn chat(
314            &self,
315            _request: agent_base::llm_trait::ChatRequest,
316        ) -> Result<agent_base::llm_trait::ChatResponse, agent_base::llm_trait::LlmError> {
317            Ok(agent_base::llm_trait::ChatResponse {
318                content: "stub".to_string(),
319                reasoning_content: None,
320                tool_calls: vec![],
321                usage: agent_base::UsageInfo::default(),
322                finish_reason: agent_base::llm_trait::FinishReason::Stop,
323                raw: None,
324                thinking_signature: None,
325            })
326        }
327        fn capabilities(&self) -> agent_base::llm_trait::Capabilities {
328            agent_base::llm_trait::Capabilities::default()
329        }
330        fn info(&self) -> agent_base::llm_trait::ProviderInfo {
331            agent_base::llm_trait::ProviderInfo { name: "stub".to_string(), model: "stub".to_string(), version: None }
332        }
333    }
334
335    #[test]
336    fn test_max_tool_output_chars_default() {
337        unsafe { std::env::remove_var("PHI_MAX_TOOL_OUTPUT_CHARS") };
338        let builder = base_agent_builder(Arc::new(StubClient));
339        let _ = builder;
340    }
341
342    #[test]
343    fn test_max_tool_output_chars_custom() {
344        unsafe { std::env::set_var("PHI_MAX_TOOL_OUTPUT_CHARS", "8000") };
345        let builder = base_agent_builder(Arc::new(StubClient));
346        let _ = builder;
347        unsafe { std::env::remove_var("PHI_MAX_TOOL_OUTPUT_CHARS") };
348    }
349
350    #[test]
351    fn test_max_tool_output_chars_invalid_fallback() {
352        unsafe { std::env::set_var("PHI_MAX_TOOL_OUTPUT_CHARS", "not-a-number") };
353        let builder = base_agent_builder(Arc::new(StubClient));
354        let _ = builder;
355        unsafe { std::env::remove_var("PHI_MAX_TOOL_OUTPUT_CHARS") };
356    }
357
358    /// Verify that the default `base_agent_builder()` registers the 4 multi-agent tools.
359    #[cfg(feature = "multi-agent")]
360    #[tokio::test(flavor = "multi_thread")]
361    async fn test_base_agent_builder_registers_multi_agent_tools() {
362        let builder = base_agent_builder(Arc::new(StubClient)).system_prompt("test");
363
364        let runtime = builder.build().unwrap();
365
366        let tools = tokio::task::block_in_place(|| {
367            let tools = runtime.tools_mut();
368            let guard = tools.blocking_read();
369            guard.metadatas().into_iter().map(|m| m.name).collect::<Vec<String>>()
370        });
371
372        assert!(tools.contains(&"spawn_agent".to_string()), "expected spawn_agent tool");
373        assert!(tools.contains(&"send_message".to_string()), "expected send_message tool");
374        // followup_task is deprecated and dropped from the factory (agent-works
375        // §8.3); its trigger semantics now live in send_message(trigger=true).
376        assert!(!tools.contains(&"followup_task".to_string()), "followup_task must not be registered");
377        // wait_agent was removed with the result-push model (agent-works §8.3):
378        // child results are pushed to the parent's mailbox automatically, so
379        // there is nothing left to poll.
380        assert!(!tools.contains(&"wait_agent".to_string()), "wait_agent must not be registered");
381        assert!(tools.contains(&"list_agents".to_string()), "expected list_agents tool");
382        assert!(tools.contains(&"close_agent".to_string()), "expected close_agent tool");
383    }
384
385    /// Verify that `.without_multi_agent()` on the returned builder removes the tools.
386    #[cfg(feature = "multi-agent")]
387    #[tokio::test(flavor = "multi_thread")]
388    async fn test_base_agent_builder_without_multi_agent() {
389        let builder = base_agent_builder(Arc::new(StubClient)).system_prompt("test").without_multi_agent();
390
391        let runtime = builder.build().unwrap();
392
393        let tools = tokio::task::block_in_place(|| {
394            let tools = runtime.tools_mut();
395            let guard = tools.blocking_read();
396            guard.metadatas().into_iter().map(|m| m.name).collect::<Vec<String>>()
397        });
398
399        assert!(!tools.contains(&"spawn_agent".to_string()), "spawn_agent should not be registered");
400        assert!(!tools.contains(&"list_agents".to_string()), "list_agents should not be registered");
401    }
402}