phi-agent 0.18.1

phi-agent — Rust AI Agent runtime framework: orchestration, sessions, streaming built-in. You define tools, prompts, domain knowledge.
Documentation
//! General-purpose AgentBuilder factory — provides default configuration
//! shared across consumers.
//!
//! Returns a pre-configured [`agent_works::AgentBuilder`]; callers then register
//! tools and approval handlers on top.

#[cfg(any(feature = "file", feature = "skill"))]
use std::path::PathBuf;
use std::sync::Arc;

use agent_base::{ConsecutiveFailureRecovery, Language, ReasoningConfig, ReasoningEffort, UpdatePlanTool};

#[cfg(feature = "compression")]
use agent_works::compression::{CompressionConfig, CompressionMiddleware, ContextCompactor};

/// Module-level compactor handle, set once by `base_agent_builder_with_excludes`
/// when the `compression` feature is enabled.  Shared cache with the middleware.
///
/// **Limitation**: This is a process-global static, so it only works correctly
/// for single-agent processes (the normal phi-agent use case).  Multi-agent
/// processes that run multiple independent agents in the same process would
/// need a per-agent compactor instead.
#[cfg(feature = "compression")]
static COMPACTOR: std::sync::Mutex<Option<ContextCompactor>> = std::sync::Mutex::new(None);

/// Clear the compression summary cache.
///
/// Called by the `/compact` REPL command.  The next turn will re-summarise
/// (if compression triggers).  No-op when `compression` is disabled.
pub fn clear_compression_cache() {
    #[cfg(feature = "compression")]
    {
        if let Ok(guard) = COMPACTOR.lock()
            && let Some(ref compactor) = *guard
        {
            compactor.clear_cache();
            tracing::info!("compression cache cleared");
            return;
        }
        tracing::warn!("clear_compression_cache: no compactor registered");
    }
}

/// Run `compact_session` on the current session: read → compress → write back.
///
/// - `Ok(Some(true))` — compression applied, session rewritten.
/// - `Ok(Some(false))` — session is below the threshold, no-op.
/// - `Ok(None)` — compression not available (feature disabled or no compactor registered).
/// - `Err(...)` — actual error (write-back failure, concurrent modification, etc.).
///
/// When `emit_fn` is provided, lifecycle events
/// (`CompressionEvent::Preparing/Started/Progress/Completed`) with
/// `CompressionTrigger::Manual` are emitted through it.
pub async fn run_compact_session(
    runtime: &agent_base::AgentRuntime,
    session_id: &agent_base::SessionId,
    emit_fn: Option<std::sync::Arc<dyn Fn(agent_base::UserEvent) + Send + Sync>>,
) -> agent_base::AgentResult<Option<bool>> {
    #[cfg(feature = "compression")]
    {
        // Clone the handle so we can drop the lock before the async call.
        let handle = {
            let guard = COMPACTOR
                .lock()
                .map_err(|e| agent_base::AgentError::internal(format!("COMPACTOR lock poisoned: {e}")))?;
            guard.as_ref().map(|c| c.clone_handle())
        };
        if let Some(compactor) = handle {
            return compactor.compact_session(runtime, session_id, emit_fn).await.map(Some);
        }
        tracing::warn!("run_compact_session: no compactor registered");
        Ok(None)
    }
    #[cfg(not(feature = "compression"))]
    {
        let _ = (runtime, session_id, emit_fn);
        Ok(None)
    }
}

/// Returns an [`agent_works::AgentBuilder`] with sensible defaults:
/// - English
/// - Medium reasoning effort
/// - Thinking enabled
/// - Consecutive failure recovery (default 3 retries)
/// - Session limits (50 sessions / 100 turns per session / 50k per-message cap)
/// - Per-run react-loop cap (200 iterations for one user input)
/// - LLM-based context compression for long tool-heavy conversations
///   (uses `CompressionMiddleware` from agent-works when `compression` feature is enabled)
/// - File tools (read_file / write_file / edit_file / list_files) — enabled by default
/// - Plan checklist (update_plan) — display-only progress tracking, enabled by default
/// - Skills injected into system prompt (not as tools — LLM uses read_file;
///   enabled by default via `file` feature)
/// - MCP protocol support — enabled by default
/// - Telemetry + logging — enabled by default
///
/// Callers are responsible for: registering additional tools, setting the approval
/// handler, setting the system prompt, then calling `.build()`.
///
/// Feature groups (opt-in):
/// - `shell`: shell execution (`--features shell`)
/// - `multi-agent`: multi-agent support (`--features multi-agent`)
/// - `browser`: browser automation via CDP (`--features browser`)
/// - `protocol` meta: MCP
/// - `observability` meta: telemetry + logging
/// - `app` meta: browser (NOT included in `full`)
/// - `full`: file + shell + mcp + telemetry + logging (excludes browser and multi-agent)
#[allow(unused_mut)]
pub fn base_agent_builder(llm_client: Arc<dyn agent_base::llm_trait::LlmProvider>) -> agent_works::AgentBuilder {
    base_agent_builder_with_excludes(llm_client, Vec::new())
}

