phi-agent 0.18.1

phi-agent — Rust AI Agent runtime framework: orchestration, sessions, streaming built-in. You define tools, prompts, domain knowledge.
Documentation
use agent_base::ReasoningEffort;
use clap::{Parser, Subcommand};
use std::path::PathBuf;

#[derive(Parser, Debug)]
#[command(
    name = "phi",
    about = "phi — General-purpose AI Agent CLI tool",
    version,
    long_about = "phi — Drive local dev tasks via natural language.\n\n\
                  Supports interactive mode and one-shot mode."
)]
pub struct CliArgs {
    /// One-shot query (one-shot mode). If provided, runs the query and exits.
    /// If omitted, enters interactive REPL mode.
    #[arg(value_name = "QUERY")]
    pub query: Option<String>,

    #[command(subcommand)]
    pub command: Option<SubCommand>,

    // ── Output control ──
    #[arg(long, value_enum, default_value = "terminal")]
    pub format: OutputFormatArg,

    /// Hide AI thinking process
    #[arg(long, default_value = "false")]
    pub no_thinking: bool,

    /// Thinking token budget
    #[arg(long)]
    pub thinking_budget: Option<u64>,

    /// Thinking effort (low/medium/high/xhigh)
    #[arg(long, value_enum, default_value = "medium")]
    pub thinking_effort: ReasoningEffortArg,

    /// Hide tool argument details
    #[arg(long, default_value = "false")]
    pub no_tool_args: bool,

    /// Disable terminal colors
    #[arg(long, default_value = "false")]
    pub no_color: bool,

    // ── Approval control ──
    /// Auto-approve all operations (skip confirmation)
    #[arg(long, short = 'y', default_value = "false")]
    pub auto_approve: bool,

    // ── Session control ──
    /// Session ID (for session persistence)
    #[arg(long, env = "PHI_SESSION_ID")]
    pub session_id: Option<String>,

    // ── Model config ──
    /// LLM model name
    #[arg(long)]
    pub model: Option<String>,

    /// LLM API base URL
    #[arg(long)]
    pub base_url: Option<String>,

    // ── Logging control ──
    /// Log directory
    #[arg(long, default_value = "~/.phi-agent")]
    pub log_dir: String,

    /// Log level
    #[arg(long, default_value = "info")]
    pub log_level: String,

    /// Disable file logging
    #[arg(long, default_value = "false")]
    pub no_log: bool,

    // ── Safety limits ──
    /// Max tool calls per turn
    #[arg(long)]
    pub max_tool_calls: Option<usize>,

    /// Max consecutive failures for the same tool
    #[arg(long)]
    pub max_failures: Option<usize>,

    /// Max react-loop iterations for a single run (one user input). Default: 200.
    #[arg(long, env = "PHI_MAX_TURNS")]
    pub max_turns: Option<u32>,

    // ── Tool config ──
    /// Shell command timeout (milliseconds)
    #[arg(long, default_value = "30000")]
    pub shell_timeout_ms: u64,

    // ── Browser config ──
    /// Enable browser automation tools (launches headless Chrome)
    #[arg(long, default_value = "false")]
    pub enable_browser: bool,

    /// Run browser in headed mode (visible window, useful for debugging)
    #[arg(long, default_value = "false")]
    pub headed: bool,

    /// Connect to an existing Chrome instance via WebSocket (e.g., ws://localhost:9222)
    #[arg(long)]
    pub connect_ws: Option<String>,
}

#[derive(Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
pub enum OutputFormatArg {
    /// Rich terminal output
    Terminal,
    /// One JSON object per line
    Json,
    /// No output
    Quiet,
}

#[derive(Clone, Debug, clap::ValueEnum)]
pub enum ReasoningEffortArg {
    Low,
    Medium,
    High,
    Xhigh,
}

impl From<ReasoningEffortArg> for ReasoningEffort {
    fn from(arg: ReasoningEffortArg) -> Self {
        match arg {
            ReasoningEffortArg::Low => ReasoningEffort::Low,
            ReasoningEffortArg::Medium => ReasoningEffort::Medium,
            ReasoningEffortArg::High => ReasoningEffort::High,
            ReasoningEffortArg::Xhigh => ReasoningEffort::XHigh,
        }
    }
}

