use std::collections::HashMap;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use async_trait::async_trait;
use car_external_agents::{InvokeError, InvokeOptions, InvokeResult, StreamEventEmitter};
use super::budget::SessionDeadline;
use super::contract::{evaluate_contract, CheckResult, OutcomeContract};
use super::native_loop::{
primary_failure, record_recurrence, recurrence_notice, LoopFailure, LoopOutcome,
};
use super::session::{CancelFlag, CoderEventKind, EventSink};
use super::shell_tool::WorktreeExecutor;
#[derive(Debug, Clone)]
pub struct ExternalLoopConfig {
pub max_turns: Option<u32>,
pub timeout_secs: Option<u64>,
pub repair_invokes: u32,
pub transient_retries: u32,
pub model: Option<String>,
pub deadline: Arc<SessionDeadline>,
}
impl Default for ExternalLoopConfig {
fn default() -> Self {
Self {
max_turns: Some(50),
timeout_secs: Some(1800),
repair_invokes: 2,
transient_retries: 1,
model: None,
deadline: SessionDeadline::shared_default(),
}
}
}
#[async_trait]
pub trait CliInvoker: Send + Sync {
async fn invoke(
&self,
agent_id: &str,
task: &str,
opts: InvokeOptions,
emitter: StreamEventEmitter,
) -> Result<InvokeResult, InvokeError>;
}
pub struct LiveInvoker;
#[async_trait]
impl CliInvoker for LiveInvoker {
async fn invoke(
&self,
agent_id: &str,
task: &str,
opts: InvokeOptions,
emitter: StreamEventEmitter,
) -> Result<InvokeResult, InvokeError> {
car_external_agents::invoke_with_emitter(agent_id, task, opts, Some(emitter)).await
}
}
fn build_task(intent: &str, contract: &OutcomeContract, feedback: Option<&str>) -> String {
let mut task = format!(
"{intent}\n\n\
OUTCOME CONTRACT — your work is verified by running these checks at the repository \
root; all must pass:\n{}\n\
Ground rules:\n\
- Work only inside the current directory (an isolated git worktree).\n\
- Do NOT git commit, push, or touch remotes; the runtime owns version control.\n\
- Run the checks yourself before finishing.\n",
contract.render()
);
if let Some(fb) = feedback {
task.push_str(&format!(
"\nA previous attempt left these checks FAILING — fix the code so they pass:\n{fb}"
));
}
task
}
fn build_invoke_opts(
executor: &WorktreeExecutor,
cfg: &ExternalLoopConfig,
mcp_endpoint: Option<&str>,
) -> InvokeOptions {
InvokeOptions {
cwd: Some(executor.worktree().to_path_buf()),
allowed_tools: None, max_turns: cfg.max_turns,
timeout_secs: match (cfg.timeout_secs, cfg.deadline.remaining_secs()) {
(Some(own), Some(left)) => Some(own.min(left)),
(own, None) => own,
(None, left) => left,
},
model: cfg.model.clone(),
mcp_endpoint: mcp_endpoint.map(String::from),
..Default::default()
}
}
pub async fn run_external_loop(
invoker: &dyn CliInvoker,
agent_id: &str,
intent: &str,
contract: &OutcomeContract,
executor: &WorktreeExecutor,
sink: &Arc<EventSink>,
cancel: &CancelFlag,
cfg: &ExternalLoopConfig,
mcp_endpoint: Option<&str>,
) -> LoopOutcome {
let max_hypotheses = 1 + cfg.repair_invokes;
let mut feedback: Option<String> = None;
let mut last_results = Vec::new();
let mut hypothesis = 1u32;
let mut transient_budget = cfg.transient_retries;
let mut rounds = 0u32;
let mut retrying = false;
let mut seen_sigs: HashMap<String, u32> = HashMap::new();
let mut spent_usd: Option<f64> = None;
loop {
if cancel.load(Ordering::SeqCst) {
return LoopOutcome::lost(
LoopFailure::Cancelled,
Some("cancelled".into()),
rounds,
last_results,
)
.with_cost(spent_usd);
}
if let Some(reason) = cfg.deadline.admit() {
sink.emit(CoderEventKind::BudgetExhausted {
reason: reason.clone(),
elapsed_secs: cfg.deadline.elapsed_secs(),
iterations: rounds,
});
return LoopOutcome::lost(
LoopFailure::BudgetExhausted,
Some(reason),
rounds,
last_results,
)
.with_cost(spent_usd);
}
if !retrying {
sink.emit(CoderEventKind::IterationStarted {
n: hypothesis,
max: max_hypotheses,
});
}
retrying = false;
let task = build_task(intent, contract, feedback.as_deref());
let opts = build_invoke_opts(executor, cfg, mcp_endpoint);
let emitter_sink = sink.clone();
let emitter: StreamEventEmitter = Arc::new(move |event| {
if let Ok(raw) = serde_json::to_value(&event) {
emitter_sink.emit(CoderEventKind::ExternalEvent { raw });
}
});
let invocation: Result<Option<String>, (LoopFailure, String)> =
match invoker.invoke(agent_id, &task, opts, emitter).await {
Ok(result) if result.is_error => {
record_spend(&mut spent_usd, result.total_cost_usd);
let msg = result.error.unwrap_or_else(|| "unknown".into());
sink.emit(CoderEventKind::Error {
message: format!("external agent '{agent_id}' reported an error: {msg}"),
});
Ok(Some(msg))
}
Ok(result) => {
record_spend(&mut spent_usd, result.total_cost_usd);
Ok(None)
}
Err(e) => Err((classify_invoke_error(&e), e.to_string())),
};
let terminal = match &invocation {
Err((LoopFailure::EngineUnavailable, msg)) => Some((
LoopFailure::EngineUnavailable,
format!("external agent '{agent_id}' failed: {msg}"),
)),
Err((LoopFailure::Cancelled, _)) => {
Some((LoopFailure::Cancelled, "cancelled".to_string()))
}
Err((LoopFailure::Infrastructure, _))
| Err((LoopFailure::NeedsAuth, _))
| Err((LoopFailure::Execution, _))
| Err((LoopFailure::Verification, _))
| Err((LoopFailure::BudgetExhausted, _))
| Ok(_) => None,
};
if let Some((failure, error)) = terminal {
return LoopOutcome::lost(failure, Some(error), rounds, last_results)
.with_cost(spent_usd);
}
last_results = evaluate_contract(contract, executor, sink).await;
rounds += 1;
if last_results.iter().all(|r| r.passed) {
return LoopOutcome::green(rounds, last_results).with_cost(spent_usd);
}
let failure = match &invocation {
Err((class, _)) => *class,
Ok(Some(_)) => LoopFailure::Execution,
Ok(None) => LoopFailure::Verification,
};
if failure == LoopFailure::Infrastructure && transient_budget > 0 {
transient_budget -= 1;
retrying = true;
sink.emit(CoderEventKind::InvocationRetried {
hypothesis,
reason: match &invocation {
Err((_, msg)) => msg.clone(),
Ok(_) => String::new(),
},
retries_remaining: transient_budget,
});
continue;
}
if hypothesis >= max_hypotheses {
let error = match (failure, &invocation) {
(LoopFailure::Infrastructure, Err((_, msg))) => {
Some(format!("external agent '{agent_id}' failed: {msg}"))
}
_ => None,
};
return LoopOutcome::lost(failure, error, rounds, last_results).with_cost(spent_usd);
}
let check_feedback = render_check_failures(&last_results);
feedback = Some(match &invocation {
Ok(Some(msg)) => {
format!("A previous attempt reported this error:\n{msg}\n\n{check_feedback}")
}
Err((_, msg)) => format!(
"A previous attempt was cut short ({msg}); its work may be partially applied.\n\n\
{check_feedback}"
),
Ok(None) => {
match record_recurrence(&mut seen_sigs, primary_failure(&last_results).as_ref()) {
0 => check_feedback,
n => format!("{check_feedback}\n\n{}", recurrence_notice(n)),
}
}
});
hypothesis += 1;
}
}
fn classify_invoke_error(e: &car_external_agents::InvokeError) -> LoopFailure {
use car_external_agents::InvokeError as E;
match e {
E::Spawn(_) | E::Setup(_) => LoopFailure::EngineUnavailable,
E::Timeout(_) | E::Io(_) => LoopFailure::Infrastructure,
E::Cancelled => LoopFailure::Cancelled,
}
}
fn record_spend(total: &mut Option<f64>, reported: Option<f64>) {
let Some(usd) = reported else { return };
if !usd.is_finite() || usd < 0.0 {
return;
}
*total = Some(total.unwrap_or(0.0) + usd);
}
fn render_check_failures(results: &[CheckResult]) -> String {
results
.iter()
.filter(|r| !r.passed)
.map(|r| {
format!(
"FAILED {} (exit {:?}):\n{}",
r.name, r.exit_code, r.output_tail
)
})
.collect::<Vec<_>>()
.join("\n\n")
}
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use std::sync::atomic::AtomicU32;
use std::sync::Mutex;
use super::*;
use crate::coder::contract::ContractCheck;
use crate::coder::session::CoderEvent;
use crate::coder::test_cmds::PASS;
fn contract() -> OutcomeContract {
OutcomeContract {
description: "x".into(),
checks: vec![ContractCheck {
name: "tests".into(),
command: "cargo test".into(),
expect_exit_zero: true,
output_contains: None,
timeout_secs: 300,
}],
}
}
#[test]
fn task_carries_intent_contract_and_ground_rules() {
let t = build_task("add a CLI flag", &contract(), None);
assert!(t.contains("add a CLI flag"));
assert!(t.contains("cargo test"));
assert!(t.contains("Do NOT git commit"));
assert!(!t.contains("FAILING"));
}
#[test]
fn repair_task_carries_failure_feedback() {
let t = build_task("x", &contract(), Some("FAILED tests (exit Some(1)):\nboom"));
assert!(t.contains("previous attempt"));
assert!(t.contains("boom"));
}
#[test]
fn mcp_endpoint_is_threaded_into_invoke_opts() {
let dir = tempfile::tempdir().unwrap();
let executor = WorktreeExecutor::new(dir.path());
let cfg = ExternalLoopConfig::default();
let opts = build_invoke_opts(&executor, &cfg, Some("http://127.0.0.1:9102/mcp"));
assert_eq!(
opts.mcp_endpoint.as_deref(),
Some("http://127.0.0.1:9102/mcp")
);
assert!(opts.allowed_tools.is_none());
}
#[test]
fn absent_mcp_endpoint_degrades_to_none() {
let dir = tempfile::tempdir().unwrap();
let executor = WorktreeExecutor::new(dir.path());
let cfg = ExternalLoopConfig::default();
let opts = build_invoke_opts(&executor, &cfg, None);
assert!(opts.mcp_endpoint.is_none());
}
#[test]
fn every_invoke_error_maps_to_its_outcome() {
use car_external_agents::InvokeError as E;
let cases = [
(E::Spawn("no binary".into()), LoopFailure::EngineUnavailable),
(
E::Setup("stdin closed".into()),
LoopFailure::EngineUnavailable,
),
(E::Timeout(1800), LoopFailure::Infrastructure),
(E::Io("stdout read".into()), LoopFailure::Infrastructure),
(E::Cancelled, LoopFailure::Cancelled),
];
for (err, want) in cases {
assert_eq!(classify_invoke_error(&err), want, "{err}");
}
}
#[test]
fn setup_and_midrun_io_are_not_the_same_outcome() {
use car_external_agents::InvokeError as E;
assert_ne!(
classify_invoke_error(&E::Setup("stdin closed".into())),
classify_invoke_error(&E::Io("stdout read".into())),
);
}
struct ScriptedInvoker {
script: Mutex<VecDeque<Result<InvokeResult, InvokeError>>>,
calls: AtomicU32,
tasks: Mutex<Vec<String>>,
}
impl ScriptedInvoker {
fn new(script: Vec<Result<InvokeResult, InvokeError>>) -> Self {
Self {
script: Mutex::new(script.into()),
calls: AtomicU32::new(0),
tasks: Mutex::new(Vec::new()),
}
}
fn calls(&self) -> u32 {
self.calls.load(Ordering::SeqCst)
}
fn task(&self, n: usize) -> String {
self.tasks.lock().expect("tasks poisoned")[n].clone()
}
}
#[async_trait]
impl CliInvoker for ScriptedInvoker {
async fn invoke(
&self,
_agent_id: &str,
task: &str,
_opts: InvokeOptions,
_emitter: StreamEventEmitter,
) -> Result<InvokeResult, InvokeError> {
self.calls.fetch_add(1, Ordering::SeqCst);
self.tasks
.lock()
.expect("tasks poisoned")
.push(task.to_string());
self.script
.lock()
.expect("script poisoned")
.pop_front()
.unwrap_or_else(|| Err(InvokeError::Spawn("script exhausted".into())))
}
}
fn contract_with(command: &str) -> OutcomeContract {
OutcomeContract {
description: "x".into(),
checks: vec![ContractCheck {
name: "gate".into(),
command: command.into(),
expect_exit_zero: true,
output_contains: None,
timeout_secs: 30,
}],
}
}
fn clean_run() -> Result<InvokeResult, InvokeError> {
Ok(InvokeResult::default())
}
fn errored_run(msg: &str) -> Result<InvokeResult, InvokeError> {
Ok(InvokeResult {
is_error: true,
error: Some(msg.into()),
..Default::default()
})
}
async fn run(
invoker: &dyn CliInvoker,
contract: &OutcomeContract,
cfg: &ExternalLoopConfig,
) -> (LoopOutcome, Vec<CoderEvent>) {
let dir = tempfile::tempdir().unwrap();
let executor = WorktreeExecutor::new(dir.path());
let (sink, collected) = EventSink::collecting("t");
let sink = Arc::new(sink);
let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
let outcome = run_external_loop(
invoker, "codex", "x", contract, &executor, &sink, &cancel, cfg, None,
)
.await;
let events = collected.lock().unwrap().clone();
(outcome, events)
}
#[tokio::test]
async fn a_timeout_over_green_checks_still_passes() {
let invoker = ScriptedInvoker::new(vec![Err(InvokeError::Timeout(1800))]);
let (outcome, _) = run(
&invoker,
&contract_with(PASS),
&ExternalLoopConfig::default(),
)
.await;
assert!(outcome.passed, "the contract, not the transport, decides");
assert_eq!(outcome.failure, None);
assert_eq!(outcome.iterations, 1);
}
#[tokio::test]
async fn a_transient_retry_does_not_spend_a_hypothesis() {
let invoker = ScriptedInvoker::new(vec![
Err(InvokeError::Timeout(1)),
Err(InvokeError::Timeout(1)),
Err(InvokeError::Timeout(1)),
Err(InvokeError::Timeout(1)),
]);
let (outcome, events) = run(
&invoker,
&contract_with("exit 1"),
&ExternalLoopConfig::default(),
)
.await;
assert_eq!(invoker.calls(), 4, "3 hypotheses + 1 transient retry");
assert_eq!(outcome.iterations, 4, "every invocation evaluated");
let started = events
.iter()
.filter(|e| matches!(e.kind, CoderEventKind::IterationStarted { .. }))
.count();
let retried = events
.iter()
.filter(|e| matches!(e.kind, CoderEventKind::InvocationRetried { .. }))
.count();
assert_eq!(started, 3, "one banner per hypothesis");
assert_eq!(retried, 1);
}
#[tokio::test]
async fn exhausted_infrastructure_keeps_the_scraped_error_prefix() {
let invoker = ScriptedInvoker::new(vec![
Err(InvokeError::Timeout(1)),
Err(InvokeError::Timeout(1)),
Err(InvokeError::Timeout(1)),
Err(InvokeError::Timeout(1)),
]);
let (outcome, _) = run(
&invoker,
&contract_with("exit 1"),
&ExternalLoopConfig::default(),
)
.await;
assert_eq!(outcome.failure, Some(LoopFailure::Infrastructure));
let err = outcome
.error
.expect("an exhausted infra failure must still surface as infra");
assert!(
err.starts_with("external agent '"),
"car-cli INFRA_MARKERS depends on this prefix: {err}"
);
}
#[tokio::test]
async fn a_setup_failure_does_not_retry_or_evaluate() {
let invoker = ScriptedInvoker::new(vec![Err(InvokeError::Setup("stdout missing".into()))]);
let (outcome, _) = run(
&invoker,
&contract_with("exit 1"),
&ExternalLoopConfig::default(),
)
.await;
assert_eq!(invoker.calls(), 1, "no retry: nothing ran");
assert_eq!(outcome.iterations, 0, "the contract was never consulted");
assert_eq!(outcome.failure, Some(LoopFailure::EngineUnavailable));
assert!(outcome.error.unwrap().starts_with("external agent '"));
}
#[tokio::test]
async fn execution_and_verification_are_distinguished() {
let cfg = ExternalLoopConfig {
repair_invokes: 0,
..Default::default()
};
let (errored, _) = run(
&ScriptedInvoker::new(vec![errored_run("tool denied")]),
&contract_with("exit 1"),
&cfg,
)
.await;
assert_eq!(errored.failure, Some(LoopFailure::Execution));
assert!(errored.error.is_none(), "the CLI ran; this is a task loss");
let (clean, _) = run(
&ScriptedInvoker::new(vec![clean_run()]),
&contract_with("exit 1"),
&cfg,
)
.await;
assert_eq!(clean.failure, Some(LoopFailure::Verification));
}
#[tokio::test]
async fn a_repeated_failure_escalates_the_repair_feedback() {
let cfg = ExternalLoopConfig {
repair_invokes: 2,
..Default::default()
};
let invoker = ScriptedInvoker::new(vec![clean_run(), clean_run(), clean_run()]);
let (outcome, _) = run(&invoker, &contract_with("exit 1"), &cfg).await;
assert_eq!(invoker.calls(), 3);
assert_eq!(outcome.failure, Some(LoopFailure::Verification));
assert!(!invoker.task(0).contains("failed the same way"));
assert!(!invoker.task(1).contains("failed the same way"));
let repair = invoker.task(2);
assert!(repair.contains("failed the same way 2 times"), "{repair}");
assert!(repair.contains("DIFFERENT hypothesis"));
}
#[tokio::test]
async fn a_cut_short_attempt_does_not_escalate() {
let invoker = ScriptedInvoker::new(vec![
Err(InvokeError::Timeout(1)),
Err(InvokeError::Timeout(1)),
Err(InvokeError::Timeout(1)),
Err(InvokeError::Timeout(1)),
]);
let (_, _) = run(
&invoker,
&contract_with("exit 1"),
&ExternalLoopConfig::default(),
)
.await;
for n in 0..invoker.calls() as usize {
assert!(
!invoker.task(n).contains("failed the same way"),
"a timeout is not evidence about the hypothesis (task {n})"
);
}
}
#[tokio::test]
async fn an_exhausted_budget_denies_admission_before_invoking() {
let cfg = ExternalLoopConfig {
deadline: std::sync::Arc::new(SessionDeadline::new(Some(0))),
..Default::default()
};
let invoker = ScriptedInvoker::new(vec![clean_run()]);
let (outcome, events) = run(&invoker, &contract_with("exit 1"), &cfg).await;
assert_eq!(invoker.calls(), 0, "the budget gates before any work");
assert_eq!(outcome.failure, Some(LoopFailure::BudgetExhausted));
assert_ne!(outcome.failure, Some(LoopFailure::Verification));
assert!(outcome
.error
.expect("the reason must surface")
.contains("session budget exhausted"));
assert!(events
.iter()
.any(|e| matches!(e.kind, CoderEventKind::BudgetExhausted { .. })));
}
#[test]
fn an_invocation_timeout_is_clamped_to_the_session_remainder() {
let dir = tempfile::tempdir().unwrap();
let executor = WorktreeExecutor::new(dir.path());
let tight = ExternalLoopConfig {
timeout_secs: Some(1800),
deadline: std::sync::Arc::new(SessionDeadline::new(Some(10))),
..Default::default()
};
let opts = build_invoke_opts(&executor, &tight, None);
assert_eq!(
opts.timeout_secs,
Some(10),
"the round must not outlive the session"
);
let roomy = ExternalLoopConfig {
timeout_secs: Some(60),
..Default::default()
};
assert_eq!(
build_invoke_opts(&executor, &roomy, None).timeout_secs,
Some(60)
);
let unbounded = ExternalLoopConfig {
timeout_secs: Some(60),
deadline: SessionDeadline::unlimited(),
..Default::default()
};
assert_eq!(
build_invoke_opts(&executor, &unbounded, None).timeout_secs,
Some(60)
);
}
#[test]
fn a_second_rung_shares_the_first_rungs_clock() {
let first = ExternalLoopConfig {
deadline: std::sync::Arc::new(SessionDeadline::new(Some(0))),
..Default::default()
};
let second = ExternalLoopConfig {
deadline: std::sync::Arc::clone(&first.deadline),
..Default::default()
};
assert!(
std::sync::Arc::ptr_eq(&first.deadline, &second.deadline),
"the fallback must not buy the session another full ceiling"
);
assert!(
second.deadline.admit().is_some(),
"an already-spent session must stay spent across the ladder"
);
}
#[tokio::test]
async fn the_default_budget_does_not_gate_a_normal_run() {
let invoker = ScriptedInvoker::new(vec![clean_run()]);
let (outcome, _) = run(
&invoker,
&contract_with(PASS),
&ExternalLoopConfig::default(),
)
.await;
assert!(outcome.passed);
assert_eq!(invoker.calls(), 1);
}
#[tokio::test]
async fn a_verification_loss_carries_no_infra_marker() {
let cfg = ExternalLoopConfig {
repair_invokes: 0,
..Default::default()
};
let (outcome, _) = run(
&ScriptedInvoker::new(vec![clean_run()]),
&contract_with("exit 1"),
&cfg,
)
.await;
assert_eq!(outcome.failure, Some(LoopFailure::Verification));
assert!(
outcome.error.is_none(),
"a genuine task loss must stay in the scored denominator"
);
}
#[tokio::test]
async fn a_missing_cli_is_engine_unavailable_through_the_live_invoker() {
let dir = tempfile::tempdir().unwrap();
let executor = WorktreeExecutor::new(dir.path());
let sink = Arc::new(EventSink::test_sink());
let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
let outcome = run_external_loop(
&LiveInvoker,
"no-such-cli",
"x",
&contract(),
&executor,
&sink,
&cancel,
&ExternalLoopConfig::default(),
None,
)
.await;
assert!(!outcome.passed);
assert_eq!(outcome.failure, Some(LoopFailure::EngineUnavailable));
let err = outcome.error.expect("spawn failure must surface");
assert!(err.starts_with("external agent '"), "{err}");
assert!(err.contains("no-such-cli"), "{err}");
}
#[tokio::test]
async fn cancellation_is_not_engine_unavailable() {
let dir = tempfile::tempdir().unwrap();
let executor = WorktreeExecutor::new(dir.path());
let sink = Arc::new(EventSink::test_sink());
let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(true));
let invoker = ScriptedInvoker::new(vec![]);
let outcome = run_external_loop(
&invoker,
"claude-code",
"x",
&contract(),
&executor,
&sink,
&cancel,
&ExternalLoopConfig::default(),
None,
)
.await;
assert_eq!(invoker.calls(), 0, "pre-cancelled must not invoke");
assert_eq!(outcome.failure, Some(LoopFailure::Cancelled));
assert_ne!(outcome.failure, Some(LoopFailure::EngineUnavailable));
assert_eq!(outcome.error.as_deref(), Some("cancelled"));
}
#[test]
fn rendered_feedback_carries_only_failing_checks() {
let results = vec![
CheckResult {
name: "build".into(),
passed: true,
exit_code: Some(0),
output_tail: "ok".into(),
duration_ms: 1,
},
CheckResult {
name: "tests".into(),
passed: false,
exit_code: Some(1),
output_tail: "assertion failed".into(),
duration_ms: 2,
},
];
let rendered = render_check_failures(&results);
assert!(rendered.contains("FAILED tests"));
assert!(rendered.contains("assertion failed"));
assert!(!rendered.contains("build"), "passing checks are noise");
}
}