/// Like [`base_agent_builder`], but lets the consumer inject a list of entry
/// names for [`phi_kernel_tools::file::ListFilesTool`] to skip (e.g. a coding agent passing
/// `["target", "node_modules"]`). The framework stays domain-agnostic: it
/// defaults to no excludes; the consumer decides what counts as noise.
#[allow(unused_mut)]
pub fn base_agent_builder_with_excludes(
    llm_client: Arc<dyn agent_base::llm_trait::LlmProvider>,
    file_excludes: Vec<String>,
) -> agent_works::AgentBuilder {
    base_agent_builder_with_options(llm_client, file_excludes, None)
}

/// Like [`base_agent_builder_with_excludes`], but also lets the consumer
/// override the [`CompressionConfig`] used by the context-compression
/// middleware.  Pass `None` to use the defaults.
#[allow(unused_mut)]
pub fn base_agent_builder_with_options(
    llm_client: Arc<dyn agent_base::llm_trait::LlmProvider>,
    file_excludes: Vec<String>,
    compression_config: Option<CompressionConfig>,
) -> agent_works::AgentBuilder {
    base_agent_builder_with_options_inner(llm_client, file_excludes, compression_config, false)
}

/// Like [`base_agent_builder_with_options`], but skips registering the
/// compression middleware entirely. Use when a custom context compactor
/// (e.g. token-budget window rotation) replaces LLM-based summarization.
#[allow(unused_mut)]
pub fn base_agent_builder_no_compression(
    llm_client: Arc<dyn agent_base::llm_trait::LlmProvider>,
    file_excludes: Vec<String>,
) -> agent_works::AgentBuilder {
    base_agent_builder_with_options_inner(llm_client, file_excludes, None, true)
}

