Skip to main content

car_server_core/coder/
declarative.rs

1//! In-daemon execution of declarative agents, and the coder→agent build loop.
2//!
3//! Two pieces:
4//! - [`DeclarativeAgentRunner`] runs a [`DeclarativeAgentSpec`] on an input —
5//!   a model→tool loop executed entirely inside the daemon, with the tool set
6//!   restricted to the spec's allowlist and policy-gated by the executor's
7//!   `InspectorChain`. No external process.
8//! - [`build_agent`] is the coder→agent loop: it asks the model for an agent
9//!   spec that satisfies the user's intent, runs the spec's scenarios through
10//!   the runner, and repairs until every scenario passes (or it gives up) —
11//!   the same generate→verify→repair shape as contract derivation, so an
12//!   Agent project never touches the file-editing native loop.
13
14use 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/// Result of one declarative-agent run.
35#[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    /// Preserves the model error for the agent builder. Ordinary invocations
42    /// continue to consume `error` as operator-facing text.
43    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    /// Whether the goal result came from deterministic verifier evidence.
54    ///
55    /// A nonzero shell exit is still grounded evidence: it proves the goal is
56    /// not met yet. Keep this separate from `met` so hosts and observability do
57    /// not count ordinary verifier failures as ungrounded model judgment.
58    pub grounded: bool,
59    pub last_exit_code: Option<i32>,
60    pub last_reason: String,
61}
62
63/// One-shot log gates for a whole [`DeclarativeAgentRunner::run`] invocation.
64///
65/// A goal-bearing invoke re-drives `run_once` up to 50 times, and a policy
66/// notice repeated 50 times is noise that trains the reader to skip it. These
67/// live on the invoke, not the pass, so each says its piece once no matter how
68/// many passes the verifier costs. Atomics rather than `Cell` so the futures
69/// holding a reference stay `Send`.
70#[derive(Default)]
71struct RunNotices {
72    self_managed: AtomicBool,
73    unknown_window: AtomicBool,
74    stale_window: AtomicBool,
75}
76
77impl RunNotices {
78    /// `true` the FIRST time it is asked, `false` forever after.
79    fn first(flag: &AtomicBool) -> bool {
80        !flag.swap(true, Ordering::SeqCst)
81    }
82}
83
84/// Filter the executor's available tool schemas to the spec's allowlist.
85/// **Strict**: an empty intersection yields ZERO tools (NOT all) — a typo'd or
86/// empty allowlist must never silently grant the full toolset. Denied tools
87/// are removed even if allowlisted.
88pub 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
98/// Runs a declarative agent in-daemon.
99pub 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/// Told which model served each completed model turn of a run. The agent build
111/// uses it so progress names the model running a scenario, not the one that
112/// generated the spec.
113#[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    /// Pin this invocation to an explicitly selected CAR model. `None`
147    /// preserves the runner's adaptive, quality-first routing behavior.
148    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    /// Run the agent on `input`, returning its final text answer.
163    pub async fn run(&self, input: &str) -> AgentRunResult {
164        if self.is_cancelled() {
165            return cancelled_result(0, 0, None);
166        }
167        // One set of notice gates for the whole invocation, so the context-policy
168        // lines below fire once per invoke rather than once per goal pass.
169        let notices = RunNotices::default();
170        let Some(goal) = self.normalized_goal() else {
171            return self.run_once(input, &notices).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, &notices).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                    // Exit 126 ("cannot execute") / 127 ("command not found"):
275                    // the CHECK ITSELF is broken config, not unfinished work —
276                    // re-driving the agent can never make a missing command
277                    // exist, so stop at once with a defect-naming error the
278                    // builder feeds back into spec repair (car#1523). Any
279                    // other non-zero exit may still mean "not done yet" and
280                    // keeps the retry path below, byte-for-byte.
281                    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                // This run advertises the delegate surface, so it may call it.
386                self.executor.advertise_delegates();
387                &self.executor.all_tool_defs()
388            },
389            // Unlike the coding loop, this path does NOT consult
390            // `permits_full_access` first, so a spec that allowlists a
391            // `full_access` delegate (`http_request`, car#1073) is offered a tool
392            // the per-agent gate will refuse until an operator grants the tier.
393            // Left as-is deliberately: the spec author asked for that tool by
394            // name, so the refusal names a permission they can go and grant,
395            // rather than a capability that silently does not exist.
396            &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        // Who bounds this run's conversation (spec `context`). `car` — the
411        // default, and what every agent written before the field gets — applies
412        // the same compaction the assistant and coder loops use before each
413        // model call. `self` means the spec author owns the transcript, so CAR
414        // must not touch it; say so once, because a history that silently stops
415        // being managed is exactly the failure the `[history compacted:` notice
416        // exists to prevent in the other direction.
417        let car_manages_context = self.spec.context.is_car_managed();
418        if !car_manages_context && RunNotices::first(&notices.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        // The window compaction is measured against, seeded from a pinned model
426        // when there is one and RE-RESOLVED after every call from the model that
427        // actually served it (below). Adaptive routing (`model: None`) only
428        // picks at call time, so turn 1 may have no window at all — which costs
429        // nothing, because before that call the history is two messages and
430        // cannot exceed any budget.
431        let mut context_window = self
432            .model
433            .as_deref()
434            .map(|m| self.generator.context_window(m))
435            .unwrap_or(0);
436        // The model this run's requests are addressed to. It starts as the
437        // caller's pin (usually none) and becomes the model that served the
438        // first turn, because the budget and the serving model must not be able
439        // to diverge: unpinned, the window is only known AFTER a call, so a
440        // large-window turn followed by a reroute would send a history sized for
441        // the old model to the new one — one turn late is exactly when it
442        // matters. Pinning is done only under `context: car`; an agent that
443        // manages its own history keeps today's routing untouched.
444        let mut route = self.model.clone();
445        // Whether the ROUTE is the caller's to own. A caller-supplied pin is
446        // never rewritten by this loop; an unpinned run's route is adopted from
447        // whichever model serves it.
448        let caller_pinned = self.model.is_some();
449        // One line per pass when a caller's pin is not what served the turn.
450        let mut route_divergence_logged = false;
451        // What the per-message estimate cannot see: the tool definitions ride on
452        // every turn. The provider's own prompt count is folded in after each
453        // call, so the decision to compact runs on ground truth once there is
454        // any (see `PromptMeasure`).
455        // Tool-call ids whose results this run has already shortened.
456        let mut shrunk_tool_results: HashSet<String> = HashSet::new();
457        // Every tool-call id this run has handed out, so the next one can be
458        // made distinct from all of them (see the assignment loop below).
459        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            // Bound the running conversation BEFORE the call — an over-budget
472            // history is only a failure once it is sent. Pins the system prompt
473            // and the original input, drops the oldest middle turns on a turn
474            // boundary, and leaves the `[history compacted:` notice. No-op on
475            // turn 1, when the window is unknown, or when the history fits.
476            if car_manages_context {
477                // Compare CONTENT, not length, exactly as the assistant loop
478                // does (agent_loop.rs): dropping one message and inserting the
479                // notice in its place leaves the length identical while the
480                // history is entirely different, and a stale reported count
481                // then decides the next turn's compaction.
482                let before_compaction = messages.clone();
483                // Learn the provider-vs-estimate ratio from the history the
484                // report actually described, BEFORE compaction rewrites it.
485                let scale = measure_scale(&message_estimates(&messages), prompt_measure);
486                // `Unrecoverable`, not the default notice: this runner binds no
487                // event log and offers only the spec's allowlisted tools, so
488                // telling the model to call `events_query` would promise a
489                // recovery path that does not exist here.
490                compact_history_measured_with_recovery(
491                    &mut messages,
492                    context_window,
493                    prompt_measure,
494                    CompactionRecovery::Unrecoverable,
495                );
496                // Dropping turns cannot fix a history whose ONE tool result is
497                // the overflow, and the shared function's tail rule protects
498                // exactly that message. Shrink what it cannot drop.
499                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                    // The reported count described the pre-compaction history;
516                    // the next call reports afresh.
517                    prompt_measure.reported = None;
518                }
519            }
520            // How many leading messages this request carries — the index the
521            // provider's reported prompt size is attributed to next turn.
522            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                    // `caller_pinned`, NOT `route.is_some()`: `strict_model`
530                    // is a hard-failure switch, not a routing preference. It
531                    // suppresses the on-device last-resort fallback
532                    // (car-inference `should_append_local_last_resort`), which
533                    // is right for a caller who named a backbone and must not
534                    // be silently swapped off it — and wrong for the route this
535                    // loop LEARNED, where flipping it true from turn 2 would
536                    // make one transient cloud blip end an unpinned run with
537                    // `inference failed` on a machine with a working local
538                    // model. The learned route is still preferred (it rides in
539                    // `model`); it is just allowed to degrade.
540                    strict_model: caller_pinned,
541                    // Deterministic tool use, not open reasoning: force thinking
542                    // OFF. Hybrid-thinking models (Qwen3) otherwise burn the
543                    // whole budget inside an unclosed `<think>` and return empty
544                    // text — the same failure the coder's contract derivation
545                    // hit. Route on the Code hint so a capable model wins.
546                    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                    // A declarative agent's correctness matters more than its
554                    // latency (it's verified against scenarios at build time and
555                    // invoked deliberately) — run it on the most capable model.
556                    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                // Re-resolve EVERY turn from the model that actually served it,
581                // and take that value rather than the widest seen: routing can
582                // fall back mid-run, and the next call must be measured against
583                // the window in force for it. Latching the first non-zero window
584                // would keep compacting a 5k fallback against a 200k budget —
585                // the overflow this exists to prevent. The pinned case is
586                // unaffected (a strict pin reports itself back).
587                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                        // Keeping the last known budget is the deliberate
603                        // choice (losing it would unbind the run over a routing
604                        // detail), but it must not be SILENT: the budget now
605                        // describes a model that is no longer serving, and it
606                        // may be the larger of the two.
607                        if RunNotices::first(&notices.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                // Keep the route and the budget on the SAME model. Only when
623                // the caller pinned nothing: an explicit `--model` (or an alias
624                // that is meant to route within a family) is the caller's
625                // decision and this loop does not get to overwrite it — it only
626                // says so when the engine served something else.
627                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(&notices.unknown_window) {
667                    // Loud, once per invocation: a model the catalog does not
668                    // know leaves this run with NO history bound but its turn
669                    // cap, and the quiet version of that is a local model
670                    // overflowing its 5-10k window with nothing in the log to
671                    // say why the answers got worse.
672                    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                // Ground truth for the next turn's decision. All three input
684                // buckets: a cached prefix is billed separately but still
685                // occupies the window.
686                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                // Tool-call ids must be unique across the whole RUN, not just
714                // within a turn. The local tool-call parser restarts its index
715                // at every completion (car-inference `tasks::generate`,
716                // `parse_one_tool_call` + its per-call `idx`), so a local model
717                // re-emits `call_0` turn after turn — and anything keyed by that
718                // id, the shrink guard included, would treat two different
719                // results as the same one.
720                //
721                // Rewrite only a MISSING or ALREADY-USED id. A provider whose
722                // ids are genuinely unique keeps its own, because its replayed
723                // continuity items (the Responses `ProviderOutputItems` this
724                // runner forwards) reference those exact strings and a rewrite
725                // would orphan them. Both sides of the pair — the assistant
726                // record below and the `ToolResult` pushed after it — take the
727                // id from this same vector, so the model always sees a matched
728                // call/result pair either way.
729                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                // The allowlist already removed disallowed tools from the model's
751                // view; this is the hard backstop if a name leaks in anyway.
752                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, &params).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                    // The coder's tools are local: shell, file read/write, git. None
770                    // reach the network, so nothing here crosses the trust boundary.
771                    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/// What a turn's window resolution means for the run's budget.
788///
789/// Pure, and separated from the logging, because the interesting cases are a
790/// decision table and this crate has no tracing subscriber in its dev
791/// dependencies — the table can be asserted directly, the emission cannot.
792#[derive(Debug, Clone, Copy, PartialEq, Eq)]
793enum WindowUpdate {
794    /// The catalog knows the serving model: adopt its window, larger or smaller.
795    Adopted(usize),
796    /// The serving model has no known window but an earlier turn's did. Keep
797    /// that budget — losing it would unbind the run over a routing detail — and
798    /// say so, because it now describes a model that is not serving.
799    KeptLastKnown(usize),
800    /// Nothing is known yet; compaction stays off and the unknown-window
801    /// warning covers it.
802    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
813/// How much of an oversized tool result survives truncation, at each end.
814const TOOL_RESULT_KEEP_CHARS: usize = 600;
815
816/// Marker left in a truncated tool result — for the MODEL's benefit only, so it
817/// does not read the seam as content. It is deliberately NOT the "already
818/// shrunk" test: a genuine tool output can contain this text. That guard is the
819/// caller's `already_shrunk` set, keyed by the runner's own tool-call id.
820const TOOL_RESULT_TRUNCATION_MARKER: &str =
821    "[tool result truncated to fit the model's context window";
822
823/// Shrink oversized tool results until the history fits `budget` — measured in
824/// the same scaled accounting the compaction decision used — largest first. Returns how many were truncated; `already_shrunk` carries the
825/// tool-call ids shortened on earlier turns of the same run, so each result is
826/// only ever cut once and the guard cannot be spoofed by a tool output that
827/// happens to contain the marker text.
828///
829/// Compaction alone cannot save this run. The shared function never drops into
830/// the last `HISTORY_MIN_TAIL` messages, and after one tool turn a declarative
831/// history is `[System, User, Assistant, ToolResult]` — every message either
832/// pinned or in that tail. So a single unbounded tool result (a `read_file` of
833/// a large file is the everyday case) ships whole, and on an 8k local window
834/// one result can exceed the entire budget. Dropping turns cannot fix a run
835/// whose ONE tool result is the overflow; shrinking that result can.
836///
837/// Only `ToolResult` content is touched, which by construction is never in the
838/// pinned head (leading system prompts + the first user turn + any compaction
839/// notice) and is never an assistant turn's text. Head and tail excerpts are
840/// kept because the useful parts of a big tool output cluster at both ends, and
841/// the marker names what went so the model does not read the seam as content.
842fn 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    // The SAME number compaction decided on, scale included. Measuring this
853    // pass with the raw estimate instead would let it stop while the request is
854    // still over budget in the tokens the provider bills: once a provider
855    // reports 1.4× the estimate, compaction acts on 1.4× and this pass would act
856    // on 1.0×, and the two would disagree about whether the history fits.
857    let total = |messages: &[Message]| scaled_prompt_tokens(messages, fixed_overhead, scale);
858    if total(messages) <= budget {
859        return 0;
860    }
861    // Pick the candidates ONCE, largest first, measured in the same unit the
862    // filter uses (characters — `len()` would order by bytes while the filter
863    // counted chars, so a multibyte result could sort above a larger one). A
864    // fixed list is also what makes this terminate: a `while over budget` loop
865    // that re-scanned would spin forever the moment a candidate declined to
866    // shrink.
867    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                // Keyed by the tool-call id, not by looking for the marker in
893                // the text: a genuine tool output can CONTAIN the marker (a
894                // `read_file` of this very source file does), and a
895                // content-sniffing guard would then treat a huge real result as
896                // already shortened and let it through whole.
897                //
898                // The id is unique by assignment (see the append loop): a
899                // missing or already-used id is replaced with `call_{turn}_{i}`,
900                // and only a genuinely unique provider id is preserved — because
901                // the Responses continuity items this runner forwards reference
902                // those exact strings. Named assumption: a provider that BOTH
903                // reuses call ids across turns AND emits continuity items would
904                // have its duplicates renamed and its items desync. No provider
905                // does both today — reuse is the local parser, which emits no
906                // continuity items.
907                already_shrunk.insert(tool_use_id);
908                truncated += 1;
909            }
910        }
911    }
912    if total(messages) > budget {
913        // Nothing left to shrink: the turn cap and the provider's own
914        // truncation are what remain. Said out loud because an over-budget
915        // request that ships anyway is exactly the silent failure this path
916        // exists to remove.
917        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
928/// Keep the first and last `TOOL_RESULT_KEEP_CHARS` characters, name what was
929/// removed in between. Char-boundary safe.
930///
931/// `None` when truncating would not actually shrink the message. The marker is
932/// ~180 characters of its own, so content just over `KEEP * 2` renders LONGER
933/// than it started — a "fix" that grows the request it was called to shrink.
934/// The length test is on the rendered form rather than a computed threshold
935/// because the marker's own length varies with the numbers in it.
936fn 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    // Not just the debug_assert: this is the indexing precondition below, and a
943    // helper that panics when called directly is a trap for the next caller.
944    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
976/// Evaluate every scenario against the spec. Returns per-scenario pass/fail and
977/// the failures rendered for a repair prompt.
978pub struct ScenarioResults {
979    pub passed: usize,
980    pub total: usize,
981    pub failures: Vec<String>,
982    /// A serving failure that scenario repair cannot change.
983    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
1004/// Reports a scenario turn's serving model to the build's progress, once per
1005/// change, so a long scenario shows the model actually running it.
1006struct 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        // Scenario runs are unpinned and route on their own, so the model that
1068        // generated the spec says nothing about the one about to serve.
1069        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        // The session's cancel flag reaches the runner, so `coder.cancel` stops
1088        // an in-flight scenario at its next turn boundary.
1089        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        // Case-insensitive substring: the `expect` is a property the output
1106        // must contain, and small models vary capitalization freely. Exact
1107        // case would reject "Hello" against an expect of "hello".
1108        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
1149// ---------------------------------------------------------------------------
1150// The coder→agent build loop
1151// ---------------------------------------------------------------------------
1152
1153/// Tunables for [`build_agent`].
1154pub struct BuildAgentConfig {
1155    pub agent_id: String,
1156    pub available_tools: Vec<String>,
1157    pub max_attempts: u32,
1158}
1159
1160/// What one progress transition says about the model on screen.
1161#[derive(Debug, Clone, PartialEq, Eq)]
1162pub enum BuildProgressModel {
1163    /// Leave the displayed model as it is (before any generation has served,
1164    /// that is the session's requested pin).
1165    Keep,
1166    /// The step about to run routes on its own and has not served yet, so no
1167    /// model is known. Spec repairs and scenario runs are unpinned: the model
1168    /// that served the previous step says nothing about the one serving this.
1169    Clear,
1170    /// The model that served the most recent completed generation.
1171    Served(String),
1172}
1173
1174/// One progress transition from the build loop. The RPC adapter persists these
1175/// in the coder session; other callers use the no-op reporter.
1176#[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/// A terminal agent-build cause that changing the generated spec cannot fix.
1199#[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
1215/// Outcome of the build loop.
1216pub struct BuildAgentOutcome {
1217    /// The best spec produced (valid + scenarios pass on success; the last
1218    /// parseable attempt otherwise).
1219    pub spec: Option<DeclarativeAgentSpec>,
1220    pub passed: bool,
1221    /// Per-attempt issue summary (empty on first-try success).
1222    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
1284/// Generate an agent spec from `intent`, run its scenarios, and repair until
1285/// they pass. Tool names the model invents that aren't in `available_tools`
1286/// are dropped (the allowlist can only contain real tools).
1287pub 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
1296/// [`build_agent`] with progress transitions and the session's cancel flag for
1297/// a live coder session. A set flag ends the loop before its next attempt, and
1298/// an in-flight scenario stops at its next turn boundary.
1299pub 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            // A repair routes on its own; the previous scenario's model is stale.
1329            (
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                    // Structured JSON extraction — force thinking OFF and give
1351                    // room for the object (hybrid models otherwise return empty
1352                    // text after an unclosed `<think>`; surfaced live on
1353                    // Qwen3-1.7B during the agent-build shakedown).
1354                    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                    // Building an agent is infrequent and quality-critical — a
1363                    // weak code model writes broken specs/scenarios (the live
1364                    // shakedown saw Qwen3-1.7B win on cost and fail). Prefer the
1365                    // most capable code model, not the cheapest.
1366                    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        // Build the spec; force the id, clamp tools to the real available set.
1416        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        // A scenario the user stopped proves nothing about the spec, so its red
1460        // result is not feedback worth a repair attempt.
1461        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
1493/// Parse a spec from model JSON, forcing the id and clamping the tool allowlist
1494/// to names that actually exist (the model can't invent tools).
1495fn 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        // The build loop never asks the model to choose a context policy: the
1561        // CAR-managed default is the right answer for an agent whose author is
1562        // a prompt, and `self` is a deliberate hand-edit.
1563        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    /// A generator that never finishes: every turn returns a large assistant
1645    /// message and a DISTINCT tool call, so the runner's history grows by two
1646    /// messages per turn unless something bounds it. Records what each request
1647    /// actually carried.
1648    struct GrowingHistory {
1649        /// Per turn: (messages in the request, System first, the compaction
1650        /// notice's text when one is present).
1651        seen: Arc<StdMutex<Vec<(usize, bool, Option<String>)>>>,
1652        turn_no: AtomicUsize,
1653        /// What the catalog knows about this run's model. `0` = unknown.
1654        window: usize,
1655        /// A smaller window the catalog reports from the second completed call
1656        /// on — routing fell back to a smaller model mid-run.
1657        window_after_shrink: Option<usize>,
1658        /// The model the call reports having used — what adaptive routing
1659        /// picked, which the runner has no way to know before the first call.
1660        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            // ~2k tokens of assistant text per turn: one turn alone dwarfs the
1683            // budget of the tiny window these tests use.
1684            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    /// Run a `GrowingHistory` agent to its turn cap and return what each turn's
1705    /// request carried.
1706    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    /// As above, with a window the catalog reports smaller from the second
1715    /// completed call on.
1716    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    /// Messages the 12th turn would carry with nothing bounding the history:
1746    /// the initial System + User, then an assistant message and a tool result
1747    /// for each of the 11 turns before it.
1748    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        // The gap this bead exists to close: `declagents.invoke` ran up to 12
1753        // turns appending history with NO window bound, so a tool-heavy agent
1754        // on a small model shipped an over-budget prompt and the provider
1755        // truncated its own task. With `context: car` (the default) the runner
1756        // now applies the same compaction the assistant and coder loops use.
1757        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        // Not merely bounded: the existing marker is left behind, so a run that
1767        // degrades after compaction is distinguishable from a model that got
1768        // worse on its own (#815).
1769        let notice = seen
1770            .iter()
1771            .find_map(|(_, _, notice)| notice.clone())
1772            .expect("a compacted request must carry the `[history compacted:` notice");
1773        // …and it must not promise a recovery path this runner does not have:
1774        // no event log is bound to a declarative run and `events_query` is not
1775        // in any spec's allowlist, so the shared notice's default advice would
1776        // be a dead end dressed as a lifeline.
1777        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        // The off switch. The author said they would manage the window; CAR
1790        // dropping turns underneath them would be the bug.
1791        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        // Most declarative runs pin no model — the runtime routes at call time,
1809        // so the window is unknowable before the first call and knowable right
1810        // after it. Resolving only from a pin would leave the common case
1811        // uncompacted while looking implemented.
1812        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        // Routing can move a run onto a different model between turns. The
1826        // budget has to follow the model that will serve the NEXT call: a run
1827        // that started on a 100k window and fell back to a 200-token one must
1828        // not keep filling to the old budget, which is the overflow compaction
1829        // exists to prevent. Latching the first non-zero window looked correct
1830        // on a fixed-model run and was wrong exactly here.
1831        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        // The first turns ran under the large window, so nothing was dropped
1845        // before the fallback — the bound arrives with the smaller model, it was
1846        // not there all along.
1847        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        // The gate itself, asserted directly — the point of `RunNotices::first`
1856        // being a function rather than an inline `swap`. A goal-bearing invoke
1857        // re-drives `run_once` up to 50 times; a policy line repeated 50 times
1858        // trains the reader to skip it.
1859        let notices = RunNotices::default();
1860        assert!(RunNotices::first(&notices.stale_window), "first ask fires");
1861        assert!(
1862            !RunNotices::first(&notices.stale_window),
1863            "every later ask in the same invocation is silent"
1864        );
1865        assert!(
1866            RunNotices::first(&notices.unknown_window),
1867            "the gates are independent of one another"
1868        );
1869        assert!(!RunNotices::first(&notices.unknown_window));
1870        assert!(RunNotices::first(&notices.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        // Three cases, and the middle one is the whole point: a zero from an
1882        // unresolvable model after a known turn KEEPS the last budget (losing it
1883        // would unbind the run) but is reported, because that budget now
1884        // describes a model that is not serving and may be the larger of the
1885        // two. The emission itself is not asserted here — car-server-core has no
1886        // tracing subscriber in its dev dependencies — but the arm wired to the
1887        // once-per-invoke warning is.
1888        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        // `model_used` can be a string the catalog does not know — a dated
1901        // provider id, a local GGUF path — even on a run whose pin resolved
1902        // fine. Taking that 0 at face value would unbind the rest of the run on
1903        // a routing detail, so a 0 means "this turn taught us nothing", not
1904        // "there is no window". Observable form: the 200-token window learned
1905        // on turn 1 keeps compacting after the catalog goes blank.
1906        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        // The marker is ~180 characters of its own, so content just over the
1923        // two kept excerpts renders LONGER than it started — a shrink pass that
1924        // grows the request it was called to shrink. The test is on the
1925        // rendered form, not a computed threshold, because the marker's length
1926        // varies with the numbers in it.
1927        let small = "x".repeat(TOOL_RESULT_KEEP_CHARS * 2 + 50);
1928        assert_eq!(truncate_tool_result(&small), None, "must not grow it");
1929        // Below the indexing precondition the helper returns None rather than
1930        // indexing out of bounds. Not asserted here on purpose: the same misuse
1931        // trips the `debug_assert!` that documents the caller contract, and the
1932        // early return is the release-build backstop, not a supported call.
1933
1934        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        // The already-shrunk guard is keyed by tool-call id, not by looking for
1943        // the marker in the text: a genuine tool output can contain it (a
1944        // `read_file` of this source file does), and a content-sniffing guard
1945        // would wave that huge result through whole.
1946        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        // Second pass: the id is remembered, so it is not cut again.
1980        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        // Adaptive budgeting was one turn behind: the window is only known
1993        // AFTER a call, so a large-window turn followed by a reroute sent a
1994        // history sized for the old model to the new one. Once a model has
1995        // served a turn the run is addressed to it, and when the engine serves
1996        // something else anyway the budget follows the model that actually ran.
1997        struct Rerouting {
1998            /// Per turn: (the model the request was addressed to, whether that
1999            /// address was a HARD pin, the request's messages).
2000            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                // Turns 1-2 on the big model; the engine reroutes from turn 3.
2023                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        // ~6k characters per read: three of them overflow a 4k window's budget.
2037        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        // No caller pin: the adaptive case, which is most declarative runs.
2047        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        // …and preferring that model must not become a HARD pin. `strict_model`
2060        // suppresses the on-device last-resort fallback, so flipping it true on
2061        // a route this loop merely learned would let one transient cloud blip
2062        // end an unpinned run with `inference failed` on a machine with a
2063        // working local model.
2064        assert!(
2065            seen.iter().all(|(_, strict, _)| !*strict),
2066            "an unpinned run must never send strict_model"
2067        );
2068        // Turn 3 rerouted to the 4k model, so turn 4 must fit ITS budget.
2069        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    /// Drive one turn against a generator that reports `multiplier` times the
2085    /// chars/4 estimate as its prompt size, and hand back the SECOND request's
2086    /// messages. The tool result is sized to sit UNDER an 8k budget by the raw
2087    /// estimate and OVER it once the provider's own accounting is applied —
2088    /// which is the whole point: the two passes must not disagree.
2089    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                // Ground truth from the provider, the input the compaction
2122                // decision prefers over its own estimate.
2123                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        // Sized so the second turn sits BETWEEN the two measures: under the
2136        // 150k-token budget by the raw chars/4 estimate (~90k), over it once the
2137        // provider's 2× accounting is applied (~180k). `read_file` numbers its
2138        // lines, so the result is wider than the file.
2139        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        // A substantial system prompt, so the FIRST request (the one the
2149        // provider reports on) is dominated by the history rather than by the
2150        // tool definitions. `compact_history_measured` derives its scale from
2151        // reported-vs-estimated over the covered prefix INCLUDING the fixed
2152        // overhead; a two-line system prompt would leave the tool defs dwarfing
2153        // the prefix and the 25% gate would never open, which is a property of
2154        // the fixture, not of the code under test.
2155        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        // Compaction runs on the provider's reported prompt size; the fallback
2170        // shrink pass used the raw chars/4 estimate. Once a provider reports
2171        // more than the estimate — 1.4× was the observed case — the second pass
2172        // saw a history that "fits" and left the request over the real budget.
2173        const WINDOW: usize = SCALE_TEST_WINDOW;
2174
2175        // 2×: under budget by the estimate, over it in the provider's tokens.
2176        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        // Control at 1×: the same history genuinely fits, so nothing is cut —
2196        // the fix must not shrink what the run can afford to keep.
2197        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        // The local tool-call parser restarts its index at every completion, so
2220        // a local model emits `call_0` turn after turn. Keying the shrink guard
2221        // by the model's string then makes turn 2's `call_0` look like turn 1's
2222        // already-shortened result, and every later oversized result ships
2223        // whole — an id collision replacing the content collision it fixed.
2224        struct RepeatIdGen {
2225            /// The messages each request carried.
2226            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                // The SAME id every turn, exactly as the local parser emits it.
2237                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        // One read is ~11k tokens — bigger than the whole 6,144-token budget, so
2249        // every turn's fresh result is over on its own.
2250        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        // Turn 4 carries the three results from turns 1-3, and nothing has been
2264        // dropped yet (8 messages: the pinned head plus the protected tail).
2265        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        // The two halves of the same switch. A caller who named a backbone
2299        // (`car agent run --model …`, an A/B arm) must get it or a loud error —
2300        // a silent swap to a weaker local model manufactures fake results. A
2301        // route this loop LEARNED carries no such promise, and `strict_model`
2302        // also suppresses the on-device last-resort fallback, so making it
2303        // strict would turn one transient cloud blip into a failed run.
2304        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        // Compaction cannot save this run: after one tool turn the history is
2352        // [System, User, Assistant, ToolResult] — all of it pinned head or
2353        // protected tail — so the shared function drops nothing and a single
2354        // `read_file` of a large file ships whole. On an 8k window that one
2355        // result is bigger than the entire budget.
2356        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        // ~40k characters: an ordinary source file, ~10k tokens, well past the
2384        // 6,144-token budget of an 8k window.
2385        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        // A model missing from the catalog resolves to 0, which disables
2444        // compaction — the honest behavior (there is no window to compact
2445        // against), and the reason the runner logs a loud warning on the first
2446        // turn instead of leaving the operator to infer it from degraded
2447        // answers. The warning is log-only; the observable contract is that
2448        // nothing is silently dropped.
2449        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        // Allowlist that matches nothing → ZERO, never all.
2482        assert!(select_tool_defs_strict(&all, &["nonexistent".into()], &[]).is_empty());
2483        // Empty allowlist → zero.
2484        assert!(select_tool_defs_strict(&all, &[], &[]).is_empty());
2485        // A real name → exactly that one.
2486        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        // Denied even if allowed.
2490        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        // Agent allows only read_file, but the model tries write_file.
2583        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        // The disallowed write must not have happened.
2597        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    // Exit 127 is /bin/sh's "command not found". Windows' cmd reports a
2669    // missing command as 9009, which this fix deliberately does not treat
2670    // (car#1523 names it a follow-up), so the fixture is Unix-only.
2671    #[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        // Eight turns: the goal's full retry budget below. The old loop
2677        // burned every one of them re-driving the agent against a broken
2678        // check, so a regression fails on the iteration and cursor
2679        // assertions with all eight retries observed, not on an exhausted
2680        // script.
2681        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    // Regression guard: only 126/127-style "cannot run" exits stop early;
2715    // plain exit 1 still means "not done yet" and keeps the full retry budget.
2716    // `exit 1` is a builtin of both sh and cmd (crate::coder::test_cmds::FAIL).
2717    #[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    /// The producer's own sentence, rebuilt from `InferenceError` rather than
2890    /// copied, so a reworded message in car-inference fails here instead of
2891    /// letting the builder's terminal screen drift away from Chat's.
2892    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    /// The spec generated, and the FIRST scenario turn found no provider key.
2905    /// Nothing about the next two attempts sets a key that was never set, so
2906    /// the build has to end here — and the half-run agent is a configuration
2907    /// casualty, not a scenario mismatch that repair feedback could fix.
2908    #[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        // Turn 1: the agent spec (a greeter with one scenario).
3017        // Turn 2: the scenario run — agent answers containing "hello".
3018        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        // send_email isn't a real tool → dropped; read_file kept.
3065        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                        // Surrounding whitespace exercises the parser's trim.
3079                        "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                // Attempt 1 spec.
3113                turn(
3114                    r#"{"name":"A","identity":"v1","tools":[],"standing_goal":"g","scenarios":[{"input":"q","expect":"RIGHT"}]}"#,
3115                    json!([]),
3116                ),
3117                // Scenario run for attempt 1 → wrong.
3118                turn("WRONG", json!([])),
3119                // Attempt 2 spec (repaired).
3120                turn(
3121                    r#"{"name":"A","identity":"v2","tools":[],"standing_goal":"g","scenarios":[{"input":"q","expect":"RIGHT"}]}"#,
3122                    json!([]),
3123                ),
3124                // Scenario run for attempt 2 → right.
3125                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    // Attempt 1's spec carries a goal.check that is not a real command; the
3141    // scenario must fail with the defect error after one iteration, and
3142    // attempt 2's generation prompt must carry that text back to the model.
3143    // Unix-only like the runner fixture above: /bin/sh reports 127, Windows'
3144    // cmd reports 9009.
3145    #[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                // Attempt 1 spec: goal.check is prose-shaped, not a command.
3154                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                // Scenario run for attempt 1: the output matches `expect`, but
3161                // the broken goal check fails the scenario after one iteration.
3162                turn("the RIGHT answer", json!([])),
3163                // Attempt 2 spec (repaired: `goal` removed).
3164                turn(
3165                    r#"{"name":"A","identity":"v2","tools":[],"standing_goal":"g","scenarios":[{"input":"q","expect":"RIGHT"}]}"#,
3166                    json!([]),
3167                ),
3168                // Scenario run for attempt 2.
3169                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        // Spec-generation prompts open with the design preamble; scenario-run
3184        // requests carry the scenario input instead, so this filter separates
3185        // the two request kinds sharing one generator.
3186        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}