use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Default,
serde::Serialize,
serde::Deserialize,
clap::ValueEnum,
)]
#[serde(rename_all = "kebab-case")]
#[clap(rename_all = "kebab-case")]
pub enum Backend {
#[default]
ClaudeCodeCli,
ClaudeAgentSdk,
CodexLocal,
Vercel,
}
impl Backend {
pub fn as_str(&self) -> &'static str {
match self {
Backend::ClaudeCodeCli => "claude-code-cli",
Backend::ClaudeAgentSdk => "claude-agent-sdk",
Backend::CodexLocal => "codex-local",
Backend::Vercel => "vercel",
}
}
}
impl std::fmt::Display for Backend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
pub struct AdapterResult {
pub ok: bool,
pub raw: String,
pub latency_ms: Option<u64>,
pub cost: Option<f64>,
pub turns: Option<u64>,
}
pub trait BackendAdapter: Sync {
fn invoke(
&self,
project: &Path,
agent: &str,
path_env: &str,
question: &str,
model_override: Option<&str>,
) -> AdapterResult;
}
pub(crate) struct ClaudeCodeCliAdapter;
impl BackendAdapter for ClaudeCodeCliAdapter {
fn invoke(
&self,
project: &Path,
agent: &str,
path_env: &str,
question: &str,
model_override: Option<&str>,
) -> AdapterResult {
let mut args: Vec<String> = vec![
"-p".to_string(),
question.to_string(),
"--agent".to_string(),
agent.to_string(),
];
if let Some(model) = model_override {
args.push("--model".to_string());
args.push(model.to_string());
}
let settings_path = project.join(".claude/settings.json");
match std::fs::read_to_string(&settings_path) {
Ok(settings_json) => {
args.push("--settings".to_string());
args.push(settings_json);
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
return AdapterResult {
ok: false,
raw: format!(
".claude/settings.json exists but could not be read ({e}); refusing to run \
without its capability envelope"
),
latency_ms: None,
cost: None,
turns: None,
};
}
}
args.push("--output-format".to_string());
args.push("json".to_string());
let output = Command::new("claude")
.args(&args)
.current_dir(project)
.env("PATH", path_env)
.output();
let raw = match output {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).into_owned(),
_ => {
return AdapterResult {
ok: false,
raw: String::new(),
latency_ms: None,
cost: None,
turns: None,
}
}
};
let meta: serde_json::Value = serde_json::from_str(&raw).unwrap_or(serde_json::Value::Null);
let result_text = meta
.get("result")
.and_then(|v| v.as_str())
.unwrap_or(&raw)
.to_string();
let latency_ms = meta.get("duration_ms").and_then(|v| v.as_u64());
let cost = meta.get("total_cost_usd").and_then(|v| v.as_f64());
let turns = meta.get("num_turns").and_then(|v| v.as_u64());
AdapterResult {
ok: true,
raw: result_text,
latency_ms,
cost,
turns,
}
}
}
fn claude_agent_sdk_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("dispatcher")
.join("claude-agent-sdk")
}
fn claude_agent_sdk_cli_js() -> PathBuf {
claude_agent_sdk_dir().join("dist").join("cli.js")
}
pub(crate) struct ClaudeAgentSdkAdapter;
impl ClaudeAgentSdkAdapter {
fn absolute(path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir().unwrap_or_default().join(path)
}
}
}
impl BackendAdapter for ClaudeAgentSdkAdapter {
fn invoke(
&self,
project: &Path,
agent: &str,
path_env: &str,
question: &str,
model_override: Option<&str>,
) -> AdapterResult {
let fail = |reason: String| AdapterResult {
ok: false,
raw: reason,
latency_ms: None,
cost: None,
turns: None,
};
let out_dir = match tempfile::tempdir() {
Ok(dir) => dir,
Err(e) => {
return fail(format!(
"could not create a temporary output directory: {e}"
))
}
};
let ir_path = Self::absolute(Path::new(agent));
let project_abs = Self::absolute(project);
let mut args: Vec<String> = vec![
"dispatch".to_string(),
ir_path.display().to_string(),
question.to_string(),
"--out".to_string(),
out_dir.path().display().to_string(),
"--project".to_string(),
project_abs.display().to_string(),
];
if let Some(model) = model_override {
args.extend([
"--strong".to_string(),
model.to_string(),
"--cheap".to_string(),
model.to_string(),
"--orchestrator".to_string(),
model.to_string(),
]);
}
let output = Command::new("node")
.arg(claude_agent_sdk_cli_js())
.args(&args)
.current_dir(claude_agent_sdk_dir())
.env("PATH", path_env)
.output();
let o = match output {
Ok(o) => o,
Err(e) => return fail(format!("could not run the dispatch CLI via node: {e}")),
};
if !o.status.success() {
let stderr = String::from_utf8_lossy(&o.stderr);
let detail = stderr
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or("no stderr output");
return fail(format!("dispatch CLI failed ({}): {detail}", o.status));
}
let raw = std::fs::read_to_string(out_dir.path().join("result.txt")).unwrap_or_default();
let trace: serde_json::Value = std::fs::read_to_string(out_dir.path().join("trace.json"))
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or(serde_json::Value::Null);
let run = trace.get("run");
let latency_ms = run
.and_then(|r| r.get("duration_ms"))
.and_then(|v| v.as_u64());
let cost = run
.and_then(|r| r.get("total_cost_usd"))
.and_then(|v| v.as_f64());
let turns = run
.and_then(|r| r.get("num_turns"))
.and_then(|v| v.as_u64());
AdapterResult {
ok: true,
raw,
latency_ms,
cost,
turns,
}
}
}
fn codex_local_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("dispatcher")
.join("codex-local")
}
fn codex_local_cli_js() -> PathBuf {
codex_local_dir().join("dist").join("cli.js")
}
fn absolute_path(path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir().unwrap_or_default().join(path)
}
}
const CODEX_LOCAL_SPEC_SHAPE: &str = concat!(
r#"{"ir_path": "<path to a compiled IR>", "component": "<component id in that IR, e.g. "#,
r#""build_context">", "mcp": {"command": "<path to an MCP server executable>", "args": [], "#,
r#""source_tools": [], "context_tools": ["<tool name>"]}}"#
);
const CODEX_LOCAL_SPEC_DOC_POINTER: &str =
"see the \"codex-local\" section of docs/site/docs/guides/evaluating.md for the full shape and a worked example";
fn looks_like_compiled_ir(spec_text: &str) -> bool {
serde_json::from_str::<serde_json::Value>(spec_text)
.ok()
.is_some_and(|v| v.get("warble_ir_version").is_some())
}
fn describe_spec_parse_failure(
spec_path: &Path,
spec_text: &str,
cause: &serde_json::Error,
) -> String {
if looks_like_compiled_ir(spec_text) {
format!(
"{} looks like a compiled IR (it has a `warble_ir_version` field), not a codex-local \
dispatch spec. Unlike `claude-agent-sdk`, backend 'codex-local' does not take the compiled IR \
directly via --ir — point --ir at a small JSON dispatch spec that names this IR (plus the \
component and MCP server to dispatch it with) instead: {CODEX_LOCAL_SPEC_SHAPE}. \
{CODEX_LOCAL_SPEC_DOC_POINTER}.",
spec_path.display()
)
} else {
format!(
"could not parse {} as a codex-local dispatch spec ({cause}). Backend 'codex-local' \
needs a small JSON dispatch spec: {CODEX_LOCAL_SPEC_SHAPE}. {CODEX_LOCAL_SPEC_DOC_POINTER}.",
spec_path.display()
)
}
}
#[derive(Debug, serde::Deserialize)]
struct CodexLocalMcp {
#[serde(default = "CodexLocalMcp::default_name")]
name: String,
command: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
source_tools: Vec<String>,
#[serde(default)]
context_tools: Vec<String>,
}
impl CodexLocalMcp {
fn default_name() -> String {
"setup".to_string()
}
}
#[derive(Debug, serde::Deserialize)]
struct CodexLocalDispatchSpec {
ir_path: String,
component: String,
mcp: CodexLocalMcp,
}
fn build_dispatch_args(
spec: &CodexLocalDispatchSpec,
ir_path: &Path,
server_command: &Path,
project_abs: &Path,
question: &str,
model_override: Option<&str>,
) -> Vec<String> {
let mut args: Vec<String> = vec![
"dispatch".to_string(),
ir_path.display().to_string(),
question.to_string(),
"--component".to_string(),
spec.component.clone(),
"--server".to_string(),
spec.mcp.name.clone(),
"--server-command".to_string(),
server_command.display().to_string(),
];
for server_arg in &spec.mcp.args {
args.push("--server-arg".to_string());
args.push(server_arg.clone());
}
for tool in &spec.mcp.source_tools {
args.push("--source-tool".to_string());
args.push(tool.clone());
}
for tool in &spec.mcp.context_tools {
args.push("--context-tool".to_string());
args.push(tool.clone());
}
args.push("--project".to_string());
args.push(project_abs.display().to_string());
if let Some(model) = model_override {
args.push("--model".to_string());
args.push(model.to_string());
}
args
}
fn first_stderr_line(stderr: &str) -> &str {
stderr
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or("no stderr output")
}
pub(crate) struct CodexLocalAdapter;
impl BackendAdapter for CodexLocalAdapter {
fn invoke(
&self,
project: &Path,
agent: &str,
path_env: &str,
question: &str,
model_override: Option<&str>,
) -> AdapterResult {
let fail = |reason: String| AdapterResult {
ok: false,
raw: reason,
latency_ms: None,
cost: None,
turns: None,
};
let spec_path = absolute_path(Path::new(agent));
let spec_text = match std::fs::read_to_string(&spec_path) {
Ok(text) => text,
Err(e) => {
return fail(format!(
"could not read codex-local dispatch spec {}: {e}",
spec_path.display()
))
}
};
let spec: CodexLocalDispatchSpec = match serde_json::from_str(&spec_text) {
Ok(spec) => spec,
Err(e) => return fail(describe_spec_parse_failure(&spec_path, &spec_text, &e)),
};
let spec_dir = spec_path.parent().unwrap_or_else(|| Path::new("."));
let ir_path = absolute_path(&spec_dir.join(&spec.ir_path));
let server_command = absolute_path(&spec_dir.join(&spec.mcp.command));
let project_abs = absolute_path(project);
let args = build_dispatch_args(
&spec,
&ir_path,
&server_command,
&project_abs,
question,
model_override,
);
let output = Command::new("node")
.arg(codex_local_cli_js())
.args(&args)
.current_dir(codex_local_dir())
.env("PATH", path_env)
.output();
let o = match output {
Ok(o) => o,
Err(e) => {
return fail(format!(
"could not run the codex-local dispatch CLI via node: {e}"
))
}
};
if !o.status.success() {
let stderr = String::from_utf8_lossy(&o.stderr);
let detail = first_stderr_line(&stderr);
return fail(format!(
"codex-local dispatch CLI failed ({}): {detail}",
o.status
));
}
let raw = String::from_utf8_lossy(&o.stdout).trim_end().to_string();
AdapterResult {
ok: true,
raw,
latency_ms: None,
cost: None,
turns: None,
}
}
}
pub(crate) fn resolve_adapter(backend: Backend) -> Result<Box<dyn BackendAdapter>, String> {
let supported = format!(
"{}, {}, {}",
Backend::ClaudeCodeCli.as_str(),
Backend::ClaudeAgentSdk.as_str(),
Backend::CodexLocal.as_str()
);
match backend {
Backend::ClaudeCodeCli => Ok(Box::new(ClaudeCodeCliAdapter)),
Backend::ClaudeAgentSdk => {
let cli_js = claude_agent_sdk_cli_js();
if !cli_js.exists() {
return Err(format!(
"backend 'claude-agent-sdk' has no built CLI at {} — run `just build-ts` first",
cli_js.display()
));
}
Ok(Box::new(ClaudeAgentSdkAdapter))
}
Backend::CodexLocal => {
let cli_js = codex_local_cli_js();
if !cli_js.exists() {
return Err(format!(
"backend 'codex-local' has no built CLI at {} — run `just build-codex-ts` first",
cli_js.display()
));
}
Ok(Box::new(CodexLocalAdapter))
}
other => Err(format!(
"backend '{other}' has no eval runner adapter yet — supported: {supported}"
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_backend_is_claude_code_cli() {
assert_eq!(Backend::default(), Backend::ClaudeCodeCli);
}
#[test]
fn as_str_matches_dispatcher_directory_names() {
assert_eq!(Backend::ClaudeCodeCli.as_str(), "claude-code-cli");
assert_eq!(Backend::ClaudeAgentSdk.as_str(), "claude-agent-sdk");
assert_eq!(Backend::CodexLocal.as_str(), "codex-local");
assert_eq!(Backend::Vercel.as_str(), "vercel");
}
#[test]
fn resolve_adapter_supports_claude_code_cli_claude_agent_sdk_and_codex_local() {
assert!(resolve_adapter(Backend::ClaudeCodeCli).is_ok());
for (backend, build_fix) in [
(Backend::ClaudeAgentSdk, "just build-ts"),
(Backend::CodexLocal, "just build-codex-ts"),
] {
match resolve_adapter(backend) {
Ok(_) => {}
Err(e) => assert!(
e.contains(build_fix),
"missing-build error for {backend:?} names the fix: {e}"
),
}
}
let err = match resolve_adapter(Backend::Vercel) {
Err(e) => e,
Ok(_) => panic!("no adapter yet for this backend"),
};
assert!(
err.contains("claude-code-cli")
&& err.contains("claude-agent-sdk")
&& err.contains("codex-local"),
"error names the supported subset: {err}"
);
}
#[test]
fn serde_roundtrip_is_kebab_case() {
let json = serde_json::to_string(&Backend::ClaudeAgentSdk).unwrap();
assert_eq!(json, "\"claude-agent-sdk\"");
let back: Backend = serde_json::from_str(&json).unwrap();
assert_eq!(back, Backend::ClaudeAgentSdk);
}
#[test]
fn spec_parse_failure_on_a_compiled_ir_names_the_real_problem() {
let spec_path = Path::new("compiled-ir.json");
let ir_text = r#"{"warble_ir_version": "0.5", "components": []}"#;
let cause = serde_json::from_str::<CodexLocalDispatchSpec>(ir_text).unwrap_err();
let msg = describe_spec_parse_failure(spec_path, ir_text, &cause);
assert!(
msg.contains("looks like a compiled IR"),
"names the real shape mismatch: {msg}"
);
assert!(
msg.contains("ir_path") && msg.contains("component") && msg.contains("mcp"),
"shows the expected sidecar shape inline: {msg}"
);
assert!(
msg.contains("evaluating.md"),
"points at the documented shape: {msg}"
);
assert!(
!msg.contains("missing field"),
"must not just echo the raw serde error against the IR's own fields: {msg}"
);
}
#[test]
fn spec_parse_failure_on_garbage_names_the_expected_shape() {
let spec_path = Path::new("not-a-spec.json");
let garbage = "not json at all";
let cause = serde_json::from_str::<CodexLocalDispatchSpec>(garbage).unwrap_err();
let msg = describe_spec_parse_failure(spec_path, garbage, &cause);
assert!(!msg.contains("looks like a compiled IR"));
assert!(msg.contains("ir_path") && msg.contains("component") && msg.contains("mcp"));
assert!(msg.contains("evaluating.md"));
}
#[test]
fn spec_parse_failure_on_valid_json_missing_a_field_names_the_expected_shape() {
let spec_path = Path::new("almost-a-spec.json");
let almost = r#"{"component": "build_context", "mcp": {"command": "./x"}}"#;
let cause = serde_json::from_str::<CodexLocalDispatchSpec>(almost).unwrap_err();
let msg = describe_spec_parse_failure(spec_path, almost, &cause);
assert!(!msg.contains("looks like a compiled IR"));
assert!(msg.contains("ir_path") && msg.contains("mcp"));
}
fn sample_mcp() -> CodexLocalMcp {
CodexLocalMcp {
name: "setup".to_string(),
command: "fake-mcp.mjs".to_string(),
args: vec!["--flag".to_string()],
source_tools: vec!["probe_source".to_string()],
context_tools: vec!["probe_setup".to_string()],
}
}
#[test]
fn dispatch_args_include_component_server_and_tool_flags_in_order() {
let spec = CodexLocalDispatchSpec {
ir_path: "ir.json".to_string(),
component: "build_context".to_string(),
mcp: sample_mcp(),
};
let args = build_dispatch_args(
&spec,
Path::new("/abs/ir.json"),
Path::new("/abs/fake-mcp.mjs"),
Path::new("/abs/project"),
"what tables exist?",
Some("gpt-5.4-mini"),
);
assert_eq!(
args,
vec![
"dispatch",
"/abs/ir.json",
"what tables exist?",
"--component",
"build_context",
"--server",
"setup",
"--server-command",
"/abs/fake-mcp.mjs",
"--server-arg",
"--flag",
"--source-tool",
"probe_source",
"--context-tool",
"probe_setup",
"--project",
"/abs/project",
"--model",
"gpt-5.4-mini",
]
);
}
#[test]
fn dispatch_args_omit_model_flag_when_no_override() {
let spec = CodexLocalDispatchSpec {
ir_path: "ir.json".to_string(),
component: "connect_source".to_string(),
mcp: CodexLocalMcp {
name: CodexLocalMcp::default_name(),
command: "fake-mcp.mjs".to_string(),
args: vec![],
source_tools: vec![],
context_tools: vec![],
},
};
let args = build_dispatch_args(
&spec,
Path::new("/abs/ir.json"),
Path::new("/abs/fake-mcp.mjs"),
Path::new("/abs/project"),
"q",
None,
);
assert!(!args.contains(&"--model".to_string()));
assert!(!args.contains(&"--server-arg".to_string()));
}
#[test]
fn first_stderr_line_skips_blank_lines() {
assert_eq!(
first_stderr_line("\n\n real error \nmore\n"),
"real error"
);
}
#[test]
fn first_stderr_line_falls_back_when_all_blank() {
assert_eq!(first_stderr_line(" \n\n"), "no stderr output");
assert_eq!(first_stderr_line(""), "no stderr output");
}
#[test]
fn invoke_reports_a_missing_spec_file_with_its_own_path() {
let result = CodexLocalAdapter.invoke(
Path::new("/nonexistent-project"),
"/nonexistent-codex-local-spec.json",
"",
"question",
None,
);
assert!(!result.ok);
assert!(result.raw.contains("/nonexistent-codex-local-spec.json"));
assert_eq!(result.cost, None);
assert_eq!(result.latency_ms, None);
assert_eq!(result.turns, None);
}
}