car-external-agents 0.16.0

Detection of installed agentic CLIs (Claude Code, Codex, Gemini) for the Common Agent Runtime.
Documentation
//! Bridge between `car-multi` orchestration and the external-agent
//! invocation runner.
//!
//! Phase 3 of `docs/proposals/external-agent-detection.md`. The
//! coordination patterns in `car-multi` (`run_swarm`, `run_pipeline`,
//! `run_supervisor`, etc.) take a single `Arc<dyn AgentRunner>` that
//! every spec routes through. With [`ExternalAwareRunner`], callers
//! can mix external CLI agents with in-process runners in the same
//! swarm: any [`car_multi::AgentSpec`] whose `name` starts with
//! `external:` is dispatched to [`crate::invoke`]; everything else
//! falls through to the caller-supplied inner runner.
//!
//! ## Naming convention
//!
//! - `external:claude-code` → invoke the Claude Code adapter
//! - `external:codex` → invoke Codex
//! - `external:gemini` → invoke Gemini
//!
//! Anything other than the three known adapter ids returns
//! [`car_multi::MultiError::AgentFailed`] so the orchestrator can
//! report which spec failed without crashing the whole swarm.
//!
//! ## Wiring
//!
//! ```ignore
//! use std::sync::Arc;
//! use car_external_agents::ExternalAwareRunner;
//!
//! let host_runner: Arc<dyn car_multi::AgentRunner> = Arc::new(MyChatRunner);
//! let runner: Arc<dyn car_multi::AgentRunner> =
//!     Arc::new(ExternalAwareRunner::new(host_runner));
//!
//! // run_swarm now accepts both in-process and external specs.
//! ```
//!
//! Each external spec can pass per-invocation options through the
//! `metadata` map — see [`extract_invoke_options`] for the
//! recognised keys.

use std::sync::Arc;

use async_trait::async_trait;
use car_engine::Runtime;
use car_multi::{
    error::MultiError,
    mailbox::Mailbox,
    runner::AgentRunner,
    types::{AgentOutput, AgentSpec, TokenAccounting},
};
use serde_json::Value;

use crate::runner::{InvokeOptions, InvokeResult};

/// Adapter id prefix on `AgentSpec.name` that flips dispatch to
/// [`crate::invoke`].
pub const EXTERNAL_PREFIX: &str = "external:";

/// Wraps any inner [`AgentRunner`] and routes specs whose `name`
/// starts with [`EXTERNAL_PREFIX`] to the external-agent invocation
/// path. Everything else passes through unchanged.
///
/// Cheap to clone (the wrapper holds an `Arc`); pass it as
/// `Arc<dyn AgentRunner>` into `run_swarm` / `run_pipeline` /
/// `run_supervisor` like any other runner.
pub struct ExternalAwareRunner {
    inner: Arc<dyn AgentRunner>,
}

impl ExternalAwareRunner {
    pub fn new(inner: Arc<dyn AgentRunner>) -> Self {
        Self { inner }
    }
}

#[async_trait]
impl AgentRunner for ExternalAwareRunner {
    async fn run(
        &self,
        spec: &AgentSpec,
        task: &str,
        runtime: &Runtime,
        mailbox: &Mailbox,
    ) -> Result<AgentOutput, MultiError> {
        match spec.name.strip_prefix(EXTERNAL_PREFIX) {
            Some(adapter_id) => run_external(spec, adapter_id, task).await,
            None => self.inner.run(spec, task, runtime, mailbox).await,
        }
    }
}

/// Pure dispatch path the wrapper uses. Pulled out so callers can
/// drive an external agent through the multi-agent shape without
/// holding a runner — useful for one-off `agents.invoke_external`
/// composition tests.
pub async fn run_external(
    spec: &AgentSpec,
    adapter_id: &str,
    task: &str,
) -> Result<AgentOutput, MultiError> {
    let opts = extract_invoke_options(spec);
    let started = std::time::Instant::now();
    let invoke_result = crate::invoke(adapter_id, task, opts)
        .await
        .map_err(|e| MultiError::AgentFailed(spec.name.clone(), e.to_string()))?;
    Ok(map_invoke_to_agent_output(spec, invoke_result, started))
}

/// Read the recognised keys out of `AgentSpec.metadata` into an
/// [`InvokeOptions`]. The keys mirror the `agents.invoke_external`
/// WS shape exactly so a host can move a spec between the swarm
/// and the WS-direct paths without reshaping. Unknown keys are
/// ignored.
///
/// Recognised keys:
///
/// | metadata key      | type         | -> InvokeOptions field |
/// |-------------------|--------------|------------------------|
/// | `cwd`             | string       | `cwd`                  |
/// | `allowed_tools`   | string array | `allowed_tools`        |
/// | `max_turns`       | uint         | `max_turns` (also derived from `spec.max_turns` when unset) |
/// | `timeout_secs`    | uint         | `timeout_secs`         |
/// | `mcp_endpoint`    | string       | `mcp_endpoint` (`""` opts out) |
pub fn extract_invoke_options(spec: &AgentSpec) -> InvokeOptions {
    let mut opts = InvokeOptions::default();
    if let Some(cwd) = spec.metadata.get("cwd").and_then(Value::as_str) {
        opts.cwd = Some(std::path::PathBuf::from(cwd));
    }
    if let Some(arr) = spec.metadata.get("allowed_tools").and_then(Value::as_array) {
        let tools: Vec<String> = arr
            .iter()
            .filter_map(|v| v.as_str().map(String::from))
            .collect();
        opts.allowed_tools = Some(tools);
    }
    if let Some(n) = spec
        .metadata
        .get("max_turns")
        .and_then(Value::as_u64)
        .map(|n| n as u32)
    {
        opts.max_turns = Some(n);
    } else if spec.max_turns > 0 && spec.max_turns < u32::MAX {
        // Fall back to AgentSpec.max_turns when metadata didn't
        // override. The default constructor uses 10; honoring it
        // matches the rest of car-multi's behaviour.
        opts.max_turns = Some(spec.max_turns);
    }
    if let Some(t) = spec.metadata.get("timeout_secs").and_then(Value::as_u64) {
        opts.timeout_secs = Some(t);
    }
    if let Some(url) = spec.metadata.get("mcp_endpoint").and_then(Value::as_str) {
        opts.mcp_endpoint = Some(url.to_string());
    }
    opts
}

