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::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 goal: Option<AgentGoalRun>,
42}
43
44#[derive(Debug, Clone)]
45pub struct AgentGoalRun {
46 pub check: String,
47 pub max_iterations: u32,
48 pub iterations: u32,
49 pub met: bool,
50 pub grounded: bool,
56 pub last_exit_code: Option<i32>,
57 pub last_reason: String,
58}
59
60#[derive(Default)]
68struct RunNotices {
69 self_managed: AtomicBool,
70 unknown_window: AtomicBool,
71 stale_window: AtomicBool,
72}
73
74impl RunNotices {
75 fn first(flag: &AtomicBool) -> bool {
77 !flag.swap(true, Ordering::SeqCst)
78 }
79}
80
81pub fn select_tool_defs_strict(all: &[Value], allow: &[String], deny: &[String]) -> Vec<Value> {
86 all.iter()
87 .filter(|d| {
88 let name = d.get("name").and_then(Value::as_str).unwrap_or("");
89 allow.iter().any(|a| a == name) && !deny.iter().any(|x| x == name)
90 })
91 .cloned()
92 .collect()
93}
94
95pub struct DeclarativeAgentRunner<'a> {
97 spec: &'a DeclarativeAgentSpec,
98 generator: &'a dyn TurnGenerator,
99 executor: &'a WorktreeExecutor,
100 max_turns: u32,
101 max_tokens_per_turn: usize,
102 cancel: Option<Arc<AtomicBool>>,
103 model: Option<String>,
104 turn_observer: Option<&'a dyn RunTurnObserver>,
105}
106
107#[async_trait]
111trait RunTurnObserver: Send + Sync {
112 async fn turn_served(&self, model_used: &str);
113}
114
115impl<'a> DeclarativeAgentRunner<'a> {
116 pub fn new(
117 spec: &'a DeclarativeAgentSpec,
118 generator: &'a dyn TurnGenerator,
119 executor: &'a WorktreeExecutor,
120 ) -> Self {
121 Self {
122 spec,
123 generator,
124 executor,
125 max_turns: 12,
126 max_tokens_per_turn: 2048,
127 cancel: None,
128 model: None,
129 turn_observer: None,
130 }
131 }
132
133 pub fn with_cancel(mut self, cancel: Option<Arc<AtomicBool>>) -> Self {
134 self.cancel = cancel;
135 self
136 }
137
138 fn with_turn_observer(mut self, observer: &'a dyn RunTurnObserver) -> Self {
139 self.turn_observer = Some(observer);
140 self
141 }
142
143 pub fn with_model(mut self, model: Option<String>) -> Self {
146 self.model = model;
147 self
148 }
149
150 fn system_prompt(&self) -> String {
151 let mut p = self.spec.identity.trim().to_string();
152 if !self.spec.standing_goal.trim().is_empty() {
153 p.push_str("\n\nStanding goal: ");
154 p.push_str(self.spec.standing_goal.trim());
155 }
156 p
157 }
158
159 pub async fn run(&self, input: &str) -> AgentRunResult {
161 if self.is_cancelled() {
162 return cancelled_result(0, 0, None);
163 }
164 let notices = RunNotices::default();
167 let Some(goal) = self.normalized_goal() else {
168 return self.run_once(input, ¬ices).await;
169 };
170
171 let mut total_turns = 0u32;
172 let mut total_tool_calls = 0u32;
173 let mut last_output = String::new();
174 let mut last_exit_code = None;
175 let mut last_reason = String::new();
176
177 for iteration in 1..=goal.max_iterations {
178 if self.is_cancelled() {
179 return AgentRunResult {
180 output: last_output,
181 turns: total_turns,
182 tool_calls: total_tool_calls,
183 error: Some("cancelled".into()),
184 goal: Some(AgentGoalRun {
185 check: goal.check,
186 max_iterations: goal.max_iterations,
187 iterations: iteration.saturating_sub(1),
188 met: false,
189 grounded: true,
190 last_exit_code,
191 last_reason: "cancelled".into(),
192 }),
193 };
194 }
195 let directive = if last_reason.is_empty() {
196 input.to_string()
197 } else {
198 format!(
199 "{input}\n\nThe previous deterministic goal check did not pass: \
200 {last_reason}. Keep working toward the original input until \
201 the check succeeds."
202 )
203 };
204 let result = self.run_once(&directive, ¬ices).await;
205 total_turns += result.turns;
206 total_tool_calls += result.tool_calls;
207 last_output = result.output;
208
209 if self.is_cancelled() {
210 return AgentRunResult {
211 output: last_output,
212 turns: total_turns,
213 tool_calls: total_tool_calls,
214 error: Some("cancelled".into()),
215 goal: Some(AgentGoalRun {
216 check: goal.check,
217 max_iterations: goal.max_iterations,
218 iterations: iteration,
219 met: false,
220 grounded: true,
221 last_exit_code,
222 last_reason: "cancelled".into(),
223 }),
224 };
225 }
226
227 if let Some(error) = result.error {
228 return AgentRunResult {
229 output: last_output,
230 turns: total_turns,
231 tool_calls: total_tool_calls,
232 error: Some(error),
233 goal: Some(AgentGoalRun {
234 check: goal.check,
235 max_iterations: goal.max_iterations,
236 iterations: iteration,
237 met: false,
238 grounded: true,
239 last_exit_code,
240 last_reason: "agent run failed before goal check".into(),
241 }),
242 };
243 }
244
245 match self.executor.run_shell(&goal.check, Some(120)).await {
246 Ok(v) => {
247 let exit = v.get("exit_code").and_then(Value::as_i64).map(|n| n as i32);
248 last_exit_code = exit;
249 if exit == Some(0) {
250 return AgentRunResult {
251 output: last_output,
252 turns: total_turns,
253 tool_calls: total_tool_calls,
254 error: None,
255 goal: Some(AgentGoalRun {
256 check: goal.check,
257 max_iterations: goal.max_iterations,
258 iterations: iteration,
259 met: true,
260 grounded: true,
261 last_exit_code,
262 last_reason: "goal check exited 0".into(),
263 }),
264 };
265 }
266 let output = v.get("output").and_then(Value::as_str).unwrap_or("").trim();
267 if matches!(exit, Some(126) | Some(127)) {
275 last_reason = format!(
276 "goal check is not a runnable command (exit {}): {} — \
277 fix or remove goal.check",
278 exit.unwrap_or(-1),
279 truncate(output, 200)
280 );
281 return AgentRunResult {
282 output: last_output,
283 turns: total_turns,
284 tool_calls: total_tool_calls,
285 error: Some(last_reason.clone()),
286 goal: Some(AgentGoalRun {
287 check: goal.check,
288 max_iterations: goal.max_iterations,
289 iterations: iteration,
290 met: false,
291 grounded: true,
292 last_exit_code: exit,
293 last_reason,
294 }),
295 };
296 }
297 last_reason = if output.is_empty() {
298 format!("goal check exited {}", exit.unwrap_or(-1))
299 } else {
300 format!(
301 "goal check exited {}: {}",
302 exit.unwrap_or(-1),
303 truncate(output, 200)
304 )
305 };
306 }
307 Err(e) => {
308 last_reason = format!("goal check failed to run: {e}");
309 return AgentRunResult {
310 output: last_output,
311 turns: total_turns,
312 tool_calls: total_tool_calls,
313 error: Some(last_reason.clone()),
314 goal: Some(AgentGoalRun {
315 check: goal.check,
316 max_iterations: goal.max_iterations,
317 iterations: iteration,
318 met: false,
319 grounded: true,
320 last_exit_code,
321 last_reason,
322 }),
323 };
324 }
325 }
326 }
327
328 AgentRunResult {
329 output: last_output,
330 turns: total_turns,
331 tool_calls: total_tool_calls,
332 error: Some(format!(
333 "goal_not_met after {} iteration(s): {}",
334 goal.max_iterations, last_reason
335 )),
336 goal: Some(AgentGoalRun {
337 check: goal.check,
338 max_iterations: goal.max_iterations,
339 iterations: goal.max_iterations,
340 met: false,
341 grounded: true,
342 last_exit_code,
343 last_reason,
344 }),
345 }
346 }
347
348 fn is_cancelled(&self) -> bool {
349 self.cancel
350 .as_ref()
351 .map(|flag| flag.load(Ordering::SeqCst))
352 .unwrap_or(false)
353 }
354
355 fn normalized_goal(&self) -> Option<DeclarativeGoal> {
356 self.spec.goal.as_ref().and_then(|goal| {
357 let check = goal.check.trim();
358 if check.is_empty() {
359 None
360 } else {
361 Some(DeclarativeGoal {
362 check: check.to_string(),
363 max_iterations: goal.max_iterations.clamp(1, 50),
364 })
365 }
366 })
367 }
368
369 async fn run_once(&self, input: &str, notices: &RunNotices) -> AgentRunResult {
370 if self.is_cancelled() {
371 return cancelled_result(0, 0, None);
372 }
373 let tools = select_tool_defs_strict(
374 {
375 self.executor.advertise_delegates();
377 &self.executor.all_tool_defs()
378 },
379 &self.spec.tools,
387 &self.spec.denied_tools,
388 );
389 let tools = if tools.is_empty() { None } else { Some(tools) };
390
391 let mut messages = vec![
392 Message::System {
393 content: self.system_prompt(),
394 },
395 Message::User {
396 content: input.to_string(),
397 },
398 ];
399
400 let car_manages_context = self.spec.context.is_car_managed();
408 if !car_manages_context && RunNotices::first(¬ices.self_managed) {
409 tracing::info!(
410 agent = %self.spec.id,
411 context = self.spec.context.as_str(),
412 "CAR compaction is off for this agent (context: self); the spec owns its history"
413 );
414 }
415 let mut context_window = self
422 .model
423 .as_deref()
424 .map(|m| self.generator.context_window(m))
425 .unwrap_or(0);
426 let mut route = self.model.clone();
435 let caller_pinned = self.model.is_some();
439 let mut route_divergence_logged = false;
441 let mut shrunk_tool_results: HashSet<String> = HashSet::new();
447 let mut used_call_ids: HashSet<String> = HashSet::new();
450 let mut prompt_measure = PromptMeasure {
451 fixed_overhead: car_inference::media_tokens::tool_defs_tokens(
452 tools.as_deref().unwrap_or(&[]),
453 ),
454 reported: None,
455 };
456 let mut tool_calls_total = 0u32;
457 for turn in 1..=self.max_turns {
458 if self.is_cancelled() {
459 return cancelled_result(turn.saturating_sub(1), tool_calls_total, None);
460 }
461 if car_manages_context {
467 let before_compaction = messages.clone();
473 let scale = measure_scale(&message_estimates(&messages), prompt_measure);
476 compact_history_measured_with_recovery(
481 &mut messages,
482 context_window,
483 prompt_measure,
484 CompactionRecovery::Unrecoverable,
485 );
486 let shrunk = shrink_oversized_tool_results(
490 &mut messages,
491 history_budget(context_window),
492 prompt_measure.fixed_overhead,
493 scale,
494 &mut shrunk_tool_results,
495 );
496 if shrunk > 0 {
497 tracing::info!(
498 agent = %self.spec.id,
499 tool_results_truncated = shrunk,
500 context_window,
501 "truncated oversized tool results to fit the model's context window"
502 );
503 }
504 if before_compaction != messages {
505 prompt_measure.reported = None;
508 }
509 }
510 let request_covers = messages.len();
513 let req = GenerateRequest {
514 prompt: input.to_string(),
515 model: route.clone(),
516 params: GenerateParams {
517 temperature: 0.0,
518 max_tokens: self.max_tokens_per_turn,
519 strict_model: caller_pinned,
531 thinking: car_inference::tasks::generate::ThinkingMode::Off,
537 ..Default::default()
538 },
539 tools: tools.clone(),
540 messages: Some(messages.clone()),
541 intent: Some(car_inference::IntentHint {
542 task: Some(car_inference::TaskHint::Code),
543 prefer_quality: true,
547 ..Default::default()
548 }),
549 ..Default::default()
550 };
551 let result = match self.generator.generate(req).await {
552 Ok(r) => r,
553 Err(e) => {
554 return AgentRunResult {
555 output: String::new(),
556 turns: turn,
557 tool_calls: tool_calls_total,
558 error: Some(format!("inference failed: {e}")),
559 goal: None,
560 };
561 }
562 };
563 if let Some(observer) = self.turn_observer {
564 observer.turn_served(&result.model_used).await;
565 }
566
567 if car_manages_context {
568 let resolved = self.generator.context_window(&result.model_used);
576 match window_update(resolved, context_window) {
577 WindowUpdate::Adopted(window) => {
578 if window != context_window {
579 tracing::debug!(
580 agent = %self.spec.id,
581 model = %result.model_used,
582 previous_context_window = context_window,
583 context_window = window,
584 "declarative run's context window changed with the serving model"
585 );
586 }
587 context_window = window;
588 }
589 WindowUpdate::KeptLastKnown(window) => {
590 if RunNotices::first(¬ices.stale_window) {
596 tracing::warn!(
597 agent = %self.spec.id,
598 model = %result.model_used,
599 context_window = window,
600 "model {} has no known context window; keeping the last known \
601 budget of {window} tokens — it may not fit the model now \
602 serving this run. Add the model to the catalog to bound it \
603 properly.",
604 result.model_used
605 );
606 }
607 }
608 WindowUpdate::StillUnknown => {}
609 }
610 if !caller_pinned {
616 if resolved != 0
617 && !result.model_used.is_empty()
618 && route.as_deref() != Some(result.model_used.as_str())
619 {
620 if route.is_some() {
621 tracing::warn!(
622 agent = %self.spec.id,
623 previous_route = route.as_deref().unwrap_or(""),
624 served = %result.model_used,
625 context_window,
626 "declarative run was served by a different model than its \
627 pinned route; following it so the budget and the serving \
628 model cannot diverge"
629 );
630 } else {
631 tracing::debug!(
632 agent = %self.spec.id,
633 model = %result.model_used,
634 context_window,
635 "pinning the declarative run to the model that served it"
636 );
637 }
638 route = Some(result.model_used.clone());
639 }
640 } else if !route_divergence_logged
641 && !result.model_used.is_empty()
642 && route.as_deref() != Some(result.model_used.as_str())
643 {
644 route_divergence_logged = true;
645 tracing::warn!(
646 agent = %self.spec.id,
647 pinned = route.as_deref().unwrap_or(""),
648 served = %result.model_used,
649 context_window,
650 "declarative run was served by a different model than the caller's \
651 pin; budgeting against the model that served it"
652 );
653 }
654 if context_window == 0 && RunNotices::first(¬ices.unknown_window) {
655 tracing::warn!(
661 agent = %self.spec.id,
662 model = %result.model_used,
663 max_turns = self.max_turns,
664 "compaction disabled: unknown context window for model {} \
665 — this agent's history is bounded only by its turn cap. \
666 Add the model to the catalog, or set `context: self` to \
667 own the transcript deliberately.",
668 result.model_used
669 );
670 }
671 if let Some(usage) = &result.usage {
675 let input = usage.prompt_tokens
676 + usage.cache_read_input_tokens
677 + usage.cache_creation_input_tokens;
678 if input > 0 {
679 prompt_measure.reported = Some((input as usize, request_covers));
680 }
681 }
682 }
683
684 if self.is_cancelled() {
685 return cancelled_result(turn, tool_calls_total, None);
686 }
687
688 if result.tool_calls.is_empty() {
689 return AgentRunResult {
690 output: result.text,
691 turns: turn,
692 tool_calls: tool_calls_total,
693 error: None,
694 goal: None,
695 };
696 }
697
698 let mut calls = result.tool_calls.clone();
699 for (i, call) in calls.iter_mut().enumerate() {
700 let unique = match &call.id {
717 Some(id) if !used_call_ids.contains(id) => id.clone(),
718 _ => {
719 let mut minted = format!("call_{turn}_{i}");
720 let mut collision = 0;
721 while used_call_ids.contains(&minted) {
722 collision += 1;
723 minted = format!("call_{turn}_{i}_{collision}");
724 }
725 minted
726 }
727 };
728 used_call_ids.insert(unique.clone());
729 call.id = Some(unique);
730 }
731 result.append_assistant_history(&mut messages, calls.clone());
732 for call in &calls {
733 if self.is_cancelled() {
734 return cancelled_result(turn, tool_calls_total, Some(result.text.clone()));
735 }
736 let params = Value::Object(call.arguments.clone().into_iter().collect());
737 let (_, content) = if tools_contains(&self.spec.tools, &call.name)
740 && !self.spec.denied_tools.iter().any(|d| d == &call.name)
741 {
742 match self.executor.execute(&call.name, ¶ms).await {
743 Ok(v) => (true, v.to_string()),
744 Err(e) => (false, format!("ERROR: {e}")),
745 }
746 } else {
747 (
748 false,
749 format!("ERROR: tool '{}' is not allowed for this agent", call.name),
750 )
751 };
752 tool_calls_total += 1;
753 messages.push(Message::ToolResult {
754 tool_use_id: call.id.clone().expect("assigned above"),
755 content,
756 provenance: Provenance::Internal,
759 });
760 }
761 }
762
763 AgentRunResult {
764 output: String::new(),
765 turns: self.max_turns,
766 tool_calls: tool_calls_total,
767 error: Some("max_turns_exceeded".into()),
768 goal: None,
769 }
770 }
771}
772
773#[derive(Debug, Clone, Copy, PartialEq, Eq)]
779enum WindowUpdate {
780 Adopted(usize),
782 KeptLastKnown(usize),
786 StillUnknown,
789}
790
791fn window_update(resolved: usize, current: usize) -> WindowUpdate {
792 match (resolved, current) {
793 (0, 0) => WindowUpdate::StillUnknown,
794 (0, known) => WindowUpdate::KeptLastKnown(known),
795 (window, _) => WindowUpdate::Adopted(window),
796 }
797}
798
799const TOOL_RESULT_KEEP_CHARS: usize = 600;
801
802const TOOL_RESULT_TRUNCATION_MARKER: &str =
807 "[tool result truncated to fit the model's context window";
808
809fn shrink_oversized_tool_results(
829 messages: &mut [Message],
830 budget: usize,
831 fixed_overhead: usize,
832 scale: f64,
833 already_shrunk: &mut HashSet<String>,
834) -> usize {
835 if budget == 0 {
836 return 0;
837 }
838 let total = |messages: &[Message]| scaled_prompt_tokens(messages, fixed_overhead, scale);
844 if total(messages) <= budget {
845 return 0;
846 }
847 let mut candidates: Vec<(usize, usize, String)> = messages
854 .iter()
855 .enumerate()
856 .filter_map(|(i, m)| match m {
857 Message::ToolResult {
858 tool_use_id,
859 content,
860 ..
861 } if !already_shrunk.contains(tool_use_id) => {
862 let chars = content.chars().count();
863 (chars > TOOL_RESULT_KEEP_CHARS * 2).then(|| (i, chars, tool_use_id.clone()))
864 }
865 _ => None,
866 })
867 .collect();
868 candidates.sort_by(|a, b| b.1.cmp(&a.1));
869
870 let mut truncated = 0;
871 for (index, _, tool_use_id) in candidates {
872 if total(messages) <= budget {
873 break;
874 }
875 if let Message::ToolResult { content, .. } = &mut messages[index] {
876 if let Some(shorter) = truncate_tool_result(content) {
877 *content = shorter;
878 already_shrunk.insert(tool_use_id);
894 truncated += 1;
895 }
896 }
897 }
898 if total(messages) > budget {
899 tracing::warn!(
904 measured_prompt_tokens = total(messages),
905 budget,
906 tool_results_truncated = truncated,
907 "declarative history is over the context budget and nothing is left to \
908 shrink; the request ships as-is"
909 );
910 }
911 truncated
912}
913
914fn truncate_tool_result(content: &str) -> Option<String> {
923 let chars: Vec<char> = content.chars().collect();
924 debug_assert!(
925 chars.len() > TOOL_RESULT_KEEP_CHARS * 2,
926 "callers filter to content larger than the two kept excerpts"
927 );
928 if chars.len() <= TOOL_RESULT_KEEP_CHARS * 2 {
931 return None;
932 }
933 let head: String = chars[..TOOL_RESULT_KEEP_CHARS].iter().collect();
934 let tail: String = chars[chars.len() - TOOL_RESULT_KEEP_CHARS..]
935 .iter()
936 .collect();
937 let dropped = chars.len() - TOOL_RESULT_KEEP_CHARS * 2;
938 let rendered = format!(
939 "{head}\n{TOOL_RESULT_TRUNCATION_MARKER}: {dropped} of {} characters removed from \
940 the middle and not recoverable in this run; re-read a narrower slice if you need \
941 them]\n{tail}",
942 chars.len()
943 );
944 (rendered.chars().count() < chars.len()).then_some(rendered)
945}
946
947fn cancelled_result(turns: u32, tool_calls: u32, output: Option<String>) -> AgentRunResult {
948 AgentRunResult {
949 output: output.unwrap_or_default(),
950 turns,
951 tool_calls,
952 error: Some("cancelled".into()),
953 goal: None,
954 }
955}
956
957fn tools_contains(allow: &[String], name: &str) -> bool {
958 allow.iter().any(|a| a == name)
959}
960
961pub struct ScenarioResults {
964 pub passed: usize,
965 pub total: usize,
966 pub failures: Vec<String>,
967}
968
969impl ScenarioResults {
970 pub fn all_passed(&self) -> bool {
971 self.passed == self.total
972 }
973}
974
975pub async fn run_scenarios(
976 spec: &DeclarativeAgentSpec,
977 generator: &dyn TurnGenerator,
978 executor: &WorktreeExecutor,
979) -> ScenarioResults {
980 run_scenarios_with_progress(spec, generator, executor, 1, 1, None, &NoBuildProgress).await
981}
982
983fn cancel_requested(cancel: Option<&Arc<AtomicBool>>) -> bool {
984 cancel.is_some_and(|flag| flag.load(Ordering::SeqCst))
985}
986
987struct ScenarioTurnModels<'a> {
990 progress: &'a dyn BuildAgentProgressReporter,
991 attempt: u32,
992 max_attempts: u32,
993 scenario: u32,
994 scenarios_total: u32,
995 last: std::sync::Mutex<Option<String>>,
996}
997
998#[async_trait]
999impl RunTurnObserver for ScenarioTurnModels<'_> {
1000 async fn turn_served(&self, model_used: &str) {
1001 let model_used = model_used.trim();
1002 if model_used.is_empty() {
1003 return;
1004 }
1005 {
1006 let mut last = self
1007 .last
1008 .lock()
1009 .unwrap_or_else(std::sync::PoisonError::into_inner);
1010 if last.as_deref() == Some(model_used) {
1011 return;
1012 }
1013 *last = Some(model_used.to_string());
1014 }
1015 self.progress
1016 .report(BuildAgentProgressUpdate {
1017 phase: super::session::AgentBuildPhase::RunningScenario,
1018 attempt: self.attempt,
1019 max_attempts: self.max_attempts,
1020 scenario: Some(self.scenario),
1021 scenarios_total: Some(self.scenarios_total),
1022 model: BuildProgressModel::Served(model_used.to_string()),
1023 })
1024 .await;
1025 }
1026}
1027
1028#[allow(clippy::too_many_arguments)]
1029async fn run_scenarios_with_progress(
1030 spec: &DeclarativeAgentSpec,
1031 generator: &dyn TurnGenerator,
1032 executor: &WorktreeExecutor,
1033 attempt: u32,
1034 max_attempts: u32,
1035 cancel: Option<&Arc<AtomicBool>>,
1036 progress: &dyn BuildAgentProgressReporter,
1037) -> ScenarioResults {
1038 let mut passed = 0;
1039 let mut failures = Vec::new();
1040 let total = spec.scenarios.len();
1041 for (i, scenario) in spec.scenarios.iter().enumerate() {
1042 if cancel_requested(cancel) {
1043 failures.push(format!(
1044 "scenario #{} not run: the build was cancelled",
1045 i + 1
1046 ));
1047 break;
1048 }
1049 let scenario_no = (i + 1) as u32;
1050 progress
1053 .report(BuildAgentProgressUpdate {
1054 phase: super::session::AgentBuildPhase::RunningScenario,
1055 attempt,
1056 max_attempts,
1057 scenario: Some(scenario_no),
1058 scenarios_total: Some(total as u32),
1059 model: BuildProgressModel::Clear,
1060 })
1061 .await;
1062 let turn_models = ScenarioTurnModels {
1063 progress,
1064 attempt,
1065 max_attempts,
1066 scenario: scenario_no,
1067 scenarios_total: total as u32,
1068 last: std::sync::Mutex::new(None),
1069 };
1070 let runner = DeclarativeAgentRunner::new(spec, generator, executor)
1073 .with_cancel(cancel.cloned())
1074 .with_turn_observer(&turn_models);
1075 let result = runner.run(&scenario.input).await;
1076 let ok = result.error.is_none()
1080 && result
1081 .output
1082 .to_lowercase()
1083 .contains(&scenario.expect.to_lowercase());
1084 if ok {
1085 passed += 1;
1086 } else {
1087 failures.push(format!(
1088 "scenario #{} (input {:?}) expected output containing {:?} but got {:?}{}",
1089 i + 1,
1090 scenario.input,
1091 scenario.expect,
1092 truncate(&result.output, 200),
1093 result
1094 .error
1095 .as_ref()
1096 .map(|e| format!(" [error: {e}]"))
1097 .unwrap_or_default()
1098 ));
1099 }
1100 }
1101 ScenarioResults {
1102 passed,
1103 total,
1104 failures,
1105 }
1106}
1107
1108fn truncate(s: &str, max: usize) -> String {
1109 if s.len() <= max {
1110 return s.to_string();
1111 }
1112 let mut end = max;
1113 while !s.is_char_boundary(end) {
1114 end -= 1;
1115 }
1116 format!("{}…", &s[..end])
1117}
1118
1119pub struct BuildAgentConfig {
1125 pub agent_id: String,
1126 pub available_tools: Vec<String>,
1127 pub max_attempts: u32,
1128}
1129
1130#[derive(Debug, Clone, PartialEq, Eq)]
1132pub enum BuildProgressModel {
1133 Keep,
1136 Clear,
1140 Served(String),
1142}
1143
1144#[derive(Debug, Clone, PartialEq, Eq)]
1147pub struct BuildAgentProgressUpdate {
1148 pub phase: super::session::AgentBuildPhase,
1149 pub attempt: u32,
1150 pub max_attempts: u32,
1151 pub scenario: Option<u32>,
1152 pub scenarios_total: Option<u32>,
1153 pub model: BuildProgressModel,
1154}
1155
1156#[async_trait]
1157pub trait BuildAgentProgressReporter: Send + Sync {
1158 async fn report(&self, update: BuildAgentProgressUpdate);
1159}
1160
1161struct NoBuildProgress;
1162
1163#[async_trait]
1164impl BuildAgentProgressReporter for NoBuildProgress {
1165 async fn report(&self, _update: BuildAgentProgressUpdate) {}
1166}
1167
1168pub struct BuildAgentOutcome {
1170 pub spec: Option<DeclarativeAgentSpec>,
1173 pub passed: bool,
1174 pub issues: Vec<String>,
1176 pub attempts: u32,
1177}
1178
1179fn build_prompt(intent: &str, available_tools: &[String], feedback: &[String]) -> String {
1180 let mut p = format!(
1181 "You are designing an in-daemon CAR agent from a user's request. Output ONLY a JSON \
1182 object (no prose, no fences) describing the agent:\n\
1183 {{\n \"name\": \"short human name\",\n \"identity\": \"system prompt — who the agent \
1184 is and how it behaves\",\n \"tools\": [\"only names from the AVAILABLE TOOLS list\"],\n \
1185 \"standing_goal\": \"the agent's persistent objective\",\n \"goal\": {{\"check\": \
1186 \"optional shell check run after each invocation\", \"max_iterations\": 8}},\n \"scenarios\": [{{\"input\": \
1187 \"an example request\", \"expect\": \"a stable substring the correct output must \
1188 contain\"}}]\n}}\n\n\
1189 User request:\n{intent}\n\n\
1190 AVAILABLE TOOLS (use only these names; pick the minimal set, or [] for a pure-reasoning \
1191 agent):\n{}\n\n\
1192 Rules:\n\
1193 - 1 to 3 scenarios. CRITICAL: each `expect` must be the SHORTEST string that proves the \
1194 answer is correct — usually a single word, number, or short phrase taken from the \
1195 USER'S REQUEST itself. NEVER a full sentence you imagine the agent saying, and never \
1196 a value you haven't computed.\n\
1197 Example — request \"a greeter that always says hello\": a good scenario is \
1198 {{\"input\": \"hi\", \"expect\": \"hello\"}} (matched case-insensitively). A BAD scenario \
1199 invents a whole reply like \"Hello! How can I help you today?\".\n\
1200 Example — request \"converts Celsius to Fahrenheit\": for input \"100\" the `expect` is \
1201 \"212\" (you must actually compute 100*9/5+32), NOT \"273.15\" (that is Kelvin) and NOT a \
1202 sentence.\n\
1203 - `expect` is matched as a case-insensitive substring of the agent's output.\n\
1204 - Prefer no tools unless the task truly needs to read/write files or run commands.\n\
1205 - Include `goal` only when there is an obvious deterministic shell check for completion \
1206 (for example `test -f output.json`, `cargo test -q`, or `npm test`). Omit `goal` \
1207 for pure question-answering agents or vague quality checks. A goal check must be a \
1208 real, runnable shell command; if a previous attempt reported \"not a runnable \
1209 command\", remove `goal` or replace it with a real command.\n\
1210 - Write `identity` so the agent answers DIRECTLY and deterministically (it should perform \
1211 the task, not chat about it) — terse enough to reliably contain each `expect`.\n",
1212 if available_tools.is_empty() {
1213 "(none)".to_string()
1214 } else {
1215 available_tools.join(", ")
1216 }
1217 );
1218 if !feedback.is_empty() {
1219 p.push_str("\nYour previous attempt did not pass its own scenarios — revise so they do:\n");
1220 for f in feedback {
1221 p.push_str(&format!("- {f}\n"));
1222 }
1223 }
1224 p
1225}
1226
1227pub(crate) fn extract_json_object(text: &str) -> Result<Value, String> {
1228 let start = text.find('{').ok_or("no JSON object in output")?;
1229 let end = text.rfind('}').ok_or("no closing brace in output")?;
1230 if end < start {
1231 return Err("malformed JSON object".into());
1232 }
1233 serde_json::from_str(&text[start..=end]).map_err(|e| format!("invalid JSON: {e}"))
1234}
1235
1236pub async fn build_agent(
1240 intent: &str,
1241 generator: &dyn TurnGenerator,
1242 executor: &WorktreeExecutor,
1243 cfg: &BuildAgentConfig,
1244) -> BuildAgentOutcome {
1245 build_agent_with_progress(intent, generator, executor, cfg, None, &NoBuildProgress).await
1246}
1247
1248pub async fn build_agent_with_progress(
1252 intent: &str,
1253 generator: &dyn TurnGenerator,
1254 executor: &WorktreeExecutor,
1255 cfg: &BuildAgentConfig,
1256 cancel: Option<Arc<AtomicBool>>,
1257 progress: &dyn BuildAgentProgressReporter,
1258) -> BuildAgentOutcome {
1259 let max = cfg.max_attempts.max(1);
1260 let mut feedback: Vec<String> = Vec::new();
1261 let mut last_spec: Option<DeclarativeAgentSpec> = None;
1262 let mut last_issues: Vec<String> = Vec::new();
1263
1264 for attempt in 1..=max {
1265 if cancel_requested(cancel.as_ref()) {
1266 return BuildAgentOutcome {
1267 spec: last_spec,
1268 passed: false,
1269 issues: vec!["cancelled".into()],
1270 attempts: attempt - 1,
1271 };
1272 }
1273 let (phase, model) = if attempt == 1 {
1274 (
1275 super::session::AgentBuildPhase::GeneratingSpec,
1276 BuildProgressModel::Keep,
1277 )
1278 } else {
1279 (
1281 super::session::AgentBuildPhase::Repairing,
1282 BuildProgressModel::Clear,
1283 )
1284 };
1285 progress
1286 .report(BuildAgentProgressUpdate {
1287 phase,
1288 attempt,
1289 max_attempts: max,
1290 scenario: None,
1291 scenarios_total: None,
1292 model,
1293 })
1294 .await;
1295 let prompt = build_prompt(intent, &cfg.available_tools, &feedback);
1296 let generated = match generator
1297 .generate(GenerateRequest {
1298 prompt: prompt.clone(),
1299 params: GenerateParams {
1300 temperature: 0.0,
1301 max_tokens: 2048,
1306 thinking: car_inference::tasks::generate::ThinkingMode::Off,
1307 ..Default::default()
1308 },
1309 messages: Some(vec![Message::User { content: prompt }]),
1310 intent: Some(car_inference::IntentHint {
1311 task: Some(car_inference::TaskHint::Code),
1312 require: vec![car_inference::ModelCapability::Code],
1313 prefer_quality: true,
1318 ..Default::default()
1319 }),
1320 ..Default::default()
1321 })
1322 .await
1323 {
1324 Ok(r) => r,
1325 Err(e) => {
1326 last_issues = vec![format!("generation failed: {e}")];
1327 continue;
1328 }
1329 };
1330 let served = if generated.model_used.trim().is_empty() {
1331 BuildProgressModel::Keep
1332 } else {
1333 BuildProgressModel::Served(generated.model_used.clone())
1334 };
1335 progress
1336 .report(BuildAgentProgressUpdate {
1337 phase,
1338 attempt,
1339 max_attempts: max,
1340 scenario: None,
1341 scenarios_total: None,
1342 model: served,
1343 })
1344 .await;
1345
1346 let value = match extract_json_object(&generated.text) {
1347 Ok(v) => v,
1348 Err(e) => {
1349 feedback = vec![format!(
1350 "output did not parse: {e}. Return ONLY the JSON object."
1351 )];
1352 last_issues = feedback.clone();
1353 continue;
1354 }
1355 };
1356
1357 let mut spec = match parse_spec(&value, &cfg.agent_id, &cfg.available_tools) {
1359 Ok(s) => s,
1360 Err(e) => {
1361 feedback = vec![e.clone()];
1362 last_issues = vec![e];
1363 continue;
1364 }
1365 };
1366 spec.enabled = true;
1367
1368 let problems = spec.validate();
1369 if !problems.is_empty() {
1370 feedback = problems.clone();
1371 last_issues = problems;
1372 last_spec = Some(spec);
1373 continue;
1374 }
1375 if spec.scenarios.is_empty() {
1376 feedback = vec!["include at least one scenario".into()];
1377 last_issues = feedback.clone();
1378 last_spec = Some(spec);
1379 continue;
1380 }
1381
1382 let results = run_scenarios_with_progress(
1383 &spec,
1384 generator,
1385 executor,
1386 attempt,
1387 max,
1388 cancel.as_ref(),
1389 progress,
1390 )
1391 .await;
1392 if cancel_requested(cancel.as_ref()) {
1395 return BuildAgentOutcome {
1396 spec: Some(spec),
1397 passed: false,
1398 issues: vec!["cancelled".into()],
1399 attempts: attempt,
1400 };
1401 }
1402 if results.all_passed() {
1403 return BuildAgentOutcome {
1404 spec: Some(spec),
1405 passed: true,
1406 issues: Vec::new(),
1407 attempts: attempt,
1408 };
1409 }
1410 feedback = results.failures.clone();
1411 last_issues = results.failures;
1412 last_spec = Some(spec);
1413 }
1414
1415 BuildAgentOutcome {
1416 spec: last_spec,
1417 passed: false,
1418 issues: last_issues,
1419 attempts: max,
1420 }
1421}
1422
1423fn parse_spec(
1426 value: &Value,
1427 agent_id: &str,
1428 available_tools: &[String],
1429) -> Result<DeclarativeAgentSpec, String> {
1430 let name = value
1431 .get("name")
1432 .and_then(Value::as_str)
1433 .unwrap_or("")
1434 .trim()
1435 .to_string();
1436 let identity = value
1437 .get("identity")
1438 .and_then(Value::as_str)
1439 .unwrap_or("")
1440 .trim()
1441 .to_string();
1442 let standing_goal = value
1443 .get("standing_goal")
1444 .and_then(Value::as_str)
1445 .unwrap_or("")
1446 .to_string();
1447 let tools: Vec<String> = value
1448 .get("tools")
1449 .and_then(Value::as_array)
1450 .map(|a| {
1451 a.iter()
1452 .filter_map(|t| t.as_str())
1453 .map(String::from)
1454 .filter(|t| available_tools.iter().any(|a| a == t))
1455 .collect()
1456 })
1457 .unwrap_or_default();
1458 let scenarios: Vec<Scenario> = value
1459 .get("scenarios")
1460 .and_then(Value::as_array)
1461 .map(|a| {
1462 a.iter()
1463 .filter_map(|s| {
1464 Some(Scenario {
1465 input: s.get("input")?.as_str()?.to_string(),
1466 expect: s.get("expect")?.as_str()?.to_string(),
1467 })
1468 })
1469 .collect()
1470 })
1471 .unwrap_or_default();
1472
1473 Ok(DeclarativeAgentSpec {
1474 id: agent_id.to_string(),
1475 name: if name.is_empty() {
1476 agent_id.to_string()
1477 } else {
1478 name
1479 },
1480 identity,
1481 tools,
1482 denied_tools: Vec::new(),
1483 standing_goal,
1484 goal: parse_goal(value)?,
1485 scenarios,
1486 enabled: true,
1487 context: ContextPolicy::default(),
1491 })
1492}
1493
1494fn parse_goal(value: &Value) -> Result<Option<DeclarativeGoal>, String> {
1495 let Some(goal) = value.get("goal") else {
1496 return Ok(None);
1497 };
1498 if goal.is_null() {
1499 return Ok(None);
1500 }
1501 let obj = goal
1502 .as_object()
1503 .ok_or_else(|| "`goal` must be an object".to_string())?;
1504 let check = obj
1505 .get("check")
1506 .and_then(Value::as_str)
1507 .map(str::trim)
1508 .filter(|s| !s.is_empty())
1509 .ok_or_else(|| "`goal.check` must be a non-empty string".to_string())?;
1510 let max_iterations = obj
1511 .get("max_iterations")
1512 .and_then(Value::as_u64)
1513 .unwrap_or(8)
1514 .clamp(1, 50) as u32;
1515 Ok(Some(DeclarativeGoal {
1516 check: check.to_string(),
1517 max_iterations,
1518 }))
1519}
1520
1521#[cfg(test)]
1522mod tests {
1523 use super::*;
1524
1525 use async_trait::async_trait;
1526 use car_inference::{GenerateRequest, InferenceResult};
1527 use serde_json::json;
1528 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1529 use std::sync::{Arc, Mutex as StdMutex};
1530
1531 struct Script {
1532 turns: Vec<InferenceResult>,
1533 cursor: AtomicUsize,
1534 }
1535 fn turn(text: &str, tool_calls: Value) -> InferenceResult {
1536 serde_json::from_value(json!({
1537 "text": text, "tool_calls": tool_calls,
1538 "trace_id": "t", "model_used": "scripted", "latency_ms": 0,
1539 }))
1540 .unwrap()
1541 }
1542 #[async_trait]
1543 impl TurnGenerator for Script {
1544 async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
1545 let i = self.cursor.fetch_add(1, Ordering::SeqCst);
1546 self.turns
1547 .get(i)
1548 .cloned()
1549 .ok_or_else(|| "script exhausted".into())
1550 }
1551 }
1552
1553 struct CapturingScript {
1554 turns: Vec<InferenceResult>,
1555 cursor: AtomicUsize,
1556 seen: Arc<StdMutex<Vec<GenerateRequest>>>,
1557 }
1558
1559 #[async_trait]
1560 impl TurnGenerator for CapturingScript {
1561 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
1562 self.seen.lock().unwrap().push(req);
1563 let i = self.cursor.fetch_add(1, Ordering::SeqCst);
1564 self.turns
1565 .get(i)
1566 .cloned()
1567 .ok_or_else(|| "script exhausted".into())
1568 }
1569 }
1570
1571 struct GrowingHistory {
1576 seen: Arc<StdMutex<Vec<(usize, bool, Option<String>)>>>,
1579 turn_no: AtomicUsize,
1580 window: usize,
1582 window_after_shrink: Option<usize>,
1585 model_used: &'static str,
1588 }
1589
1590 #[async_trait]
1591 impl TurnGenerator for GrowingHistory {
1592 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
1593 let msgs = req
1594 .messages
1595 .as_ref()
1596 .expect("the runner always sets messages");
1597 let notice = msgs.iter().find_map(|m| match m {
1598 Message::System { content } if content.starts_with("[history compacted:") => {
1599 Some(content.clone())
1600 }
1601 _ => None,
1602 });
1603 self.seen.lock().unwrap().push((
1604 msgs.len(),
1605 matches!(msgs.first(), Some(Message::System { .. })),
1606 notice,
1607 ));
1608 let n = self.turn_no.fetch_add(1, Ordering::SeqCst);
1609 let mut result = turn(
1612 &"x".repeat(8_000),
1613 json!([{
1614 "id": format!("c{n}"),
1615 "name": "write_file",
1616 "arguments": {"path": format!("big{n}.txt"), "content": "y"}
1617 }]),
1618 );
1619 result.model_used = self.model_used.to_string();
1620 Ok(result)
1621 }
1622
1623 fn context_window(&self, _model: &str) -> usize {
1624 match self.window_after_shrink {
1625 Some(smaller) if self.turn_no.load(Ordering::SeqCst) >= 2 => smaller,
1626 _ => self.window,
1627 }
1628 }
1629 }
1630
1631 async fn run_growing_history(
1634 spec: &DeclarativeAgentSpec,
1635 model: Option<&str>,
1636 window: usize,
1637 ) -> Vec<(usize, bool, Option<String>)> {
1638 run_growing_history_shrinking(spec, model, window, None).await
1639 }
1640
1641 async fn run_growing_history_shrinking(
1644 spec: &DeclarativeAgentSpec,
1645 model: Option<&str>,
1646 window: usize,
1647 window_after_shrink: Option<usize>,
1648 ) -> Vec<(usize, bool, Option<String>)> {
1649 let dir = tempfile::tempdir().unwrap();
1650 let exec = WorktreeExecutor::new(dir.path());
1651 let seen = Arc::new(StdMutex::new(Vec::new()));
1652 let generator = GrowingHistory {
1653 seen: seen.clone(),
1654 turn_no: AtomicUsize::new(0),
1655 window,
1656 window_after_shrink,
1657 model_used: "tiny-local",
1658 };
1659 let runner = DeclarativeAgentRunner::new(spec, &generator, &exec)
1660 .with_model(model.map(String::from));
1661 let result = runner.run("grow the thread").await;
1662 assert_eq!(result.error.as_deref(), Some("max_turns_exceeded"));
1663 let seen = seen.lock().unwrap().clone();
1664 assert_eq!(seen.len(), 12, "all 12 turns generated");
1665 assert!(
1666 seen.iter().all(|(_, system_first, _)| *system_first),
1667 "the agent identity must stay pinned at the head of every request"
1668 );
1669 seen
1670 }
1671
1672 const UNBOUNDED_TWELFTH_TURN: usize = 2 + 2 * 11;
1676
1677 #[tokio::test]
1678 async fn runner_compacts_history_that_exceeds_the_model_context_budget() {
1679 let spec = spec_with(vec!["write_file"]);
1685 assert!(spec.context.is_car_managed(), "default is CAR-managed");
1686 let seen = run_growing_history(&spec, Some("scripted"), 200).await;
1687
1688 let max_len = seen.iter().map(|(n, _, _)| *n).max().unwrap();
1689 assert!(
1690 max_len < UNBOUNDED_TWELFTH_TURN,
1691 "history not bounded — max messages/turn = {max_len}"
1692 );
1693 let notice = seen
1697 .iter()
1698 .find_map(|(_, _, notice)| notice.clone())
1699 .expect("a compacted request must carry the `[history compacted:` notice");
1700 assert!(
1705 !notice.contains("events_query") && !notice.contains("event log"),
1706 "declarative notice must not point at an events log it cannot read: {notice}"
1707 );
1708 assert!(
1709 notice.contains("not recoverable in this run"),
1710 "declarative notice must say the turns are gone: {notice}"
1711 );
1712 }
1713
1714 #[tokio::test]
1715 async fn context_self_leaves_the_history_entirely_to_the_agent() {
1716 let mut spec = spec_with(vec!["write_file"]);
1719 spec.context = ContextPolicy::SelfManaged;
1720 let seen = run_growing_history(&spec, Some("scripted"), 200).await;
1721
1722 assert_eq!(
1723 seen.last().unwrap().0,
1724 UNBOUNDED_TWELFTH_TURN,
1725 "context: self must not drop a single message"
1726 );
1727 assert!(
1728 seen.iter().all(|(_, _, notice)| notice.is_none()),
1729 "context: self must never leave a compaction notice"
1730 );
1731 }
1732
1733 #[tokio::test]
1734 async fn adaptive_routing_learns_the_window_from_the_model_that_ran() {
1735 let spec = spec_with(vec!["write_file"]);
1740 let seen = run_growing_history(&spec, None, 200).await;
1741
1742 let max_len = seen.iter().map(|(n, _, _)| *n).max().unwrap();
1743 assert!(
1744 max_len < UNBOUNDED_TWELFTH_TURN,
1745 "an unpinned run must still be bounded once the model is known — \
1746 max messages/turn = {max_len}"
1747 );
1748 }
1749
1750 #[tokio::test]
1751 async fn a_mid_run_fallback_to_a_smaller_model_is_compacted_against_the_smaller_window() {
1752 let spec = spec_with(vec!["write_file"]);
1759 let seen = run_growing_history_shrinking(&spec, Some("scripted"), 100_000, Some(200)).await;
1760
1761 let max_len = seen.iter().map(|(n, _, _)| *n).max().unwrap();
1762 assert!(
1763 max_len < UNBOUNDED_TWELFTH_TURN,
1764 "the run must be bounded by the window in force after the fallback — \
1765 max messages/turn = {max_len}"
1766 );
1767 assert!(
1768 seen.iter().any(|(_, _, notice)| notice.is_some()),
1769 "the smaller window must actually have compacted something"
1770 );
1771 assert!(
1775 seen[0].2.is_none() && seen[1].2.is_none(),
1776 "no compaction before the window shrank"
1777 );
1778 }
1779
1780 #[test]
1781 fn a_run_notice_fires_once_per_invocation_and_resets_with_a_new_one() {
1782 let notices = RunNotices::default();
1787 assert!(RunNotices::first(¬ices.stale_window), "first ask fires");
1788 assert!(
1789 !RunNotices::first(¬ices.stale_window),
1790 "every later ask in the same invocation is silent"
1791 );
1792 assert!(
1793 RunNotices::first(¬ices.unknown_window),
1794 "the gates are independent of one another"
1795 );
1796 assert!(!RunNotices::first(¬ices.unknown_window));
1797 assert!(RunNotices::first(¬ices.self_managed));
1798
1799 let next_invocation = RunNotices::default();
1800 assert!(
1801 RunNotices::first(&next_invocation.stale_window),
1802 "a new invocation starts clean — once per invoke, not once per process"
1803 );
1804 }
1805
1806 #[test]
1807 fn the_window_decision_table_is_exhaustive_and_never_silent() {
1808 assert_eq!(window_update(8_192, 0), WindowUpdate::Adopted(8_192));
1816 assert_eq!(window_update(4_096, 200_000), WindowUpdate::Adopted(4_096));
1817 assert_eq!(
1818 window_update(0, 200_000),
1819 WindowUpdate::KeptLastKnown(200_000),
1820 "a known budget is kept, and the caller warns"
1821 );
1822 assert_eq!(window_update(0, 0), WindowUpdate::StillUnknown);
1823 }
1824
1825 #[tokio::test]
1826 async fn a_known_window_survives_a_model_the_catalog_cannot_resolve() {
1827 let spec = spec_with(vec!["write_file"]);
1834 let seen = run_growing_history_shrinking(&spec, Some("scripted"), 200, Some(0)).await;
1835
1836 let max_len = seen.iter().map(|(n, _, _)| *n).max().unwrap();
1837 assert!(
1838 max_len < UNBOUNDED_TWELFTH_TURN,
1839 "a known window must survive an unresolvable model — max messages/turn = {max_len}"
1840 );
1841 assert!(
1842 seen.iter().any(|(_, _, notice)| notice.is_some()),
1843 "and must still be compacting"
1844 );
1845 }
1846
1847 #[test]
1848 fn a_tool_result_too_small_to_pay_for_the_marker_is_left_alone() {
1849 let small = "x".repeat(TOOL_RESULT_KEEP_CHARS * 2 + 50);
1855 assert_eq!(truncate_tool_result(&small), None, "must not grow it");
1856 let big = "y".repeat(44_000);
1862 let shrunk = truncate_tool_result(&big).expect("44k must truncate");
1863 assert!(shrunk.chars().count() < big.chars().count());
1864 assert!(shrunk.contains(TOOL_RESULT_TRUNCATION_MARKER));
1865 }
1866
1867 #[test]
1868 fn a_tool_output_that_quotes_the_truncation_marker_is_still_truncated() {
1869 let mut messages = vec![
1874 Message::System {
1875 content: "identity".into(),
1876 },
1877 Message::User {
1878 content: "read the file".into(),
1879 },
1880 Message::ToolResult {
1881 tool_use_id: "call_1".into(),
1882 content: format!(
1883 "{TOOL_RESULT_TRUNCATION_MARKER}: quoted by the file itself]{}",
1884 "z".repeat(44_000)
1885 ),
1886 provenance: Provenance::Internal,
1887 },
1888 ];
1889 let mut already = HashSet::new();
1890
1891 let truncated = shrink_oversized_tool_results(
1892 &mut messages,
1893 history_budget(8_192),
1894 0,
1895 1.0,
1896 &mut already,
1897 );
1898
1899 assert_eq!(truncated, 1, "a marker-quoting result must still be cut");
1900 assert!(already.contains("call_1"), "and recorded by id");
1901 let Message::ToolResult { content, .. } = &messages[2] else {
1902 panic!("tool result");
1903 };
1904 assert!(content.chars().count() < 44_000);
1905
1906 let again = shrink_oversized_tool_results(
1908 &mut messages,
1909 history_budget(8_192),
1910 0,
1911 1.0,
1912 &mut already,
1913 );
1914 assert_eq!(again, 0, "identity guard stops a second cut");
1915 }
1916
1917 #[tokio::test]
1918 async fn an_unpinned_run_pins_the_model_that_served_it_and_follows_a_reroute() {
1919 struct Rerouting {
1925 seen: Arc<StdMutex<Vec<(Option<String>, bool, Vec<Message>)>>>,
1928 turn_no: AtomicUsize,
1929 }
1930 #[async_trait]
1931 impl TurnGenerator for Rerouting {
1932 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
1933 let n = self.turn_no.fetch_add(1, Ordering::SeqCst);
1934 self.seen.lock().unwrap().push((
1935 req.model.clone(),
1936 req.params.strict_model,
1937 req.messages
1938 .clone()
1939 .expect("the runner always sets messages"),
1940 ));
1941 let mut result = turn(
1942 "",
1943 json!([{
1944 "id": format!("c{n}"),
1945 "name": "read_file",
1946 "arguments": {"path": "big.txt"}
1947 }]),
1948 );
1949 result.model_used = if n < 2 { "big-model" } else { "small-model" }.to_string();
1951 Ok(result)
1952 }
1953 fn context_window(&self, model: &str) -> usize {
1954 match model {
1955 "big-model" => 100_000,
1956 "small-model" => 4_096,
1957 _ => 0,
1958 }
1959 }
1960 }
1961
1962 let dir = tempfile::tempdir().unwrap();
1963 std::fs::write(dir.path().join("big.txt"), "abcde\n".repeat(1_000)).unwrap();
1965 let exec = WorktreeExecutor::new(dir.path());
1966 let seen = Arc::new(StdMutex::new(Vec::new()));
1967 let generator = Rerouting {
1968 seen: seen.clone(),
1969 turn_no: AtomicUsize::new(0),
1970 };
1971 let spec = spec_with(vec!["read_file"]);
1972
1973 let _ = DeclarativeAgentRunner::new(&spec, &generator, &exec)
1975 .run("read big.txt")
1976 .await;
1977
1978 let seen = seen.lock().unwrap();
1979 assert!(seen.len() >= 4, "at least four turns ran");
1980 assert_eq!(seen[0].0, None, "turn 1 is unpinned — nothing served yet");
1981 assert_eq!(
1982 seen[1].0.as_deref(),
1983 Some("big-model"),
1984 "turn 2 must be addressed to the model that served turn 1"
1985 );
1986 assert!(
1992 seen.iter().all(|(_, strict, _)| !*strict),
1993 "an unpinned run must never send strict_model"
1994 );
1995 let fourth = &seen[3].2;
1997 let measured = car_inference::media_tokens::request_prompt_tokens(
1998 "",
1999 None,
2000 None,
2001 None,
2002 Some(fourth.as_slice()),
2003 );
2004 assert!(
2005 measured <= history_budget(4_096),
2006 "turn 4 must fit the rerouted model's budget: {measured} > {}",
2007 history_budget(4_096)
2008 );
2009 }
2010
2011 const SCALE_TEST_WINDOW: usize = 200_000;
2017
2018 async fn second_turn_under_reported_scale(multiplier: u64) -> Vec<Message> {
2019 struct Reporting {
2020 seen: Arc<StdMutex<Vec<Vec<Message>>>>,
2021 cursor: AtomicUsize,
2022 multiplier: u64,
2023 }
2024 #[async_trait]
2025 impl TurnGenerator for Reporting {
2026 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
2027 let msgs = req
2028 .messages
2029 .clone()
2030 .expect("the runner always sets messages");
2031 self.seen.lock().unwrap().push(msgs.clone());
2032 let estimate = car_inference::media_tokens::request_prompt_tokens(
2033 "",
2034 None,
2035 None,
2036 None,
2037 Some(msgs.as_slice()),
2038 ) as u64;
2039 let i = self.cursor.fetch_add(1, Ordering::SeqCst);
2040 let mut result = if i == 0 {
2041 turn(
2042 "",
2043 json!([{"id":"c1","name":"read_file","arguments":{"path":"medium.txt"}}]),
2044 )
2045 } else {
2046 turn("done", json!([]))
2047 };
2048 result.usage = Some(car_inference::TokenUsage {
2051 prompt_tokens: estimate * self.multiplier,
2052 ..Default::default()
2053 });
2054 Ok(result)
2055 }
2056 fn context_window(&self, _model: &str) -> usize {
2057 SCALE_TEST_WINDOW
2058 }
2059 }
2060
2061 let dir = tempfile::tempdir().unwrap();
2062 std::fs::write(dir.path().join("medium.txt"), "abcdefghij\n".repeat(14_700)).unwrap();
2067 let exec = WorktreeExecutor::new(dir.path());
2068 let seen = Arc::new(StdMutex::new(Vec::new()));
2069 let generator = Reporting {
2070 seen: seen.clone(),
2071 cursor: AtomicUsize::new(0),
2072 multiplier,
2073 };
2074 let mut spec = spec_with(vec!["read_file"]);
2075 spec.identity = "You answer questions carefully. ".repeat(2_500);
2083
2084 let _ = DeclarativeAgentRunner::new(&spec, &generator, &exec)
2085 .with_model(Some("scripted".into()))
2086 .run("read medium.txt")
2087 .await;
2088
2089 let seen = seen.lock().unwrap();
2090 assert_eq!(seen.len(), 2, "two turns");
2091 seen[1].clone()
2092 }
2093
2094 #[tokio::test]
2095 async fn the_shrink_pass_measures_in_the_same_scale_compaction_decided_on() {
2096 const WINDOW: usize = SCALE_TEST_WINDOW;
2101
2102 let scaled = second_turn_under_reported_scale(2).await;
2104 let tool_result = scaled
2105 .iter()
2106 .find_map(|m| match m {
2107 Message::ToolResult { content, .. } => Some(content.clone()),
2108 _ => None,
2109 })
2110 .expect("the second turn carries the tool result");
2111 assert!(
2112 tool_result.contains(TOOL_RESULT_TRUNCATION_MARKER),
2113 "a history that only fits by the unscaled estimate must still be shrunk"
2114 );
2115 assert!(
2116 scaled_prompt_tokens(&scaled, 0, 2.0) <= history_budget(WINDOW),
2117 "and must land under the budget in the SAME scaled tokens: {} > {}",
2118 scaled_prompt_tokens(&scaled, 0, 2.0),
2119 history_budget(WINDOW)
2120 );
2121
2122 let unscaled = second_turn_under_reported_scale(1).await;
2125 let tool_result = unscaled
2126 .iter()
2127 .find_map(|m| match m {
2128 Message::ToolResult { content, .. } => Some(content.clone()),
2129 _ => None,
2130 })
2131 .expect("the second turn carries the tool result");
2132 assert!(
2133 scaled_prompt_tokens(&unscaled, 0, 1.0) <= history_budget(WINDOW),
2134 "the control only means something if the raw history genuinely fits: {} > {}",
2135 scaled_prompt_tokens(&unscaled, 0, 1.0),
2136 history_budget(WINDOW)
2137 );
2138 assert!(
2139 !tool_result.contains(TOOL_RESULT_TRUNCATION_MARKER),
2140 "a history that fits must be left alone"
2141 );
2142 }
2143
2144 #[tokio::test]
2145 async fn a_model_that_reuses_call_0_every_turn_still_gets_every_result_truncated() {
2146 struct RepeatIdGen {
2152 seen: Arc<StdMutex<Vec<Vec<Message>>>>,
2154 }
2155 #[async_trait]
2156 impl TurnGenerator for RepeatIdGen {
2157 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
2158 self.seen.lock().unwrap().push(
2159 req.messages
2160 .clone()
2161 .expect("the runner always sets messages"),
2162 );
2163 Ok(turn(
2165 "",
2166 json!([{"id":"call_0","name":"read_file","arguments":{"path":"big.txt"}}]),
2167 ))
2168 }
2169 fn context_window(&self, _model: &str) -> usize {
2170 8_192
2171 }
2172 }
2173
2174 let dir = tempfile::tempdir().unwrap();
2175 std::fs::write(dir.path().join("big.txt"), "abcdefghij\n".repeat(4_000)).unwrap();
2178 let exec = WorktreeExecutor::new(dir.path());
2179 let seen = Arc::new(StdMutex::new(Vec::new()));
2180 let generator = RepeatIdGen { seen: seen.clone() };
2181 let spec = spec_with(vec!["read_file"]);
2182
2183 let _ = DeclarativeAgentRunner::new(&spec, &generator, &exec)
2184 .with_model(Some("scripted".into()))
2185 .run("read it again")
2186 .await;
2187
2188 let seen = seen.lock().unwrap();
2189 assert!(seen.len() >= 4, "at least four turns ran");
2190 let results: Vec<(&String, &String)> = seen[3]
2193 .iter()
2194 .filter_map(|m| match m {
2195 Message::ToolResult {
2196 tool_use_id,
2197 content,
2198 ..
2199 } => Some((tool_use_id, content)),
2200 _ => None,
2201 })
2202 .collect();
2203 assert_eq!(results.len(), 3, "three tool results by turn 4");
2204 for (id, content) in &results {
2205 assert!(
2206 content.contains(TOOL_RESULT_TRUNCATION_MARKER),
2207 "every oversized result must be truncated, not just the first: {id}"
2208 );
2209 assert_eq!(
2210 content.matches(TOOL_RESULT_TRUNCATION_MARKER).count(),
2211 1,
2212 "and truncated exactly once — the guard still blocks a second cut: {id}"
2213 );
2214 }
2215 let ids: HashSet<&String> = results.iter().map(|(id, _)| *id).collect();
2216 assert_eq!(
2217 ids.len(),
2218 3,
2219 "the runner must give colliding model ids distinct run-unique keys: {ids:?}"
2220 );
2221 }
2222
2223 #[tokio::test]
2224 async fn a_caller_pin_stays_strict_while_a_learned_route_does_not() {
2225 struct CapturingStrict {
2232 seen: Arc<StdMutex<Vec<(Option<String>, bool)>>>,
2233 }
2234 #[async_trait]
2235 impl TurnGenerator for CapturingStrict {
2236 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
2237 self.seen
2238 .lock()
2239 .unwrap()
2240 .push((req.model.clone(), req.params.strict_model));
2241 Ok(turn("done", json!([])))
2242 }
2243 fn context_window(&self, _model: &str) -> usize {
2244 100_000
2245 }
2246 }
2247
2248 let dir = tempfile::tempdir().unwrap();
2249 let exec = WorktreeExecutor::new(dir.path());
2250 let spec = spec_with(vec![]);
2251
2252 let seen = Arc::new(StdMutex::new(Vec::new()));
2253 let generator = CapturingStrict { seen: seen.clone() };
2254 let _ = DeclarativeAgentRunner::new(&spec, &generator, &exec)
2255 .with_model(Some("pinned-model".into()))
2256 .run("hello")
2257 .await;
2258 assert_eq!(
2259 seen.lock().unwrap().as_slice(),
2260 [(Some("pinned-model".to_string()), true)],
2261 "a caller's pin keeps strict_model"
2262 );
2263
2264 let seen = Arc::new(StdMutex::new(Vec::new()));
2265 let generator = CapturingStrict { seen: seen.clone() };
2266 let _ = DeclarativeAgentRunner::new(&spec, &generator, &exec)
2267 .run("hello")
2268 .await;
2269 assert_eq!(
2270 seen.lock().unwrap().as_slice(),
2271 [(None, false)],
2272 "an unpinned run never sends strict_model"
2273 );
2274 }
2275
2276 #[tokio::test]
2277 async fn an_oversized_tool_result_is_truncated_to_fit_the_window() {
2278 struct WindowedCapture {
2284 turns: Vec<InferenceResult>,
2285 cursor: AtomicUsize,
2286 seen: Arc<StdMutex<Vec<Vec<Message>>>>,
2287 window: usize,
2288 }
2289 #[async_trait]
2290 impl TurnGenerator for WindowedCapture {
2291 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
2292 self.seen.lock().unwrap().push(
2293 req.messages
2294 .clone()
2295 .expect("the runner always sets messages"),
2296 );
2297 let i = self.cursor.fetch_add(1, Ordering::SeqCst);
2298 self.turns
2299 .get(i)
2300 .cloned()
2301 .ok_or_else(|| "script exhausted".into())
2302 }
2303 fn context_window(&self, _model: &str) -> usize {
2304 self.window
2305 }
2306 }
2307
2308 const WINDOW: usize = 8_192;
2309 let dir = tempfile::tempdir().unwrap();
2310 std::fs::write(dir.path().join("big.txt"), "abcdefghij\n".repeat(4_000)).unwrap();
2313 let exec = WorktreeExecutor::new(dir.path());
2314 let seen = Arc::new(StdMutex::new(Vec::new()));
2315 let generator = WindowedCapture {
2316 turns: vec![
2317 turn(
2318 "",
2319 json!([{"id":"c1","name":"read_file","arguments":{"path":"big.txt"}}]),
2320 ),
2321 turn("done", json!([])),
2322 ],
2323 cursor: AtomicUsize::new(0),
2324 seen: seen.clone(),
2325 window: WINDOW,
2326 };
2327 let spec = spec_with(vec!["read_file"]);
2328
2329 let result = DeclarativeAgentRunner::new(&spec, &generator, &exec)
2330 .with_model(Some("scripted".into()))
2331 .run("read big.txt")
2332 .await;
2333 assert_eq!(result.output, "done");
2334
2335 let seen = seen.lock().unwrap();
2336 assert_eq!(seen.len(), 2, "two turns");
2337 let second = &seen[1];
2338 let tool_result = second
2339 .iter()
2340 .find_map(|m| match m {
2341 Message::ToolResult { content, .. } => Some(content.clone()),
2342 _ => None,
2343 })
2344 .expect("the second turn carries the tool result");
2345 assert!(
2346 tool_result.contains(TOOL_RESULT_TRUNCATION_MARKER),
2347 "the oversized tool result must say it was truncated"
2348 );
2349 assert!(
2350 tool_result.contains("not recoverable in this run"),
2351 "and must not imply the middle can be recovered: {}",
2352 &tool_result[..tool_result.len().min(400)]
2353 );
2354 let measured = car_inference::media_tokens::request_prompt_tokens(
2355 "",
2356 None,
2357 None,
2358 None,
2359 Some(second.as_slice()),
2360 );
2361 assert!(
2362 measured <= history_budget(WINDOW),
2363 "the second request must fit the budget: {measured} > {}",
2364 history_budget(WINDOW)
2365 );
2366 }
2367
2368 #[tokio::test]
2369 async fn an_unknown_context_window_leaves_the_history_unbounded_and_says_so() {
2370 let spec = spec_with(vec!["write_file"]);
2377 let seen = run_growing_history(&spec, Some("unknown-model"), 0).await;
2378
2379 assert_eq!(
2380 seen.last().unwrap().0,
2381 UNBOUNDED_TWELFTH_TURN,
2382 "an unknown window must not fabricate a budget"
2383 );
2384 }
2385
2386 fn spec_with(tools: Vec<&str>) -> DeclarativeAgentSpec {
2387 DeclarativeAgentSpec {
2388 id: "t".into(),
2389 name: "T".into(),
2390 identity: "You answer.".into(),
2391 tools: tools.into_iter().map(String::from).collect(),
2392 denied_tools: vec![],
2393 standing_goal: "help".into(),
2394 goal: None,
2395 scenarios: vec![],
2396 enabled: true,
2397 context: ContextPolicy::default(),
2398 }
2399 }
2400
2401 #[test]
2402 fn strict_allowlist_empty_intersection_is_zero_tools() {
2403 let all = WorktreeExecutor::tool_defs();
2404 assert!(!all.is_empty());
2405 assert!(select_tool_defs_strict(&all, &["nonexistent".into()], &[]).is_empty());
2407 assert!(select_tool_defs_strict(&all, &[], &[]).is_empty());
2409 let sel = select_tool_defs_strict(&all, &["read_file".into()], &[]);
2411 assert_eq!(sel.len(), 1);
2412 assert_eq!(sel[0]["name"], "read_file");
2413 assert!(
2415 select_tool_defs_strict(&all, &["read_file".into()], &["read_file".into()]).is_empty()
2416 );
2417 }
2418
2419 #[tokio::test]
2420 async fn runner_returns_text_answer_with_no_tools() {
2421 let dir = tempfile::tempdir().unwrap();
2422 let exec = WorktreeExecutor::new(dir.path());
2423 let script = Script {
2424 turns: vec![turn("the answer is 42", json!([]))],
2425 cursor: AtomicUsize::new(0),
2426 };
2427 let spec = spec_with(vec![]);
2428 let runner = DeclarativeAgentRunner::new(&spec, &script, &exec);
2429 let r = runner.run("what is the answer?").await;
2430 assert_eq!(r.output, "the answer is 42");
2431 assert_eq!(r.tool_calls, 0);
2432 assert!(r.error.is_none());
2433 }
2434
2435 #[tokio::test]
2436 async fn runner_executes_an_allowed_tool() {
2437 let dir = tempfile::tempdir().unwrap();
2438 std::fs::write(dir.path().join("data.txt"), "secret content").unwrap();
2439 let exec = WorktreeExecutor::new(dir.path());
2440 let script = Script {
2441 turns: vec![
2442 turn(
2443 "",
2444 json!([{"id":"c1","name":"read_file","arguments":{"path":"data.txt"}}]),
2445 ),
2446 turn("the file says secret content", json!([])),
2447 ],
2448 cursor: AtomicUsize::new(0),
2449 };
2450 let spec = spec_with(vec!["read_file"]);
2451 let runner = DeclarativeAgentRunner::new(&spec, &script, &exec);
2452 let r = runner.run("read data.txt").await;
2453 assert!(r.output.contains("secret content"));
2454 assert_eq!(r.tool_calls, 1);
2455 }
2456
2457 #[tokio::test]
2458 async fn runner_replays_managed_responses_continuity_on_second_turn() {
2459 let dir = tempfile::tempdir().unwrap();
2460 std::fs::write(dir.path().join("data.txt"), "secret content").unwrap();
2461 let exec = WorktreeExecutor::new(dir.path());
2462 let reasoning = json!({
2463 "type": "reasoning",
2464 "id": "rs_coder",
2465 "status": "completed",
2466 "summary": [{"type": "summary_text", "text": "safe"}],
2467 "encrypted_content": "opaque-coder",
2468 });
2469 let mut first = turn(
2470 "reading",
2471 json!([{"id":"c1","name":"read_file","arguments":{"path":"data.txt"}}]),
2472 );
2473 first.provider_output_items = vec![reasoning.clone()];
2474 let seen = Arc::new(StdMutex::new(Vec::new()));
2475 let script = CapturingScript {
2476 turns: vec![first, turn("done", json!([]))],
2477 cursor: AtomicUsize::new(0),
2478 seen: seen.clone(),
2479 };
2480 let spec = spec_with(vec!["read_file"]);
2481 let runner = DeclarativeAgentRunner::new(&spec, &script, &exec);
2482
2483 let result = runner.run("read data.txt").await;
2484
2485 assert_eq!(result.output, "done");
2486 assert!(!result.output.contains("opaque-coder"));
2487 let seen = seen.lock().unwrap();
2488 let second = seen[1].messages.as_ref().expect("second-turn history");
2489 assert!(matches!(
2490 &second[2],
2491 Message::ProviderOutputItems { protocol, items }
2492 if protocol == car_inference::protocol::OPENAI_RESPONSES_PROTOCOL
2493 && items == &vec![reasoning]
2494 ));
2495 assert!(matches!(
2496 &second[3],
2497 Message::Assistant { content, .. } if content == "reading"
2498 ));
2499 assert!(matches!(&second[4], Message::ToolResult { .. }));
2500 }
2501
2502 #[tokio::test]
2503 async fn runner_blocks_a_disallowed_tool_even_if_the_model_calls_it() {
2504 let dir = tempfile::tempdir().unwrap();
2505 let exec = WorktreeExecutor::new(dir.path());
2506 let script = Script {
2508 turns: vec![
2509 turn(
2510 "",
2511 json!([{"id":"c1","name":"write_file","arguments":{"path":"x","content":"y"}}]),
2512 ),
2513 turn("done", json!([])),
2514 ],
2515 cursor: AtomicUsize::new(0),
2516 };
2517 let spec = spec_with(vec!["read_file"]);
2518 let runner = DeclarativeAgentRunner::new(&spec, &script, &exec);
2519 let _ = runner.run("write a file").await;
2520 assert!(!dir.path().join("x").exists(), "disallowed tool executed");
2522 }
2523
2524 #[tokio::test]
2525 async fn runner_redrives_until_manifest_goal_check_passes() {
2526 let dir = tempfile::tempdir().unwrap();
2527 let exec = WorktreeExecutor::new(dir.path());
2528 let script = Script {
2529 turns: vec![
2530 turn("not done yet", json!([])),
2531 turn(
2532 "",
2533 json!([{"id":"w1","name":"write_file","arguments":{"path":"done.txt","content":"ok"}}]),
2534 ),
2535 turn("done", json!([])),
2536 ],
2537 cursor: AtomicUsize::new(0),
2538 };
2539 let mut spec = spec_with(vec!["write_file"]);
2540 spec.goal = Some(DeclarativeGoal {
2541 check: crate::coder::test_cmds::file_exists("done.txt"),
2542 max_iterations: 3,
2543 });
2544 let runner = DeclarativeAgentRunner::new(&spec, &script, &exec);
2545 let r = runner.run("create done.txt").await;
2546
2547 assert_eq!(r.output, "done");
2548 assert!(r.error.is_none(), "{:?}", r.error);
2549 assert_eq!(r.turns, 3);
2550 assert_eq!(r.tool_calls, 1);
2551 assert_eq!(
2552 std::fs::read_to_string(dir.path().join("done.txt")).unwrap(),
2553 "ok"
2554 );
2555 let goal = r.goal.expect("goal audit is present");
2556 assert!(goal.met, "{goal:?}");
2557 assert!(goal.grounded, "{goal:?}");
2558 assert_eq!(goal.iterations, 2);
2559 assert_eq!(goal.last_exit_code, Some(0));
2560 }
2561
2562 #[tokio::test]
2563 async fn runner_reports_error_when_manifest_goal_never_passes() {
2564 let dir = tempfile::tempdir().unwrap();
2565 let exec = WorktreeExecutor::new(dir.path());
2566 let script = Script {
2567 turns: vec![
2568 turn("still missing", json!([])),
2569 turn("still missing", json!([])),
2570 ],
2571 cursor: AtomicUsize::new(0),
2572 };
2573 let mut spec = spec_with(vec![]);
2574 spec.goal = Some(DeclarativeGoal {
2575 check: crate::coder::test_cmds::file_exists("done.txt"),
2576 max_iterations: 2,
2577 });
2578 let runner = DeclarativeAgentRunner::new(&spec, &script, &exec);
2579 let r = runner.run("create done.txt").await;
2580
2581 assert!(r.error.as_deref().unwrap_or("").contains("goal_not_met"));
2582 let goal = r.goal.expect("goal audit is present");
2583 assert!(!goal.met);
2584 assert!(
2585 goal.grounded,
2586 "a deterministic nonzero shell exit is grounded evidence, not model judgment"
2587 );
2588 assert_eq!(goal.iterations, 2);
2589 assert_eq!(goal.last_exit_code, Some(1));
2590 }
2591
2592 #[cfg(unix)]
2596 #[tokio::test]
2597 async fn runner_stops_once_when_goal_check_is_not_a_runnable_command() {
2598 let dir = tempfile::tempdir().unwrap();
2599 let exec = WorktreeExecutor::new(dir.path());
2600 let script = Script {
2603 turns: vec![
2604 turn("working on it", json!([])),
2605 turn("must not run", json!([])),
2606 ],
2607 cursor: AtomicUsize::new(0),
2608 };
2609 let mut spec = spec_with(vec![]);
2610 spec.goal = Some(DeclarativeGoal {
2611 check: "definitely-not-a-real-command-xyz".into(),
2612 max_iterations: 8,
2613 });
2614 let runner = DeclarativeAgentRunner::new(&spec, &script, &exec);
2615 let r = runner.run("do the work").await;
2616
2617 let error = r.error.as_deref().unwrap_or_default();
2618 assert!(
2619 error.contains("not a runnable command"),
2620 "error must name the defect: {error}"
2621 );
2622 assert!(error.contains("fix or remove goal.check"));
2623 let goal = r.goal.expect("goal audit is present");
2624 assert!(!goal.met);
2625 assert!(
2626 goal.grounded,
2627 "a deterministic shell exit is grounded evidence, not model judgment"
2628 );
2629 assert_eq!(goal.iterations, 1);
2630 assert_eq!(goal.last_exit_code, Some(127));
2631 assert_eq!(
2632 script.cursor.load(Ordering::SeqCst),
2633 1,
2634 "a broken check must not re-drive the agent"
2635 );
2636 }
2637
2638 #[tokio::test]
2642 async fn runner_still_retries_a_goal_check_that_exits_1() {
2643 let dir = tempfile::tempdir().unwrap();
2644 let exec = WorktreeExecutor::new(dir.path());
2645 let script = Script {
2646 turns: (0..8).map(|_| turn("not done yet", json!([]))).collect(),
2647 cursor: AtomicUsize::new(0),
2648 };
2649 let mut spec = spec_with(vec![]);
2650 spec.goal = Some(DeclarativeGoal {
2651 check: crate::coder::test_cmds::FAIL.to_string(),
2652 max_iterations: 8,
2653 });
2654 let runner = DeclarativeAgentRunner::new(&spec, &script, &exec);
2655 let r = runner.run("keep working").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_eq!(
2661 goal.iterations, 8,
2662 "exit 1 means 'not done yet', not broken config — full retry budget"
2663 );
2664 assert_eq!(goal.last_exit_code, Some(1));
2665 }
2666
2667 #[tokio::test]
2668 async fn runner_honors_cancel_before_manifest_goal_redrive() {
2669 let dir = tempfile::tempdir().unwrap();
2670 let exec = WorktreeExecutor::new(dir.path());
2671 let cancel = Arc::new(AtomicBool::new(false));
2672 let script = Script {
2673 turns: vec![
2674 turn("still missing", json!([])),
2675 turn("should not run", json!([])),
2676 ],
2677 cursor: AtomicUsize::new(0),
2678 };
2679 let mut spec = spec_with(vec![]);
2680 spec.goal = Some(DeclarativeGoal {
2681 check: crate::coder::test_cmds::file_exists("done.txt"),
2682 max_iterations: 3,
2683 });
2684 let runner =
2685 DeclarativeAgentRunner::new(&spec, &script, &exec).with_cancel(Some(cancel.clone()));
2686
2687 cancel.store(true, Ordering::SeqCst);
2688 let r = runner.run("create done.txt").await;
2689
2690 assert_eq!(r.error.as_deref(), Some("cancelled"));
2691 assert_eq!(r.turns, 0);
2692 assert_eq!(script.cursor.load(Ordering::SeqCst), 0);
2693 }
2694
2695 #[test]
2696 fn build_prompt_preserves_a_400_character_description_as_the_spec_source() {
2697 let mut description = "Build an agent whose identity, standing goal, and scenarios follow this complete request: ".to_string();
2698 description.push_str(&"z".repeat(400 - description.len()));
2699 assert_eq!(description.chars().count(), 400);
2700
2701 let prompt = build_prompt(&description, &["read_file".into()], &[]);
2702
2703 assert!(prompt.contains(&format!("User request:\n{description}\n\nAVAILABLE TOOLS")));
2704 assert!(prompt.contains("\"identity\""));
2705 assert!(prompt.contains("\"standing_goal\""));
2706 assert!(prompt.contains("\"scenarios\""));
2707 }
2708
2709 #[tokio::test]
2710 async fn build_agent_generates_then_passes_scenarios() {
2711 let dir = tempfile::tempdir().unwrap();
2712 let exec = WorktreeExecutor::new(dir.path());
2713 let script = Script {
2716 turns: vec![
2717 turn(
2718 r#"{"name":"Greeter","identity":"You greet people warmly.","tools":[],
2719 "standing_goal":"greet","scenarios":[{"input":"hi","expect":"hello"}]}"#,
2720 json!([]),
2721 ),
2722 turn("hello there, friend!", json!([])),
2723 ],
2724 cursor: AtomicUsize::new(0),
2725 };
2726 let cfg = BuildAgentConfig {
2727 agent_id: "greeter".into(),
2728 available_tools: vec!["read_file".into(), "write_file".into()],
2729 max_attempts: 3,
2730 };
2731 let outcome = build_agent("make a friendly greeter", &script, &exec, &cfg).await;
2732 assert!(outcome.passed, "issues: {:?}", outcome.issues);
2733 let spec = outcome.spec.unwrap();
2734 assert_eq!(spec.id, "greeter");
2735 assert_eq!(spec.name, "Greeter");
2736 assert_eq!(spec.scenarios.len(), 1);
2737 }
2738
2739 #[tokio::test]
2740 async fn build_agent_drops_invented_tool_names() {
2741 let dir = tempfile::tempdir().unwrap();
2742 let exec = WorktreeExecutor::new(dir.path());
2743 let script = Script {
2744 turns: vec![
2745 turn(
2746 r#"{"name":"X","identity":"You help.","tools":["send_email","read_file"],
2747 "standing_goal":"g","scenarios":[{"input":"q","expect":"a"}]}"#,
2748 json!([]),
2749 ),
2750 turn("answer: a", json!([])),
2751 ],
2752 cursor: AtomicUsize::new(0),
2753 };
2754 let cfg = BuildAgentConfig {
2755 agent_id: "x".into(),
2756 available_tools: vec!["read_file".into()],
2757 max_attempts: 2,
2758 };
2759 let outcome = build_agent("intent", &script, &exec, &cfg).await;
2760 assert!(outcome.passed);
2761 assert_eq!(outcome.spec.unwrap().tools, vec!["read_file".to_string()]);
2763 }
2764
2765 #[tokio::test]
2766 async fn build_agent_parses_optional_goal_contract() {
2767 let dir = tempfile::tempdir().unwrap();
2768 let exec = WorktreeExecutor::new(dir.path());
2769 let script = Script {
2770 turns: vec![
2771 turn(
2772 &json!({
2773 "name":"Writer","identity":"You write the requested file.","tools":["write_file"],
2774 "standing_goal":"write files",
2775 "goal":{"check": format!(" {} ", crate::coder::test_cmds::file_exists("done.txt")),
2777 "max_iterations":99},
2778 "scenarios":[{"input":"make it","expect":"done"}]
2779 })
2780 .to_string(),
2781 json!([]),
2782 ),
2783 turn(
2784 "",
2785 json!([{"id":"w1","name":"write_file","arguments":{"path":"done.txt","content":"ok"}}]),
2786 ),
2787 turn("done", json!([])),
2788 ],
2789 cursor: AtomicUsize::new(0),
2790 };
2791 let cfg = BuildAgentConfig {
2792 agent_id: "writer".into(),
2793 available_tools: vec!["write_file".into()],
2794 max_attempts: 1,
2795 };
2796 let outcome = build_agent("make a file writer", &script, &exec, &cfg).await;
2797 assert!(outcome.passed, "issues: {:?}", outcome.issues);
2798 let goal = outcome.spec.unwrap().goal.expect("goal parsed");
2799 assert_eq!(goal.check, crate::coder::test_cmds::file_exists("done.txt"));
2800 assert_eq!(goal.max_iterations, 50);
2801 }
2802
2803 #[tokio::test]
2804 async fn build_agent_repairs_a_failing_scenario() {
2805 let dir = tempfile::tempdir().unwrap();
2806 let exec = WorktreeExecutor::new(dir.path());
2807 let script = Script {
2808 turns: vec![
2809 turn(
2811 r#"{"name":"A","identity":"v1","tools":[],"standing_goal":"g","scenarios":[{"input":"q","expect":"RIGHT"}]}"#,
2812 json!([]),
2813 ),
2814 turn("WRONG", json!([])),
2816 turn(
2818 r#"{"name":"A","identity":"v2","tools":[],"standing_goal":"g","scenarios":[{"input":"q","expect":"RIGHT"}]}"#,
2819 json!([]),
2820 ),
2821 turn("the RIGHT answer", json!([])),
2823 ],
2824 cursor: AtomicUsize::new(0),
2825 };
2826 let cfg = BuildAgentConfig {
2827 agent_id: "a".into(),
2828 available_tools: vec![],
2829 max_attempts: 3,
2830 };
2831 let outcome = build_agent("intent", &script, &exec, &cfg).await;
2832 assert!(outcome.passed);
2833 assert_eq!(outcome.attempts, 2);
2834 assert_eq!(outcome.spec.unwrap().identity, "v2");
2835 }
2836
2837 #[cfg(unix)]
2843 #[tokio::test]
2844 async fn build_agent_feeds_a_not_runnable_goal_back_into_the_next_attempt() {
2845 let dir = tempfile::tempdir().unwrap();
2846 let exec = WorktreeExecutor::new(dir.path());
2847 let seen = Arc::new(StdMutex::new(Vec::new()));
2848 let script = CapturingScript {
2849 turns: vec![
2850 turn(
2852 r#"{"name":"A","identity":"v1","tools":[],"standing_goal":"g",
2853 "goal":{"check":"definitely-not-a-real-command-xyz","max_iterations":8},
2854 "scenarios":[{"input":"q","expect":"RIGHT"}]}"#,
2855 json!([]),
2856 ),
2857 turn("the RIGHT answer", json!([])),
2860 turn(
2862 r#"{"name":"A","identity":"v2","tools":[],"standing_goal":"g","scenarios":[{"input":"q","expect":"RIGHT"}]}"#,
2863 json!([]),
2864 ),
2865 turn("the RIGHT answer", json!([])),
2867 ],
2868 cursor: AtomicUsize::new(0),
2869 seen: seen.clone(),
2870 };
2871 let cfg = BuildAgentConfig {
2872 agent_id: "a".into(),
2873 available_tools: vec![],
2874 max_attempts: 3,
2875 };
2876 let outcome = build_agent("intent", &script, &exec, &cfg).await;
2877 assert!(outcome.passed, "issues: {:?}", outcome.issues);
2878 assert_eq!(outcome.attempts, 2);
2879
2880 let prompts: Vec<String> = seen
2884 .lock()
2885 .unwrap()
2886 .iter()
2887 .map(|req| req.prompt.clone())
2888 .filter(|p| p.starts_with("You are designing"))
2889 .collect();
2890 assert_eq!(prompts.len(), 2, "exactly two spec-generation prompts");
2891 assert!(
2892 !prompts[0].contains("goal check is not a runnable command"),
2893 "attempt 1 has no feedback to carry yet"
2894 );
2895 assert!(
2896 prompts[1].contains("goal check is not a runnable command"),
2897 "attempt 2's generation prompt must carry attempt 1's defect text"
2898 );
2899 assert!(
2900 prompts[1].contains("definitely-not-a-real-command-xyz"),
2901 "the feedback must name the broken check so the model can repair it"
2902 );
2903 }
2904}