1use crate::assistant::agent_loop::{
15 compact_history_measured_with_recovery, history_budget, measure_scale, message_estimates,
16 scaled_prompt_tokens, CompactionRecovery, PromptMeasure,
17};
18use async_trait::async_trait;
19use car_engine::ToolExecutor;
20use car_inference::tasks::generate::{Message, Provenance};
21use car_inference::{GenerateParams, GenerateRequest};
22use serde_json::Value;
23use std::collections::HashSet;
24use std::sync::atomic::{AtomicBool, Ordering};
25use std::sync::Arc;
26
27pub use car_registry::declarative::{
28 ContextPolicy, DeclarativeAgentSpec, DeclarativeGoal, Scenario,
29};
30
31use super::native_loop::{InferenceFailureKind, TurnGenerationError, TurnGenerator};
32use super::shell_tool::WorktreeExecutor;
33
34#[derive(Debug, Clone)]
36pub struct AgentRunResult {
37 pub output: String,
38 pub turns: u32,
39 pub tool_calls: u32,
40 pub error: Option<String>,
41 pub inference_error: Option<TurnGenerationError>,
44 pub goal: Option<AgentGoalRun>,
45}
46
47#[derive(Debug, Clone)]
48pub struct AgentGoalRun {
49 pub check: String,
50 pub max_iterations: u32,
51 pub iterations: u32,
52 pub met: bool,
53 pub grounded: bool,
59 pub last_exit_code: Option<i32>,
60 pub last_reason: String,
61}
62
63#[derive(Default)]
71struct RunNotices {
72 self_managed: AtomicBool,
73 unknown_window: AtomicBool,
74 stale_window: AtomicBool,
75}
76
77impl RunNotices {
78 fn first(flag: &AtomicBool) -> bool {
80 !flag.swap(true, Ordering::SeqCst)
81 }
82}
83
84pub fn select_tool_defs_strict(all: &[Value], allow: &[String], deny: &[String]) -> Vec<Value> {
89 all.iter()
90 .filter(|d| {
91 let name = d.get("name").and_then(Value::as_str).unwrap_or("");
92 allow.iter().any(|a| a == name) && !deny.iter().any(|x| x == name)
93 })
94 .cloned()
95 .collect()
96}
97
98pub struct DeclarativeAgentRunner<'a> {
100 spec: &'a DeclarativeAgentSpec,
101 generator: &'a dyn TurnGenerator,
102 executor: &'a WorktreeExecutor,
103 max_turns: u32,
104 max_tokens_per_turn: usize,
105 cancel: Option<Arc<AtomicBool>>,
106 model: Option<String>,
107 turn_observer: Option<&'a dyn RunTurnObserver>,
108}
109
110#[async_trait]
114trait RunTurnObserver: Send + Sync {
115 async fn turn_served(&self, model_used: &str);
116}
117
118impl<'a> DeclarativeAgentRunner<'a> {
119 pub fn new(
120 spec: &'a DeclarativeAgentSpec,
121 generator: &'a dyn TurnGenerator,
122 executor: &'a WorktreeExecutor,
123 ) -> Self {
124 Self {
125 spec,
126 generator,
127 executor,
128 max_turns: 12,
129 max_tokens_per_turn: 2048,
130 cancel: None,
131 model: None,
132 turn_observer: None,
133 }
134 }
135
136 pub fn with_cancel(mut self, cancel: Option<Arc<AtomicBool>>) -> Self {
137 self.cancel = cancel;
138 self
139 }
140
141 fn with_turn_observer(mut self, observer: &'a dyn RunTurnObserver) -> Self {
142 self.turn_observer = Some(observer);
143 self
144 }
145
146 pub fn with_model(mut self, model: Option<String>) -> Self {
149 self.model = model;
150 self
151 }
152
153 fn system_prompt(&self) -> String {
154 let mut p = self.spec.identity.trim().to_string();
155 if !self.spec.standing_goal.trim().is_empty() {
156 p.push_str("\n\nStanding goal: ");
157 p.push_str(self.spec.standing_goal.trim());
158 }
159 p
160 }
161
162 pub async fn run(&self, input: &str) -> AgentRunResult {
164 if self.is_cancelled() {
165 return cancelled_result(0, 0, None);
166 }
167 let notices = RunNotices::default();
170 let Some(goal) = self.normalized_goal() else {
171 return self.run_once(input, ¬ices).await;
172 };
173
174 let mut total_turns = 0u32;
175 let mut total_tool_calls = 0u32;
176 let mut last_output = String::new();
177 let mut last_exit_code = None;
178 let mut last_reason = String::new();
179
180 for iteration in 1..=goal.max_iterations {
181 if self.is_cancelled() {
182 return AgentRunResult {
183 output: last_output,
184 turns: total_turns,
185 tool_calls: total_tool_calls,
186 error: Some("cancelled".into()),
187 inference_error: None,
188 goal: Some(AgentGoalRun {
189 check: goal.check,
190 max_iterations: goal.max_iterations,
191 iterations: iteration.saturating_sub(1),
192 met: false,
193 grounded: true,
194 last_exit_code,
195 last_reason: "cancelled".into(),
196 }),
197 };
198 }
199 let directive = if last_reason.is_empty() {
200 input.to_string()
201 } else {
202 format!(
203 "{input}\n\nThe previous deterministic goal check did not pass: \
204 {last_reason}. Keep working toward the original input until \
205 the check succeeds."
206 )
207 };
208 let result = self.run_once(&directive, ¬ices).await;
209 total_turns += result.turns;
210 total_tool_calls += result.tool_calls;
211 last_output = result.output;
212
213 if self.is_cancelled() {
214 return AgentRunResult {
215 output: last_output,
216 turns: total_turns,
217 tool_calls: total_tool_calls,
218 error: Some("cancelled".into()),
219 inference_error: None,
220 goal: Some(AgentGoalRun {
221 check: goal.check,
222 max_iterations: goal.max_iterations,
223 iterations: iteration,
224 met: false,
225 grounded: true,
226 last_exit_code,
227 last_reason: "cancelled".into(),
228 }),
229 };
230 }
231
232 if let Some(error) = result.error {
233 return AgentRunResult {
234 output: last_output,
235 turns: total_turns,
236 tool_calls: total_tool_calls,
237 error: Some(error),
238 inference_error: result.inference_error,
239 goal: Some(AgentGoalRun {
240 check: goal.check,
241 max_iterations: goal.max_iterations,
242 iterations: iteration,
243 met: false,
244 grounded: true,
245 last_exit_code,
246 last_reason: "agent run failed before goal check".into(),
247 }),
248 };
249 }
250
251 match self.executor.run_shell(&goal.check, Some(120)).await {
252 Ok(v) => {
253 let exit = v.get("exit_code").and_then(Value::as_i64).map(|n| n as i32);
254 last_exit_code = exit;
255 if exit == Some(0) {
256 return AgentRunResult {
257 output: last_output,
258 turns: total_turns,
259 tool_calls: total_tool_calls,
260 error: None,
261 inference_error: None,
262 goal: Some(AgentGoalRun {
263 check: goal.check,
264 max_iterations: goal.max_iterations,
265 iterations: iteration,
266 met: true,
267 grounded: true,
268 last_exit_code,
269 last_reason: "goal check exited 0".into(),
270 }),
271 };
272 }
273 let output = v.get("output").and_then(Value::as_str).unwrap_or("").trim();
274 if matches!(exit, Some(126) | Some(127)) {
282 last_reason = format!(
283 "goal check is not a runnable command (exit {}): {} — \
284 fix or remove goal.check",
285 exit.unwrap_or(-1),
286 truncate(output, 200)
287 );
288 return AgentRunResult {
289 output: last_output,
290 turns: total_turns,
291 tool_calls: total_tool_calls,
292 error: Some(last_reason.clone()),
293 inference_error: None,
294 goal: Some(AgentGoalRun {
295 check: goal.check,
296 max_iterations: goal.max_iterations,
297 iterations: iteration,
298 met: false,
299 grounded: true,
300 last_exit_code: exit,
301 last_reason,
302 }),
303 };
304 }
305 last_reason = if output.is_empty() {
306 format!("goal check exited {}", exit.unwrap_or(-1))
307 } else {
308 format!(
309 "goal check exited {}: {}",
310 exit.unwrap_or(-1),
311 truncate(output, 200)
312 )
313 };
314 }
315 Err(e) => {
316 last_reason = format!("goal check failed to run: {e}");
317 return AgentRunResult {
318 output: last_output,
319 turns: total_turns,
320 tool_calls: total_tool_calls,
321 error: Some(last_reason.clone()),
322 inference_error: None,
323 goal: Some(AgentGoalRun {
324 check: goal.check,
325 max_iterations: goal.max_iterations,
326 iterations: iteration,
327 met: false,
328 grounded: true,
329 last_exit_code,
330 last_reason,
331 }),
332 };
333 }
334 }
335 }
336
337 AgentRunResult {
338 output: last_output,
339 turns: total_turns,
340 tool_calls: total_tool_calls,
341 error: Some(format!(
342 "goal_not_met after {} iteration(s): {}",
343 goal.max_iterations, last_reason
344 )),
345 inference_error: None,
346 goal: Some(AgentGoalRun {
347 check: goal.check,
348 max_iterations: goal.max_iterations,
349 iterations: goal.max_iterations,
350 met: false,
351 grounded: true,
352 last_exit_code,
353 last_reason,
354 }),
355 }
356 }
357
358 fn is_cancelled(&self) -> bool {
359 self.cancel
360 .as_ref()
361 .map(|flag| flag.load(Ordering::SeqCst))
362 .unwrap_or(false)
363 }
364
365 fn normalized_goal(&self) -> Option<DeclarativeGoal> {
366 self.spec.goal.as_ref().and_then(|goal| {
367 let check = goal.check.trim();
368 if check.is_empty() {
369 None
370 } else {
371 Some(DeclarativeGoal {
372 check: check.to_string(),
373 max_iterations: goal.max_iterations.clamp(1, 50),
374 })
375 }
376 })
377 }
378
379 async fn run_once(&self, input: &str, notices: &RunNotices) -> AgentRunResult {
380 if self.is_cancelled() {
381 return cancelled_result(0, 0, None);
382 }
383 let tools = select_tool_defs_strict(
384 {
385 self.executor.advertise_delegates();
387 &self.executor.all_tool_defs()
388 },
389 &self.spec.tools,
397 &self.spec.denied_tools,
398 );
399 let tools = if tools.is_empty() { None } else { Some(tools) };
400
401 let mut messages = vec![
402 Message::System {
403 content: self.system_prompt(),
404 },
405 Message::User {
406 content: input.to_string(),
407 },
408 ];
409
410 let car_manages_context = self.spec.context.is_car_managed();
418 if !car_manages_context && RunNotices::first(¬ices.self_managed) {
419 tracing::info!(
420 agent = %self.spec.id,
421 context = self.spec.context.as_str(),
422 "CAR compaction is off for this agent (context: self); the spec owns its history"
423 );
424 }
425 let mut context_window = self
432 .model
433 .as_deref()
434 .map(|m| self.generator.context_window(m))
435 .unwrap_or(0);
436 let mut route = self.model.clone();
445 let caller_pinned = self.model.is_some();
449 let mut route_divergence_logged = false;
451 let mut shrunk_tool_results: HashSet<String> = HashSet::new();
457 let mut used_call_ids: HashSet<String> = HashSet::new();
460 let mut prompt_measure = PromptMeasure {
461 fixed_overhead: car_inference::media_tokens::tool_defs_tokens(
462 tools.as_deref().unwrap_or(&[]),
463 ),
464 reported: None,
465 };
466 let mut tool_calls_total = 0u32;
467 for turn in 1..=self.max_turns {
468 if self.is_cancelled() {
469 return cancelled_result(turn.saturating_sub(1), tool_calls_total, None);
470 }
471 if car_manages_context {
477 let before_compaction = messages.clone();
483 let scale = measure_scale(&message_estimates(&messages), prompt_measure);
486 compact_history_measured_with_recovery(
491 &mut messages,
492 context_window,
493 prompt_measure,
494 CompactionRecovery::Unrecoverable,
495 );
496 let shrunk = shrink_oversized_tool_results(
500 &mut messages,
501 history_budget(context_window),
502 prompt_measure.fixed_overhead,
503 scale,
504 &mut shrunk_tool_results,
505 );
506 if shrunk > 0 {
507 tracing::info!(
508 agent = %self.spec.id,
509 tool_results_truncated = shrunk,
510 context_window,
511 "truncated oversized tool results to fit the model's context window"
512 );
513 }
514 if before_compaction != messages {
515 prompt_measure.reported = None;
518 }
519 }
520 let request_covers = messages.len();
523 let req = GenerateRequest {
524 prompt: input.to_string(),
525 model: route.clone(),
526 params: GenerateParams {
527 temperature: 0.0,
528 max_tokens: self.max_tokens_per_turn,
529 strict_model: caller_pinned,
541 thinking: car_inference::tasks::generate::ThinkingMode::Off,
547 ..Default::default()
548 },
549 tools: tools.clone(),
550 messages: Some(messages.clone()),
551 intent: Some(car_inference::IntentHint {
552 task: Some(car_inference::TaskHint::Code),
553 prefer_quality: true,
557 ..Default::default()
558 }),
559 ..Default::default()
560 };
561 let result = match self.generator.generate_coder(req).await {
562 Ok(r) => r,
563 Err(error) => {
564 let message = format!("inference failed: {error}");
565 return AgentRunResult {
566 output: String::new(),
567 turns: turn,
568 tool_calls: tool_calls_total,
569 error: Some(message),
570 inference_error: Some(error),
571 goal: None,
572 };
573 }
574 };
575 if let Some(observer) = self.turn_observer {
576 observer.turn_served(&result.model_used).await;
577 }
578
579 if car_manages_context {
580 let resolved = self.generator.context_window(&result.model_used);
588 match window_update(resolved, context_window) {
589 WindowUpdate::Adopted(window) => {
590 if window != context_window {
591 tracing::debug!(
592 agent = %self.spec.id,
593 model = %result.model_used,
594 previous_context_window = context_window,
595 context_window = window,
596 "declarative run's context window changed with the serving model"
597 );
598 }
599 context_window = window;
600 }
601 WindowUpdate::KeptLastKnown(window) => {
602 if RunNotices::first(¬ices.stale_window) {
608 tracing::warn!(
609 agent = %self.spec.id,
610 model = %result.model_used,
611 context_window = window,
612 "model {} has no known context window; keeping the last known \
613 budget of {window} tokens — it may not fit the model now \
614 serving this run. Add the model to the catalog to bound it \
615 properly.",
616 result.model_used
617 );
618 }
619 }
620 WindowUpdate::StillUnknown => {}
621 }
622 if !caller_pinned {
628 if resolved != 0
629 && !result.model_used.is_empty()
630 && route.as_deref() != Some(result.model_used.as_str())
631 {
632 if route.is_some() {
633 tracing::warn!(
634 agent = %self.spec.id,
635 previous_route = route.as_deref().unwrap_or(""),
636 served = %result.model_used,
637 context_window,
638 "declarative run was served by a different model than its \
639 pinned route; following it so the budget and the serving \
640 model cannot diverge"
641 );
642 } else {
643 tracing::debug!(
644 agent = %self.spec.id,
645 model = %result.model_used,
646 context_window,
647 "pinning the declarative run to the model that served it"
648 );
649 }
650 route = Some(result.model_used.clone());
651 }
652 } else if !route_divergence_logged
653 && !result.model_used.is_empty()
654 && route.as_deref() != Some(result.model_used.as_str())
655 {
656 route_divergence_logged = true;
657 tracing::warn!(
658 agent = %self.spec.id,
659 pinned = route.as_deref().unwrap_or(""),
660 served = %result.model_used,
661 context_window,
662 "declarative run was served by a different model than the caller's \
663 pin; budgeting against the model that served it"
664 );
665 }
666 if context_window == 0 && RunNotices::first(¬ices.unknown_window) {
667 tracing::warn!(
673 agent = %self.spec.id,
674 model = %result.model_used,
675 max_turns = self.max_turns,
676 "compaction disabled: unknown context window for model {} \
677 — this agent's history is bounded only by its turn cap. \
678 Add the model to the catalog, or set `context: self` to \
679 own the transcript deliberately.",
680 result.model_used
681 );
682 }
683 if let Some(usage) = &result.usage {
687 let input = usage.prompt_tokens
688 + usage.cache_read_input_tokens
689 + usage.cache_creation_input_tokens;
690 if input > 0 {
691 prompt_measure.reported = Some((input as usize, request_covers));
692 }
693 }
694 }
695
696 if self.is_cancelled() {
697 return cancelled_result(turn, tool_calls_total, None);
698 }
699
700 if result.tool_calls.is_empty() {
701 return AgentRunResult {
702 output: result.text,
703 turns: turn,
704 tool_calls: tool_calls_total,
705 error: None,
706 inference_error: None,
707 goal: None,
708 };
709 }
710
711 let mut calls = result.tool_calls.clone();
712 for (i, call) in calls.iter_mut().enumerate() {
713 let unique = match &call.id {
730 Some(id) if !used_call_ids.contains(id) => id.clone(),
731 _ => {
732 let mut minted = format!("call_{turn}_{i}");
733 let mut collision = 0;
734 while used_call_ids.contains(&minted) {
735 collision += 1;
736 minted = format!("call_{turn}_{i}_{collision}");
737 }
738 minted
739 }
740 };
741 used_call_ids.insert(unique.clone());
742 call.id = Some(unique);
743 }
744 result.append_assistant_history(&mut messages, calls.clone());
745 for call in &calls {
746 if self.is_cancelled() {
747 return cancelled_result(turn, tool_calls_total, Some(result.text.clone()));
748 }
749 let params = Value::Object(call.arguments.clone().into_iter().collect());
750 let (_, content) = if tools_contains(&self.spec.tools, &call.name)
753 && !self.spec.denied_tools.iter().any(|d| d == &call.name)
754 {
755 match self.executor.execute(&call.name, ¶ms).await {
756 Ok(v) => (true, v.to_string()),
757 Err(e) => (false, format!("ERROR: {e}")),
758 }
759 } else {
760 (
761 false,
762 format!("ERROR: tool '{}' is not allowed for this agent", call.name),
763 )
764 };
765 tool_calls_total += 1;
766 messages.push(Message::ToolResult {
767 tool_use_id: call.id.clone().expect("assigned above"),
768 content,
769 provenance: Provenance::Internal,
772 });
773 }
774 }
775
776 AgentRunResult {
777 output: String::new(),
778 turns: self.max_turns,
779 tool_calls: tool_calls_total,
780 error: Some("max_turns_exceeded".into()),
781 inference_error: None,
782 goal: None,
783 }
784 }
785}
786
787#[derive(Debug, Clone, Copy, PartialEq, Eq)]
793enum WindowUpdate {
794 Adopted(usize),
796 KeptLastKnown(usize),
800 StillUnknown,
803}
804
805fn window_update(resolved: usize, current: usize) -> WindowUpdate {
806 match (resolved, current) {
807 (0, 0) => WindowUpdate::StillUnknown,
808 (0, known) => WindowUpdate::KeptLastKnown(known),
809 (window, _) => WindowUpdate::Adopted(window),
810 }
811}
812
813const TOOL_RESULT_KEEP_CHARS: usize = 600;
815
816const TOOL_RESULT_TRUNCATION_MARKER: &str =
821 "[tool result truncated to fit the model's context window";
822
823fn shrink_oversized_tool_results(
843 messages: &mut [Message],
844 budget: usize,
845 fixed_overhead: usize,
846 scale: f64,
847 already_shrunk: &mut HashSet<String>,
848) -> usize {
849 if budget == 0 {
850 return 0;
851 }
852 let total = |messages: &[Message]| scaled_prompt_tokens(messages, fixed_overhead, scale);
858 if total(messages) <= budget {
859 return 0;
860 }
861 let mut candidates: Vec<(usize, usize, String)> = messages
868 .iter()
869 .enumerate()
870 .filter_map(|(i, m)| match m {
871 Message::ToolResult {
872 tool_use_id,
873 content,
874 ..
875 } if !already_shrunk.contains(tool_use_id) => {
876 let chars = content.chars().count();
877 (chars > TOOL_RESULT_KEEP_CHARS * 2).then(|| (i, chars, tool_use_id.clone()))
878 }
879 _ => None,
880 })
881 .collect();
882 candidates.sort_by(|a, b| b.1.cmp(&a.1));
883
884 let mut truncated = 0;
885 for (index, _, tool_use_id) in candidates {
886 if total(messages) <= budget {
887 break;
888 }
889 if let Message::ToolResult { content, .. } = &mut messages[index] {
890 if let Some(shorter) = truncate_tool_result(content) {
891 *content = shorter;
892 already_shrunk.insert(tool_use_id);
908 truncated += 1;
909 }
910 }
911 }
912 if total(messages) > budget {
913 tracing::warn!(
918 measured_prompt_tokens = total(messages),
919 budget,
920 tool_results_truncated = truncated,
921 "declarative history is over the context budget and nothing is left to \
922 shrink; the request ships as-is"
923 );
924 }
925 truncated
926}
927
928fn truncate_tool_result(content: &str) -> Option<String> {
937 let chars: Vec<char> = content.chars().collect();
938 debug_assert!(
939 chars.len() > TOOL_RESULT_KEEP_CHARS * 2,
940 "callers filter to content larger than the two kept excerpts"
941 );
942 if chars.len() <= TOOL_RESULT_KEEP_CHARS * 2 {
945 return None;
946 }
947 let head: String = chars[..TOOL_RESULT_KEEP_CHARS].iter().collect();
948 let tail: String = chars[chars.len() - TOOL_RESULT_KEEP_CHARS..]
949 .iter()
950 .collect();
951 let dropped = chars.len() - TOOL_RESULT_KEEP_CHARS * 2;
952 let rendered = format!(
953 "{head}\n{TOOL_RESULT_TRUNCATION_MARKER}: {dropped} of {} characters removed from \
954 the middle and not recoverable in this run; re-read a narrower slice if you need \
955 them]\n{tail}",
956 chars.len()
957 );
958 (rendered.chars().count() < chars.len()).then_some(rendered)
959}
960
961fn cancelled_result(turns: u32, tool_calls: u32, output: Option<String>) -> AgentRunResult {
962 AgentRunResult {
963 output: output.unwrap_or_default(),
964 turns,
965 tool_calls,
966 error: Some("cancelled".into()),
967 inference_error: None,
968 goal: None,
969 }
970}
971
972fn tools_contains(allow: &[String], name: &str) -> bool {
973 allow.iter().any(|a| a == name)
974}
975
976pub struct ScenarioResults {
979 pub passed: usize,
980 pub total: usize,
981 pub failures: Vec<String>,
982 pub failure: Option<BuildFailure>,
984}
985
986impl ScenarioResults {
987 pub fn all_passed(&self) -> bool {
988 self.passed == self.total
989 }
990}
991
992pub async fn run_scenarios(
993 spec: &DeclarativeAgentSpec,
994 generator: &dyn TurnGenerator,
995 executor: &WorktreeExecutor,
996) -> ScenarioResults {
997 run_scenarios_with_progress(spec, generator, executor, 1, 1, None, &NoBuildProgress).await
998}
999
1000fn cancel_requested(cancel: Option<&Arc<AtomicBool>>) -> bool {
1001 cancel.is_some_and(|flag| flag.load(Ordering::SeqCst))
1002}
1003
1004struct ScenarioTurnModels<'a> {
1007 progress: &'a dyn BuildAgentProgressReporter,
1008 attempt: u32,
1009 max_attempts: u32,
1010 scenario: u32,
1011 scenarios_total: u32,
1012 last: std::sync::Mutex<Option<String>>,
1013}
1014
1015#[async_trait]
1016impl RunTurnObserver for ScenarioTurnModels<'_> {
1017 async fn turn_served(&self, model_used: &str) {
1018 let model_used = model_used.trim();
1019 if model_used.is_empty() {
1020 return;
1021 }
1022 {
1023 let mut last = self
1024 .last
1025 .lock()
1026 .unwrap_or_else(std::sync::PoisonError::into_inner);
1027 if last.as_deref() == Some(model_used) {
1028 return;
1029 }
1030 *last = Some(model_used.to_string());
1031 }
1032 self.progress
1033 .report(BuildAgentProgressUpdate {
1034 phase: super::session::AgentBuildPhase::RunningScenario,
1035 attempt: self.attempt,
1036 max_attempts: self.max_attempts,
1037 scenario: Some(self.scenario),
1038 scenarios_total: Some(self.scenarios_total),
1039 model: BuildProgressModel::Served(model_used.to_string()),
1040 })
1041 .await;
1042 }
1043}
1044
1045#[allow(clippy::too_many_arguments)]
1046async fn run_scenarios_with_progress(
1047 spec: &DeclarativeAgentSpec,
1048 generator: &dyn TurnGenerator,
1049 executor: &WorktreeExecutor,
1050 attempt: u32,
1051 max_attempts: u32,
1052 cancel: Option<&Arc<AtomicBool>>,
1053 progress: &dyn BuildAgentProgressReporter,
1054) -> ScenarioResults {
1055 let mut passed = 0;
1056 let mut failures = Vec::new();
1057 let total = spec.scenarios.len();
1058 for (i, scenario) in spec.scenarios.iter().enumerate() {
1059 if cancel_requested(cancel) {
1060 failures.push(format!(
1061 "scenario #{} not run: the build was cancelled",
1062 i + 1
1063 ));
1064 break;
1065 }
1066 let scenario_no = (i + 1) as u32;
1067 progress
1070 .report(BuildAgentProgressUpdate {
1071 phase: super::session::AgentBuildPhase::RunningScenario,
1072 attempt,
1073 max_attempts,
1074 scenario: Some(scenario_no),
1075 scenarios_total: Some(total as u32),
1076 model: BuildProgressModel::Clear,
1077 })
1078 .await;
1079 let turn_models = ScenarioTurnModels {
1080 progress,
1081 attempt,
1082 max_attempts,
1083 scenario: scenario_no,
1084 scenarios_total: total as u32,
1085 last: std::sync::Mutex::new(None),
1086 };
1087 let runner = DeclarativeAgentRunner::new(spec, generator, executor)
1090 .with_cancel(cancel.cloned())
1091 .with_turn_observer(&turn_models);
1092 let result = runner.run(&scenario.input).await;
1093 if let Some(failure) = result
1094 .inference_error
1095 .as_ref()
1096 .and_then(BuildFailure::from_generation_error)
1097 {
1098 return ScenarioResults {
1099 passed,
1100 total,
1101 failures,
1102 failure: Some(failure),
1103 };
1104 }
1105 let ok = result.error.is_none()
1109 && result
1110 .output
1111 .to_lowercase()
1112 .contains(&scenario.expect.to_lowercase());
1113 if ok {
1114 passed += 1;
1115 } else {
1116 failures.push(format!(
1117 "scenario #{} (input {:?}) expected output containing {:?} but got {:?}{}",
1118 i + 1,
1119 scenario.input,
1120 scenario.expect,
1121 truncate(&result.output, 200),
1122 result
1123 .error
1124 .as_ref()
1125 .map(|e| format!(" [error: {e}]"))
1126 .unwrap_or_default()
1127 ));
1128 }
1129 }
1130 ScenarioResults {
1131 passed,
1132 total,
1133 failures,
1134 failure: None,
1135 }
1136}
1137
1138fn truncate(s: &str, max: usize) -> String {
1139 if s.len() <= max {
1140 return s.to_string();
1141 }
1142 let mut end = max;
1143 while !s.is_char_boundary(end) {
1144 end -= 1;
1145 }
1146 format!("{}…", &s[..end])
1147}
1148
1149pub struct BuildAgentConfig {
1155 pub agent_id: String,
1156 pub available_tools: Vec<String>,
1157 pub max_attempts: u32,
1158}
1159
1160#[derive(Debug, Clone, PartialEq, Eq)]
1162pub enum BuildProgressModel {
1163 Keep,
1166 Clear,
1170 Served(String),
1172}
1173
1174#[derive(Debug, Clone, PartialEq, Eq)]
1177pub struct BuildAgentProgressUpdate {
1178 pub phase: super::session::AgentBuildPhase,
1179 pub attempt: u32,
1180 pub max_attempts: u32,
1181 pub scenario: Option<u32>,
1182 pub scenarios_total: Option<u32>,
1183 pub model: BuildProgressModel,
1184}
1185
1186#[async_trait]
1187pub trait BuildAgentProgressReporter: Send + Sync {
1188 async fn report(&self, update: BuildAgentProgressUpdate);
1189}
1190
1191struct NoBuildProgress;
1192
1193#[async_trait]
1194impl BuildAgentProgressReporter for NoBuildProgress {
1195 async fn report(&self, _update: BuildAgentProgressUpdate) {}
1196}
1197
1198#[derive(Debug, Clone, PartialEq, Eq)]
1200pub enum BuildFailure {
1201 Inference {
1202 kind: InferenceFailureKind,
1203 recovery: String,
1204 },
1205}
1206
1207impl BuildFailure {
1208 fn from_generation_error(error: &TurnGenerationError) -> Option<Self> {
1209 error
1210 .terminal_inference()
1211 .map(|(kind, recovery)| Self::Inference { kind, recovery })
1212 }
1213}
1214
1215pub struct BuildAgentOutcome {
1217 pub spec: Option<DeclarativeAgentSpec>,
1220 pub passed: bool,
1221 pub issues: Vec<String>,
1223 pub attempts: u32,
1224 pub failure: Option<BuildFailure>,
1225}
1226
1227fn build_prompt(intent: &str, available_tools: &[String], feedback: &[String]) -> String {
1228 let mut p = format!(
1229 "You are designing an in-daemon CAR agent from a user's request. Output ONLY a JSON \
1230 object (no prose, no fences) describing the agent:\n\
1231 {{\n \"name\": \"short human name\",\n \"identity\": \"system prompt — who the agent \
1232 is and how it behaves\",\n \"tools\": [\"only names from the AVAILABLE TOOLS list\"],\n \
1233 \"standing_goal\": \"the agent's persistent objective\",\n \"goal\": {{\"check\": \
1234 \"optional shell check run after each invocation\", \"max_iterations\": 8}},\n \"scenarios\": [{{\"input\": \
1235 \"an example request\", \"expect\": \"a stable substring the correct output must \
1236 contain\"}}]\n}}\n\n\
1237 User request:\n{intent}\n\n\
1238 AVAILABLE TOOLS (use only these names; pick the minimal set, or [] for a pure-reasoning \
1239 agent):\n{}\n\n\
1240 Rules:\n\
1241 - 1 to 3 scenarios. CRITICAL: each `expect` must be the SHORTEST string that proves the \
1242 answer is correct — usually a single word, number, or short phrase taken from the \
1243 USER'S REQUEST itself. NEVER a full sentence you imagine the agent saying, and never \
1244 a value you haven't computed.\n\
1245 Example — request \"a greeter that always says hello\": a good scenario is \
1246 {{\"input\": \"hi\", \"expect\": \"hello\"}} (matched case-insensitively). A BAD scenario \
1247 invents a whole reply like \"Hello! How can I help you today?\".\n\
1248 Example — request \"converts Celsius to Fahrenheit\": for input \"100\" the `expect` is \
1249 \"212\" (you must actually compute 100*9/5+32), NOT \"273.15\" (that is Kelvin) and NOT a \
1250 sentence.\n\
1251 - `expect` is matched as a case-insensitive substring of the agent's output.\n\
1252 - Prefer no tools unless the task truly needs to read/write files or run commands.\n\
1253 - Include `goal` only when there is an obvious deterministic shell check for completion \
1254 (for example `test -f output.json`, `cargo test -q`, or `npm test`). Omit `goal` \
1255 for pure question-answering agents or vague quality checks. A goal check must be a \
1256 real, runnable shell command; if a previous attempt reported \"not a runnable \
1257 command\", remove `goal` or replace it with a real command.\n\
1258 - Write `identity` so the agent answers DIRECTLY and deterministically (it should perform \
1259 the task, not chat about it) — terse enough to reliably contain each `expect`.\n",
1260 if available_tools.is_empty() {
1261 "(none)".to_string()
1262 } else {
1263 available_tools.join(", ")
1264 }
1265 );
1266 if !feedback.is_empty() {
1267 p.push_str("\nYour previous attempt did not pass its own scenarios — revise so they do:\n");
1268 for f in feedback {
1269 p.push_str(&format!("- {f}\n"));
1270 }
1271 }
1272 p
1273}
1274
1275pub(crate) fn extract_json_object(text: &str) -> Result<Value, String> {
1276 let start = text.find('{').ok_or("no JSON object in output")?;
1277 let end = text.rfind('}').ok_or("no closing brace in output")?;
1278 if end < start {
1279 return Err("malformed JSON object".into());
1280 }
1281 serde_json::from_str(&text[start..=end]).map_err(|e| format!("invalid JSON: {e}"))
1282}
1283
1284pub async fn build_agent(
1288 intent: &str,
1289 generator: &dyn TurnGenerator,
1290 executor: &WorktreeExecutor,
1291 cfg: &BuildAgentConfig,
1292) -> BuildAgentOutcome {
1293 build_agent_with_progress(intent, generator, executor, cfg, None, &NoBuildProgress).await
1294}
1295
1296pub async fn build_agent_with_progress(
1300 intent: &str,
1301 generator: &dyn TurnGenerator,
1302 executor: &WorktreeExecutor,
1303 cfg: &BuildAgentConfig,
1304 cancel: Option<Arc<AtomicBool>>,
1305 progress: &dyn BuildAgentProgressReporter,
1306) -> BuildAgentOutcome {
1307 let max = cfg.max_attempts.max(1);
1308 let mut feedback: Vec<String> = Vec::new();
1309 let mut last_spec: Option<DeclarativeAgentSpec> = None;
1310 let mut last_issues: Vec<String> = Vec::new();
1311
1312 for attempt in 1..=max {
1313 if cancel_requested(cancel.as_ref()) {
1314 return BuildAgentOutcome {
1315 spec: last_spec,
1316 passed: false,
1317 issues: vec!["cancelled".into()],
1318 attempts: attempt - 1,
1319 failure: None,
1320 };
1321 }
1322 let (phase, model) = if attempt == 1 {
1323 (
1324 super::session::AgentBuildPhase::GeneratingSpec,
1325 BuildProgressModel::Keep,
1326 )
1327 } else {
1328 (
1330 super::session::AgentBuildPhase::Repairing,
1331 BuildProgressModel::Clear,
1332 )
1333 };
1334 progress
1335 .report(BuildAgentProgressUpdate {
1336 phase,
1337 attempt,
1338 max_attempts: max,
1339 scenario: None,
1340 scenarios_total: None,
1341 model,
1342 })
1343 .await;
1344 let prompt = build_prompt(intent, &cfg.available_tools, &feedback);
1345 let generated = match generator
1346 .generate_coder(GenerateRequest {
1347 prompt: prompt.clone(),
1348 params: GenerateParams {
1349 temperature: 0.0,
1350 max_tokens: 2048,
1355 thinking: car_inference::tasks::generate::ThinkingMode::Off,
1356 ..Default::default()
1357 },
1358 messages: Some(vec![Message::User { content: prompt }]),
1359 intent: Some(car_inference::IntentHint {
1360 task: Some(car_inference::TaskHint::Code),
1361 require: vec![car_inference::ModelCapability::Code],
1362 prefer_quality: true,
1367 ..Default::default()
1368 }),
1369 ..Default::default()
1370 })
1371 .await
1372 {
1373 Ok(r) => r,
1374 Err(error) => {
1375 if let Some(failure) = BuildFailure::from_generation_error(&error) {
1376 return BuildAgentOutcome {
1377 spec: last_spec,
1378 passed: false,
1379 issues: Vec::new(),
1380 attempts: attempt,
1381 failure: Some(failure),
1382 };
1383 }
1384 last_issues = vec![format!("generation failed: {error}")];
1385 continue;
1386 }
1387 };
1388 let served = if generated.model_used.trim().is_empty() {
1389 BuildProgressModel::Keep
1390 } else {
1391 BuildProgressModel::Served(generated.model_used.clone())
1392 };
1393 progress
1394 .report(BuildAgentProgressUpdate {
1395 phase,
1396 attempt,
1397 max_attempts: max,
1398 scenario: None,
1399 scenarios_total: None,
1400 model: served,
1401 })
1402 .await;
1403
1404 let value = match extract_json_object(&generated.text) {
1405 Ok(v) => v,
1406 Err(e) => {
1407 feedback = vec![format!(
1408 "output did not parse: {e}. Return ONLY the JSON object."
1409 )];
1410 last_issues = feedback.clone();
1411 continue;
1412 }
1413 };
1414
1415 let mut spec = match parse_spec(&value, &cfg.agent_id, &cfg.available_tools) {
1417 Ok(s) => s,
1418 Err(e) => {
1419 feedback = vec![e.clone()];
1420 last_issues = vec![e];
1421 continue;
1422 }
1423 };
1424 spec.enabled = true;
1425
1426 let problems = spec.validate();
1427 if !problems.is_empty() {
1428 feedback = problems.clone();
1429 last_issues = problems;
1430 last_spec = Some(spec);
1431 continue;
1432 }
1433 if spec.scenarios.is_empty() {
1434 feedback = vec!["include at least one scenario".into()];
1435 last_issues = feedback.clone();
1436 last_spec = Some(spec);
1437 continue;
1438 }
1439
1440 let results = run_scenarios_with_progress(
1441 &spec,
1442 generator,
1443 executor,
1444 attempt,
1445 max,
1446 cancel.as_ref(),
1447 progress,
1448 )
1449 .await;
1450 if let Some(failure) = results.failure {
1451 return BuildAgentOutcome {
1452 spec: Some(spec),
1453 passed: false,
1454 issues: Vec::new(),
1455 attempts: attempt,
1456 failure: Some(failure),
1457 };
1458 }
1459 if cancel_requested(cancel.as_ref()) {
1462 return BuildAgentOutcome {
1463 spec: Some(spec),
1464 passed: false,
1465 issues: vec!["cancelled".into()],
1466 attempts: attempt,
1467 failure: None,
1468 };
1469 }
1470 if results.all_passed() {
1471 return BuildAgentOutcome {
1472 spec: Some(spec),
1473 passed: true,
1474 issues: Vec::new(),
1475 attempts: attempt,
1476 failure: None,
1477 };
1478 }
1479 feedback = results.failures.clone();
1480 last_issues = results.failures;
1481 last_spec = Some(spec);
1482 }
1483
1484 BuildAgentOutcome {
1485 spec: last_spec,
1486 passed: false,
1487 issues: last_issues,
1488 attempts: max,
1489 failure: None,
1490 }
1491}
1492
1493fn parse_spec(
1496 value: &Value,
1497 agent_id: &str,
1498 available_tools: &[String],
1499) -> Result<DeclarativeAgentSpec, String> {
1500 let name = value
1501 .get("name")
1502 .and_then(Value::as_str)
1503 .unwrap_or("")
1504 .trim()
1505 .to_string();
1506 let identity = value
1507 .get("identity")
1508 .and_then(Value::as_str)
1509 .unwrap_or("")
1510 .trim()
1511 .to_string();
1512 let standing_goal = value
1513 .get("standing_goal")
1514 .and_then(Value::as_str)
1515 .unwrap_or("")
1516 .to_string();
1517 let tools: Vec<String> = value
1518 .get("tools")
1519 .and_then(Value::as_array)
1520 .map(|a| {
1521 a.iter()
1522 .filter_map(|t| t.as_str())
1523 .map(String::from)
1524 .filter(|t| available_tools.iter().any(|a| a == t))
1525 .collect()
1526 })
1527 .unwrap_or_default();
1528 let scenarios: Vec<Scenario> = value
1529 .get("scenarios")
1530 .and_then(Value::as_array)
1531 .map(|a| {
1532 a.iter()
1533 .filter_map(|s| {
1534 Some(Scenario {
1535 input: s.get("input")?.as_str()?.to_string(),
1536 expect: s.get("expect")?.as_str()?.to_string(),
1537 })
1538 })
1539 .collect()
1540 })
1541 .unwrap_or_default();
1542
1543 Ok(DeclarativeAgentSpec {
1544 id: agent_id.to_string(),
1545 name: if name.is_empty() {
1546 agent_id.to_string()
1547 } else {
1548 name
1549 },
1550 identity,
1551 tools,
1552 denied_tools: Vec::new(),
1553 standing_goal,
1554 goal: parse_goal(value)?,
1555 cadence: None,
1556 scenarios,
1557 builder_draft: None,
1558 previous: None,
1559 enabled: true,
1560 context: ContextPolicy::default(),
1564 })
1565}
1566
1567fn parse_goal(value: &Value) -> Result<Option<DeclarativeGoal>, String> {
1568 let Some(goal) = value.get("goal") else {
1569 return Ok(None);
1570 };
1571 if goal.is_null() {
1572 return Ok(None);
1573 }
1574 let obj = goal
1575 .as_object()
1576 .ok_or_else(|| "`goal` must be an object".to_string())?;
1577 let check = obj
1578 .get("check")
1579 .and_then(Value::as_str)
1580 .map(str::trim)
1581 .filter(|s| !s.is_empty())
1582 .ok_or_else(|| "`goal.check` must be a non-empty string".to_string())?;
1583 let max_iterations = obj
1584 .get("max_iterations")
1585 .and_then(Value::as_u64)
1586 .unwrap_or(8)
1587 .clamp(1, 50) as u32;
1588 Ok(Some(DeclarativeGoal {
1589 check: check.to_string(),
1590 max_iterations,
1591 }))
1592}
1593
1594#[cfg(test)]
1595mod tests {
1596 use super::*;
1597
1598 use async_trait::async_trait;
1599 use car_inference::{GenerateRequest, InferenceResult};
1600 use serde_json::json;
1601 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1602 use std::sync::{Arc, Mutex as StdMutex};
1603
1604 struct Script {
1605 turns: Vec<InferenceResult>,
1606 cursor: AtomicUsize,
1607 }
1608 fn turn(text: &str, tool_calls: Value) -> InferenceResult {
1609 serde_json::from_value(json!({
1610 "text": text, "tool_calls": tool_calls,
1611 "trace_id": "t", "model_used": "scripted", "latency_ms": 0,
1612 }))
1613 .unwrap()
1614 }
1615 #[async_trait]
1616 impl TurnGenerator for Script {
1617 async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
1618 let i = self.cursor.fetch_add(1, Ordering::SeqCst);
1619 self.turns
1620 .get(i)
1621 .cloned()
1622 .ok_or_else(|| "script exhausted".into())
1623 }
1624 }
1625
1626 struct CapturingScript {
1627 turns: Vec<InferenceResult>,
1628 cursor: AtomicUsize,
1629 seen: Arc<StdMutex<Vec<GenerateRequest>>>,
1630 }
1631
1632 #[async_trait]
1633 impl TurnGenerator for CapturingScript {
1634 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
1635 self.seen.lock().unwrap().push(req);
1636 let i = self.cursor.fetch_add(1, Ordering::SeqCst);
1637 self.turns
1638 .get(i)
1639 .cloned()
1640 .ok_or_else(|| "script exhausted".into())
1641 }
1642 }
1643
1644 struct GrowingHistory {
1649 seen: Arc<StdMutex<Vec<(usize, bool, Option<String>)>>>,
1652 turn_no: AtomicUsize,
1653 window: usize,
1655 window_after_shrink: Option<usize>,
1658 model_used: &'static str,
1661 }
1662
1663 #[async_trait]
1664 impl TurnGenerator for GrowingHistory {
1665 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
1666 let msgs = req
1667 .messages
1668 .as_ref()
1669 .expect("the runner always sets messages");
1670 let notice = msgs.iter().find_map(|m| match m {
1671 Message::System { content } if content.starts_with("[history compacted:") => {
1672 Some(content.clone())
1673 }
1674 _ => None,
1675 });
1676 self.seen.lock().unwrap().push((
1677 msgs.len(),
1678 matches!(msgs.first(), Some(Message::System { .. })),
1679 notice,
1680 ));
1681 let n = self.turn_no.fetch_add(1, Ordering::SeqCst);
1682 let mut result = turn(
1685 &"x".repeat(8_000),
1686 json!([{
1687 "id": format!("c{n}"),
1688 "name": "write_file",
1689 "arguments": {"path": format!("big{n}.txt"), "content": "y"}
1690 }]),
1691 );
1692 result.model_used = self.model_used.to_string();
1693 Ok(result)
1694 }
1695
1696 fn context_window(&self, _model: &str) -> usize {
1697 match self.window_after_shrink {
1698 Some(smaller) if self.turn_no.load(Ordering::SeqCst) >= 2 => smaller,
1699 _ => self.window,
1700 }
1701 }
1702 }
1703
1704 async fn run_growing_history(
1707 spec: &DeclarativeAgentSpec,
1708 model: Option<&str>,
1709 window: usize,
1710 ) -> Vec<(usize, bool, Option<String>)> {
1711 run_growing_history_shrinking(spec, model, window, None).await
1712 }
1713
1714 async fn run_growing_history_shrinking(
1717 spec: &DeclarativeAgentSpec,
1718 model: Option<&str>,
1719 window: usize,
1720 window_after_shrink: Option<usize>,
1721 ) -> Vec<(usize, bool, Option<String>)> {
1722 let dir = tempfile::tempdir().unwrap();
1723 let exec = WorktreeExecutor::new(dir.path());
1724 let seen = Arc::new(StdMutex::new(Vec::new()));
1725 let generator = GrowingHistory {
1726 seen: seen.clone(),
1727 turn_no: AtomicUsize::new(0),
1728 window,
1729 window_after_shrink,
1730 model_used: "tiny-local",
1731 };
1732 let runner = DeclarativeAgentRunner::new(spec, &generator, &exec)
1733 .with_model(model.map(String::from));
1734 let result = runner.run("grow the thread").await;
1735 assert_eq!(result.error.as_deref(), Some("max_turns_exceeded"));
1736 let seen = seen.lock().unwrap().clone();
1737 assert_eq!(seen.len(), 12, "all 12 turns generated");
1738 assert!(
1739 seen.iter().all(|(_, system_first, _)| *system_first),
1740 "the agent identity must stay pinned at the head of every request"
1741 );
1742 seen
1743 }
1744
1745 const UNBOUNDED_TWELFTH_TURN: usize = 2 + 2 * 11;
1749
1750 #[tokio::test]
1751 async fn runner_compacts_history_that_exceeds_the_model_context_budget() {
1752 let spec = spec_with(vec!["write_file"]);
1758 assert!(spec.context.is_car_managed(), "default is CAR-managed");
1759 let seen = run_growing_history(&spec, Some("scripted"), 200).await;
1760
1761 let max_len = seen.iter().map(|(n, _, _)| *n).max().unwrap();
1762 assert!(
1763 max_len < UNBOUNDED_TWELFTH_TURN,
1764 "history not bounded — max messages/turn = {max_len}"
1765 );
1766 let notice = seen
1770 .iter()
1771 .find_map(|(_, _, notice)| notice.clone())
1772 .expect("a compacted request must carry the `[history compacted:` notice");
1773 assert!(
1778 !notice.contains("events_query") && !notice.contains("event log"),
1779 "declarative notice must not point at an events log it cannot read: {notice}"
1780 );
1781 assert!(
1782 notice.contains("not recoverable in this run"),
1783 "declarative notice must say the turns are gone: {notice}"
1784 );
1785 }
1786
1787 #[tokio::test]
1788 async fn context_self_leaves_the_history_entirely_to_the_agent() {
1789 let mut spec = spec_with(vec!["write_file"]);
1792 spec.context = ContextPolicy::SelfManaged;
1793 let seen = run_growing_history(&spec, Some("scripted"), 200).await;
1794
1795 assert_eq!(
1796 seen.last().unwrap().0,
1797 UNBOUNDED_TWELFTH_TURN,
1798 "context: self must not drop a single message"
1799 );
1800 assert!(
1801 seen.iter().all(|(_, _, notice)| notice.is_none()),
1802 "context: self must never leave a compaction notice"
1803 );
1804 }
1805
1806 #[tokio::test]
1807 async fn adaptive_routing_learns_the_window_from_the_model_that_ran() {
1808 let spec = spec_with(vec!["write_file"]);
1813 let seen = run_growing_history(&spec, None, 200).await;
1814
1815 let max_len = seen.iter().map(|(n, _, _)| *n).max().unwrap();
1816 assert!(
1817 max_len < UNBOUNDED_TWELFTH_TURN,
1818 "an unpinned run must still be bounded once the model is known — \
1819 max messages/turn = {max_len}"
1820 );
1821 }
1822
1823 #[tokio::test]
1824 async fn a_mid_run_fallback_to_a_smaller_model_is_compacted_against_the_smaller_window() {
1825 let spec = spec_with(vec!["write_file"]);
1832 let seen = run_growing_history_shrinking(&spec, Some("scripted"), 100_000, Some(200)).await;
1833
1834 let max_len = seen.iter().map(|(n, _, _)| *n).max().unwrap();
1835 assert!(
1836 max_len < UNBOUNDED_TWELFTH_TURN,
1837 "the run must be bounded by the window in force after the fallback — \
1838 max messages/turn = {max_len}"
1839 );
1840 assert!(
1841 seen.iter().any(|(_, _, notice)| notice.is_some()),
1842 "the smaller window must actually have compacted something"
1843 );
1844 assert!(
1848 seen[0].2.is_none() && seen[1].2.is_none(),
1849 "no compaction before the window shrank"
1850 );
1851 }
1852
1853 #[test]
1854 fn a_run_notice_fires_once_per_invocation_and_resets_with_a_new_one() {
1855 let notices = RunNotices::default();
1860 assert!(RunNotices::first(¬ices.stale_window), "first ask fires");
1861 assert!(
1862 !RunNotices::first(¬ices.stale_window),
1863 "every later ask in the same invocation is silent"
1864 );
1865 assert!(
1866 RunNotices::first(¬ices.unknown_window),
1867 "the gates are independent of one another"
1868 );
1869 assert!(!RunNotices::first(¬ices.unknown_window));
1870 assert!(RunNotices::first(¬ices.self_managed));
1871
1872 let next_invocation = RunNotices::default();
1873 assert!(
1874 RunNotices::first(&next_invocation.stale_window),
1875 "a new invocation starts clean — once per invoke, not once per process"
1876 );
1877 }
1878
1879 #[test]
1880 fn the_window_decision_table_is_exhaustive_and_never_silent() {
1881 assert_eq!(window_update(8_192, 0), WindowUpdate::Adopted(8_192));
1889 assert_eq!(window_update(4_096, 200_000), WindowUpdate::Adopted(4_096));
1890 assert_eq!(
1891 window_update(0, 200_000),
1892 WindowUpdate::KeptLastKnown(200_000),
1893 "a known budget is kept, and the caller warns"
1894 );
1895 assert_eq!(window_update(0, 0), WindowUpdate::StillUnknown);
1896 }
1897
1898 #[tokio::test]
1899 async fn a_known_window_survives_a_model_the_catalog_cannot_resolve() {
1900 let spec = spec_with(vec!["write_file"]);
1907 let seen = run_growing_history_shrinking(&spec, Some("scripted"), 200, Some(0)).await;
1908
1909 let max_len = seen.iter().map(|(n, _, _)| *n).max().unwrap();
1910 assert!(
1911 max_len < UNBOUNDED_TWELFTH_TURN,
1912 "a known window must survive an unresolvable model — max messages/turn = {max_len}"
1913 );
1914 assert!(
1915 seen.iter().any(|(_, _, notice)| notice.is_some()),
1916 "and must still be compacting"
1917 );
1918 }
1919
1920 #[test]
1921 fn a_tool_result_too_small_to_pay_for_the_marker_is_left_alone() {
1922 let small = "x".repeat(TOOL_RESULT_KEEP_CHARS * 2 + 50);
1928 assert_eq!(truncate_tool_result(&small), None, "must not grow it");
1929 let big = "y".repeat(44_000);
1935 let shrunk = truncate_tool_result(&big).expect("44k must truncate");
1936 assert!(shrunk.chars().count() < big.chars().count());
1937 assert!(shrunk.contains(TOOL_RESULT_TRUNCATION_MARKER));
1938 }
1939
1940 #[test]
1941 fn a_tool_output_that_quotes_the_truncation_marker_is_still_truncated() {
1942 let mut messages = vec![
1947 Message::System {
1948 content: "identity".into(),
1949 },
1950 Message::User {
1951 content: "read the file".into(),
1952 },
1953 Message::ToolResult {
1954 tool_use_id: "call_1".into(),
1955 content: format!(
1956 "{TOOL_RESULT_TRUNCATION_MARKER}: quoted by the file itself]{}",
1957 "z".repeat(44_000)
1958 ),
1959 provenance: Provenance::Internal,
1960 },
1961 ];
1962 let mut already = HashSet::new();
1963
1964 let truncated = shrink_oversized_tool_results(
1965 &mut messages,
1966 history_budget(8_192),
1967 0,
1968 1.0,
1969 &mut already,
1970 );
1971
1972 assert_eq!(truncated, 1, "a marker-quoting result must still be cut");
1973 assert!(already.contains("call_1"), "and recorded by id");
1974 let Message::ToolResult { content, .. } = &messages[2] else {
1975 panic!("tool result");
1976 };
1977 assert!(content.chars().count() < 44_000);
1978
1979 let again = shrink_oversized_tool_results(
1981 &mut messages,
1982 history_budget(8_192),
1983 0,
1984 1.0,
1985 &mut already,
1986 );
1987 assert_eq!(again, 0, "identity guard stops a second cut");
1988 }
1989
1990 #[tokio::test]
1991 async fn an_unpinned_run_pins_the_model_that_served_it_and_follows_a_reroute() {
1992 struct Rerouting {
1998 seen: Arc<StdMutex<Vec<(Option<String>, bool, Vec<Message>)>>>,
2001 turn_no: AtomicUsize,
2002 }
2003 #[async_trait]
2004 impl TurnGenerator for Rerouting {
2005 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
2006 let n = self.turn_no.fetch_add(1, Ordering::SeqCst);
2007 self.seen.lock().unwrap().push((
2008 req.model.clone(),
2009 req.params.strict_model,
2010 req.messages
2011 .clone()
2012 .expect("the runner always sets messages"),
2013 ));
2014 let mut result = turn(
2015 "",
2016 json!([{
2017 "id": format!("c{n}"),
2018 "name": "read_file",
2019 "arguments": {"path": "big.txt"}
2020 }]),
2021 );
2022 result.model_used = if n < 2 { "big-model" } else { "small-model" }.to_string();
2024 Ok(result)
2025 }
2026 fn context_window(&self, model: &str) -> usize {
2027 match model {
2028 "big-model" => 100_000,
2029 "small-model" => 4_096,
2030 _ => 0,
2031 }
2032 }
2033 }
2034
2035 let dir = tempfile::tempdir().unwrap();
2036 std::fs::write(dir.path().join("big.txt"), "abcde\n".repeat(1_000)).unwrap();
2038 let exec = WorktreeExecutor::new(dir.path());
2039 let seen = Arc::new(StdMutex::new(Vec::new()));
2040 let generator = Rerouting {
2041 seen: seen.clone(),
2042 turn_no: AtomicUsize::new(0),
2043 };
2044 let spec = spec_with(vec!["read_file"]);
2045
2046 let _ = DeclarativeAgentRunner::new(&spec, &generator, &exec)
2048 .run("read big.txt")
2049 .await;
2050
2051 let seen = seen.lock().unwrap();
2052 assert!(seen.len() >= 4, "at least four turns ran");
2053 assert_eq!(seen[0].0, None, "turn 1 is unpinned — nothing served yet");
2054 assert_eq!(
2055 seen[1].0.as_deref(),
2056 Some("big-model"),
2057 "turn 2 must be addressed to the model that served turn 1"
2058 );
2059 assert!(
2065 seen.iter().all(|(_, strict, _)| !*strict),
2066 "an unpinned run must never send strict_model"
2067 );
2068 let fourth = &seen[3].2;
2070 let measured = car_inference::media_tokens::request_prompt_tokens(
2071 "",
2072 None,
2073 None,
2074 None,
2075 Some(fourth.as_slice()),
2076 );
2077 assert!(
2078 measured <= history_budget(4_096),
2079 "turn 4 must fit the rerouted model's budget: {measured} > {}",
2080 history_budget(4_096)
2081 );
2082 }
2083
2084 const SCALE_TEST_WINDOW: usize = 200_000;
2090
2091 async fn second_turn_under_reported_scale(multiplier: u64) -> Vec<Message> {
2092 struct Reporting {
2093 seen: Arc<StdMutex<Vec<Vec<Message>>>>,
2094 cursor: AtomicUsize,
2095 multiplier: u64,
2096 }
2097 #[async_trait]
2098 impl TurnGenerator for Reporting {
2099 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
2100 let msgs = req
2101 .messages
2102 .clone()
2103 .expect("the runner always sets messages");
2104 self.seen.lock().unwrap().push(msgs.clone());
2105 let estimate = car_inference::media_tokens::request_prompt_tokens(
2106 "",
2107 None,
2108 None,
2109 None,
2110 Some(msgs.as_slice()),
2111 ) as u64;
2112 let i = self.cursor.fetch_add(1, Ordering::SeqCst);
2113 let mut result = if i == 0 {
2114 turn(
2115 "",
2116 json!([{"id":"c1","name":"read_file","arguments":{"path":"medium.txt"}}]),
2117 )
2118 } else {
2119 turn("done", json!([]))
2120 };
2121 result.usage = Some(car_inference::TokenUsage {
2124 prompt_tokens: estimate * self.multiplier,
2125 ..Default::default()
2126 });
2127 Ok(result)
2128 }
2129 fn context_window(&self, _model: &str) -> usize {
2130 SCALE_TEST_WINDOW
2131 }
2132 }
2133
2134 let dir = tempfile::tempdir().unwrap();
2135 std::fs::write(dir.path().join("medium.txt"), "abcdefghij\n".repeat(14_700)).unwrap();
2140 let exec = WorktreeExecutor::new(dir.path());
2141 let seen = Arc::new(StdMutex::new(Vec::new()));
2142 let generator = Reporting {
2143 seen: seen.clone(),
2144 cursor: AtomicUsize::new(0),
2145 multiplier,
2146 };
2147 let mut spec = spec_with(vec!["read_file"]);
2148 spec.identity = "You answer questions carefully. ".repeat(2_500);
2156
2157 let _ = DeclarativeAgentRunner::new(&spec, &generator, &exec)
2158 .with_model(Some("scripted".into()))
2159 .run("read medium.txt")
2160 .await;
2161
2162 let seen = seen.lock().unwrap();
2163 assert_eq!(seen.len(), 2, "two turns");
2164 seen[1].clone()
2165 }
2166
2167 #[tokio::test]
2168 async fn the_shrink_pass_measures_in_the_same_scale_compaction_decided_on() {
2169 const WINDOW: usize = SCALE_TEST_WINDOW;
2174
2175 let scaled = second_turn_under_reported_scale(2).await;
2177 let tool_result = scaled
2178 .iter()
2179 .find_map(|m| match m {
2180 Message::ToolResult { content, .. } => Some(content.clone()),
2181 _ => None,
2182 })
2183 .expect("the second turn carries the tool result");
2184 assert!(
2185 tool_result.contains(TOOL_RESULT_TRUNCATION_MARKER),
2186 "a history that only fits by the unscaled estimate must still be shrunk"
2187 );
2188 assert!(
2189 scaled_prompt_tokens(&scaled, 0, 2.0) <= history_budget(WINDOW),
2190 "and must land under the budget in the SAME scaled tokens: {} > {}",
2191 scaled_prompt_tokens(&scaled, 0, 2.0),
2192 history_budget(WINDOW)
2193 );
2194
2195 let unscaled = second_turn_under_reported_scale(1).await;
2198 let tool_result = unscaled
2199 .iter()
2200 .find_map(|m| match m {
2201 Message::ToolResult { content, .. } => Some(content.clone()),
2202 _ => None,
2203 })
2204 .expect("the second turn carries the tool result");
2205 assert!(
2206 scaled_prompt_tokens(&unscaled, 0, 1.0) <= history_budget(WINDOW),
2207 "the control only means something if the raw history genuinely fits: {} > {}",
2208 scaled_prompt_tokens(&unscaled, 0, 1.0),
2209 history_budget(WINDOW)
2210 );
2211 assert!(
2212 !tool_result.contains(TOOL_RESULT_TRUNCATION_MARKER),
2213 "a history that fits must be left alone"
2214 );
2215 }
2216
2217 #[tokio::test]
2218 async fn a_model_that_reuses_call_0_every_turn_still_gets_every_result_truncated() {
2219 struct RepeatIdGen {
2225 seen: Arc<StdMutex<Vec<Vec<Message>>>>,
2227 }
2228 #[async_trait]
2229 impl TurnGenerator for RepeatIdGen {
2230 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
2231 self.seen.lock().unwrap().push(
2232 req.messages
2233 .clone()
2234 .expect("the runner always sets messages"),
2235 );
2236 Ok(turn(
2238 "",
2239 json!([{"id":"call_0","name":"read_file","arguments":{"path":"big.txt"}}]),
2240 ))
2241 }
2242 fn context_window(&self, _model: &str) -> usize {
2243 8_192
2244 }
2245 }
2246
2247 let dir = tempfile::tempdir().unwrap();
2248 std::fs::write(dir.path().join("big.txt"), "abcdefghij\n".repeat(4_000)).unwrap();
2251 let exec = WorktreeExecutor::new(dir.path());
2252 let seen = Arc::new(StdMutex::new(Vec::new()));
2253 let generator = RepeatIdGen { seen: seen.clone() };
2254 let spec = spec_with(vec!["read_file"]);
2255
2256 let _ = DeclarativeAgentRunner::new(&spec, &generator, &exec)
2257 .with_model(Some("scripted".into()))
2258 .run("read it again")
2259 .await;
2260
2261 let seen = seen.lock().unwrap();
2262 assert!(seen.len() >= 4, "at least four turns ran");
2263 let results: Vec<(&String, &String)> = seen[3]
2266 .iter()
2267 .filter_map(|m| match m {
2268 Message::ToolResult {
2269 tool_use_id,
2270 content,
2271 ..
2272 } => Some((tool_use_id, content)),
2273 _ => None,
2274 })
2275 .collect();
2276 assert_eq!(results.len(), 3, "three tool results by turn 4");
2277 for (id, content) in &results {
2278 assert!(
2279 content.contains(TOOL_RESULT_TRUNCATION_MARKER),
2280 "every oversized result must be truncated, not just the first: {id}"
2281 );
2282 assert_eq!(
2283 content.matches(TOOL_RESULT_TRUNCATION_MARKER).count(),
2284 1,
2285 "and truncated exactly once — the guard still blocks a second cut: {id}"
2286 );
2287 }
2288 let ids: HashSet<&String> = results.iter().map(|(id, _)| *id).collect();
2289 assert_eq!(
2290 ids.len(),
2291 3,
2292 "the runner must give colliding model ids distinct run-unique keys: {ids:?}"
2293 );
2294 }
2295
2296 #[tokio::test]
2297 async fn a_caller_pin_stays_strict_while_a_learned_route_does_not() {
2298 struct CapturingStrict {
2305 seen: Arc<StdMutex<Vec<(Option<String>, bool)>>>,
2306 }
2307 #[async_trait]
2308 impl TurnGenerator for CapturingStrict {
2309 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
2310 self.seen
2311 .lock()
2312 .unwrap()
2313 .push((req.model.clone(), req.params.strict_model));
2314 Ok(turn("done", json!([])))
2315 }
2316 fn context_window(&self, _model: &str) -> usize {
2317 100_000
2318 }
2319 }
2320
2321 let dir = tempfile::tempdir().unwrap();
2322 let exec = WorktreeExecutor::new(dir.path());
2323 let spec = spec_with(vec![]);
2324
2325 let seen = Arc::new(StdMutex::new(Vec::new()));
2326 let generator = CapturingStrict { seen: seen.clone() };
2327 let _ = DeclarativeAgentRunner::new(&spec, &generator, &exec)
2328 .with_model(Some("pinned-model".into()))
2329 .run("hello")
2330 .await;
2331 assert_eq!(
2332 seen.lock().unwrap().as_slice(),
2333 [(Some("pinned-model".to_string()), true)],
2334 "a caller's pin keeps strict_model"
2335 );
2336
2337 let seen = Arc::new(StdMutex::new(Vec::new()));
2338 let generator = CapturingStrict { seen: seen.clone() };
2339 let _ = DeclarativeAgentRunner::new(&spec, &generator, &exec)
2340 .run("hello")
2341 .await;
2342 assert_eq!(
2343 seen.lock().unwrap().as_slice(),
2344 [(None, false)],
2345 "an unpinned run never sends strict_model"
2346 );
2347 }
2348
2349 #[tokio::test]
2350 async fn an_oversized_tool_result_is_truncated_to_fit_the_window() {
2351 struct WindowedCapture {
2357 turns: Vec<InferenceResult>,
2358 cursor: AtomicUsize,
2359 seen: Arc<StdMutex<Vec<Vec<Message>>>>,
2360 window: usize,
2361 }
2362 #[async_trait]
2363 impl TurnGenerator for WindowedCapture {
2364 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
2365 self.seen.lock().unwrap().push(
2366 req.messages
2367 .clone()
2368 .expect("the runner always sets messages"),
2369 );
2370 let i = self.cursor.fetch_add(1, Ordering::SeqCst);
2371 self.turns
2372 .get(i)
2373 .cloned()
2374 .ok_or_else(|| "script exhausted".into())
2375 }
2376 fn context_window(&self, _model: &str) -> usize {
2377 self.window
2378 }
2379 }
2380
2381 const WINDOW: usize = 8_192;
2382 let dir = tempfile::tempdir().unwrap();
2383 std::fs::write(dir.path().join("big.txt"), "abcdefghij\n".repeat(4_000)).unwrap();
2386 let exec = WorktreeExecutor::new(dir.path());
2387 let seen = Arc::new(StdMutex::new(Vec::new()));
2388 let generator = WindowedCapture {
2389 turns: vec![
2390 turn(
2391 "",
2392 json!([{"id":"c1","name":"read_file","arguments":{"path":"big.txt"}}]),
2393 ),
2394 turn("done", json!([])),
2395 ],
2396 cursor: AtomicUsize::new(0),
2397 seen: seen.clone(),
2398 window: WINDOW,
2399 };
2400 let spec = spec_with(vec!["read_file"]);
2401
2402 let result = DeclarativeAgentRunner::new(&spec, &generator, &exec)
2403 .with_model(Some("scripted".into()))
2404 .run("read big.txt")
2405 .await;
2406 assert_eq!(result.output, "done");
2407
2408 let seen = seen.lock().unwrap();
2409 assert_eq!(seen.len(), 2, "two turns");
2410 let second = &seen[1];
2411 let tool_result = second
2412 .iter()
2413 .find_map(|m| match m {
2414 Message::ToolResult { content, .. } => Some(content.clone()),
2415 _ => None,
2416 })
2417 .expect("the second turn carries the tool result");
2418 assert!(
2419 tool_result.contains(TOOL_RESULT_TRUNCATION_MARKER),
2420 "the oversized tool result must say it was truncated"
2421 );
2422 assert!(
2423 tool_result.contains("not recoverable in this run"),
2424 "and must not imply the middle can be recovered: {}",
2425 &tool_result[..tool_result.len().min(400)]
2426 );
2427 let measured = car_inference::media_tokens::request_prompt_tokens(
2428 "",
2429 None,
2430 None,
2431 None,
2432 Some(second.as_slice()),
2433 );
2434 assert!(
2435 measured <= history_budget(WINDOW),
2436 "the second request must fit the budget: {measured} > {}",
2437 history_budget(WINDOW)
2438 );
2439 }
2440
2441 #[tokio::test]
2442 async fn an_unknown_context_window_leaves_the_history_unbounded_and_says_so() {
2443 let spec = spec_with(vec!["write_file"]);
2450 let seen = run_growing_history(&spec, Some("unknown-model"), 0).await;
2451
2452 assert_eq!(
2453 seen.last().unwrap().0,
2454 UNBOUNDED_TWELFTH_TURN,
2455 "an unknown window must not fabricate a budget"
2456 );
2457 }
2458
2459 fn spec_with(tools: Vec<&str>) -> DeclarativeAgentSpec {
2460 DeclarativeAgentSpec {
2461 id: "t".into(),
2462 name: "T".into(),
2463 identity: "You answer.".into(),
2464 tools: tools.into_iter().map(String::from).collect(),
2465 denied_tools: vec![],
2466 standing_goal: "help".into(),
2467 goal: None,
2468 cadence: None,
2469 scenarios: vec![],
2470 builder_draft: None,
2471 previous: None,
2472 enabled: true,
2473 context: ContextPolicy::default(),
2474 }
2475 }
2476
2477 #[test]
2478 fn strict_allowlist_empty_intersection_is_zero_tools() {
2479 let all = WorktreeExecutor::tool_defs();
2480 assert!(!all.is_empty());
2481 assert!(select_tool_defs_strict(&all, &["nonexistent".into()], &[]).is_empty());
2483 assert!(select_tool_defs_strict(&all, &[], &[]).is_empty());
2485 let sel = select_tool_defs_strict(&all, &["read_file".into()], &[]);
2487 assert_eq!(sel.len(), 1);
2488 assert_eq!(sel[0]["name"], "read_file");
2489 assert!(
2491 select_tool_defs_strict(&all, &["read_file".into()], &["read_file".into()]).is_empty()
2492 );
2493 }
2494
2495 #[tokio::test]
2496 async fn runner_returns_text_answer_with_no_tools() {
2497 let dir = tempfile::tempdir().unwrap();
2498 let exec = WorktreeExecutor::new(dir.path());
2499 let script = Script {
2500 turns: vec![turn("the answer is 42", json!([]))],
2501 cursor: AtomicUsize::new(0),
2502 };
2503 let spec = spec_with(vec![]);
2504 let runner = DeclarativeAgentRunner::new(&spec, &script, &exec);
2505 let r = runner.run("what is the answer?").await;
2506 assert_eq!(r.output, "the answer is 42");
2507 assert_eq!(r.tool_calls, 0);
2508 assert!(r.error.is_none());
2509 }
2510
2511 #[tokio::test]
2512 async fn runner_executes_an_allowed_tool() {
2513 let dir = tempfile::tempdir().unwrap();
2514 std::fs::write(dir.path().join("data.txt"), "secret content").unwrap();
2515 let exec = WorktreeExecutor::new(dir.path());
2516 let script = Script {
2517 turns: vec![
2518 turn(
2519 "",
2520 json!([{"id":"c1","name":"read_file","arguments":{"path":"data.txt"}}]),
2521 ),
2522 turn("the file says secret content", json!([])),
2523 ],
2524 cursor: AtomicUsize::new(0),
2525 };
2526 let spec = spec_with(vec!["read_file"]);
2527 let runner = DeclarativeAgentRunner::new(&spec, &script, &exec);
2528 let r = runner.run("read data.txt").await;
2529 assert!(r.output.contains("secret content"));
2530 assert_eq!(r.tool_calls, 1);
2531 }
2532
2533 #[tokio::test]
2534 async fn runner_replays_managed_responses_continuity_on_second_turn() {
2535 let dir = tempfile::tempdir().unwrap();
2536 std::fs::write(dir.path().join("data.txt"), "secret content").unwrap();
2537 let exec = WorktreeExecutor::new(dir.path());
2538 let reasoning = json!({
2539 "type": "reasoning",
2540 "id": "rs_coder",
2541 "status": "completed",
2542 "summary": [{"type": "summary_text", "text": "safe"}],
2543 "encrypted_content": "opaque-coder",
2544 });
2545 let mut first = turn(
2546 "reading",
2547 json!([{"id":"c1","name":"read_file","arguments":{"path":"data.txt"}}]),
2548 );
2549 first.provider_output_items = vec![reasoning.clone()];
2550 let seen = Arc::new(StdMutex::new(Vec::new()));
2551 let script = CapturingScript {
2552 turns: vec![first, turn("done", json!([]))],
2553 cursor: AtomicUsize::new(0),
2554 seen: seen.clone(),
2555 };
2556 let spec = spec_with(vec!["read_file"]);
2557 let runner = DeclarativeAgentRunner::new(&spec, &script, &exec);
2558
2559 let result = runner.run("read data.txt").await;
2560
2561 assert_eq!(result.output, "done");
2562 assert!(!result.output.contains("opaque-coder"));
2563 let seen = seen.lock().unwrap();
2564 let second = seen[1].messages.as_ref().expect("second-turn history");
2565 assert!(matches!(
2566 &second[2],
2567 Message::ProviderOutputItems { protocol, items }
2568 if protocol == car_inference::protocol::OPENAI_RESPONSES_PROTOCOL
2569 && items == &vec![reasoning]
2570 ));
2571 assert!(matches!(
2572 &second[3],
2573 Message::Assistant { content, .. } if content == "reading"
2574 ));
2575 assert!(matches!(&second[4], Message::ToolResult { .. }));
2576 }
2577
2578 #[tokio::test]
2579 async fn runner_blocks_a_disallowed_tool_even_if_the_model_calls_it() {
2580 let dir = tempfile::tempdir().unwrap();
2581 let exec = WorktreeExecutor::new(dir.path());
2582 let script = Script {
2584 turns: vec![
2585 turn(
2586 "",
2587 json!([{"id":"c1","name":"write_file","arguments":{"path":"x","content":"y"}}]),
2588 ),
2589 turn("done", json!([])),
2590 ],
2591 cursor: AtomicUsize::new(0),
2592 };
2593 let spec = spec_with(vec!["read_file"]);
2594 let runner = DeclarativeAgentRunner::new(&spec, &script, &exec);
2595 let _ = runner.run("write a file").await;
2596 assert!(!dir.path().join("x").exists(), "disallowed tool executed");
2598 }
2599
2600 #[tokio::test]
2601 async fn runner_redrives_until_manifest_goal_check_passes() {
2602 let dir = tempfile::tempdir().unwrap();
2603 let exec = WorktreeExecutor::new(dir.path());
2604 let script = Script {
2605 turns: vec![
2606 turn("not done yet", json!([])),
2607 turn(
2608 "",
2609 json!([{"id":"w1","name":"write_file","arguments":{"path":"done.txt","content":"ok"}}]),
2610 ),
2611 turn("done", json!([])),
2612 ],
2613 cursor: AtomicUsize::new(0),
2614 };
2615 let mut spec = spec_with(vec!["write_file"]);
2616 spec.goal = Some(DeclarativeGoal {
2617 check: crate::coder::test_cmds::file_exists("done.txt"),
2618 max_iterations: 3,
2619 });
2620 let runner = DeclarativeAgentRunner::new(&spec, &script, &exec);
2621 let r = runner.run("create done.txt").await;
2622
2623 assert_eq!(r.output, "done");
2624 assert!(r.error.is_none(), "{:?}", r.error);
2625 assert_eq!(r.turns, 3);
2626 assert_eq!(r.tool_calls, 1);
2627 assert_eq!(
2628 std::fs::read_to_string(dir.path().join("done.txt")).unwrap(),
2629 "ok"
2630 );
2631 let goal = r.goal.expect("goal audit is present");
2632 assert!(goal.met, "{goal:?}");
2633 assert!(goal.grounded, "{goal:?}");
2634 assert_eq!(goal.iterations, 2);
2635 assert_eq!(goal.last_exit_code, Some(0));
2636 }
2637
2638 #[tokio::test]
2639 async fn runner_reports_error_when_manifest_goal_never_passes() {
2640 let dir = tempfile::tempdir().unwrap();
2641 let exec = WorktreeExecutor::new(dir.path());
2642 let script = Script {
2643 turns: vec![
2644 turn("still missing", json!([])),
2645 turn("still missing", json!([])),
2646 ],
2647 cursor: AtomicUsize::new(0),
2648 };
2649 let mut spec = spec_with(vec![]);
2650 spec.goal = Some(DeclarativeGoal {
2651 check: crate::coder::test_cmds::file_exists("done.txt"),
2652 max_iterations: 2,
2653 });
2654 let runner = DeclarativeAgentRunner::new(&spec, &script, &exec);
2655 let r = runner.run("create done.txt").await;
2656
2657 assert!(r.error.as_deref().unwrap_or("").contains("goal_not_met"));
2658 let goal = r.goal.expect("goal audit is present");
2659 assert!(!goal.met);
2660 assert!(
2661 goal.grounded,
2662 "a deterministic nonzero shell exit is grounded evidence, not model judgment"
2663 );
2664 assert_eq!(goal.iterations, 2);
2665 assert_eq!(goal.last_exit_code, Some(1));
2666 }
2667
2668 #[cfg(unix)]
2672 #[tokio::test]
2673 async fn runner_stops_once_when_goal_check_is_not_a_runnable_command() {
2674 let dir = tempfile::tempdir().unwrap();
2675 let exec = WorktreeExecutor::new(dir.path());
2676 let script = Script {
2682 turns: (0..8).map(|_| turn("working on it", json!([]))).collect(),
2683 cursor: AtomicUsize::new(0),
2684 };
2685 let mut spec = spec_with(vec![]);
2686 spec.goal = Some(DeclarativeGoal {
2687 check: "definitely-not-a-real-command-xyz".into(),
2688 max_iterations: 8,
2689 });
2690 let runner = DeclarativeAgentRunner::new(&spec, &script, &exec);
2691 let r = runner.run("do the work").await;
2692
2693 let error = r.error.as_deref().unwrap_or_default();
2694 assert!(
2695 error.contains("not a runnable command"),
2696 "error must name the defect: {error}"
2697 );
2698 assert!(error.contains("fix or remove goal.check"));
2699 let goal = r.goal.expect("goal audit is present");
2700 assert!(!goal.met);
2701 assert!(
2702 goal.grounded,
2703 "a deterministic shell exit is grounded evidence, not model judgment"
2704 );
2705 assert_eq!(goal.iterations, 1);
2706 assert_eq!(goal.last_exit_code, Some(127));
2707 assert_eq!(
2708 script.cursor.load(Ordering::SeqCst),
2709 1,
2710 "a broken check must not re-drive the agent"
2711 );
2712 }
2713
2714 #[tokio::test]
2718 async fn runner_still_retries_a_goal_check_that_exits_1() {
2719 let dir = tempfile::tempdir().unwrap();
2720 let exec = WorktreeExecutor::new(dir.path());
2721 let script = Script {
2722 turns: (0..8).map(|_| turn("not done yet", json!([]))).collect(),
2723 cursor: AtomicUsize::new(0),
2724 };
2725 let mut spec = spec_with(vec![]);
2726 spec.goal = Some(DeclarativeGoal {
2727 check: crate::coder::test_cmds::FAIL.to_string(),
2728 max_iterations: 8,
2729 });
2730 let runner = DeclarativeAgentRunner::new(&spec, &script, &exec);
2731 let r = runner.run("keep working").await;
2732
2733 assert!(r.error.as_deref().unwrap_or("").contains("goal_not_met"));
2734 let goal = r.goal.expect("goal audit is present");
2735 assert!(!goal.met);
2736 assert_eq!(
2737 goal.iterations, 8,
2738 "exit 1 means 'not done yet', not broken config — full retry budget"
2739 );
2740 assert_eq!(goal.last_exit_code, Some(1));
2741 }
2742
2743 #[tokio::test]
2744 async fn runner_honors_cancel_before_manifest_goal_redrive() {
2745 let dir = tempfile::tempdir().unwrap();
2746 let exec = WorktreeExecutor::new(dir.path());
2747 let cancel = Arc::new(AtomicBool::new(false));
2748 let script = Script {
2749 turns: vec![
2750 turn("still missing", json!([])),
2751 turn("should not run", json!([])),
2752 ],
2753 cursor: AtomicUsize::new(0),
2754 };
2755 let mut spec = spec_with(vec![]);
2756 spec.goal = Some(DeclarativeGoal {
2757 check: crate::coder::test_cmds::file_exists("done.txt"),
2758 max_iterations: 3,
2759 });
2760 let runner =
2761 DeclarativeAgentRunner::new(&spec, &script, &exec).with_cancel(Some(cancel.clone()));
2762
2763 cancel.store(true, Ordering::SeqCst);
2764 let r = runner.run("create done.txt").await;
2765
2766 assert_eq!(r.error.as_deref(), Some("cancelled"));
2767 assert_eq!(r.turns, 0);
2768 assert_eq!(script.cursor.load(Ordering::SeqCst), 0);
2769 }
2770
2771 #[test]
2772 fn build_prompt_preserves_a_400_character_description_as_the_spec_source() {
2773 let mut description = "Build an agent whose identity, standing goal, and scenarios follow this complete request: ".to_string();
2774 description.push_str(&"z".repeat(400 - description.len()));
2775 assert_eq!(description.chars().count(), 400);
2776
2777 let prompt = build_prompt(&description, &["read_file".into()], &[]);
2778
2779 assert!(prompt.contains(&format!("User request:\n{description}\n\nAVAILABLE TOOLS")));
2780 assert!(prompt.contains("\"identity\""));
2781 assert!(prompt.contains("\"standing_goal\""));
2782 assert!(prompt.contains("\"scenarios\""));
2783 }
2784
2785 struct TypedFailureScript {
2786 spec: Option<InferenceResult>,
2787 error: super::super::native_loop::TurnGenerationError,
2788 calls: AtomicUsize,
2789 }
2790
2791 #[async_trait]
2792 impl TurnGenerator for TypedFailureScript {
2793 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
2794 self.generate_coder(req)
2795 .await
2796 .map_err(|error| error.to_string())
2797 }
2798
2799 async fn generate_coder(
2800 &self,
2801 req: GenerateRequest,
2802 ) -> Result<InferenceResult, super::super::native_loop::TurnGenerationError> {
2803 self.calls.fetch_add(1, Ordering::SeqCst);
2804 if req.prompt.starts_with("You are designing") {
2805 if let Some(spec) = &self.spec {
2806 return Ok(spec.clone());
2807 }
2808 }
2809 Err(self.error.clone())
2810 }
2811 }
2812
2813 fn local_resource_failure() -> super::super::native_loop::TurnGenerationError {
2814 super::super::native_loop::TurnGenerationError::NonRetryableInference {
2815 kind: super::super::native_loop::InferenceFailureKind::LocalResourceBlocked,
2816 recovery: "Close memory-heavy apps or choose a smaller model.".into(),
2817 }
2818 }
2819
2820 #[tokio::test]
2821 async fn build_agent_stops_after_one_spec_generation_resource_refusal() {
2822 let dir = tempfile::tempdir().unwrap();
2823 let exec = WorktreeExecutor::new(dir.path());
2824 let script = TypedFailureScript {
2825 spec: None,
2826 error: local_resource_failure(),
2827 calls: AtomicUsize::new(0),
2828 };
2829 let cfg = BuildAgentConfig {
2830 agent_id: "blocked".into(),
2831 available_tools: vec![],
2832 max_attempts: 3,
2833 };
2834
2835 let outcome = build_agent("intent", &script, &exec, &cfg).await;
2836
2837 assert_eq!(outcome.attempts, 1, "a resource refusal cannot be repaired");
2838 assert_eq!(script.calls.load(Ordering::SeqCst), 1, "no retry");
2839 assert_eq!(
2840 outcome.failure,
2841 Some(BuildFailure::Inference {
2842 kind: InferenceFailureKind::LocalResourceBlocked,
2843 recovery: "Close memory-heavy apps or choose a smaller model.".into(),
2844 })
2845 );
2846 assert!(outcome.issues.is_empty(), "no repair feedback is built");
2847 }
2848
2849 #[tokio::test]
2850 async fn build_agent_stops_when_a_scenario_turn_has_a_resource_refusal() {
2851 let dir = tempfile::tempdir().unwrap();
2852 let exec = WorktreeExecutor::new(dir.path());
2853 let script = TypedFailureScript {
2854 spec: Some(turn(
2855 r#"{"name":"Greeter","identity":"Greet.","tools":[],
2856 "standing_goal":"greet","scenarios":[{"input":"hi","expect":"hello"}]}"#,
2857 json!([]),
2858 )),
2859 error: local_resource_failure(),
2860 calls: AtomicUsize::new(0),
2861 };
2862 let cfg = BuildAgentConfig {
2863 agent_id: "blocked".into(),
2864 available_tools: vec![],
2865 max_attempts: 3,
2866 };
2867
2868 let outcome = build_agent("intent", &script, &exec, &cfg).await;
2869
2870 assert_eq!(outcome.attempts, 1, "a scenario refusal is not a mismatch");
2871 assert_eq!(
2872 script.calls.load(Ordering::SeqCst),
2873 2,
2874 "one spec turn and one scenario turn, with no repair"
2875 );
2876 assert_eq!(
2877 outcome.failure,
2878 Some(BuildFailure::Inference {
2879 kind: InferenceFailureKind::LocalResourceBlocked,
2880 recovery: "Close memory-heavy apps or choose a smaller model.".into(),
2881 })
2882 );
2883 assert!(
2884 outcome.issues.is_empty(),
2885 "the refusal is not mismatch feedback"
2886 );
2887 }
2888
2889 fn missing_provider_key_recovery() -> String {
2893 car_inference::InferenceError::ProviderKeyMissing {
2894 provider: "openrouter".into(),
2895 model: "openrouter/auto".into(),
2896 env_vars: vec!["OPENROUTER_API_KEY".into()],
2897 message: "OpenRouter requires a key — run `car keys set openrouter` or connect \
2898 your OpenRouter account in CarHost"
2899 .into(),
2900 }
2901 .to_string()
2902 }
2903
2904 #[tokio::test]
2909 async fn build_agent_stops_when_a_scenario_turn_has_no_provider_key() {
2910 let dir = tempfile::tempdir().unwrap();
2911 let exec = WorktreeExecutor::new(dir.path());
2912 let recovery = missing_provider_key_recovery();
2913 let script = TypedFailureScript {
2914 spec: Some(turn(
2915 r#"{"name":"Greeter","identity":"Greet.","tools":[],
2916 "standing_goal":"greet","scenarios":[{"input":"hi","expect":"hello"}]}"#,
2917 json!([]),
2918 )),
2919 error: TurnGenerationError::NonRetryableInference {
2920 kind: InferenceFailureKind::ProviderKeyMissing,
2921 recovery: recovery.clone(),
2922 },
2923 calls: AtomicUsize::new(0),
2924 };
2925 let cfg = BuildAgentConfig {
2926 agent_id: "keyless".into(),
2927 available_tools: vec![],
2928 max_attempts: 3,
2929 };
2930
2931 let outcome = build_agent("intent", &script, &exec, &cfg).await;
2932
2933 assert_eq!(
2934 outcome.attempts, 1,
2935 "a key that was never set cannot appear on attempt two"
2936 );
2937 assert_eq!(
2938 script.calls.load(Ordering::SeqCst),
2939 2,
2940 "one spec turn and one scenario turn, with no repair"
2941 );
2942 assert_eq!(
2943 outcome.failure,
2944 Some(BuildFailure::Inference {
2945 kind: InferenceFailureKind::ProviderKeyMissing,
2946 recovery,
2947 })
2948 );
2949 assert!(
2950 outcome.issues.is_empty(),
2951 "a missing key is not mismatch feedback"
2952 );
2953 }
2954
2955 #[tokio::test]
2956 async fn build_agent_still_retries_a_transient_generation_error() {
2957 let dir = tempfile::tempdir().unwrap();
2958 let exec = WorktreeExecutor::new(dir.path());
2959 let script = TypedFailureScript {
2960 spec: None,
2961 error: TurnGenerationError::Other("temporary provider failure".into()),
2962 calls: AtomicUsize::new(0),
2963 };
2964 let cfg = BuildAgentConfig {
2965 agent_id: "retry".into(),
2966 available_tools: vec![],
2967 max_attempts: 3,
2968 };
2969
2970 let outcome = build_agent("intent", &script, &exec, &cfg).await;
2971
2972 assert_eq!(outcome.attempts, 3);
2973 assert_eq!(script.calls.load(Ordering::SeqCst), 3);
2974 assert_eq!(outcome.failure, None);
2975 assert_eq!(
2976 outcome.issues,
2977 vec!["generation failed: temporary provider failure"]
2978 );
2979 }
2980
2981 #[tokio::test]
2982 async fn build_agent_stops_on_credential_unavailable() {
2983 let dir = tempfile::tempdir().unwrap();
2984 let exec = WorktreeExecutor::new(dir.path());
2985 let script = TypedFailureScript {
2986 spec: None,
2987 error: TurnGenerationError::NonRetryableInference {
2988 kind: InferenceFailureKind::CredentialUnavailable,
2989 recovery: "Sign in again, then retry.".into(),
2990 },
2991 calls: AtomicUsize::new(0),
2992 };
2993 let cfg = BuildAgentConfig {
2994 agent_id: "signed-out".into(),
2995 available_tools: vec![],
2996 max_attempts: 3,
2997 };
2998
2999 let outcome = build_agent("intent", &script, &exec, &cfg).await;
3000
3001 assert_eq!(outcome.attempts, 1);
3002 assert_eq!(script.calls.load(Ordering::SeqCst), 1);
3003 assert_eq!(
3004 outcome.failure,
3005 Some(BuildFailure::Inference {
3006 kind: InferenceFailureKind::CredentialUnavailable,
3007 recovery: "Sign in again, then retry.".into(),
3008 })
3009 );
3010 }
3011
3012 #[tokio::test]
3013 async fn build_agent_generates_then_passes_scenarios() {
3014 let dir = tempfile::tempdir().unwrap();
3015 let exec = WorktreeExecutor::new(dir.path());
3016 let script = Script {
3019 turns: vec![
3020 turn(
3021 r#"{"name":"Greeter","identity":"You greet people warmly.","tools":[],
3022 "standing_goal":"greet","scenarios":[{"input":"hi","expect":"hello"}]}"#,
3023 json!([]),
3024 ),
3025 turn("hello there, friend!", json!([])),
3026 ],
3027 cursor: AtomicUsize::new(0),
3028 };
3029 let cfg = BuildAgentConfig {
3030 agent_id: "greeter".into(),
3031 available_tools: vec!["read_file".into(), "write_file".into()],
3032 max_attempts: 3,
3033 };
3034 let outcome = build_agent("make a friendly greeter", &script, &exec, &cfg).await;
3035 assert!(outcome.passed, "issues: {:?}", outcome.issues);
3036 let spec = outcome.spec.unwrap();
3037 assert_eq!(spec.id, "greeter");
3038 assert_eq!(spec.name, "Greeter");
3039 assert_eq!(spec.scenarios.len(), 1);
3040 }
3041
3042 #[tokio::test]
3043 async fn build_agent_drops_invented_tool_names() {
3044 let dir = tempfile::tempdir().unwrap();
3045 let exec = WorktreeExecutor::new(dir.path());
3046 let script = Script {
3047 turns: vec![
3048 turn(
3049 r#"{"name":"X","identity":"You help.","tools":["send_email","read_file"],
3050 "standing_goal":"g","scenarios":[{"input":"q","expect":"a"}]}"#,
3051 json!([]),
3052 ),
3053 turn("answer: a", json!([])),
3054 ],
3055 cursor: AtomicUsize::new(0),
3056 };
3057 let cfg = BuildAgentConfig {
3058 agent_id: "x".into(),
3059 available_tools: vec!["read_file".into()],
3060 max_attempts: 2,
3061 };
3062 let outcome = build_agent("intent", &script, &exec, &cfg).await;
3063 assert!(outcome.passed);
3064 assert_eq!(outcome.spec.unwrap().tools, vec!["read_file".to_string()]);
3066 }
3067
3068 #[tokio::test]
3069 async fn build_agent_parses_optional_goal_contract() {
3070 let dir = tempfile::tempdir().unwrap();
3071 let exec = WorktreeExecutor::new(dir.path());
3072 let script = Script {
3073 turns: vec![
3074 turn(
3075 &json!({
3076 "name":"Writer","identity":"You write the requested file.","tools":["write_file"],
3077 "standing_goal":"write files",
3078 "goal":{"check": format!(" {} ", crate::coder::test_cmds::file_exists("done.txt")),
3080 "max_iterations":99},
3081 "scenarios":[{"input":"make it","expect":"done"}]
3082 })
3083 .to_string(),
3084 json!([]),
3085 ),
3086 turn(
3087 "",
3088 json!([{"id":"w1","name":"write_file","arguments":{"path":"done.txt","content":"ok"}}]),
3089 ),
3090 turn("done", json!([])),
3091 ],
3092 cursor: AtomicUsize::new(0),
3093 };
3094 let cfg = BuildAgentConfig {
3095 agent_id: "writer".into(),
3096 available_tools: vec!["write_file".into()],
3097 max_attempts: 1,
3098 };
3099 let outcome = build_agent("make a file writer", &script, &exec, &cfg).await;
3100 assert!(outcome.passed, "issues: {:?}", outcome.issues);
3101 let goal = outcome.spec.unwrap().goal.expect("goal parsed");
3102 assert_eq!(goal.check, crate::coder::test_cmds::file_exists("done.txt"));
3103 assert_eq!(goal.max_iterations, 50);
3104 }
3105
3106 #[tokio::test]
3107 async fn build_agent_repairs_a_failing_scenario() {
3108 let dir = tempfile::tempdir().unwrap();
3109 let exec = WorktreeExecutor::new(dir.path());
3110 let script = Script {
3111 turns: vec![
3112 turn(
3114 r#"{"name":"A","identity":"v1","tools":[],"standing_goal":"g","scenarios":[{"input":"q","expect":"RIGHT"}]}"#,
3115 json!([]),
3116 ),
3117 turn("WRONG", json!([])),
3119 turn(
3121 r#"{"name":"A","identity":"v2","tools":[],"standing_goal":"g","scenarios":[{"input":"q","expect":"RIGHT"}]}"#,
3122 json!([]),
3123 ),
3124 turn("the RIGHT answer", json!([])),
3126 ],
3127 cursor: AtomicUsize::new(0),
3128 };
3129 let cfg = BuildAgentConfig {
3130 agent_id: "a".into(),
3131 available_tools: vec![],
3132 max_attempts: 3,
3133 };
3134 let outcome = build_agent("intent", &script, &exec, &cfg).await;
3135 assert!(outcome.passed);
3136 assert_eq!(outcome.attempts, 2);
3137 assert_eq!(outcome.spec.unwrap().identity, "v2");
3138 }
3139
3140 #[cfg(unix)]
3146 #[tokio::test]
3147 async fn build_agent_feeds_a_not_runnable_goal_back_into_the_next_attempt() {
3148 let dir = tempfile::tempdir().unwrap();
3149 let exec = WorktreeExecutor::new(dir.path());
3150 let seen = Arc::new(StdMutex::new(Vec::new()));
3151 let script = CapturingScript {
3152 turns: vec![
3153 turn(
3155 r#"{"name":"A","identity":"v1","tools":[],"standing_goal":"g",
3156 "goal":{"check":"definitely-not-a-real-command-xyz","max_iterations":8},
3157 "scenarios":[{"input":"q","expect":"RIGHT"}]}"#,
3158 json!([]),
3159 ),
3160 turn("the RIGHT answer", json!([])),
3163 turn(
3165 r#"{"name":"A","identity":"v2","tools":[],"standing_goal":"g","scenarios":[{"input":"q","expect":"RIGHT"}]}"#,
3166 json!([]),
3167 ),
3168 turn("the RIGHT answer", json!([])),
3170 ],
3171 cursor: AtomicUsize::new(0),
3172 seen: seen.clone(),
3173 };
3174 let cfg = BuildAgentConfig {
3175 agent_id: "a".into(),
3176 available_tools: vec![],
3177 max_attempts: 3,
3178 };
3179 let outcome = build_agent("intent", &script, &exec, &cfg).await;
3180 assert!(outcome.passed, "issues: {:?}", outcome.issues);
3181 assert_eq!(outcome.attempts, 2);
3182
3183 let prompts: Vec<String> = seen
3187 .lock()
3188 .unwrap()
3189 .iter()
3190 .map(|req| req.prompt.clone())
3191 .filter(|p| p.starts_with("You are designing"))
3192 .collect();
3193 assert_eq!(prompts.len(), 2, "exactly two spec-generation prompts");
3194 assert!(
3195 !prompts[0].contains("goal check is not a runnable command"),
3196 "attempt 1 has no feedback to carry yet"
3197 );
3198 assert!(
3199 prompts[1].contains("goal check is not a runnable command"),
3200 "attempt 2's generation prompt must carry attempt 1's defect text"
3201 );
3202 assert!(
3203 prompts[1].contains("definitely-not-a-real-command-xyz"),
3204 "the feedback must name the broken check so the model can repair it"
3205 );
3206 }
3207}