/// Build the `AgentOutput` shape the orchestrator consumes from the
/// `InvokeResult` shape the external-agent runner produces.
/// Token accounting falls through verbatim where the adapter
/// reports it; cost is reported as `total_cost_usd` (would-be API
/// cost — subscription users don't actually pay this).
pub fn map_invoke_to_agent_output(
    spec: &AgentSpec,
    invoke: InvokeResult,
    started: std::time::Instant,
) -> AgentOutput {
    let elapsed_ms = started.elapsed().as_millis() as f64;
    let duration_ms = if invoke.duration_ms > 0 {
        invoke.duration_ms as f64
    } else {
        elapsed_ms
    };
    let tokens = invoke
        .total_cost_usd
        .map(|cost| TokenAccounting::new(0, 0, cost.max(0.0)));
    AgentOutput {
        name: spec.name.clone(),
        answer: invoke.answer,
        turns: invoke.turns,
        tool_calls: invoke.tool_calls,
        duration_ms,
        error: invoke.error,
        outcome: None,
        tokens,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;
    use std::collections::HashMap;

    fn spec_with_metadata(name: &str, metadata: HashMap<String, Value>) -> AgentSpec {
        AgentSpec {
            name: name.to_string(),
            system_prompt: String::new(),
            tools: Vec::new(),
            max_turns: 10,
            metadata,
            cache_control: false,
        }
    }

    #[test]
    fn extract_options_reads_metadata_keys() {
        let mut meta = HashMap::new();
        meta.insert("cwd".into(), json!("/tmp/work"));
        meta.insert("allowed_tools".into(), json!(["Read", "Bash"]));
        meta.insert("max_turns".into(), json!(7));
        meta.insert("timeout_secs".into(), json!(60));
        meta.insert("mcp_endpoint".into(), json!("http://127.0.0.1:9102/mcp"));
        let spec = spec_with_metadata("external:claude-code", meta);
        let opts = extract_invoke_options(&spec);
        assert_eq!(opts.cwd.unwrap().to_string_lossy(), "/tmp/work");
        assert_eq!(opts.allowed_tools.unwrap(), vec!["Read", "Bash"]);
        assert_eq!(opts.max_turns, Some(7));
        assert_eq!(opts.timeout_secs, Some(60));
        assert_eq!(
            opts.mcp_endpoint.as_deref(),
            Some("http://127.0.0.1:9102/mcp")
        );
    }

    #[test]
    fn extract_options_falls_back_to_spec_max_turns() {
        // No `max_turns` in metadata — use the AgentSpec field.
        let spec = AgentSpec {
            name: "external:claude-code".to_string(),
            system_prompt: String::new(),
            tools: Vec::new(),
            max_turns: 5,
            metadata: HashMap::new(),
            cache_control: false,
        };
        let opts = extract_invoke_options(&spec);
        assert_eq!(opts.max_turns, Some(5));
    }

    #[test]
    fn map_invoke_preserves_answer_and_counts() {
        let spec = spec_with_metadata("external:claude-code", HashMap::new());
        let invoke = InvokeResult {
            answer: "ok".into(),
            session_id: Some("sess".into()),
            turns: 3,
            tool_calls: 2,
            duration_ms: 1500,
            total_cost_usd: Some(0.05),
            is_error: false,
            error: None,
            tool_uses: Vec::new(),
        };
        let started = std::time::Instant::now();
        let output = map_invoke_to_agent_output(&spec, invoke, started);
        assert_eq!(output.name, "external:claude-code");
        assert_eq!(output.answer, "ok");
        assert_eq!(output.turns, 3);
        assert_eq!(output.tool_calls, 2);
        assert_eq!(output.duration_ms, 1500.0);
        assert!(output.tokens.is_some());
        assert_eq!(output.tokens.unwrap().cost_usd, 0.05);
        assert!(output.error.is_none());
    }

    #[test]
    fn map_invoke_propagates_error() {
        let spec = spec_with_metadata("external:claude-code", HashMap::new());
        let invoke = InvokeResult {
            answer: String::new(),
            is_error: true,
            error: Some("network timeout".into()),
            ..Default::default()
        };
        let output = map_invoke_to_agent_output(&spec, invoke, std::time::Instant::now());
        assert_eq!(output.error.as_deref(), Some("network timeout"));
    }

    #[test]
    fn external_prefix_check_round_trips() {
        let name = "external:claude-code";
        assert_eq!(name.strip_prefix(EXTERNAL_PREFIX), Some("claude-code"));
        let plain = "my-in-process-agent";
        assert!(plain.strip_prefix(EXTERNAL_PREFIX).is_none());
    }
}