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};
pub const EXTERNAL_PREFIX: &str = "external:";
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,
}
}
}
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))
}
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 {
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
}
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() {
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());
}
}