phi-agent 0.2.8

phi-agent — General-purpose AI Agent framework (builder factory, renderer, config, session management)
Documentation
mod approval;
mod args;
mod init;
mod metrics;
mod run;
mod serve;
mod tools;

use std::sync::Arc;

use anyhow::Result;
use approval::CliApprovalHandler;
use args::{CliArgs, OutputFormatArg, SubCommand};
use clap::Parser;
use phi_agent::config::resolve_llm_config;
use phi_agent::render::OutputFormat;
use phi_agent::{ApprovalMode, AutoApprovalHandler};
use phi_agent::{
    OpenAiClient, PhiAgent, SafetyConfig, TurnFactMiddleware, TurnToolLimitMiddleware, base_agent_builder,
    build_system_prompt,
};
use run::{default_node_id, init_logging, run_one_shot, run_repl};
use tools::LocalShellTool;

#[tokio::main]
async fn main() -> Result<()> {
    dotenvy::dotenv().ok();
    let args = CliArgs::parse();

    // Handle subcommands (no agent needed)
    if let Some(cmd) = &args.command {
        match cmd {
            SubCommand::Init { name, lib } => return init::run(name, *lib),
            SubCommand::Metrics { cmd } => return metrics::handle_metrics(cmd, &args),
            SubCommand::Serve => return serve::run().await,
        }
    }

    // 1. Resolve log directory
    let log_dir = args.log_dir.replace("~", &std::env::var("HOME").unwrap_or_default());
    let log_dir_path = std::path::PathBuf::from(&log_dir);

    // 2. Clean up expired sessions
    if !args.no_log {
        match phi_agent::session::cleanup_expired_sessions(&log_dir_path, 7) {
            Ok(count) => {
                if count > 0 {
                    eprintln!("[phi] cleaned up {} expired session(s)", count);
                }
            },
            Err(e) => {
                eprintln!("[phi] warning: failed to cleanup sessions: {}", e);
            },
        }
    }

    // 3. Resolve session
    let session_ctx = phi_agent::session::resolve_session(args.session_id.as_deref(), &log_dir_path)?;
    let session_id_str = session_ctx.session_id.clone();
    let is_new_session = session_ctx.is_new_session;

    // 4. Initialize logging
    if !args.no_log {
        init_logging(&session_ctx, &args.log_level).await?;
    }

    tracing::info!(
        session_id = %session_id_str,
        is_new = is_new_session,
        format = ?args.format,
        "phi starting"
    );

    // 5. Resolve LLM config
    let llm_config = resolve_llm_config(args.model.as_deref(), args.base_url.as_deref())?;

    // 6. Create LLM client
    let llm_client = Arc::new(OpenAiClient::new(
        llm_config.api_key.clone(),
        llm_config.model.clone(),
        Some(llm_config.base_url.clone()),
    ));

    // 7. Build system prompt
    let system_prompt = build_system_prompt();

    // 8. Approval handler
    let approval_handler: Arc<dyn phi_agent::ApprovalHandler> = if args.auto_approve {
        Arc::new(AutoApprovalHandler::new(ApprovalMode::Auto))
    } else {
        Arc::new(CliApprovalHandler::new())
    };

    // 9. Safety config
    let safety_config = SafetyConfig {
        max_tool_calls_per_turn: args.max_tool_calls.unwrap_or(64),
        max_consecutive_failures: args.max_failures.unwrap_or(3),
    };

    // 10. Output format
    let output_format = match args.format {
        OutputFormatArg::Terminal => OutputFormat::Terminal {
            show_thinking: !args.no_thinking,
            show_tool_args: !args.no_tool_args,
            color: !args.no_color,
        },
        OutputFormatArg::Json => OutputFormat::Json,
        OutputFormatArg::Quiet => OutputFormat::Quiet,
    };

    // 11. PhiAgent config
    let agent_config = phi_agent::PhiAgentConfig {
        model: llm_config.model.clone(),
        enable_thinking: !args.no_thinking,
        thinking_budget: args.thinking_budget,
        thinking_effort: args.thinking_effort.clone().into(),
        safety: safety_config.clone(),
        max_turns: args.max_turns,
    };

    // 12. Assemble builder — register tools here
    let builder = base_agent_builder(llm_client)
        .system_prompt(system_prompt)
        .register_tool(LocalShellTool::new(args.shell_timeout_ms))
        .register_tool(
            phi_agent::UpdatePlanTool::new().with_description(
                "Create or update a task plan to show the user a checklist with progress. \
                This is a presentation protocol.\n\n\
                [When to Use]\n\
                - Complex tasks (usually 3+ steps): call update_plan first to show the plan, \
                  then execute step by step.\n\
                - Simple tasks, Q&A, one-shot operations: do NOT call — handle directly.\n\n\
                [Requirements]\n\
                - Must provide an objective when creating a plan for the first time.\n\
                - plan is a full snapshot, not an incremental patch.\n\
                - At most one step can be in_progress at a time.\n\
                - Step text should be human-readable task descriptions only.\n\n\
                [Update Conventions]\n\
                - Update status promptly as you progress: pending → in_progress → completed.\n\
                - If blocked, explain the reason honestly in the explanation field."
                    .to_string(),
            ),
        )
        .approval_handler(approval_handler)
        .middleware(TurnFactMiddleware::new())
        .middleware(TurnToolLimitMiddleware::from_config(&safety_config))
        .apply_if(args.thinking_budget, |b, budget| b.thinking_budget(budget))
        .apply_if(args.max_turns, |b, n| b.execution_max_turns(n));

    // 13. Build agent
    let agent = PhiAgent::build(builder, agent_config)?;
    let agent_session_id = agent.create_session().await;

    tracing::info!(
        agent_session_id = %agent_session_id.id,
        session_id = %session_id_str,
        model = %llm_config.model,
        "session created"
    );

    // 14. Run
    if let Some(query) = args.query {
        // Set up telemetry
        let node_id = std::env::var("PHI_NODE_ID").unwrap_or_else(|_| default_node_id());
        let metrics_enabled = std::env::var("PHI_METRICS_ENABLED")
            .map(|v| {
                let v = v.to_lowercase();
                !matches!(v.as_str(), "false" | "0" | "no" | "off" | "")
            })
            .unwrap_or(true);

        let mut telemetry = if metrics_enabled {
            Some(phi_telemetry::init_telemetry(
                agent.runtime(),
                session_id_str.clone(),
                node_id,
                llm_config.model.clone(),
            ))
        } else {
            None
        };

        let (result, run_outcome) = run_one_shot(&agent, &agent_session_id, &session_ctx, &query, &output_format).await;

        // Finalize and save metrics
        if let Some(handle) = &mut telemetry {
            handle.shutdown().await;
            let session = handle.session.read().await;
            let mut session = session.clone();
            session.finalize(phi_telemetry::types::run_outcome_to_session_outcome(&run_outcome));
            let _ = phi_telemetry::save_metrics(&session, &session_ctx.session_dir);
        }

        result?;
        Ok(())
    } else {
        run_repl(&agent, &agent_session_id, &session_ctx, &output_format).await
    }
}