#[derive(Subcommand, Debug)]
pub enum SubCommand {
    /// Manage observability data.
    Metrics {
        #[command(subcommand)]
        cmd: MetricsCmd,
    },
    /// Scaffold a new phi-agent project.
    Init {
        /// Project name
        name: String,
        /// Generate a single-shot example instead of REPL
        #[arg(long)]
        lib: bool,
    },
    /// Start the MCP server (stdio or HTTP JSON-RPC 2.0).
    /// External orchestrators can call the `run` tool to delegate tasks.
    /// Use --bridge for the legacy NDJSON protocol (Python/Node.js SDKs).
    Serve {
        /// Use HTTP transport (SSE streaming) on the given port.
        /// Without this flag, stdio mode is used by default.
        #[arg(long, value_name = "PORT")]
        http: Option<u16>,

        /// Use the legacy bridge protocol (NDJSON) instead of JSON-RPC 2.0 / MCP.
        /// This is needed for the Python and Node.js SDKs.
        #[arg(long, default_value = "false")]
        bridge: bool,
    },
    /// Generate shell completion scripts.
    Completions {
        /// Shell to generate for (bash, zsh, fish, elvish, powershell).
        #[arg(value_enum)]
        shell: clap_complete::Shell,
    },
}

#[derive(Clone, Debug, clap::ValueEnum)]
pub enum MetricsSort {
    Date,
    Turns,
    Chars,
    Outcome,
}

#[derive(Subcommand, Debug)]
pub enum MetricsCmd {
    /// List all sessions with token usage, cost, and outcome.
    List {
        /// Sort by: date (default), turns, chars, outcome.
        #[arg(long, value_enum, default_value = "date")]
        sort: MetricsSort,
    },
    /// Show detailed metrics for a specific session.
    Show {
        /// Session ID (e.g. "20260730_c52b4c91")
        session_id: String,
    },
    /// Show the most recent session.
    Last,
    /// Export all session metrics as a JSON array.
    Export {
        /// Output file path (JSON). Defaults to stdout when omitted.
        #[arg(long, short)]
        output: Option<PathBuf>,
    },
}

#[cfg(test)]
mod tests {
    use super::*;
    use clap::CommandFactory;

    /// `ReasoningEffort` does not implement `PartialEq`, so the mapping is
    /// asserted with exhaustive `matches!` checks instead.
    fn assert_maps_to(arg: ReasoningEffortArg, expected: ReasoningEffort) {
        let mapped = ReasoningEffort::from(arg);
        assert!(
            matches!(
                (&mapped, &expected),
                (ReasoningEffort::Low, ReasoningEffort::Low)
                    | (ReasoningEffort::Medium, ReasoningEffort::Medium)
                    | (ReasoningEffort::High, ReasoningEffort::High)
                    | (ReasoningEffort::XHigh, ReasoningEffort::XHigh)
            ),
            "expected {expected:?}, got {mapped:?}"
        );
    }

    #[test]
    fn reasoning_effort_arg_maps_to_reasoning_effort() {
        assert_maps_to(ReasoningEffortArg::Low, ReasoningEffort::Low);
        assert_maps_to(ReasoningEffortArg::Medium, ReasoningEffort::Medium);
        assert_maps_to(ReasoningEffortArg::High, ReasoningEffort::High);
        // The variant is spelled `Xhigh` on the arg side and `XHigh` on the domain side.
        assert_maps_to(ReasoningEffortArg::Xhigh, ReasoningEffort::XHigh);
    }

    #[test]
    fn thinking_effort_flag_round_trips_to_reasoning_effort() {
        for (flag, expected) in [
            ("low", ReasoningEffort::Low),
            ("medium", ReasoningEffort::Medium),
            ("high", ReasoningEffort::High),
            ("xhigh", ReasoningEffort::XHigh),
        ] {
            let args = CliArgs::try_parse_from(["phi", "--thinking-effort", flag])
                .unwrap_or_else(|err| panic!("--thinking-effort {flag} should parse: {err}"));
            assert_maps_to(args.thinking_effort, expected);
        }
        assert!(CliArgs::command().get_arguments().any(|a| a.get_long() == Some("thinking-effort")));
    }
}