use car_engine::{format_tool_result, Runtime};
use car_inference::tasks::generate::{ContentBlock, Message, ToolCall};
use car_inference::{GenerateParams, GenerateRequest};
use car_ir::{ActionProposal, ActionStatus};
use serde_json::{json, Value};
use crate::coder::native_loop::TurnGenerator;
const OBSERVATION_CAP: usize = 16 * 1024;
pub enum AssistantEvent {
Text(String),
ToolCall { name: String, params: Value },
ToolResult {
name: String,
ok: bool,
content: String,
},
Done { text: String },
Error(String),
}
pub struct AssistantConfig {
pub model: Option<String>,
pub max_turns: u32,
pub tools: Vec<Value>,
pub gated_tools: Vec<String>,
pub approval_policy: Option<ApprovalPolicyFn>,
}
pub type ApprovalPolicyFn =
std::sync::Arc<dyn Fn(&str, &Value) -> ToolApprovalDecision + Send + Sync>;
pub enum ToolApprovalDecision {
Allow,
RequireApproval,
Deny(String),
}
pub enum ApprovalDecision {
Approved,
Denied(String),
}
#[async_trait::async_trait]
pub trait ApprovalGate: Send + Sync {
async fn request(&self, tool: &str, params: &Value) -> ApprovalDecision;
}
pub struct AssistantOutcome {
pub status: &'static str,
pub summary: String,
pub turns: u32,
pub tools_called: Vec<String>,
}
fn cap(mut s: String) -> String {
if s.len() <= OBSERVATION_CAP {
return s;
}
let mut end = OBSERVATION_CAP;
while !s.is_char_boundary(end) {
end -= 1;
}
s.truncate(end);
s.push_str("…[truncated]…");
s
}
const HISTORY_MIN_TAIL: usize = 6;
fn approx_message_tokens(m: &Message) -> usize {
car_inference::media_tokens::messages_history_tokens(std::slice::from_ref(m))
}
fn compact_history_to_window(messages: &mut Vec<Message>, context_window: usize) {
if context_window == 0 {
return;
}
let budget = context_window / 4 * 3;
let total: usize = messages.iter().map(approx_message_tokens).sum();
if total <= budget {
return;
}
let mut head_end = 0;
while head_end < messages.len() && matches!(messages[head_end], Message::System { .. }) {
head_end += 1;
}
if head_end < messages.len()
&& matches!(
messages[head_end],
Message::User { .. } | Message::UserMultimodal { .. }
)
{
head_end += 1;
}
if messages.len().saturating_sub(head_end) <= HISTORY_MIN_TAIL {
return;
}
let max_drop = messages.len() - HISTORY_MIN_TAIL;
let mut drop_end = head_end;
let mut running = total;
while running > budget && drop_end < max_drop {
running -= approx_message_tokens(&messages[drop_end]);
drop_end += 1;
}
while drop_end < messages.len()
&& matches!(
messages[drop_end],
Message::ToolResult { .. } | Message::ProviderOutputItems { .. }
)
{
drop_end += 1;
}
if drop_end <= head_end {
return;
}
let dropped = drop_end - head_end;
messages.drain(head_end..drop_end);
tracing::debug!(
dropped_messages = dropped,
kept = messages.len(),
context_window,
budget,
"compacted assistant history to fit the model context window"
);
}
const STALL_NUDGE: u32 = 3;
const STALL_BREAK: u32 = 6;
const EXPLORE_NUDGE: u32 = 8;
fn mutating_tool_names(tool_defs: &[Value]) -> std::collections::HashSet<String> {
let mut set: std::collections::HashSet<String> = ["write_file", "edit_file", "remember"]
.iter()
.map(|s| s.to_string())
.collect();
for def in tool_defs {
if def
.get("mutating")
.and_then(Value::as_bool)
.unwrap_or(false)
{
if let Some(name) = def.get("name").and_then(Value::as_str) {
set.insert(name.to_string());
}
}
}
set
}
fn tool_calls_signature(calls: &[ToolCall]) -> String {
let mut parts: Vec<String> = calls
.iter()
.map(|c| {
format!(
"{}({})",
c.name,
serde_json::to_string(&c.arguments).unwrap_or_default()
)
})
.collect();
parts.sort();
parts.join("|")
}
fn build_proposal(source: &str, call: &ToolCall) -> Result<ActionProposal, String> {
serde_json::from_value(json!({
"source": source,
"actions": [{
"id": call.id,
"type": "tool_call",
"tool": call.name,
"parameters": call.arguments,
}],
}))
.map_err(|e| format!("malformed proposal: {e}"))
}
pub async fn run_assistant_loop(
generator: &dyn TurnGenerator,
runtime: &Runtime,
cfg: &AssistantConfig,
messages: &mut Vec<Message>,
emit: impl FnMut(AssistantEvent),
) -> AssistantOutcome {
let never = std::sync::atomic::AtomicBool::new(false);
run_assistant_loop_cancellable(generator, runtime, cfg, messages, &never, None, None, emit)
.await
}
pub async fn run_assistant_loop_cancellable(
generator: &dyn TurnGenerator,
runtime: &Runtime,
cfg: &AssistantConfig,
messages: &mut Vec<Message>,
cancel: &std::sync::atomic::AtomicBool,
approval: Option<&dyn ApprovalGate>,
images: Option<&[ContentBlock]>,
mut emit: impl FnMut(AssistantEvent),
) -> AssistantOutcome {
use std::sync::atomic::Ordering;
let tools = if cfg.tools.is_empty() {
None
} else {
Some(cfg.tools.clone())
};
let mut tools_called: Vec<String> = Vec::new();
let mut last_text = String::new();
let mut turns = 0u32;
let context_window = cfg
.model
.as_deref()
.map(|m| generator.context_window(m))
.unwrap_or(0);
let mutating_tools = mutating_tool_names(&cfg.tools);
let mut seen_sigs: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut stall_repeats = 0u32;
let mut turns_since_mutation = 0u32;
let mut stall_nudged = false;
while turns < cfg.max_turns {
if cancel.load(Ordering::Relaxed) {
return AssistantOutcome {
status: "cancelled",
summary: "cancelled".to_string(),
turns,
tools_called,
};
}
turns += 1;
compact_history_to_window(messages, context_window);
let req = GenerateRequest {
prompt: String::new(),
model: cfg.model.clone(),
params: GenerateParams {
temperature: 0.0,
..Default::default()
},
context: None,
context_stable_prefix: None,
tools: tools.clone(),
images: if turns == 1 {
images.map(|imgs| imgs.to_vec())
} else {
None
},
messages: Some(messages.clone()),
cache_control: false,
response_format: None,
intent: None,
};
let mut result = match generator.generate(req).await {
Ok(r) => r,
Err(e) => {
let msg = format!("inference failed: {e}");
emit(AssistantEvent::Error(msg.clone()));
return AssistantOutcome {
status: "error",
summary: msg,
turns,
tools_called,
};
}
};
result.text = car_inference::tasks::generate::strip_leaked_reasoning(&result.text);
if result.tool_calls.is_empty() {
last_text = result.text.clone();
emit(AssistantEvent::Done {
text: last_text.clone(),
});
return AssistantOutcome {
status: "success",
summary: last_text,
turns,
tools_called,
};
}
if !result.text.trim().is_empty() {
last_text = result.text.clone();
emit(AssistantEvent::Text(result.text.clone()));
}
let mut calls = result.tool_calls.clone();
for (i, call) in calls.iter_mut().enumerate() {
if call.id.is_none() {
call.id = Some(format!("call_{turns}_{i}"));
}
}
messages.push(Message::Assistant {
content: result.text.clone(),
tool_calls: calls.clone(),
});
let mut mutated_ok = false;
for call in &calls {
let id = call.id.clone().expect("ids assigned above");
emit(AssistantEvent::ToolCall {
name: call.name.clone(),
params: serde_json::to_value(&call.arguments).unwrap_or_default(),
});
let params_val = serde_json::to_value(&call.arguments).unwrap_or_default();
let posture = match &cfg.approval_policy {
Some(policy) => policy(&call.name, ¶ms_val),
None => {
if cfg.gated_tools.iter().any(|t| t == &call.name) {
ToolApprovalDecision::RequireApproval
} else {
ToolApprovalDecision::Allow
}
}
};
let refusal: Option<String> = match posture {
ToolApprovalDecision::Allow => None,
ToolApprovalDecision::Deny(reason) => Some(reason),
ToolApprovalDecision::RequireApproval => {
let decision = match approval {
Some(gate) => gate.request(&call.name, ¶ms_val).await,
None => ApprovalDecision::Denied(format!(
"'{}' needs approval: re-run with --full-access to allow it on this host, \
or use the default sandbox where edits are isolated",
call.name
)),
};
match decision {
ApprovalDecision::Approved => None,
ApprovalDecision::Denied(reason) => Some(reason),
}
}
};
if let Some(reason) = refusal {
let content = cap(json!({ "error": reason }).to_string());
emit(AssistantEvent::ToolResult {
name: call.name.clone(),
ok: false,
content: content.clone(),
});
messages.push(Message::ToolResult {
tool_use_id: id,
content,
});
continue;
}
let proposal = match build_proposal(&result.model_used, call) {
Ok(p) => p,
Err(e) => {
let content = cap(json!({ "error": e }).to_string());
emit(AssistantEvent::ToolResult {
name: call.name.clone(),
ok: false,
content: content.clone(),
});
messages.push(Message::ToolResult {
tool_use_id: id,
content,
});
continue;
}
};
let exec = runtime.execute(&proposal).await;
let action = exec.results.first();
let ok = action
.map(|r| matches!(r.status, ActionStatus::Succeeded))
.unwrap_or(false);
let content = cap(action
.map(format_tool_result)
.unwrap_or_else(|| format!("tool '{}' produced no result", call.name)));
if ok {
tools_called.push(call.name.clone());
if mutating_tools.contains(&call.name) {
mutated_ok = true;
}
}
emit(AssistantEvent::ToolResult {
name: call.name.clone(),
ok,
content: content.clone(),
});
messages.push(Message::ToolResult {
tool_use_id: id,
content,
});
}
let mut inject_nudge = false;
if mutated_ok {
seen_sigs.clear();
stall_repeats = 0;
turns_since_mutation = 0;
stall_nudged = false; } else {
turns_since_mutation += 1;
if !seen_sigs.insert(tool_calls_signature(&calls)) {
stall_repeats += 1;
if stall_repeats >= STALL_BREAK {
let summary = format!(
"Stopped: repeated the same action {stall_repeats} times without \
changing anything — no progress was being made."
);
emit(AssistantEvent::Done {
text: summary.clone(),
});
return AssistantOutcome {
status: "stalled",
summary,
turns,
tools_called,
};
}
if stall_repeats >= STALL_NUDGE && !stall_nudged {
stall_nudged = true;
inject_nudge = true;
}
}
if turns_since_mutation >= EXPLORE_NUDGE && !stall_nudged {
stall_nudged = true;
inject_nudge = true;
}
}
if inject_nudge {
messages.push(Message::User {
content: "You have repeated the same action several times without \
changing anything or making progress. Stop re-reading and \
either take a concrete action (write or edit a file, run a \
command) or, if the task is genuinely complete, finish now \
with your summary."
.into(),
});
}
}
AssistantOutcome {
status: "max_turns",
summary: if last_text.is_empty() {
format!("stopped after {} turns without finishing", cfg.max_turns)
} else {
last_text
},
turns,
tools_called,
}
}
pub struct GoalLoopResult {
pub outcome: AssistantOutcome,
pub run: car_verify::goal::GoalRun,
}
pub async fn run_assistant_goal_loop<G, GF>(
generator: &dyn TurnGenerator,
runtime: &Runtime,
cfg: &AssistantConfig,
messages: &mut Vec<Message>,
cancel: &std::sync::atomic::AtomicBool,
approval: Option<&dyn ApprovalGate>,
spec: &car_verify::goal::GoalSpec,
mut gather: G,
mut emit: impl FnMut(AssistantEvent),
) -> GoalLoopResult
where
G: FnMut(&AssistantOutcome) -> GF,
GF: std::future::Future<Output = car_engine::GoalGather>,
{
use car_verify::goal::{
anchor_directive, evaluate_goal, governor_check, GoalHalt, GoalRun, GoalRunState,
GoalStatus, GoalVerdict,
};
use std::sync::atomic::Ordering;
let start = std::time::Instant::now();
let mut run_state = GoalRunState::default();
let mut evidence: Vec<GoalVerdict> = Vec::new();
let mut last_reason = String::new();
let mut last_outcome = AssistantOutcome {
status: "goal_pending",
summary: String::new(),
turns: 0,
tools_called: Vec::new(),
};
let finish = |status: GoalStatus,
grounded: bool,
reason: String,
iterations: u32,
evidence: Vec<GoalVerdict>,
outcome: AssistantOutcome|
-> GoalLoopResult {
GoalLoopResult {
run: GoalRun {
status,
iterations,
grounded,
cost_usd: 0.0,
last_reason: reason,
evidence,
},
outcome,
}
};
loop {
run_state.elapsed_secs = start.elapsed().as_secs();
if cancel.load(Ordering::Relaxed) {
return finish(
GoalStatus::Halted {
halt: GoalHalt::Cancelled,
},
evidence.last().map(|v| v.grounded).unwrap_or(true),
"cancelled".into(),
run_state.turns,
evidence,
last_outcome,
);
}
if let Some(halt) = governor_check(&spec.governor, &run_state) {
return finish(
GoalStatus::Halted { halt },
evidence.last().map(|v| v.grounded).unwrap_or(true),
if last_reason.is_empty() {
halt.as_str().to_string()
} else {
format!("{} ({})", halt.as_str(), last_reason)
},
run_state.turns,
evidence,
last_outcome,
);
}
let directive = anchor_directive(&spec.goal, &last_reason);
messages.push(Message::User { content: directive });
let outcome = run_assistant_loop_cancellable(
generator, runtime, cfg, messages, cancel, approval, None, &mut emit,
)
.await;
run_state.turns += 1;
if outcome.tools_called.is_empty() {
run_state.turns_since_progress += 1;
} else {
run_state.turns_since_progress = 0;
}
if outcome.status == "cancelled" {
return finish(
GoalStatus::Halted {
halt: GoalHalt::Cancelled,
},
evidence.last().map(|v| v.grounded).unwrap_or(true),
"cancelled".into(),
run_state.turns,
evidence,
outcome,
);
}
let g = gather(&outcome).await;
let inputs = runtime.gather_goal_inputs(&g).await;
let verdict = evaluate_goal(&spec.condition, &inputs);
evidence.push(verdict.clone());
tracing::info!(
target: "car::goal",
iteration = run_state.turns,
met = verdict.met,
grounded = verdict.grounded,
reason = %verdict.reason,
"goal evaluated"
);
if verdict.met {
return finish(
GoalStatus::Achieved,
verdict.grounded,
verdict.reason,
run_state.turns,
evidence,
outcome,
);
}
last_reason = verdict.reason;
last_outcome = outcome;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assistant::executor::GeneralExecutor;
use async_trait::async_trait;
use car_engine::{LocalSubstrate, Runtime, Substrate, ToolExecutor};
use car_inference::{InferenceEngine, InferenceResult};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
fn sys(t: &str) -> Message {
Message::System { content: t.into() }
}
fn usr(t: &str) -> Message {
Message::User { content: t.into() }
}
fn asst_call(id: &str) -> Message {
Message::Assistant {
content: String::new(),
tool_calls: vec![serde_json::from_value(json!({
"name": "write_file",
"arguments": {"path": "a.js"},
"id": id
}))
.unwrap()],
}
}
fn tool_res(id: &str, body: &str) -> Message {
Message::ToolResult {
tool_use_id: id.into(),
content: body.into(),
}
}
fn no_orphan_tool_results(msgs: &[Message]) -> bool {
let mut seen_call_ids: std::collections::HashSet<String> = Default::default();
for m in msgs {
match m {
Message::Assistant { tool_calls, .. } => {
for c in tool_calls {
if let Some(id) = &c.id {
seen_call_ids.insert(id.clone());
}
}
}
Message::ToolResult { tool_use_id, .. } if !seen_call_ids.contains(tool_use_id) => {
return false;
}
_ => {}
}
}
true
}
#[test]
fn compaction_is_noop_under_budget_and_when_window_unknown() {
let mut m = vec![
sys("s"),
usr("task"),
asst_call("c1"),
tool_res("c1", "small"),
];
let before = m.clone();
compact_history_to_window(&mut m, 128_000); assert_eq!(m, before, "under-budget history must be untouched");
compact_history_to_window(&mut m, 0); assert_eq!(m, before, "unknown window must be a no-op");
}
#[test]
fn compaction_pins_system_and_task_keeps_tail_no_orphans() {
let big = "x".repeat(20_000); let mut m = vec![sys("system"), usr("THE ORIGINAL TASK")];
for i in 0..12 {
m.push(asst_call(&format!("c{i}")));
m.push(tool_res(&format!("c{i}"), &big));
}
let window = 20_000; compact_history_to_window(&mut m, window);
assert!(matches!(&m[0], Message::System { .. }), "system pinned");
assert!(
matches!(&m[1], Message::User { content } if content == "THE ORIGINAL TASK"),
"original task pinned"
);
assert!(
matches!(m.last(), Some(Message::ToolResult { tool_use_id, .. }) if tool_use_id == "c11"),
"most-recent tool result kept"
);
assert!(
no_orphan_tool_results(&m),
"no orphaned tool results after trim"
);
assert!(m.len() < 26, "history was compacted (was 26 msgs)");
}
#[tokio::test]
async fn loop_compacts_history_to_window() {
let dir = tempfile::tempdir().unwrap();
let rt = runtime_for(dir.path()).await;
struct WindowedBig {
cursor: AtomicUsize,
}
#[async_trait]
impl TurnGenerator for WindowedBig {
async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
let i = self.cursor.fetch_add(1, Ordering::SeqCst);
if i < 6 {
Ok(turn(
&"x".repeat(8000),
json!([{ "id": format!("c{i}"), "name": "calculate",
"arguments": { "expression": format!("1+{i}") } }]),
))
} else {
Ok(turn("done", json!([])))
}
}
fn context_window(&self, _model: &str) -> usize {
4000
}
}
let generator = WindowedBig {
cursor: AtomicUsize::new(0),
};
let mut messages = vec![
Message::System {
content: "system".into(),
},
Message::User {
content: "THE TASK".into(),
},
];
let mut c = cfg();
c.max_turns = 8;
let out = run_assistant_loop(&generator, &rt, &c, &mut messages, |_e| {}).await;
assert_eq!(out.status, "success");
assert!(
messages.len() <= 11,
"history bounded by compaction, got {} messages",
messages.len()
);
assert!(
matches!(&messages[0], Message::System { .. }),
"system stays pinned"
);
assert!(
matches!(&messages[1], Message::User { content } if content == "THE TASK"),
"original task stays pinned"
);
assert!(
no_orphan_tool_results(&messages),
"no orphaned tool results in the live loop"
);
}
#[tokio::test]
async fn loop_halts_a_no_progress_repeat_loop() {
let dir = tempfile::tempdir().unwrap();
let rt = runtime_for(dir.path()).await;
struct Stuck;
#[async_trait]
impl TurnGenerator for Stuck {
async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
Ok(turn(
"re-reading",
json!([{ "name": "read_file", "arguments": { "path": "app.js" } }]),
))
}
}
let mut messages = vec![
Message::System {
content: "sys".into(),
},
Message::User {
content: "task".into(),
},
];
let mut c = cfg();
c.max_turns = 40;
let out = run_assistant_loop(&Stuck, &rt, &c, &mut messages, |_e| {}).await;
assert_eq!(
out.status, "stalled",
"a no-progress loop must halt as `stalled`, not run to max_turns"
);
assert!(
out.turns < 40,
"must stop well before the turn cap, got {} turns",
out.turns
);
}
#[tokio::test]
async fn loop_halts_a_read_plus_readonly_shell_cycle() {
let dir = tempfile::tempdir().unwrap();
let rt = runtime_for(dir.path()).await;
struct Cycle {
cursor: AtomicUsize,
}
#[async_trait]
impl TurnGenerator for Cycle {
async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
let i = self.cursor.fetch_add(1, Ordering::SeqCst);
if i.is_multiple_of(2) {
Ok(turn(
"read",
json!([{ "name": "read_file", "arguments": { "path": "app.js" } }]),
))
} else {
Ok(turn(
"probe",
json!([{ "name": "shell", "arguments": { "command": "wc -l app.js" } }]),
))
}
}
}
let mut messages = vec![
Message::System {
content: "sys".into(),
},
Message::User {
content: "task".into(),
},
];
let mut c = cfg();
c.max_turns = 40;
let out = run_assistant_loop(
&Cycle {
cursor: AtomicUsize::new(0),
},
&rt,
&c,
&mut messages,
|_e| {},
)
.await;
assert_eq!(
out.status, "stalled",
"a read/read-only-shell cycle with no file change must halt"
);
assert!(out.turns < 40, "stopped before the cap, got {}", out.turns);
}
#[tokio::test]
async fn loop_halts_a_repeatedly_failing_mutation() {
let dir = tempfile::tempdir().unwrap();
let rt = runtime_for(dir.path()).await;
struct FailWrite;
#[async_trait]
impl TurnGenerator for FailWrite {
async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
Ok(turn(
"writing",
json!([{ "name": "write_file",
"arguments": { "path": "../../etc/evil", "content": "x" } }]),
))
}
}
let mut messages = vec![
Message::System {
content: "sys".into(),
},
Message::User {
content: "task".into(),
},
];
let mut c = cfg();
c.max_turns = 40;
let out = run_assistant_loop(&FailWrite, &rt, &c, &mut messages, |_e| {}).await;
assert_eq!(
out.status, "stalled",
"a repeatedly-failing mutation makes no progress and must halt (not reset the guard)"
);
assert!(out.turns < 40, "stopped before the cap, got {}", out.turns);
}
fn turn(text: &str, tool_calls: Value) -> InferenceResult {
serde_json::from_value(json!({
"text": text,
"tool_calls": tool_calls,
"trace_id": "t",
"model_used": "scripted",
"latency_ms": 0,
}))
.expect("scripted InferenceResult shape")
}
struct Script {
turns: Vec<InferenceResult>,
cursor: AtomicUsize,
}
#[async_trait]
impl TurnGenerator for Script {
async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
let i = self.cursor.fetch_add(1, Ordering::SeqCst);
self.turns.get(i).cloned().ok_or("script exhausted".into())
}
}
async fn runtime_for(dir: &std::path::Path) -> Runtime {
let substrate: Arc<dyn Substrate> = Arc::new(LocalSubstrate::new());
let exec: Arc<dyn ToolExecutor> =
Arc::new(GeneralExecutor::new(substrate.clone(), dir, true));
let engine = Arc::new(InferenceEngine::new(Default::default()));
let rt = Runtime::new()
.with_inference(engine)
.with_executor(exec)
.with_substrate(substrate);
rt.register_agent_basics().await;
rt.register_tool_entry(
car_engine::ToolEntry::new(car_ir::builtins::shell()).with_side_effects(true),
)
.await;
rt
}
fn cfg() -> AssistantConfig {
AssistantConfig {
model: Some("scripted".into()),
max_turns: 6,
tools: GeneralExecutor::tool_defs(),
gated_tools: Vec::new(),
approval_policy: None,
}
}
#[tokio::test]
async fn loop_runs_a_tool_then_finishes() {
let dir = tempfile::tempdir().unwrap();
let rt = runtime_for(dir.path()).await;
let script = Script {
turns: vec![
turn(
"computing",
json!([{ "id": "c1", "name": "calculate", "arguments": { "expression": "6*7" } }]),
),
turn("The answer is 42.", json!([])),
],
cursor: AtomicUsize::new(0),
};
let mut messages = vec![
Message::System {
content: "sys".into(),
},
Message::User {
content: "what is 6*7?".into(),
},
];
let mut events = Vec::new();
let outcome =
run_assistant_loop(&script, &rt, &cfg(), &mut messages, |e| events.push(e)).await;
assert_eq!(outcome.status, "success");
assert_eq!(outcome.summary, "The answer is 42.");
assert!(outcome.tools_called.contains(&"calculate".to_string()));
assert!(events
.iter()
.any(|e| matches!(e, AssistantEvent::ToolResult { name, ok: true, .. } if name == "calculate")));
}
#[tokio::test]
async fn goal_loop_converges_when_the_command_check_passes() {
use car_verify::goal::{GoalCondition, GoalGovernor, GoalSpec, GoalStatus};
let dir = tempfile::tempdir().unwrap();
let rt = runtime_for(dir.path()).await;
let script = Script {
turns: vec![
turn("Let me start.", json!([])),
turn(
"creating it",
json!([{ "id": "s1", "name": "shell", "arguments": { "command": "touch donefile" } }]),
),
turn("Done — created donefile.", json!([])),
],
cursor: AtomicUsize::new(0),
};
let spec = GoalSpec {
goal: "create a file named donefile".into(),
condition: GoalCondition::Command {
id: "donefile".into(),
expect_exit: 0,
},
governor: GoalGovernor {
max_turns: Some(5),
..Default::default()
},
};
let mut messages = vec![Message::System {
content: "sys".into(),
}];
let never = std::sync::atomic::AtomicBool::new(false);
let donefile = dir.path().join("donefile");
let result = run_assistant_goal_loop(
&script,
&rt,
&cfg(),
&mut messages,
&never,
None,
&spec,
|_outcome| {
let exists = donefile.exists();
async move {
let mut g = car_engine::GoalGather::default();
g.command_exits
.insert("donefile".into(), if exists { 0 } else { 1 });
g
}
},
|_e| {},
)
.await;
assert_eq!(
result.run.status,
GoalStatus::Achieved,
"{:?}",
result.run.last_reason
);
assert_eq!(
result.run.iterations, 2,
"should converge on the 2nd iteration"
);
assert!(
result.run.grounded,
"a Command-check completion is grounded"
);
assert!(donefile.exists(), "the real file must have been created");
}
#[tokio::test]
async fn goal_loop_halts_on_turn_budget() {
use car_verify::goal::{GoalCondition, GoalGovernor, GoalHalt, GoalSpec, GoalStatus};
let dir = tempfile::tempdir().unwrap();
let rt = runtime_for(dir.path()).await;
struct Idle;
#[async_trait]
impl TurnGenerator for Idle {
async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
Ok(turn("thinking...", json!([])))
}
}
let spec = GoalSpec {
goal: "impossible".into(),
condition: GoalCondition::Command {
id: "never".into(),
expect_exit: 0,
},
governor: GoalGovernor {
max_turns: Some(3),
..Default::default()
},
};
let mut messages = vec![Message::System {
content: "sys".into(),
}];
let never = std::sync::atomic::AtomicBool::new(false);
let result = run_assistant_goal_loop(
&Idle,
&rt,
&cfg(),
&mut messages,
&never,
None,
&spec,
|_o| async {
let mut g = car_engine::GoalGather::default();
g.command_exits.insert("never".into(), 1);
g
},
|_e| {},
)
.await;
assert_eq!(
result.run.status,
GoalStatus::Halted {
halt: GoalHalt::TurnBudget
}
);
assert_eq!(result.run.iterations, 3);
}
struct FixedGate(bool);
#[async_trait]
impl ApprovalGate for FixedGate {
async fn request(&self, _tool: &str, _params: &Value) -> ApprovalDecision {
if self.0 {
ApprovalDecision::Approved
} else {
ApprovalDecision::Denied("user declined".into())
}
}
}
struct CapturingGen {
images_seen: std::sync::Arc<std::sync::Mutex<Option<usize>>>,
}
#[async_trait]
impl TurnGenerator for CapturingGen {
async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
*self.images_seen.lock().unwrap() = req.images.as_ref().map(|v| v.len());
Ok(turn("done", json!([]))) }
}
#[tokio::test]
async fn images_are_attached_to_the_first_request() {
let dir = tempfile::tempdir().unwrap();
let rt = runtime_for(dir.path()).await;
let seen = std::sync::Arc::new(std::sync::Mutex::new(None));
let generator = CapturingGen {
images_seen: seen.clone(),
};
let img = ContentBlock::ImageUrl {
url: "https://example.com/x.png".into(),
detail: "auto".into(),
};
let mut messages = vec![
Message::System {
content: "s".into(),
},
Message::User {
content: "describe".into(),
},
];
let never = std::sync::atomic::AtomicBool::new(false);
let imgs = [img];
run_assistant_loop_cancellable(
&generator,
&rt,
&cfg(),
&mut messages,
&never,
None,
Some(&imgs),
|_| {},
)
.await;
assert_eq!(
*seen.lock().unwrap(),
Some(1),
"the image should reach the first request"
);
}
#[tokio::test]
async fn gated_tool_is_denied_without_a_gate() {
let dir = tempfile::tempdir().unwrap();
let rt = runtime_for(dir.path()).await;
let script = Script {
turns: vec![
turn(
"",
json!([{ "id": "w1", "name": "write_file", "arguments": { "path": "x.txt", "content": "no" } }]),
),
turn("could not write", json!([])),
],
cursor: AtomicUsize::new(0),
};
let mut cfg = cfg();
cfg.gated_tools = vec!["write_file".into()];
let mut messages = vec![
Message::System {
content: "s".into(),
},
Message::User {
content: "write x".into(),
},
];
let never = std::sync::atomic::AtomicBool::new(false);
let outcome = run_assistant_loop_cancellable(
&script,
&rt,
&cfg,
&mut messages,
&never,
None,
None,
|_| {},
)
.await;
assert_eq!(outcome.status, "success");
assert!(
!dir.path().join("x.txt").exists(),
"gated write must not run"
);
assert!(!outcome.tools_called.contains(&"write_file".to_string()));
}
#[tokio::test]
async fn gated_tool_runs_when_approved() {
let dir = tempfile::tempdir().unwrap();
let rt = runtime_for(dir.path()).await;
let script = Script {
turns: vec![
turn(
"",
json!([{ "id": "w1", "name": "write_file", "arguments": { "path": "ok.txt", "content": "yes" } }]),
),
turn("wrote it", json!([])),
],
cursor: AtomicUsize::new(0),
};
let mut cfg = cfg();
cfg.gated_tools = vec!["write_file".into()];
let gate = FixedGate(true);
let mut messages = vec![
Message::System {
content: "s".into(),
},
Message::User {
content: "write ok".into(),
},
];
let never = std::sync::atomic::AtomicBool::new(false);
let outcome = run_assistant_loop_cancellable(
&script,
&rt,
&cfg,
&mut messages,
&never,
Some(&gate),
None,
|_| {},
)
.await;
assert_eq!(outcome.status, "success");
assert_eq!(
std::fs::read_to_string(dir.path().join("ok.txt")).unwrap(),
"yes"
);
}
#[tokio::test]
async fn loop_writes_a_file_through_the_runtime() {
let dir = tempfile::tempdir().unwrap();
let rt = runtime_for(dir.path()).await;
let script = Script {
turns: vec![
turn(
"",
json!([{ "id": "w1", "name": "write_file", "arguments": { "path": "hi.txt", "content": "hello" } }]),
),
turn("Wrote hi.txt.", json!([])),
],
cursor: AtomicUsize::new(0),
};
let mut messages = vec![
Message::System {
content: "sys".into(),
},
Message::User {
content: "write hi.txt".into(),
},
];
let outcome = run_assistant_loop(&script, &rt, &cfg(), &mut messages, |_| {}).await;
assert_eq!(outcome.status, "success");
assert_eq!(
std::fs::read_to_string(dir.path().join("hi.txt")).unwrap(),
"hello"
);
}
}