car-external-agents 0.34.0

Detection of installed agentic CLIs (Claude Code, Codex, Gemini) for the Common Agent Runtime.
//! Goal loop over an **external** agent (Codex / Claude Code / Gemini).
//!
//! See `docs/proposals/goal-loop.md`. CAR's goal loop is executor-agnostic
//! (`car_verify::goal::run_goal_loop`), so "orchestrate Claude Code like
//! everything else" is just "make one iteration invoke the CLI". Each iteration
//! runs the installed agent with the goal-anchored directive; between
//! iterations the caller's `gather` closure projects **CAR's** deterministic
//! ground truth (a command check CAR runs on its substrate, plus tool receipts
//! when the CLI's tools were routed back through CAR via
//! [`InvokeOptions::mcp_endpoint`]). Completion is decided by CAR's evaluator,
//! not the CLI's own weaker stop logic — CAR wraps the incumbent's loop in a
//! strictly stronger one (a Claude Code `/goal` run judged by Haiku becomes a
//! CAR run judged by real command exit codes).

use crate::runner::{invoke, InvokeOptions, InvokeResult};
use car_verify::goal::{run_goal_loop, GoalInputs, GoalRun, GoalSpec, IterationOutcome};
use std::future::Future;

/// Map one external-agent run into a loop [`IterationOutcome`]. Cost comes from
/// the CLI's reported `total_cost_usd`; progress means the agent actually ran a
/// tool and didn't hard-error (a no-tool erroring run is thrash the no-progress
/// governor should catch).
pub fn invoke_result_to_outcome(r: &InvokeResult) -> IterationOutcome {
    IterationOutcome {
        cost_usd: r.total_cost_usd.unwrap_or(0.0),
        made_progress: !r.is_error && r.tool_calls > 0,
    }
}

/// Drive an external agent as a deterministic goal loop.
///
/// `adapter_id` is `"claude-code" | "codex" | "gemini"`. `base_opts` is the
/// per-iteration [`InvokeOptions`] (set `mcp_endpoint` to CAR's MCP URL so the
/// CLI's tool calls route back through CAR's policy + event log, which is what
/// makes a `ToolReceiptsGrounded` condition meaningful for an external run).
/// `gather` projects [`GoalInputs`] after each iteration.
pub async fn run_external_goal_loop<Gather, GF>(
    adapter_id: &str,
    base_opts: InvokeOptions,
    spec: &GoalSpec,
    gather: Gather,
) -> GoalRun
where
    Gather: FnMut() -> GF,
    GF: Future<Output = GoalInputs>,
{
    let id = adapter_id.to_string();
    run_goal_loop(
        spec,
        move |directive| {
            let id = id.clone();
            let opts = base_opts.clone();
            async move {
                match invoke(&id, &directive, opts).await {
                    Ok(r) => invoke_result_to_outcome(&r),
                    // A failed invocation is a no-progress iteration; the loop
                    // re-drives with the reason, or the governor halts it.
                    Err(_) => IterationOutcome {
                        cost_usd: 0.0,
                        made_progress: false,
                    },
                }
            }
        },
        gather,
    )
    .await
}

#[cfg(test)]
mod tests {
    use super::*;
    use car_verify::goal::{run_goal_loop, GoalCondition, GoalGovernor, GoalSpec, GoalStatus};
    use std::cell::Cell;

    #[test]
    fn cost_and_progress_map_from_invoke_result() {
        let mut r = InvokeResult::default();
        r.total_cost_usd = Some(0.42);
        r.tool_calls = 3;
        let o = invoke_result_to_outcome(&r);
        assert!((o.cost_usd - 0.42).abs() < 1e-9);
        assert!(o.made_progress);

        // Errored run with no tools = no progress.
        r.is_error = true;
        assert!(!invoke_result_to_outcome(&r).made_progress);
    }

    /// The external-executor loop mechanism, with the CLI invocation mocked:
    /// prove that driving an external-agent-shaped iteration through CAR's
    /// deterministic evaluator converges. (The real `invoke` spawns a
    /// subprocess; this exercises the loop wiring without one, exactly as the
    /// production `run_external_goal_loop` composes it.)
    #[tokio::test]
    async fn external_loop_converges_under_cars_evaluator() {
        let iters = Cell::new(0u32);
        let spec = GoalSpec {
            goal: "fix the build".into(),
            condition: GoalCondition::Command {
                id: "build".into(),
                expect_exit: 0,
            },
            governor: GoalGovernor {
                max_turns: Some(6),
                ..Default::default()
            },
        };
        // Mocked external run: each iteration "does work"; the build passes on
        // the 2nd. This is exactly what run_external_goal_loop does, with
        // invoke() standing in for the mock here.
        let run_iteration = |_directive: String| {
            let n = iters.get() + 1;
            iters.set(n);
            async move {
                invoke_result_to_outcome(&InvokeResult {
                    tool_calls: 2,
                    total_cost_usd: Some(0.01),
                    ..Default::default()
                })
            }
        };
        let gather = || {
            let passing = iters.get() >= 2;
            async move {
                let mut i = GoalInputs::default();
                i.command_exits
                    .insert("build".into(), if passing { 0 } else { 1 });
                i
            }
        };
        let run = run_goal_loop(&spec, run_iteration, gather).await;
        assert_eq!(run.status, GoalStatus::Achieved);
        assert_eq!(run.iterations, 2);
        assert!(run.grounded, "a command-check completion is grounded");
    }
}