#[allow(unused_mut)]
fn base_agent_builder_with_options_inner(
    llm_client: Arc<dyn agent_base::llm_trait::LlmProvider>,
    file_excludes: Vec<String>,
    compression_config: Option<CompressionConfig>,
    skip_compression: bool,
) -> agent_works::AgentBuilder {
    // Tool-output cap (default 4000 chars). Tune via PHI_MAX_TOOL_OUTPUT_CHARS for large
    // outputs (HTML, base64 images, long lists). The engine REJECTS output that exceeds
    // this cap (design §6.5) rather than silently truncating. Tools that can bound
    // themselves — e.g. read_file — self-truncate to the per-call budget exposed via
    // `ToolContext::max_output_chars` and emit a "...(truncated)" continuation hint.
    let max_tool_output_chars = match std::env::var("PHI_MAX_TOOL_OUTPUT_CHARS") {
        Ok(value) => match value.trim().parse::<usize>() {
            Ok(n) => n,
            Err(_) => {
                tracing::warn!(
                    value = %value,
                    "PHI_MAX_TOOL_OUTPUT_CHARS is not a valid integer; falling back to default 4000"
                );
                4000
            },
        },
        Err(_) => 4000,
    };

    #[cfg(feature = "file")]
    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));

    let mut builder = agent_works::AgentBuilder::new(llm_client.clone())
        .language(Language::En)
        .reasoning(ReasoningConfig { effort: Some(ReasoningEffort::Medium), ..Default::default() })
        .enable_thought(true)
        .enable_thinking(true)
        .max_sessions(50)
        .max_turns_per_session(100)
        .execution_max_turns(200)
        // Session 20260904_c6559510: the fan-in batch injection (all child
        // reports in one message) hit this valve and was SILENTLY popped —
        // the parent turned without any reports and hallucinated a synthesis.
        // 50k was sized for single user inputs, not multi-report batches.
        // Injection senders still cap their own payloads; this valve is the
        // last-resort guard, not the size manager.
        .max_message_tokens(120_000)
        .max_tool_output_chars(max_tool_output_chars)
        .error_recovery(Arc::new(ConsecutiveFailureRecovery::new(3)));

    // Context compression: use CompressionMiddleware from agent-works
    // (hybrid retention + stable-prefix cache + handoff summary).
    // Skipped when `skip_compression` is true (token-budget path uses
    // its own compactor instead).
    #[cfg(feature = "compression")]
    if !skip_compression {
        let compactor = ContextCompactor::new(llm_client.clone(), compression_config.unwrap_or_default());
        // Store a cloned handle (shared cache) for the /compact command.
        let handle = compactor.clone_handle();
        if let Ok(mut guard) = COMPACTOR.lock() {
            *guard = Some(handle);
        }
        builder = builder.middleware(CompressionMiddleware::from_compactor(compactor));
    }

    // ── File tools (opt-in via `file` feature) ──
    #[cfg(feature = "file")]
    {
        use phi_kernel_tools::file::{EditFileTool, ListFilesTool, ReadFileTool, WriteFileTool};
        builder = builder
            .register_tool_arc(Arc::new(ReadFileTool::new(cwd.clone())))
            .register_tool_arc(Arc::new(WriteFileTool::new(cwd.clone())))
            .register_tool_arc(Arc::new(EditFileTool::new(cwd.clone())))
            .register_tool_arc(Arc::new(ListFilesTool::with_excludes(cwd.clone(), file_excludes)));
    }

    // ── Plan checklist (display-only progress tracking) ──
    // `build_system_prompt` instructs the model to call `update_plan` for complex tasks,
    // so the base builder registers it unconditionally — otherwise the prompt references
    // a tool the agent doesn't have (the `init`/`serve` entry points used to hit this).
    builder = builder.register_tool_arc(Arc::new(UpdatePlanTool::new()));

    // ── Multi-agent (opt-in) ──
    #[cfg(feature = "multi-agent")]
    {
        use agent_works::multi_agent::MultiAgentConfig;
        // Children share this process's cwd (phimint chdir's to the workspace
        // at startup), so it is the base their relative paths resolve against.
        // Injected as a fact into each child's system prompt — session
        // 20260904_3eeb5610: a child silently analyzed the wrong directory
        // because nothing told it where its relative paths land.
        let ma_cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
        builder = builder.with_multi_agent(MultiAgentConfig::default()).with_multi_agent_tool_factory(Arc::new(
            move |runtime| phi_kernel_tools::multi_agent::create_all_tools(runtime, ma_cwd.clone()),
        ));
    }

    // ── Skills: prompt-injection mode (uses read_file, no skill-specific tools) ──
    #[cfg(feature = "skill")]
    {
        use agent_works::skill::Skill;
        use agent_works::skill::prompt_skill::PromptSkill;
        let skill_dirs: Vec<PathBuf> = vec![
            // User-level skills (low priority)
            dirs_next().join(".claude").join("skills"),
            // Project-level skills (high priority, loaded last to override)
            PathBuf::from(".claude/skills"),
        ];

        for dir in &skill_dirs {
            if dir.is_dir() {
                match PromptSkill::scan_dir(dir) {
                    Ok(skills) => {
                        for skill in skills {
                            tracing::debug!(
                                name = skill.name(),
                                dir = %dir.display(),
                                "auto-loaded skill (prompt-injection mode)"
                            );
                            builder = builder.register_skill(skill);
                        }
                    },
                    Err(e) => {
                        tracing::warn!(dir = %dir.display(), error = %e, "failed to scan skills directory");
                    },
                }
            }
        }
    }

    builder
}

/// Resolve the user's home directory for `~/.claude/skills/`.
#[cfg(feature = "skill")]
fn dirs_next() -> std::path::PathBuf {
    std::env::var("HOME")
        .or_else(|_| std::env::var("USERPROFILE"))
        .map(PathBuf::from)
        .unwrap_or_else(|_| PathBuf::from("."))
}

#[cfg(test)]
mod tests {
    use super::*;
    use async_trait::async_trait;
    use futures_core::Stream;
    use std::pin::Pin;
    use std::task::{Context, Poll};

    struct StubClient;
    struct EmptyStream;

    impl Stream for EmptyStream {
        type Item = Result<agent_base::StreamChunk, agent_base::llm_trait::LlmError>;
        fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
            Poll::Ready(None)
        }
    }

    #[async_trait]
    impl agent_base::llm_trait::LlmProvider for StubClient {
        async fn stream(
            &self,
            _request: agent_base::llm_trait::ChatRequest,
        ) -> Result<agent_base::llm_trait::ChatStream, agent_base::llm_trait::LlmError> {
            Ok(agent_base::llm_trait::ChatStream::new(Box::pin(EmptyStream)))
        }
        async fn chat(
            &self,
            _request: agent_base::llm_trait::ChatRequest,
        ) -> Result<agent_base::llm_trait::ChatResponse, agent_base::llm_trait::LlmError> {
            Ok(agent_base::llm_trait::ChatResponse {
                content: "stub".to_string(),
                reasoning_content: None,
                tool_calls: vec![],
                usage: agent_base::UsageInfo::default(),
                finish_reason: agent_base::llm_trait::FinishReason::Stop,
                raw: None,
                thinking_signature: None,
            })
        }
        fn capabilities(&self) -> agent_base::llm_trait::Capabilities {
            agent_base::llm_trait::Capabilities::default()
        }
        fn info(&self) -> agent_base::llm_trait::ProviderInfo {
            agent_base::llm_trait::ProviderInfo { name: "stub".to_string(), model: "stub".to_string(), version: None }
        }
    }

    #[test]
    fn test_max_tool_output_chars_default() {
        unsafe { std::env::remove_var("PHI_MAX_TOOL_OUTPUT_CHARS") };
        let builder = base_agent_builder(Arc::new(StubClient));
        let _ = builder;
    }

    #[test]
    fn test_max_tool_output_chars_custom() {
        unsafe { std::env::set_var("PHI_MAX_TOOL_OUTPUT_CHARS", "8000") };
        let builder = base_agent_builder(Arc::new(StubClient));
        let _ = builder;
        unsafe { std::env::remove_var("PHI_MAX_TOOL_OUTPUT_CHARS") };
    }

    #[test]
    fn test_max_tool_output_chars_invalid_fallback() {
        unsafe { std::env::set_var("PHI_MAX_TOOL_OUTPUT_CHARS", "not-a-number") };
        let builder = base_agent_builder(Arc::new(StubClient));
        let _ = builder;
        unsafe { std::env::remove_var("PHI_MAX_TOOL_OUTPUT_CHARS") };
    }

    /// Verify that the default `base_agent_builder()` registers the 4 multi-agent tools.
    #[cfg(feature = "multi-agent")]
    #[tokio::test(flavor = "multi_thread")]
    async fn test_base_agent_builder_registers_multi_agent_tools() {
        let builder = base_agent_builder(Arc::new(StubClient)).system_prompt("test");

        let runtime = builder.build().unwrap();

        let tools = tokio::task::block_in_place(|| {
            let tools = runtime.tools_mut();
            let guard = tools.blocking_read();
            guard.metadatas().into_iter().map(|m| m.name).collect::<Vec<String>>()
        });

        assert!(tools.contains(&"spawn_agent".to_string()), "expected spawn_agent tool");
        assert!(tools.contains(&"send_message".to_string()), "expected send_message tool");
        // followup_task is deprecated and dropped from the factory (agent-works
        // §8.3); its trigger semantics now live in send_message(trigger=true).
        assert!(!tools.contains(&"followup_task".to_string()), "followup_task must not be registered");
        // wait_agent was removed with the result-push model (agent-works §8.3):
        // child results are pushed to the parent's mailbox automatically, so
        // there is nothing left to poll.
        assert!(!tools.contains(&"wait_agent".to_string()), "wait_agent must not be registered");
        assert!(tools.contains(&"list_agents".to_string()), "expected list_agents tool");
        assert!(tools.contains(&"close_agent".to_string()), "expected close_agent tool");
    }

    /// Verify that `.without_multi_agent()` on the returned builder removes the tools.
    #[cfg(feature = "multi-agent")]
    #[tokio::test(flavor = "multi_thread")]
    async fn test_base_agent_builder_without_multi_agent() {
        let builder = base_agent_builder(Arc::new(StubClient)).system_prompt("test").without_multi_agent();

        let runtime = builder.build().unwrap();

        let tools = tokio::task::block_in_place(|| {
            let tools = runtime.tools_mut();
            let guard = tools.blocking_read();
            guard.metadatas().into_iter().map(|m| m.name).collect::<Vec<String>>()
        });

        assert!(!tools.contains(&"spawn_agent".to_string()), "spawn_agent should not be registered");
        assert!(!tools.contains(&"list_agents".to_string()), "list_agents should not be registered");
    }
}