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::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    pub goal: Option<AgentGoalRun>,
42}
43
44#[derive(Debug, Clone)]
45pub struct AgentGoalRun {
46    pub check: String,
47    pub max_iterations: u32,
48    pub iterations: u32,
49    pub met: bool,
50    /// Whether the goal result came from deterministic verifier evidence.
51    ///
52    /// A nonzero shell exit is still grounded evidence: it proves the goal is
53    /// not met yet. Keep this separate from `met` so hosts and observability do
54    /// not count ordinary verifier failures as ungrounded model judgment.
55    pub grounded: bool,
56    pub last_exit_code: Option<i32>,
57    pub last_reason: String,
58}
59
60/// One-shot log gates for a whole [`DeclarativeAgentRunner::run`] invocation.
61///
62/// A goal-bearing invoke re-drives `run_once` up to 50 times, and a policy
63/// notice repeated 50 times is noise that trains the reader to skip it. These
64/// live on the invoke, not the pass, so each says its piece once no matter how
65/// many passes the verifier costs. Atomics rather than `Cell` so the futures
66/// holding a reference stay `Send`.
67#[derive(Default)]
68struct RunNotices {
69    self_managed: AtomicBool,
70    unknown_window: AtomicBool,
71    stale_window: AtomicBool,
72}
73
74impl RunNotices {
75    /// `true` the FIRST time it is asked, `false` forever after.
76    fn first(flag: &AtomicBool) -> bool {
77        !flag.swap(true, Ordering::SeqCst)
78    }
79}
80
81/// Filter the executor's available tool schemas to the spec's allowlist.
82/// **Strict**: an empty intersection yields ZERO tools (NOT all) — a typo'd or
83/// empty allowlist must never silently grant the full toolset. Denied tools
84/// are removed even if allowlisted.
85pub fn select_tool_defs_strict(all: &[Value], allow: &[String], deny: &[String]) -> Vec<Value> {
86    all.iter()
87        .filter(|d| {
88            let name = d.get("name").and_then(Value::as_str).unwrap_or("");
89            allow.iter().any(|a| a == name) && !deny.iter().any(|x| x == name)
90        })
91        .cloned()
92        .collect()
93}
94
95/// Runs a declarative agent in-daemon.
96pub struct DeclarativeAgentRunner<'a> {
97    spec: &'a DeclarativeAgentSpec,
98    generator: &'a dyn TurnGenerator,
99    executor: &'a WorktreeExecutor,
100    max_turns: u32,
101    max_tokens_per_turn: usize,
102    cancel: Option<Arc<AtomicBool>>,
103    model: Option<String>,
104    turn_observer: Option<&'a dyn RunTurnObserver>,
105}
106
107/// Told which model served each completed model turn of a run. The agent build
108/// uses it so progress names the model running a scenario, not the one that
109/// generated the spec.
110#[async_trait]
111trait RunTurnObserver: Send + Sync {
112    async fn turn_served(&self, model_used: &str);
113}
114
115impl<'a> DeclarativeAgentRunner<'a> {
116    pub fn new(
117        spec: &'a DeclarativeAgentSpec,
118        generator: &'a dyn TurnGenerator,
119        executor: &'a WorktreeExecutor,
120    ) -> Self {
121        Self {
122            spec,
123            generator,
124            executor,
125            max_turns: 12,
126            max_tokens_per_turn: 2048,
127            cancel: None,
128            model: None,
129            turn_observer: None,
130        }
131    }
132
133    pub fn with_cancel(mut self, cancel: Option<Arc<AtomicBool>>) -> Self {
134        self.cancel = cancel;
135        self
136    }
137
138    fn with_turn_observer(mut self, observer: &'a dyn RunTurnObserver) -> Self {
139        self.turn_observer = Some(observer);
140        self
141    }
142
143    /// Pin this invocation to an explicitly selected CAR model. `None`
144    /// preserves the runner's adaptive, quality-first routing behavior.
145    pub fn with_model(mut self, model: Option<String>) -> Self {
146        self.model = model;
147        self
148    }
149
150    fn system_prompt(&self) -> String {
151        let mut p = self.spec.identity.trim().to_string();
152        if !self.spec.standing_goal.trim().is_empty() {
153            p.push_str("\n\nStanding goal: ");
154            p.push_str(self.spec.standing_goal.trim());
155        }
156        p
157    }
158
159    /// Run the agent on `input`, returning its final text answer.
160    pub async fn run(&self, input: &str) -> AgentRunResult {
161        if self.is_cancelled() {
162            return cancelled_result(0, 0, None);
163        }
164        // One set of notice gates for the whole invocation, so the context-policy
165        // lines below fire once per invoke rather than once per goal pass.
166        let notices = RunNotices::default();
167        let Some(goal) = self.normalized_goal() else {
168            return self.run_once(input, &notices).await;
169        };
170
171        let mut total_turns = 0u32;
172        let mut total_tool_calls = 0u32;
173        let mut last_output = String::new();
174        let mut last_exit_code = None;
175        let mut last_reason = String::new();
176
177        for iteration in 1..=goal.max_iterations {
178            if self.is_cancelled() {
179                return AgentRunResult {
180                    output: last_output,
181                    turns: total_turns,
182                    tool_calls: total_tool_calls,
183                    error: Some("cancelled".into()),
184                    goal: Some(AgentGoalRun {
185                        check: goal.check,
186                        max_iterations: goal.max_iterations,
187                        iterations: iteration.saturating_sub(1),
188                        met: false,
189                        grounded: true,
190                        last_exit_code,
191                        last_reason: "cancelled".into(),
192                    }),
193                };
194            }
195            let directive = if last_reason.is_empty() {
196                input.to_string()
197            } else {
198                format!(
199                    "{input}\n\nThe previous deterministic goal check did not pass: \
200                     {last_reason}. Keep working toward the original input until \
201                     the check succeeds."
202                )
203            };
204            let result = self.run_once(&directive, &notices).await;
205            total_turns += result.turns;
206            total_tool_calls += result.tool_calls;
207            last_output = result.output;
208
209            if self.is_cancelled() {
210                return AgentRunResult {
211                    output: last_output,
212                    turns: total_turns,
213                    tool_calls: total_tool_calls,
214                    error: Some("cancelled".into()),
215                    goal: Some(AgentGoalRun {
216                        check: goal.check,
217                        max_iterations: goal.max_iterations,
218                        iterations: iteration,
219                        met: false,
220                        grounded: true,
221                        last_exit_code,
222                        last_reason: "cancelled".into(),
223                    }),
224                };
225            }
226
227            if let Some(error) = result.error {
228                return AgentRunResult {
229                    output: last_output,
230                    turns: total_turns,
231                    tool_calls: total_tool_calls,
232                    error: Some(error),
233                    goal: Some(AgentGoalRun {
234                        check: goal.check,
235                        max_iterations: goal.max_iterations,
236                        iterations: iteration,
237                        met: false,
238                        grounded: true,
239                        last_exit_code,
240                        last_reason: "agent run failed before goal check".into(),
241                    }),
242                };
243            }
244
245            match self.executor.run_shell(&goal.check, Some(120)).await {
246                Ok(v) => {
247                    let exit = v.get("exit_code").and_then(Value::as_i64).map(|n| n as i32);
248                    last_exit_code = exit;
249                    if exit == Some(0) {
250                        return AgentRunResult {
251                            output: last_output,
252                            turns: total_turns,
253                            tool_calls: total_tool_calls,
254                            error: None,
255                            goal: Some(AgentGoalRun {
256                                check: goal.check,
257                                max_iterations: goal.max_iterations,
258                                iterations: iteration,
259                                met: true,
260                                grounded: true,
261                                last_exit_code,
262                                last_reason: "goal check exited 0".into(),
263                            }),
264                        };
265                    }
266                    let output = v.get("output").and_then(Value::as_str).unwrap_or("").trim();
267                    // Exit 126 ("cannot execute") / 127 ("command not found"):
268                    // the CHECK ITSELF is broken config, not unfinished work —
269                    // re-driving the agent can never make a missing command
270                    // exist, so stop at once with a defect-naming error the
271                    // builder feeds back into spec repair (car#1523). Any
272                    // other non-zero exit may still mean "not done yet" and
273                    // keeps the retry path below, byte-for-byte.
274                    if matches!(exit, Some(126) | Some(127)) {
275                        last_reason = format!(
276                            "goal check is not a runnable command (exit {}): {} — \
277                             fix or remove goal.check",
278                            exit.unwrap_or(-1),
279                            truncate(output, 200)
280                        );
281                        return AgentRunResult {
282                            output: last_output,
283                            turns: total_turns,
284                            tool_calls: total_tool_calls,
285                            error: Some(last_reason.clone()),
286                            goal: Some(AgentGoalRun {
287                                check: goal.check,
288                                max_iterations: goal.max_iterations,
289                                iterations: iteration,
290                                met: false,
291                                grounded: true,
292                                last_exit_code: exit,
293                                last_reason,
294                            }),
295                        };
296                    }
297                    last_reason = if output.is_empty() {
298                        format!("goal check exited {}", exit.unwrap_or(-1))
299                    } else {
300                        format!(
301                            "goal check exited {}: {}",
302                            exit.unwrap_or(-1),
303                            truncate(output, 200)
304                        )
305                    };
306                }
307                Err(e) => {
308                    last_reason = format!("goal check failed to run: {e}");
309                    return AgentRunResult {
310                        output: last_output,
311                        turns: total_turns,
312                        tool_calls: total_tool_calls,
313                        error: Some(last_reason.clone()),
314                        goal: Some(AgentGoalRun {
315                            check: goal.check,
316                            max_iterations: goal.max_iterations,
317                            iterations: iteration,
318                            met: false,
319                            grounded: true,
320                            last_exit_code,
321                            last_reason,
322                        }),
323                    };
324                }
325            }
326        }
327
328        AgentRunResult {
329            output: last_output,
330            turns: total_turns,
331            tool_calls: total_tool_calls,
332            error: Some(format!(
333                "goal_not_met after {} iteration(s): {}",
334                goal.max_iterations, last_reason
335            )),
336            goal: Some(AgentGoalRun {
337                check: goal.check,
338                max_iterations: goal.max_iterations,
339                iterations: goal.max_iterations,
340                met: false,
341                grounded: true,
342                last_exit_code,
343                last_reason,
344            }),
345        }
346    }
347
348    fn is_cancelled(&self) -> bool {
349        self.cancel
350            .as_ref()
351            .map(|flag| flag.load(Ordering::SeqCst))
352            .unwrap_or(false)
353    }
354
355    fn normalized_goal(&self) -> Option<DeclarativeGoal> {
356        self.spec.goal.as_ref().and_then(|goal| {
357            let check = goal.check.trim();
358            if check.is_empty() {
359                None
360            } else {
361                Some(DeclarativeGoal {
362                    check: check.to_string(),
363                    max_iterations: goal.max_iterations.clamp(1, 50),
364                })
365            }
366        })
367    }
368
369    async fn run_once(&self, input: &str, notices: &RunNotices) -> AgentRunResult {
370        if self.is_cancelled() {
371            return cancelled_result(0, 0, None);
372        }
373        let tools = select_tool_defs_strict(
374            {
375                // This run advertises the delegate surface, so it may call it.
376                self.executor.advertise_delegates();
377                &self.executor.all_tool_defs()
378            },
379            // Unlike the coding loop, this path does NOT consult
380            // `permits_full_access` first, so a spec that allowlists a
381            // `full_access` delegate (`http_request`, car#1073) is offered a tool
382            // the per-agent gate will refuse until an operator grants the tier.
383            // Left as-is deliberately: the spec author asked for that tool by
384            // name, so the refusal names a permission they can go and grant,
385            // rather than a capability that silently does not exist.
386            &self.spec.tools,
387            &self.spec.denied_tools,
388        );
389        let tools = if tools.is_empty() { None } else { Some(tools) };
390
391        let mut messages = vec![
392            Message::System {
393                content: self.system_prompt(),
394            },
395            Message::User {
396                content: input.to_string(),
397            },
398        ];
399
400        // Who bounds this run's conversation (spec `context`). `car` — the
401        // default, and what every agent written before the field gets — applies
402        // the same compaction the assistant and coder loops use before each
403        // model call. `self` means the spec author owns the transcript, so CAR
404        // must not touch it; say so once, because a history that silently stops
405        // being managed is exactly the failure the `[history compacted:` notice
406        // exists to prevent in the other direction.
407        let car_manages_context = self.spec.context.is_car_managed();
408        if !car_manages_context && RunNotices::first(&notices.self_managed) {
409            tracing::info!(
410                agent = %self.spec.id,
411                context = self.spec.context.as_str(),
412                "CAR compaction is off for this agent (context: self); the spec owns its history"
413            );
414        }
415        // The window compaction is measured against, seeded from a pinned model
416        // when there is one and RE-RESOLVED after every call from the model that
417        // actually served it (below). Adaptive routing (`model: None`) only
418        // picks at call time, so turn 1 may have no window at all — which costs
419        // nothing, because before that call the history is two messages and
420        // cannot exceed any budget.
421        let mut context_window = self
422            .model
423            .as_deref()
424            .map(|m| self.generator.context_window(m))
425            .unwrap_or(0);
426        // The model this run's requests are addressed to. It starts as the
427        // caller's pin (usually none) and becomes the model that served the
428        // first turn, because the budget and the serving model must not be able
429        // to diverge: unpinned, the window is only known AFTER a call, so a
430        // large-window turn followed by a reroute would send a history sized for
431        // the old model to the new one — one turn late is exactly when it
432        // matters. Pinning is done only under `context: car`; an agent that
433        // manages its own history keeps today's routing untouched.
434        let mut route = self.model.clone();
435        // Whether the ROUTE is the caller's to own. A caller-supplied pin is
436        // never rewritten by this loop; an unpinned run's route is adopted from
437        // whichever model serves it.
438        let caller_pinned = self.model.is_some();
439        // One line per pass when a caller's pin is not what served the turn.
440        let mut route_divergence_logged = false;
441        // What the per-message estimate cannot see: the tool definitions ride on
442        // every turn. The provider's own prompt count is folded in after each
443        // call, so the decision to compact runs on ground truth once there is
444        // any (see `PromptMeasure`).
445        // Tool-call ids whose results this run has already shortened.
446        let mut shrunk_tool_results: HashSet<String> = HashSet::new();
447        // Every tool-call id this run has handed out, so the next one can be
448        // made distinct from all of them (see the assignment loop below).
449        let mut used_call_ids: HashSet<String> = HashSet::new();
450        let mut prompt_measure = PromptMeasure {
451            fixed_overhead: car_inference::media_tokens::tool_defs_tokens(
452                tools.as_deref().unwrap_or(&[]),
453            ),
454            reported: None,
455        };
456        let mut tool_calls_total = 0u32;
457        for turn in 1..=self.max_turns {
458            if self.is_cancelled() {
459                return cancelled_result(turn.saturating_sub(1), tool_calls_total, None);
460            }
461            // Bound the running conversation BEFORE the call — an over-budget
462            // history is only a failure once it is sent. Pins the system prompt
463            // and the original input, drops the oldest middle turns on a turn
464            // boundary, and leaves the `[history compacted:` notice. No-op on
465            // turn 1, when the window is unknown, or when the history fits.
466            if car_manages_context {
467                // Compare CONTENT, not length, exactly as the assistant loop
468                // does (agent_loop.rs): dropping one message and inserting the
469                // notice in its place leaves the length identical while the
470                // history is entirely different, and a stale reported count
471                // then decides the next turn's compaction.
472                let before_compaction = messages.clone();
473                // Learn the provider-vs-estimate ratio from the history the
474                // report actually described, BEFORE compaction rewrites it.
475                let scale = measure_scale(&message_estimates(&messages), prompt_measure);
476                // `Unrecoverable`, not the default notice: this runner binds no
477                // event log and offers only the spec's allowlisted tools, so
478                // telling the model to call `events_query` would promise a
479                // recovery path that does not exist here.
480                compact_history_measured_with_recovery(
481                    &mut messages,
482                    context_window,
483                    prompt_measure,
484                    CompactionRecovery::Unrecoverable,
485                );
486                // Dropping turns cannot fix a history whose ONE tool result is
487                // the overflow, and the shared function's tail rule protects
488                // exactly that message. Shrink what it cannot drop.
489                let shrunk = shrink_oversized_tool_results(
490                    &mut messages,
491                    history_budget(context_window),
492                    prompt_measure.fixed_overhead,
493                    scale,
494                    &mut shrunk_tool_results,
495                );
496                if shrunk > 0 {
497                    tracing::info!(
498                        agent = %self.spec.id,
499                        tool_results_truncated = shrunk,
500                        context_window,
501                        "truncated oversized tool results to fit the model's context window"
502                    );
503                }
504                if before_compaction != messages {
505                    // The reported count described the pre-compaction history;
506                    // the next call reports afresh.
507                    prompt_measure.reported = None;
508                }
509            }
510            // How many leading messages this request carries — the index the
511            // provider's reported prompt size is attributed to next turn.
512            let request_covers = messages.len();
513            let req = GenerateRequest {
514                prompt: input.to_string(),
515                model: route.clone(),
516                params: GenerateParams {
517                    temperature: 0.0,
518                    max_tokens: self.max_tokens_per_turn,
519                    // `caller_pinned`, NOT `route.is_some()`: `strict_model`
520                    // is a hard-failure switch, not a routing preference. It
521                    // suppresses the on-device last-resort fallback
522                    // (car-inference `should_append_local_last_resort`), which
523                    // is right for a caller who named a backbone and must not
524                    // be silently swapped off it — and wrong for the route this
525                    // loop LEARNED, where flipping it true from turn 2 would
526                    // make one transient cloud blip end an unpinned run with
527                    // `inference failed` on a machine with a working local
528                    // model. The learned route is still preferred (it rides in
529                    // `model`); it is just allowed to degrade.
530                    strict_model: caller_pinned,
531                    // Deterministic tool use, not open reasoning: force thinking
532                    // OFF. Hybrid-thinking models (Qwen3) otherwise burn the
533                    // whole budget inside an unclosed `<think>` and return empty
534                    // text — the same failure the coder's contract derivation
535                    // hit. Route on the Code hint so a capable model wins.
536                    thinking: car_inference::tasks::generate::ThinkingMode::Off,
537                    ..Default::default()
538                },
539                tools: tools.clone(),
540                messages: Some(messages.clone()),
541                intent: Some(car_inference::IntentHint {
542                    task: Some(car_inference::TaskHint::Code),
543                    // A declarative agent's correctness matters more than its
544                    // latency (it's verified against scenarios at build time and
545                    // invoked deliberately) — run it on the most capable model.
546                    prefer_quality: true,
547                    ..Default::default()
548                }),
549                ..Default::default()
550            };
551            let result = match self.generator.generate(req).await {
552                Ok(r) => r,
553                Err(e) => {
554                    return AgentRunResult {
555                        output: String::new(),
556                        turns: turn,
557                        tool_calls: tool_calls_total,
558                        error: Some(format!("inference failed: {e}")),
559                        goal: None,
560                    };
561                }
562            };
563            if let Some(observer) = self.turn_observer {
564                observer.turn_served(&result.model_used).await;
565            }
566
567            if car_manages_context {
568                // Re-resolve EVERY turn from the model that actually served it,
569                // and take that value rather than the widest seen: routing can
570                // fall back mid-run, and the next call must be measured against
571                // the window in force for it. Latching the first non-zero window
572                // would keep compacting a 5k fallback against a 200k budget —
573                // the overflow this exists to prevent. The pinned case is
574                // unaffected (a strict pin reports itself back).
575                let resolved = self.generator.context_window(&result.model_used);
576                match window_update(resolved, context_window) {
577                    WindowUpdate::Adopted(window) => {
578                        if window != context_window {
579                            tracing::debug!(
580                                agent = %self.spec.id,
581                                model = %result.model_used,
582                                previous_context_window = context_window,
583                                context_window = window,
584                                "declarative run's context window changed with the serving model"
585                            );
586                        }
587                        context_window = window;
588                    }
589                    WindowUpdate::KeptLastKnown(window) => {
590                        // Keeping the last known budget is the deliberate
591                        // choice (losing it would unbind the run over a routing
592                        // detail), but it must not be SILENT: the budget now
593                        // describes a model that is no longer serving, and it
594                        // may be the larger of the two.
595                        if RunNotices::first(&notices.stale_window) {
596                            tracing::warn!(
597                                agent = %self.spec.id,
598                                model = %result.model_used,
599                                context_window = window,
600                                "model {} has no known context window; keeping the last known \
601                                 budget of {window} tokens — it may not fit the model now \
602                                 serving this run. Add the model to the catalog to bound it \
603                                 properly.",
604                                result.model_used
605                            );
606                        }
607                    }
608                    WindowUpdate::StillUnknown => {}
609                }
610                // Keep the route and the budget on the SAME model. Only when
611                // the caller pinned nothing: an explicit `--model` (or an alias
612                // that is meant to route within a family) is the caller's
613                // decision and this loop does not get to overwrite it — it only
614                // says so when the engine served something else.
615                if !caller_pinned {
616                    if resolved != 0
617                        && !result.model_used.is_empty()
618                        && route.as_deref() != Some(result.model_used.as_str())
619                    {
620                        if route.is_some() {
621                            tracing::warn!(
622                                agent = %self.spec.id,
623                                previous_route = route.as_deref().unwrap_or(""),
624                                served = %result.model_used,
625                                context_window,
626                                "declarative run was served by a different model than its \
627                                 pinned route; following it so the budget and the serving \
628                                 model cannot diverge"
629                            );
630                        } else {
631                            tracing::debug!(
632                                agent = %self.spec.id,
633                                model = %result.model_used,
634                                context_window,
635                                "pinning the declarative run to the model that served it"
636                            );
637                        }
638                        route = Some(result.model_used.clone());
639                    }
640                } else if !route_divergence_logged
641                    && !result.model_used.is_empty()
642                    && route.as_deref() != Some(result.model_used.as_str())
643                {
644                    route_divergence_logged = true;
645                    tracing::warn!(
646                        agent = %self.spec.id,
647                        pinned = route.as_deref().unwrap_or(""),
648                        served = %result.model_used,
649                        context_window,
650                        "declarative run was served by a different model than the caller's \
651                         pin; budgeting against the model that served it"
652                    );
653                }
654                if context_window == 0 && RunNotices::first(&notices.unknown_window) {
655                    // Loud, once per invocation: a model the catalog does not
656                    // know leaves this run with NO history bound but its turn
657                    // cap, and the quiet version of that is a local model
658                    // overflowing its 5-10k window with nothing in the log to
659                    // say why the answers got worse.
660                    tracing::warn!(
661                        agent = %self.spec.id,
662                        model = %result.model_used,
663                        max_turns = self.max_turns,
664                        "compaction disabled: unknown context window for model {} \
665                         — this agent's history is bounded only by its turn cap. \
666                         Add the model to the catalog, or set `context: self` to \
667                         own the transcript deliberately.",
668                        result.model_used
669                    );
670                }
671                // Ground truth for the next turn's decision. All three input
672                // buckets: a cached prefix is billed separately but still
673                // occupies the window.
674                if let Some(usage) = &result.usage {
675                    let input = usage.prompt_tokens
676                        + usage.cache_read_input_tokens
677                        + usage.cache_creation_input_tokens;
678                    if input > 0 {
679                        prompt_measure.reported = Some((input as usize, request_covers));
680                    }
681                }
682            }
683
684            if self.is_cancelled() {
685                return cancelled_result(turn, tool_calls_total, None);
686            }
687
688            if result.tool_calls.is_empty() {
689                return AgentRunResult {
690                    output: result.text,
691                    turns: turn,
692                    tool_calls: tool_calls_total,
693                    error: None,
694                    goal: None,
695                };
696            }
697
698            let mut calls = result.tool_calls.clone();
699            for (i, call) in calls.iter_mut().enumerate() {
700                // Tool-call ids must be unique across the whole RUN, not just
701                // within a turn. The local tool-call parser restarts its index
702                // at every completion (car-inference `tasks::generate`,
703                // `parse_one_tool_call` + its per-call `idx`), so a local model
704                // re-emits `call_0` turn after turn — and anything keyed by that
705                // id, the shrink guard included, would treat two different
706                // results as the same one.
707                //
708                // Rewrite only a MISSING or ALREADY-USED id. A provider whose
709                // ids are genuinely unique keeps its own, because its replayed
710                // continuity items (the Responses `ProviderOutputItems` this
711                // runner forwards) reference those exact strings and a rewrite
712                // would orphan them. Both sides of the pair — the assistant
713                // record below and the `ToolResult` pushed after it — take the
714                // id from this same vector, so the model always sees a matched
715                // call/result pair either way.
716                let unique = match &call.id {
717                    Some(id) if !used_call_ids.contains(id) => id.clone(),
718                    _ => {
719                        let mut minted = format!("call_{turn}_{i}");
720                        let mut collision = 0;
721                        while used_call_ids.contains(&minted) {
722                            collision += 1;
723                            minted = format!("call_{turn}_{i}_{collision}");
724                        }
725                        minted
726                    }
727                };
728                used_call_ids.insert(unique.clone());
729                call.id = Some(unique);
730            }
731            result.append_assistant_history(&mut messages, calls.clone());
732            for call in &calls {
733                if self.is_cancelled() {
734                    return cancelled_result(turn, tool_calls_total, Some(result.text.clone()));
735                }
736                let params = Value::Object(call.arguments.clone().into_iter().collect());
737                // The allowlist already removed disallowed tools from the model's
738                // view; this is the hard backstop if a name leaks in anyway.
739                let (_, content) = if tools_contains(&self.spec.tools, &call.name)
740                    && !self.spec.denied_tools.iter().any(|d| d == &call.name)
741                {
742                    match self.executor.execute(&call.name, &params).await {
743                        Ok(v) => (true, v.to_string()),
744                        Err(e) => (false, format!("ERROR: {e}")),
745                    }
746                } else {
747                    (
748                        false,
749                        format!("ERROR: tool '{}' is not allowed for this agent", call.name),
750                    )
751                };
752                tool_calls_total += 1;
753                messages.push(Message::ToolResult {
754                    tool_use_id: call.id.clone().expect("assigned above"),
755                    content,
756                    // The coder's tools are local: shell, file read/write, git. None
757                    // reach the network, so nothing here crosses the trust boundary.
758                    provenance: Provenance::Internal,
759                });
760            }
761        }
762
763        AgentRunResult {
764            output: String::new(),
765            turns: self.max_turns,
766            tool_calls: tool_calls_total,
767            error: Some("max_turns_exceeded".into()),
768            goal: None,
769        }
770    }
771}
772
773/// What a turn's window resolution means for the run's budget.
774///
775/// Pure, and separated from the logging, because the interesting cases are a
776/// decision table and this crate has no tracing subscriber in its dev
777/// dependencies — the table can be asserted directly, the emission cannot.
778#[derive(Debug, Clone, Copy, PartialEq, Eq)]
779enum WindowUpdate {
780    /// The catalog knows the serving model: adopt its window, larger or smaller.
781    Adopted(usize),
782    /// The serving model has no known window but an earlier turn's did. Keep
783    /// that budget — losing it would unbind the run over a routing detail — and
784    /// say so, because it now describes a model that is not serving.
785    KeptLastKnown(usize),
786    /// Nothing is known yet; compaction stays off and the unknown-window
787    /// warning covers it.
788    StillUnknown,
789}
790
791fn window_update(resolved: usize, current: usize) -> WindowUpdate {
792    match (resolved, current) {
793        (0, 0) => WindowUpdate::StillUnknown,
794        (0, known) => WindowUpdate::KeptLastKnown(known),
795        (window, _) => WindowUpdate::Adopted(window),
796    }
797}
798
799/// How much of an oversized tool result survives truncation, at each end.
800const TOOL_RESULT_KEEP_CHARS: usize = 600;
801
802/// Marker left in a truncated tool result — for the MODEL's benefit only, so it
803/// does not read the seam as content. It is deliberately NOT the "already
804/// shrunk" test: a genuine tool output can contain this text. That guard is the
805/// caller's `already_shrunk` set, keyed by the runner's own tool-call id.
806const TOOL_RESULT_TRUNCATION_MARKER: &str =
807    "[tool result truncated to fit the model's context window";
808
809/// Shrink oversized tool results until the history fits `budget` — measured in
810/// the same scaled accounting the compaction decision used — largest first. Returns how many were truncated; `already_shrunk` carries the
811/// tool-call ids shortened on earlier turns of the same run, so each result is
812/// only ever cut once and the guard cannot be spoofed by a tool output that
813/// happens to contain the marker text.
814///
815/// Compaction alone cannot save this run. The shared function never drops into
816/// the last `HISTORY_MIN_TAIL` messages, and after one tool turn a declarative
817/// history is `[System, User, Assistant, ToolResult]` — every message either
818/// pinned or in that tail. So a single unbounded tool result (a `read_file` of
819/// a large file is the everyday case) ships whole, and on an 8k local window
820/// one result can exceed the entire budget. Dropping turns cannot fix a run
821/// whose ONE tool result is the overflow; shrinking that result can.
822///
823/// Only `ToolResult` content is touched, which by construction is never in the
824/// pinned head (leading system prompts + the first user turn + any compaction
825/// notice) and is never an assistant turn's text. Head and tail excerpts are
826/// kept because the useful parts of a big tool output cluster at both ends, and
827/// the marker names what went so the model does not read the seam as content.
828fn shrink_oversized_tool_results(
829    messages: &mut [Message],
830    budget: usize,
831    fixed_overhead: usize,
832    scale: f64,
833    already_shrunk: &mut HashSet<String>,
834) -> usize {
835    if budget == 0 {
836        return 0;
837    }
838    // The SAME number compaction decided on, scale included. Measuring this
839    // pass with the raw estimate instead would let it stop while the request is
840    // still over budget in the tokens the provider bills: once a provider
841    // reports 1.4× the estimate, compaction acts on 1.4× and this pass would act
842    // on 1.0×, and the two would disagree about whether the history fits.
843    let total = |messages: &[Message]| scaled_prompt_tokens(messages, fixed_overhead, scale);
844    if total(messages) <= budget {
845        return 0;
846    }
847    // Pick the candidates ONCE, largest first, measured in the same unit the
848    // filter uses (characters — `len()` would order by bytes while the filter
849    // counted chars, so a multibyte result could sort above a larger one). A
850    // fixed list is also what makes this terminate: a `while over budget` loop
851    // that re-scanned would spin forever the moment a candidate declined to
852    // shrink.
853    let mut candidates: Vec<(usize, usize, String)> = messages
854        .iter()
855        .enumerate()
856        .filter_map(|(i, m)| match m {
857            Message::ToolResult {
858                tool_use_id,
859                content,
860                ..
861            } if !already_shrunk.contains(tool_use_id) => {
862                let chars = content.chars().count();
863                (chars > TOOL_RESULT_KEEP_CHARS * 2).then(|| (i, chars, tool_use_id.clone()))
864            }
865            _ => None,
866        })
867        .collect();
868    candidates.sort_by(|a, b| b.1.cmp(&a.1));
869
870    let mut truncated = 0;
871    for (index, _, tool_use_id) in candidates {
872        if total(messages) <= budget {
873            break;
874        }
875        if let Message::ToolResult { content, .. } = &mut messages[index] {
876            if let Some(shorter) = truncate_tool_result(content) {
877                *content = shorter;
878                // Keyed by the tool-call id, not by looking for the marker in
879                // the text: a genuine tool output can CONTAIN the marker (a
880                // `read_file` of this very source file does), and a
881                // content-sniffing guard would then treat a huge real result as
882                // already shortened and let it through whole.
883                //
884                // The id is unique by assignment (see the append loop): a
885                // missing or already-used id is replaced with `call_{turn}_{i}`,
886                // and only a genuinely unique provider id is preserved — because
887                // the Responses continuity items this runner forwards reference
888                // those exact strings. Named assumption: a provider that BOTH
889                // reuses call ids across turns AND emits continuity items would
890                // have its duplicates renamed and its items desync. No provider
891                // does both today — reuse is the local parser, which emits no
892                // continuity items.
893                already_shrunk.insert(tool_use_id);
894                truncated += 1;
895            }
896        }
897    }
898    if total(messages) > budget {
899        // Nothing left to shrink: the turn cap and the provider's own
900        // truncation are what remain. Said out loud because an over-budget
901        // request that ships anyway is exactly the silent failure this path
902        // exists to remove.
903        tracing::warn!(
904            measured_prompt_tokens = total(messages),
905            budget,
906            tool_results_truncated = truncated,
907            "declarative history is over the context budget and nothing is left to \
908             shrink; the request ships as-is"
909        );
910    }
911    truncated
912}
913
914/// Keep the first and last `TOOL_RESULT_KEEP_CHARS` characters, name what was
915/// removed in between. Char-boundary safe.
916///
917/// `None` when truncating would not actually shrink the message. The marker is
918/// ~180 characters of its own, so content just over `KEEP * 2` renders LONGER
919/// than it started — a "fix" that grows the request it was called to shrink.
920/// The length test is on the rendered form rather than a computed threshold
921/// because the marker's own length varies with the numbers in it.
922fn truncate_tool_result(content: &str) -> Option<String> {
923    let chars: Vec<char> = content.chars().collect();
924    debug_assert!(
925        chars.len() > TOOL_RESULT_KEEP_CHARS * 2,
926        "callers filter to content larger than the two kept excerpts"
927    );
928    // Not just the debug_assert: this is the indexing precondition below, and a
929    // helper that panics when called directly is a trap for the next caller.
930    if chars.len() <= TOOL_RESULT_KEEP_CHARS * 2 {
931        return None;
932    }
933    let head: String = chars[..TOOL_RESULT_KEEP_CHARS].iter().collect();
934    let tail: String = chars[chars.len() - TOOL_RESULT_KEEP_CHARS..]
935        .iter()
936        .collect();
937    let dropped = chars.len() - TOOL_RESULT_KEEP_CHARS * 2;
938    let rendered = format!(
939        "{head}\n{TOOL_RESULT_TRUNCATION_MARKER}: {dropped} of {} characters removed from \
940         the middle and not recoverable in this run; re-read a narrower slice if you need \
941         them]\n{tail}",
942        chars.len()
943    );
944    (rendered.chars().count() < chars.len()).then_some(rendered)
945}
946
947fn cancelled_result(turns: u32, tool_calls: u32, output: Option<String>) -> AgentRunResult {
948    AgentRunResult {
949        output: output.unwrap_or_default(),
950        turns,
951        tool_calls,
952        error: Some("cancelled".into()),
953        goal: None,
954    }
955}
956
957fn tools_contains(allow: &[String], name: &str) -> bool {
958    allow.iter().any(|a| a == name)
959}
960
961/// Evaluate every scenario against the spec. Returns per-scenario pass/fail and
962/// the failures rendered for a repair prompt.
963pub struct ScenarioResults {
964    pub passed: usize,
965    pub total: usize,
966    pub failures: Vec<String>,
967}
968
969impl ScenarioResults {
970    pub fn all_passed(&self) -> bool {
971        self.passed == self.total
972    }
973}
974
975pub async fn run_scenarios(
976    spec: &DeclarativeAgentSpec,
977    generator: &dyn TurnGenerator,
978    executor: &WorktreeExecutor,
979) -> ScenarioResults {
980    run_scenarios_with_progress(spec, generator, executor, 1, 1, None, &NoBuildProgress).await
981}
982
983fn cancel_requested(cancel: Option<&Arc<AtomicBool>>) -> bool {
984    cancel.is_some_and(|flag| flag.load(Ordering::SeqCst))
985}
986
987/// Reports a scenario turn's serving model to the build's progress, once per
988/// change, so a long scenario shows the model actually running it.
989struct ScenarioTurnModels<'a> {
990    progress: &'a dyn BuildAgentProgressReporter,
991    attempt: u32,
992    max_attempts: u32,
993    scenario: u32,
994    scenarios_total: u32,
995    last: std::sync::Mutex<Option<String>>,
996}
997
998#[async_trait]
999impl RunTurnObserver for ScenarioTurnModels<'_> {
1000    async fn turn_served(&self, model_used: &str) {
1001        let model_used = model_used.trim();
1002        if model_used.is_empty() {
1003            return;
1004        }
1005        {
1006            let mut last = self
1007                .last
1008                .lock()
1009                .unwrap_or_else(std::sync::PoisonError::into_inner);
1010            if last.as_deref() == Some(model_used) {
1011                return;
1012            }
1013            *last = Some(model_used.to_string());
1014        }
1015        self.progress
1016            .report(BuildAgentProgressUpdate {
1017                phase: super::session::AgentBuildPhase::RunningScenario,
1018                attempt: self.attempt,
1019                max_attempts: self.max_attempts,
1020                scenario: Some(self.scenario),
1021                scenarios_total: Some(self.scenarios_total),
1022                model: BuildProgressModel::Served(model_used.to_string()),
1023            })
1024            .await;
1025    }
1026}
1027
1028#[allow(clippy::too_many_arguments)]
1029async fn run_scenarios_with_progress(
1030    spec: &DeclarativeAgentSpec,
1031    generator: &dyn TurnGenerator,
1032    executor: &WorktreeExecutor,
1033    attempt: u32,
1034    max_attempts: u32,
1035    cancel: Option<&Arc<AtomicBool>>,
1036    progress: &dyn BuildAgentProgressReporter,
1037) -> ScenarioResults {
1038    let mut passed = 0;
1039    let mut failures = Vec::new();
1040    let total = spec.scenarios.len();
1041    for (i, scenario) in spec.scenarios.iter().enumerate() {
1042        if cancel_requested(cancel) {
1043            failures.push(format!(
1044                "scenario #{} not run: the build was cancelled",
1045                i + 1
1046            ));
1047            break;
1048        }
1049        let scenario_no = (i + 1) as u32;
1050        // Scenario runs are unpinned and route on their own, so the model that
1051        // generated the spec says nothing about the one about to serve.
1052        progress
1053            .report(BuildAgentProgressUpdate {
1054                phase: super::session::AgentBuildPhase::RunningScenario,
1055                attempt,
1056                max_attempts,
1057                scenario: Some(scenario_no),
1058                scenarios_total: Some(total as u32),
1059                model: BuildProgressModel::Clear,
1060            })
1061            .await;
1062        let turn_models = ScenarioTurnModels {
1063            progress,
1064            attempt,
1065            max_attempts,
1066            scenario: scenario_no,
1067            scenarios_total: total as u32,
1068            last: std::sync::Mutex::new(None),
1069        };
1070        // The session's cancel flag reaches the runner, so `coder.cancel` stops
1071        // an in-flight scenario at its next turn boundary.
1072        let runner = DeclarativeAgentRunner::new(spec, generator, executor)
1073            .with_cancel(cancel.cloned())
1074            .with_turn_observer(&turn_models);
1075        let result = runner.run(&scenario.input).await;
1076        // Case-insensitive substring: the `expect` is a property the output
1077        // must contain, and small models vary capitalization freely. Exact
1078        // case would reject "Hello" against an expect of "hello".
1079        let ok = result.error.is_none()
1080            && result
1081                .output
1082                .to_lowercase()
1083                .contains(&scenario.expect.to_lowercase());
1084        if ok {
1085            passed += 1;
1086        } else {
1087            failures.push(format!(
1088                "scenario #{} (input {:?}) expected output containing {:?} but got {:?}{}",
1089                i + 1,
1090                scenario.input,
1091                scenario.expect,
1092                truncate(&result.output, 200),
1093                result
1094                    .error
1095                    .as_ref()
1096                    .map(|e| format!(" [error: {e}]"))
1097                    .unwrap_or_default()
1098            ));
1099        }
1100    }
1101    ScenarioResults {
1102        passed,
1103        total,
1104        failures,
1105    }
1106}
1107
1108fn truncate(s: &str, max: usize) -> String {
1109    if s.len() <= max {
1110        return s.to_string();
1111    }
1112    let mut end = max;
1113    while !s.is_char_boundary(end) {
1114        end -= 1;
1115    }
1116    format!("{}…", &s[..end])
1117}
1118
1119// ---------------------------------------------------------------------------
1120// The coder→agent build loop
1121// ---------------------------------------------------------------------------
1122
1123/// Tunables for [`build_agent`].
1124pub struct BuildAgentConfig {
1125    pub agent_id: String,
1126    pub available_tools: Vec<String>,
1127    pub max_attempts: u32,
1128}
1129
1130/// What one progress transition says about the model on screen.
1131#[derive(Debug, Clone, PartialEq, Eq)]
1132pub enum BuildProgressModel {
1133    /// Leave the displayed model as it is (before any generation has served,
1134    /// that is the session's requested pin).
1135    Keep,
1136    /// The step about to run routes on its own and has not served yet, so no
1137    /// model is known. Spec repairs and scenario runs are unpinned: the model
1138    /// that served the previous step says nothing about the one serving this.
1139    Clear,
1140    /// The model that served the most recent completed generation.
1141    Served(String),
1142}
1143
1144/// One progress transition from the build loop. The RPC adapter persists these
1145/// in the coder session; other callers use the no-op reporter.
1146#[derive(Debug, Clone, PartialEq, Eq)]
1147pub struct BuildAgentProgressUpdate {
1148    pub phase: super::session::AgentBuildPhase,
1149    pub attempt: u32,
1150    pub max_attempts: u32,
1151    pub scenario: Option<u32>,
1152    pub scenarios_total: Option<u32>,
1153    pub model: BuildProgressModel,
1154}
1155
1156#[async_trait]
1157pub trait BuildAgentProgressReporter: Send + Sync {
1158    async fn report(&self, update: BuildAgentProgressUpdate);
1159}
1160
1161struct NoBuildProgress;
1162
1163#[async_trait]
1164impl BuildAgentProgressReporter for NoBuildProgress {
1165    async fn report(&self, _update: BuildAgentProgressUpdate) {}
1166}
1167
1168/// Outcome of the build loop.
1169pub struct BuildAgentOutcome {
1170    /// The best spec produced (valid + scenarios pass on success; the last
1171    /// parseable attempt otherwise).
1172    pub spec: Option<DeclarativeAgentSpec>,
1173    pub passed: bool,
1174    /// Per-attempt issue summary (empty on first-try success).
1175    pub issues: Vec<String>,
1176    pub attempts: u32,
1177}
1178
1179fn build_prompt(intent: &str, available_tools: &[String], feedback: &[String]) -> String {
1180    let mut p = format!(
1181        "You are designing an in-daemon CAR agent from a user's request. Output ONLY a JSON \
1182         object (no prose, no fences) describing the agent:\n\
1183         {{\n  \"name\": \"short human name\",\n  \"identity\": \"system prompt — who the agent \
1184         is and how it behaves\",\n  \"tools\": [\"only names from the AVAILABLE TOOLS list\"],\n  \
1185         \"standing_goal\": \"the agent's persistent objective\",\n  \"goal\": {{\"check\": \
1186         \"optional shell check run after each invocation\", \"max_iterations\": 8}},\n  \"scenarios\": [{{\"input\": \
1187         \"an example request\", \"expect\": \"a stable substring the correct output must \
1188         contain\"}}]\n}}\n\n\
1189         User request:\n{intent}\n\n\
1190         AVAILABLE TOOLS (use only these names; pick the minimal set, or [] for a pure-reasoning \
1191         agent):\n{}\n\n\
1192         Rules:\n\
1193         - 1 to 3 scenarios. CRITICAL: each `expect` must be the SHORTEST string that proves the \
1194           answer is correct — usually a single word, number, or short phrase taken from the \
1195           USER'S REQUEST itself. NEVER a full sentence you imagine the agent saying, and never \
1196           a value you haven't computed.\n\
1197           Example — request \"a greeter that always says hello\": a good scenario is \
1198           {{\"input\": \"hi\", \"expect\": \"hello\"}} (matched case-insensitively). A BAD scenario \
1199           invents a whole reply like \"Hello! How can I help you today?\".\n\
1200           Example — request \"converts Celsius to Fahrenheit\": for input \"100\" the `expect` is \
1201           \"212\" (you must actually compute 100*9/5+32), NOT \"273.15\" (that is Kelvin) and NOT a \
1202           sentence.\n\
1203         - `expect` is matched as a case-insensitive substring of the agent's output.\n\
1204         - Prefer no tools unless the task truly needs to read/write files or run commands.\n\
1205         - Include `goal` only when there is an obvious deterministic shell check for completion \
1206           (for example `test -f output.json`, `cargo test -q`, or `npm test`). Omit `goal` \
1207           for pure question-answering agents or vague quality checks. A goal check must be a \
1208           real, runnable shell command; if a previous attempt reported \"not a runnable \
1209           command\", remove `goal` or replace it with a real command.\n\
1210         - Write `identity` so the agent answers DIRECTLY and deterministically (it should perform \
1211           the task, not chat about it) — terse enough to reliably contain each `expect`.\n",
1212        if available_tools.is_empty() {
1213            "(none)".to_string()
1214        } else {
1215            available_tools.join(", ")
1216        }
1217    );
1218    if !feedback.is_empty() {
1219        p.push_str("\nYour previous attempt did not pass its own scenarios — revise so they do:\n");
1220        for f in feedback {
1221            p.push_str(&format!("- {f}\n"));
1222        }
1223    }
1224    p
1225}
1226
1227pub(crate) fn extract_json_object(text: &str) -> Result<Value, String> {
1228    let start = text.find('{').ok_or("no JSON object in output")?;
1229    let end = text.rfind('}').ok_or("no closing brace in output")?;
1230    if end < start {
1231        return Err("malformed JSON object".into());
1232    }
1233    serde_json::from_str(&text[start..=end]).map_err(|e| format!("invalid JSON: {e}"))
1234}
1235
1236/// Generate an agent spec from `intent`, run its scenarios, and repair until
1237/// they pass. Tool names the model invents that aren't in `available_tools`
1238/// are dropped (the allowlist can only contain real tools).
1239pub async fn build_agent(
1240    intent: &str,
1241    generator: &dyn TurnGenerator,
1242    executor: &WorktreeExecutor,
1243    cfg: &BuildAgentConfig,
1244) -> BuildAgentOutcome {
1245    build_agent_with_progress(intent, generator, executor, cfg, None, &NoBuildProgress).await
1246}
1247
1248/// [`build_agent`] with progress transitions and the session's cancel flag for
1249/// a live coder session. A set flag ends the loop before its next attempt, and
1250/// an in-flight scenario stops at its next turn boundary.
1251pub async fn build_agent_with_progress(
1252    intent: &str,
1253    generator: &dyn TurnGenerator,
1254    executor: &WorktreeExecutor,
1255    cfg: &BuildAgentConfig,
1256    cancel: Option<Arc<AtomicBool>>,
1257    progress: &dyn BuildAgentProgressReporter,
1258) -> BuildAgentOutcome {
1259    let max = cfg.max_attempts.max(1);
1260    let mut feedback: Vec<String> = Vec::new();
1261    let mut last_spec: Option<DeclarativeAgentSpec> = None;
1262    let mut last_issues: Vec<String> = Vec::new();
1263
1264    for attempt in 1..=max {
1265        if cancel_requested(cancel.as_ref()) {
1266            return BuildAgentOutcome {
1267                spec: last_spec,
1268                passed: false,
1269                issues: vec!["cancelled".into()],
1270                attempts: attempt - 1,
1271            };
1272        }
1273        let (phase, model) = if attempt == 1 {
1274            (
1275                super::session::AgentBuildPhase::GeneratingSpec,
1276                BuildProgressModel::Keep,
1277            )
1278        } else {
1279            // A repair routes on its own; the previous scenario's model is stale.
1280            (
1281                super::session::AgentBuildPhase::Repairing,
1282                BuildProgressModel::Clear,
1283            )
1284        };
1285        progress
1286            .report(BuildAgentProgressUpdate {
1287                phase,
1288                attempt,
1289                max_attempts: max,
1290                scenario: None,
1291                scenarios_total: None,
1292                model,
1293            })
1294            .await;
1295        let prompt = build_prompt(intent, &cfg.available_tools, &feedback);
1296        let generated = match generator
1297            .generate(GenerateRequest {
1298                prompt: prompt.clone(),
1299                params: GenerateParams {
1300                    temperature: 0.0,
1301                    // Structured JSON extraction — force thinking OFF and give
1302                    // room for the object (hybrid models otherwise return empty
1303                    // text after an unclosed `<think>`; surfaced live on
1304                    // Qwen3-1.7B during the agent-build shakedown).
1305                    max_tokens: 2048,
1306                    thinking: car_inference::tasks::generate::ThinkingMode::Off,
1307                    ..Default::default()
1308                },
1309                messages: Some(vec![Message::User { content: prompt }]),
1310                intent: Some(car_inference::IntentHint {
1311                    task: Some(car_inference::TaskHint::Code),
1312                    require: vec![car_inference::ModelCapability::Code],
1313                    // Building an agent is infrequent and quality-critical — a
1314                    // weak code model writes broken specs/scenarios (the live
1315                    // shakedown saw Qwen3-1.7B win on cost and fail). Prefer the
1316                    // most capable code model, not the cheapest.
1317                    prefer_quality: true,
1318                    ..Default::default()
1319                }),
1320                ..Default::default()
1321            })
1322            .await
1323        {
1324            Ok(r) => r,
1325            Err(e) => {
1326                last_issues = vec![format!("generation failed: {e}")];
1327                continue;
1328            }
1329        };
1330        let served = if generated.model_used.trim().is_empty() {
1331            BuildProgressModel::Keep
1332        } else {
1333            BuildProgressModel::Served(generated.model_used.clone())
1334        };
1335        progress
1336            .report(BuildAgentProgressUpdate {
1337                phase,
1338                attempt,
1339                max_attempts: max,
1340                scenario: None,
1341                scenarios_total: None,
1342                model: served,
1343            })
1344            .await;
1345
1346        let value = match extract_json_object(&generated.text) {
1347            Ok(v) => v,
1348            Err(e) => {
1349                feedback = vec![format!(
1350                    "output did not parse: {e}. Return ONLY the JSON object."
1351                )];
1352                last_issues = feedback.clone();
1353                continue;
1354            }
1355        };
1356
1357        // Build the spec; force the id, clamp tools to the real available set.
1358        let mut spec = match parse_spec(&value, &cfg.agent_id, &cfg.available_tools) {
1359            Ok(s) => s,
1360            Err(e) => {
1361                feedback = vec![e.clone()];
1362                last_issues = vec![e];
1363                continue;
1364            }
1365        };
1366        spec.enabled = true;
1367
1368        let problems = spec.validate();
1369        if !problems.is_empty() {
1370            feedback = problems.clone();
1371            last_issues = problems;
1372            last_spec = Some(spec);
1373            continue;
1374        }
1375        if spec.scenarios.is_empty() {
1376            feedback = vec!["include at least one scenario".into()];
1377            last_issues = feedback.clone();
1378            last_spec = Some(spec);
1379            continue;
1380        }
1381
1382        let results = run_scenarios_with_progress(
1383            &spec,
1384            generator,
1385            executor,
1386            attempt,
1387            max,
1388            cancel.as_ref(),
1389            progress,
1390        )
1391        .await;
1392        // A scenario the user stopped proves nothing about the spec, so its red
1393        // result is not feedback worth a repair attempt.
1394        if cancel_requested(cancel.as_ref()) {
1395            return BuildAgentOutcome {
1396                spec: Some(spec),
1397                passed: false,
1398                issues: vec!["cancelled".into()],
1399                attempts: attempt,
1400            };
1401        }
1402        if results.all_passed() {
1403            return BuildAgentOutcome {
1404                spec: Some(spec),
1405                passed: true,
1406                issues: Vec::new(),
1407                attempts: attempt,
1408            };
1409        }
1410        feedback = results.failures.clone();
1411        last_issues = results.failures;
1412        last_spec = Some(spec);
1413    }
1414
1415    BuildAgentOutcome {
1416        spec: last_spec,
1417        passed: false,
1418        issues: last_issues,
1419        attempts: max,
1420    }
1421}
1422
1423/// Parse a spec from model JSON, forcing the id and clamping the tool allowlist
1424/// to names that actually exist (the model can't invent tools).
1425fn parse_spec(
1426    value: &Value,
1427    agent_id: &str,
1428    available_tools: &[String],
1429) -> Result<DeclarativeAgentSpec, String> {
1430    let name = value
1431        .get("name")
1432        .and_then(Value::as_str)
1433        .unwrap_or("")
1434        .trim()
1435        .to_string();
1436    let identity = value
1437        .get("identity")
1438        .and_then(Value::as_str)
1439        .unwrap_or("")
1440        .trim()
1441        .to_string();
1442    let standing_goal = value
1443        .get("standing_goal")
1444        .and_then(Value::as_str)
1445        .unwrap_or("")
1446        .to_string();
1447    let tools: Vec<String> = value
1448        .get("tools")
1449        .and_then(Value::as_array)
1450        .map(|a| {
1451            a.iter()
1452                .filter_map(|t| t.as_str())
1453                .map(String::from)
1454                .filter(|t| available_tools.iter().any(|a| a == t))
1455                .collect()
1456        })
1457        .unwrap_or_default();
1458    let scenarios: Vec<Scenario> = value
1459        .get("scenarios")
1460        .and_then(Value::as_array)
1461        .map(|a| {
1462            a.iter()
1463                .filter_map(|s| {
1464                    Some(Scenario {
1465                        input: s.get("input")?.as_str()?.to_string(),
1466                        expect: s.get("expect")?.as_str()?.to_string(),
1467                    })
1468                })
1469                .collect()
1470        })
1471        .unwrap_or_default();
1472
1473    Ok(DeclarativeAgentSpec {
1474        id: agent_id.to_string(),
1475        name: if name.is_empty() {
1476            agent_id.to_string()
1477        } else {
1478            name
1479        },
1480        identity,
1481        tools,
1482        denied_tools: Vec::new(),
1483        standing_goal,
1484        goal: parse_goal(value)?,
1485        scenarios,
1486        enabled: true,
1487        // The build loop never asks the model to choose a context policy: the
1488        // CAR-managed default is the right answer for an agent whose author is
1489        // a prompt, and `self` is a deliberate hand-edit.
1490        context: ContextPolicy::default(),
1491    })
1492}
1493
1494fn parse_goal(value: &Value) -> Result<Option<DeclarativeGoal>, String> {
1495    let Some(goal) = value.get("goal") else {
1496        return Ok(None);
1497    };
1498    if goal.is_null() {
1499        return Ok(None);
1500    }
1501    let obj = goal
1502        .as_object()
1503        .ok_or_else(|| "`goal` must be an object".to_string())?;
1504    let check = obj
1505        .get("check")
1506        .and_then(Value::as_str)
1507        .map(str::trim)
1508        .filter(|s| !s.is_empty())
1509        .ok_or_else(|| "`goal.check` must be a non-empty string".to_string())?;
1510    let max_iterations = obj
1511        .get("max_iterations")
1512        .and_then(Value::as_u64)
1513        .unwrap_or(8)
1514        .clamp(1, 50) as u32;
1515    Ok(Some(DeclarativeGoal {
1516        check: check.to_string(),
1517        max_iterations,
1518    }))
1519}
1520
1521#[cfg(test)]
1522mod tests {
1523    use super::*;
1524
1525    use async_trait::async_trait;
1526    use car_inference::{GenerateRequest, InferenceResult};
1527    use serde_json::json;
1528    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1529    use std::sync::{Arc, Mutex as StdMutex};
1530
1531    struct Script {
1532        turns: Vec<InferenceResult>,
1533        cursor: AtomicUsize,
1534    }
1535    fn turn(text: &str, tool_calls: Value) -> InferenceResult {
1536        serde_json::from_value(json!({
1537            "text": text, "tool_calls": tool_calls,
1538            "trace_id": "t", "model_used": "scripted", "latency_ms": 0,
1539        }))
1540        .unwrap()
1541    }
1542    #[async_trait]
1543    impl TurnGenerator for Script {
1544        async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
1545            let i = self.cursor.fetch_add(1, Ordering::SeqCst);
1546            self.turns
1547                .get(i)
1548                .cloned()
1549                .ok_or_else(|| "script exhausted".into())
1550        }
1551    }
1552
1553    struct CapturingScript {
1554        turns: Vec<InferenceResult>,
1555        cursor: AtomicUsize,
1556        seen: Arc<StdMutex<Vec<GenerateRequest>>>,
1557    }
1558
1559    #[async_trait]
1560    impl TurnGenerator for CapturingScript {
1561        async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
1562            self.seen.lock().unwrap().push(req);
1563            let i = self.cursor.fetch_add(1, Ordering::SeqCst);
1564            self.turns
1565                .get(i)
1566                .cloned()
1567                .ok_or_else(|| "script exhausted".into())
1568        }
1569    }
1570
1571    /// A generator that never finishes: every turn returns a large assistant
1572    /// message and a DISTINCT tool call, so the runner's history grows by two
1573    /// messages per turn unless something bounds it. Records what each request
1574    /// actually carried.
1575    struct GrowingHistory {
1576        /// Per turn: (messages in the request, System first, the compaction
1577        /// notice's text when one is present).
1578        seen: Arc<StdMutex<Vec<(usize, bool, Option<String>)>>>,
1579        turn_no: AtomicUsize,
1580        /// What the catalog knows about this run's model. `0` = unknown.
1581        window: usize,
1582        /// A smaller window the catalog reports from the second completed call
1583        /// on — routing fell back to a smaller model mid-run.
1584        window_after_shrink: Option<usize>,
1585        /// The model the call reports having used — what adaptive routing
1586        /// picked, which the runner has no way to know before the first call.
1587        model_used: &'static str,
1588    }
1589
1590    #[async_trait]
1591    impl TurnGenerator for GrowingHistory {
1592        async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
1593            let msgs = req
1594                .messages
1595                .as_ref()
1596                .expect("the runner always sets messages");
1597            let notice = msgs.iter().find_map(|m| match m {
1598                Message::System { content } if content.starts_with("[history compacted:") => {
1599                    Some(content.clone())
1600                }
1601                _ => None,
1602            });
1603            self.seen.lock().unwrap().push((
1604                msgs.len(),
1605                matches!(msgs.first(), Some(Message::System { .. })),
1606                notice,
1607            ));
1608            let n = self.turn_no.fetch_add(1, Ordering::SeqCst);
1609            // ~2k tokens of assistant text per turn: one turn alone dwarfs the
1610            // budget of the tiny window these tests use.
1611            let mut result = turn(
1612                &"x".repeat(8_000),
1613                json!([{
1614                    "id": format!("c{n}"),
1615                    "name": "write_file",
1616                    "arguments": {"path": format!("big{n}.txt"), "content": "y"}
1617                }]),
1618            );
1619            result.model_used = self.model_used.to_string();
1620            Ok(result)
1621        }
1622
1623        fn context_window(&self, _model: &str) -> usize {
1624            match self.window_after_shrink {
1625                Some(smaller) if self.turn_no.load(Ordering::SeqCst) >= 2 => smaller,
1626                _ => self.window,
1627            }
1628        }
1629    }
1630
1631    /// Run a `GrowingHistory` agent to its turn cap and return what each turn's
1632    /// request carried.
1633    async fn run_growing_history(
1634        spec: &DeclarativeAgentSpec,
1635        model: Option<&str>,
1636        window: usize,
1637    ) -> Vec<(usize, bool, Option<String>)> {
1638        run_growing_history_shrinking(spec, model, window, None).await
1639    }
1640
1641    /// As above, with a window the catalog reports smaller from the second
1642    /// completed call on.
1643    async fn run_growing_history_shrinking(
1644        spec: &DeclarativeAgentSpec,
1645        model: Option<&str>,
1646        window: usize,
1647        window_after_shrink: Option<usize>,
1648    ) -> Vec<(usize, bool, Option<String>)> {
1649        let dir = tempfile::tempdir().unwrap();
1650        let exec = WorktreeExecutor::new(dir.path());
1651        let seen = Arc::new(StdMutex::new(Vec::new()));
1652        let generator = GrowingHistory {
1653            seen: seen.clone(),
1654            turn_no: AtomicUsize::new(0),
1655            window,
1656            window_after_shrink,
1657            model_used: "tiny-local",
1658        };
1659        let runner = DeclarativeAgentRunner::new(spec, &generator, &exec)
1660            .with_model(model.map(String::from));
1661        let result = runner.run("grow the thread").await;
1662        assert_eq!(result.error.as_deref(), Some("max_turns_exceeded"));
1663        let seen = seen.lock().unwrap().clone();
1664        assert_eq!(seen.len(), 12, "all 12 turns generated");
1665        assert!(
1666            seen.iter().all(|(_, system_first, _)| *system_first),
1667            "the agent identity must stay pinned at the head of every request"
1668        );
1669        seen
1670    }
1671
1672    /// Messages the 12th turn would carry with nothing bounding the history:
1673    /// the initial System + User, then an assistant message and a tool result
1674    /// for each of the 11 turns before it.
1675    const UNBOUNDED_TWELFTH_TURN: usize = 2 + 2 * 11;
1676
1677    #[tokio::test]
1678    async fn runner_compacts_history_that_exceeds_the_model_context_budget() {
1679        // The gap this bead exists to close: `declagents.invoke` ran up to 12
1680        // turns appending history with NO window bound, so a tool-heavy agent
1681        // on a small model shipped an over-budget prompt and the provider
1682        // truncated its own task. With `context: car` (the default) the runner
1683        // now applies the same compaction the assistant and coder loops use.
1684        let spec = spec_with(vec!["write_file"]);
1685        assert!(spec.context.is_car_managed(), "default is CAR-managed");
1686        let seen = run_growing_history(&spec, Some("scripted"), 200).await;
1687
1688        let max_len = seen.iter().map(|(n, _, _)| *n).max().unwrap();
1689        assert!(
1690            max_len < UNBOUNDED_TWELFTH_TURN,
1691            "history not bounded — max messages/turn = {max_len}"
1692        );
1693        // Not merely bounded: the existing marker is left behind, so a run that
1694        // degrades after compaction is distinguishable from a model that got
1695        // worse on its own (#815).
1696        let notice = seen
1697            .iter()
1698            .find_map(|(_, _, notice)| notice.clone())
1699            .expect("a compacted request must carry the `[history compacted:` notice");
1700        // …and it must not promise a recovery path this runner does not have:
1701        // no event log is bound to a declarative run and `events_query` is not
1702        // in any spec's allowlist, so the shared notice's default advice would
1703        // be a dead end dressed as a lifeline.
1704        assert!(
1705            !notice.contains("events_query") && !notice.contains("event log"),
1706            "declarative notice must not point at an events log it cannot read: {notice}"
1707        );
1708        assert!(
1709            notice.contains("not recoverable in this run"),
1710            "declarative notice must say the turns are gone: {notice}"
1711        );
1712    }
1713
1714    #[tokio::test]
1715    async fn context_self_leaves_the_history_entirely_to_the_agent() {
1716        // The off switch. The author said they would manage the window; CAR
1717        // dropping turns underneath them would be the bug.
1718        let mut spec = spec_with(vec!["write_file"]);
1719        spec.context = ContextPolicy::SelfManaged;
1720        let seen = run_growing_history(&spec, Some("scripted"), 200).await;
1721
1722        assert_eq!(
1723            seen.last().unwrap().0,
1724            UNBOUNDED_TWELFTH_TURN,
1725            "context: self must not drop a single message"
1726        );
1727        assert!(
1728            seen.iter().all(|(_, _, notice)| notice.is_none()),
1729            "context: self must never leave a compaction notice"
1730        );
1731    }
1732
1733    #[tokio::test]
1734    async fn adaptive_routing_learns_the_window_from_the_model_that_ran() {
1735        // Most declarative runs pin no model — the runtime routes at call time,
1736        // so the window is unknowable before the first call and knowable right
1737        // after it. Resolving only from a pin would leave the common case
1738        // uncompacted while looking implemented.
1739        let spec = spec_with(vec!["write_file"]);
1740        let seen = run_growing_history(&spec, None, 200).await;
1741
1742        let max_len = seen.iter().map(|(n, _, _)| *n).max().unwrap();
1743        assert!(
1744            max_len < UNBOUNDED_TWELFTH_TURN,
1745            "an unpinned run must still be bounded once the model is known — \
1746             max messages/turn = {max_len}"
1747        );
1748    }
1749
1750    #[tokio::test]
1751    async fn a_mid_run_fallback_to_a_smaller_model_is_compacted_against_the_smaller_window() {
1752        // Routing can move a run onto a different model between turns. The
1753        // budget has to follow the model that will serve the NEXT call: a run
1754        // that started on a 100k window and fell back to a 200-token one must
1755        // not keep filling to the old budget, which is the overflow compaction
1756        // exists to prevent. Latching the first non-zero window looked correct
1757        // on a fixed-model run and was wrong exactly here.
1758        let spec = spec_with(vec!["write_file"]);
1759        let seen = run_growing_history_shrinking(&spec, Some("scripted"), 100_000, Some(200)).await;
1760
1761        let max_len = seen.iter().map(|(n, _, _)| *n).max().unwrap();
1762        assert!(
1763            max_len < UNBOUNDED_TWELFTH_TURN,
1764            "the run must be bounded by the window in force after the fallback — \
1765             max messages/turn = {max_len}"
1766        );
1767        assert!(
1768            seen.iter().any(|(_, _, notice)| notice.is_some()),
1769            "the smaller window must actually have compacted something"
1770        );
1771        // The first turns ran under the large window, so nothing was dropped
1772        // before the fallback — the bound arrives with the smaller model, it was
1773        // not there all along.
1774        assert!(
1775            seen[0].2.is_none() && seen[1].2.is_none(),
1776            "no compaction before the window shrank"
1777        );
1778    }
1779
1780    #[test]
1781    fn a_run_notice_fires_once_per_invocation_and_resets_with_a_new_one() {
1782        // The gate itself, asserted directly — the point of `RunNotices::first`
1783        // being a function rather than an inline `swap`. A goal-bearing invoke
1784        // re-drives `run_once` up to 50 times; a policy line repeated 50 times
1785        // trains the reader to skip it.
1786        let notices = RunNotices::default();
1787        assert!(RunNotices::first(&notices.stale_window), "first ask fires");
1788        assert!(
1789            !RunNotices::first(&notices.stale_window),
1790            "every later ask in the same invocation is silent"
1791        );
1792        assert!(
1793            RunNotices::first(&notices.unknown_window),
1794            "the gates are independent of one another"
1795        );
1796        assert!(!RunNotices::first(&notices.unknown_window));
1797        assert!(RunNotices::first(&notices.self_managed));
1798
1799        let next_invocation = RunNotices::default();
1800        assert!(
1801            RunNotices::first(&next_invocation.stale_window),
1802            "a new invocation starts clean — once per invoke, not once per process"
1803        );
1804    }
1805
1806    #[test]
1807    fn the_window_decision_table_is_exhaustive_and_never_silent() {
1808        // Three cases, and the middle one is the whole point: a zero from an
1809        // unresolvable model after a known turn KEEPS the last budget (losing it
1810        // would unbind the run) but is reported, because that budget now
1811        // describes a model that is not serving and may be the larger of the
1812        // two. The emission itself is not asserted here — car-server-core has no
1813        // tracing subscriber in its dev dependencies — but the arm wired to the
1814        // once-per-invoke warning is.
1815        assert_eq!(window_update(8_192, 0), WindowUpdate::Adopted(8_192));
1816        assert_eq!(window_update(4_096, 200_000), WindowUpdate::Adopted(4_096));
1817        assert_eq!(
1818            window_update(0, 200_000),
1819            WindowUpdate::KeptLastKnown(200_000),
1820            "a known budget is kept, and the caller warns"
1821        );
1822        assert_eq!(window_update(0, 0), WindowUpdate::StillUnknown);
1823    }
1824
1825    #[tokio::test]
1826    async fn a_known_window_survives_a_model_the_catalog_cannot_resolve() {
1827        // `model_used` can be a string the catalog does not know — a dated
1828        // provider id, a local GGUF path — even on a run whose pin resolved
1829        // fine. Taking that 0 at face value would unbind the rest of the run on
1830        // a routing detail, so a 0 means "this turn taught us nothing", not
1831        // "there is no window". Observable form: the 200-token window learned
1832        // on turn 1 keeps compacting after the catalog goes blank.
1833        let spec = spec_with(vec!["write_file"]);
1834        let seen = run_growing_history_shrinking(&spec, Some("scripted"), 200, Some(0)).await;
1835
1836        let max_len = seen.iter().map(|(n, _, _)| *n).max().unwrap();
1837        assert!(
1838            max_len < UNBOUNDED_TWELFTH_TURN,
1839            "a known window must survive an unresolvable model — max messages/turn = {max_len}"
1840        );
1841        assert!(
1842            seen.iter().any(|(_, _, notice)| notice.is_some()),
1843            "and must still be compacting"
1844        );
1845    }
1846
1847    #[test]
1848    fn a_tool_result_too_small_to_pay_for_the_marker_is_left_alone() {
1849        // The marker is ~180 characters of its own, so content just over the
1850        // two kept excerpts renders LONGER than it started — a shrink pass that
1851        // grows the request it was called to shrink. The test is on the
1852        // rendered form, not a computed threshold, because the marker's length
1853        // varies with the numbers in it.
1854        let small = "x".repeat(TOOL_RESULT_KEEP_CHARS * 2 + 50);
1855        assert_eq!(truncate_tool_result(&small), None, "must not grow it");
1856        // Below the indexing precondition the helper returns None rather than
1857        // indexing out of bounds. Not asserted here on purpose: the same misuse
1858        // trips the `debug_assert!` that documents the caller contract, and the
1859        // early return is the release-build backstop, not a supported call.
1860
1861        let big = "y".repeat(44_000);
1862        let shrunk = truncate_tool_result(&big).expect("44k must truncate");
1863        assert!(shrunk.chars().count() < big.chars().count());
1864        assert!(shrunk.contains(TOOL_RESULT_TRUNCATION_MARKER));
1865    }
1866
1867    #[test]
1868    fn a_tool_output_that_quotes_the_truncation_marker_is_still_truncated() {
1869        // The already-shrunk guard is keyed by tool-call id, not by looking for
1870        // the marker in the text: a genuine tool output can contain it (a
1871        // `read_file` of this source file does), and a content-sniffing guard
1872        // would wave that huge result through whole.
1873        let mut messages = vec![
1874            Message::System {
1875                content: "identity".into(),
1876            },
1877            Message::User {
1878                content: "read the file".into(),
1879            },
1880            Message::ToolResult {
1881                tool_use_id: "call_1".into(),
1882                content: format!(
1883                    "{TOOL_RESULT_TRUNCATION_MARKER}: quoted by the file itself]{}",
1884                    "z".repeat(44_000)
1885                ),
1886                provenance: Provenance::Internal,
1887            },
1888        ];
1889        let mut already = HashSet::new();
1890
1891        let truncated = shrink_oversized_tool_results(
1892            &mut messages,
1893            history_budget(8_192),
1894            0,
1895            1.0,
1896            &mut already,
1897        );
1898
1899        assert_eq!(truncated, 1, "a marker-quoting result must still be cut");
1900        assert!(already.contains("call_1"), "and recorded by id");
1901        let Message::ToolResult { content, .. } = &messages[2] else {
1902            panic!("tool result");
1903        };
1904        assert!(content.chars().count() < 44_000);
1905
1906        // Second pass: the id is remembered, so it is not cut again.
1907        let again = shrink_oversized_tool_results(
1908            &mut messages,
1909            history_budget(8_192),
1910            0,
1911            1.0,
1912            &mut already,
1913        );
1914        assert_eq!(again, 0, "identity guard stops a second cut");
1915    }
1916
1917    #[tokio::test]
1918    async fn an_unpinned_run_pins_the_model_that_served_it_and_follows_a_reroute() {
1919        // Adaptive budgeting was one turn behind: the window is only known
1920        // AFTER a call, so a large-window turn followed by a reroute sent a
1921        // history sized for the old model to the new one. Once a model has
1922        // served a turn the run is addressed to it, and when the engine serves
1923        // something else anyway the budget follows the model that actually ran.
1924        struct Rerouting {
1925            /// Per turn: (the model the request was addressed to, whether that
1926            /// address was a HARD pin, the request's messages).
1927            seen: Arc<StdMutex<Vec<(Option<String>, bool, Vec<Message>)>>>,
1928            turn_no: AtomicUsize,
1929        }
1930        #[async_trait]
1931        impl TurnGenerator for Rerouting {
1932            async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
1933                let n = self.turn_no.fetch_add(1, Ordering::SeqCst);
1934                self.seen.lock().unwrap().push((
1935                    req.model.clone(),
1936                    req.params.strict_model,
1937                    req.messages
1938                        .clone()
1939                        .expect("the runner always sets messages"),
1940                ));
1941                let mut result = turn(
1942                    "",
1943                    json!([{
1944                        "id": format!("c{n}"),
1945                        "name": "read_file",
1946                        "arguments": {"path": "big.txt"}
1947                    }]),
1948                );
1949                // Turns 1-2 on the big model; the engine reroutes from turn 3.
1950                result.model_used = if n < 2 { "big-model" } else { "small-model" }.to_string();
1951                Ok(result)
1952            }
1953            fn context_window(&self, model: &str) -> usize {
1954                match model {
1955                    "big-model" => 100_000,
1956                    "small-model" => 4_096,
1957                    _ => 0,
1958                }
1959            }
1960        }
1961
1962        let dir = tempfile::tempdir().unwrap();
1963        // ~6k characters per read: three of them overflow a 4k window's budget.
1964        std::fs::write(dir.path().join("big.txt"), "abcde\n".repeat(1_000)).unwrap();
1965        let exec = WorktreeExecutor::new(dir.path());
1966        let seen = Arc::new(StdMutex::new(Vec::new()));
1967        let generator = Rerouting {
1968            seen: seen.clone(),
1969            turn_no: AtomicUsize::new(0),
1970        };
1971        let spec = spec_with(vec!["read_file"]);
1972
1973        // No caller pin: the adaptive case, which is most declarative runs.
1974        let _ = DeclarativeAgentRunner::new(&spec, &generator, &exec)
1975            .run("read big.txt")
1976            .await;
1977
1978        let seen = seen.lock().unwrap();
1979        assert!(seen.len() >= 4, "at least four turns ran");
1980        assert_eq!(seen[0].0, None, "turn 1 is unpinned — nothing served yet");
1981        assert_eq!(
1982            seen[1].0.as_deref(),
1983            Some("big-model"),
1984            "turn 2 must be addressed to the model that served turn 1"
1985        );
1986        // …and preferring that model must not become a HARD pin. `strict_model`
1987        // suppresses the on-device last-resort fallback, so flipping it true on
1988        // a route this loop merely learned would let one transient cloud blip
1989        // end an unpinned run with `inference failed` on a machine with a
1990        // working local model.
1991        assert!(
1992            seen.iter().all(|(_, strict, _)| !*strict),
1993            "an unpinned run must never send strict_model"
1994        );
1995        // Turn 3 rerouted to the 4k model, so turn 4 must fit ITS budget.
1996        let fourth = &seen[3].2;
1997        let measured = car_inference::media_tokens::request_prompt_tokens(
1998            "",
1999            None,
2000            None,
2001            None,
2002            Some(fourth.as_slice()),
2003        );
2004        assert!(
2005            measured <= history_budget(4_096),
2006            "turn 4 must fit the rerouted model's budget: {measured} > {}",
2007            history_budget(4_096)
2008        );
2009    }
2010
2011    /// Drive one turn against a generator that reports `multiplier` times the
2012    /// chars/4 estimate as its prompt size, and hand back the SECOND request's
2013    /// messages. The tool result is sized to sit UNDER an 8k budget by the raw
2014    /// estimate and OVER it once the provider's own accounting is applied —
2015    /// which is the whole point: the two passes must not disagree.
2016    const SCALE_TEST_WINDOW: usize = 200_000;
2017
2018    async fn second_turn_under_reported_scale(multiplier: u64) -> Vec<Message> {
2019        struct Reporting {
2020            seen: Arc<StdMutex<Vec<Vec<Message>>>>,
2021            cursor: AtomicUsize,
2022            multiplier: u64,
2023        }
2024        #[async_trait]
2025        impl TurnGenerator for Reporting {
2026            async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
2027                let msgs = req
2028                    .messages
2029                    .clone()
2030                    .expect("the runner always sets messages");
2031                self.seen.lock().unwrap().push(msgs.clone());
2032                let estimate = car_inference::media_tokens::request_prompt_tokens(
2033                    "",
2034                    None,
2035                    None,
2036                    None,
2037                    Some(msgs.as_slice()),
2038                ) as u64;
2039                let i = self.cursor.fetch_add(1, Ordering::SeqCst);
2040                let mut result = if i == 0 {
2041                    turn(
2042                        "",
2043                        json!([{"id":"c1","name":"read_file","arguments":{"path":"medium.txt"}}]),
2044                    )
2045                } else {
2046                    turn("done", json!([]))
2047                };
2048                // Ground truth from the provider, the input the compaction
2049                // decision prefers over its own estimate.
2050                result.usage = Some(car_inference::TokenUsage {
2051                    prompt_tokens: estimate * self.multiplier,
2052                    ..Default::default()
2053                });
2054                Ok(result)
2055            }
2056            fn context_window(&self, _model: &str) -> usize {
2057                SCALE_TEST_WINDOW
2058            }
2059        }
2060
2061        let dir = tempfile::tempdir().unwrap();
2062        // Sized so the second turn sits BETWEEN the two measures: under the
2063        // 150k-token budget by the raw chars/4 estimate (~90k), over it once the
2064        // provider's 2× accounting is applied (~180k). `read_file` numbers its
2065        // lines, so the result is wider than the file.
2066        std::fs::write(dir.path().join("medium.txt"), "abcdefghij\n".repeat(14_700)).unwrap();
2067        let exec = WorktreeExecutor::new(dir.path());
2068        let seen = Arc::new(StdMutex::new(Vec::new()));
2069        let generator = Reporting {
2070            seen: seen.clone(),
2071            cursor: AtomicUsize::new(0),
2072            multiplier,
2073        };
2074        let mut spec = spec_with(vec!["read_file"]);
2075        // A substantial system prompt, so the FIRST request (the one the
2076        // provider reports on) is dominated by the history rather than by the
2077        // tool definitions. `compact_history_measured` derives its scale from
2078        // reported-vs-estimated over the covered prefix INCLUDING the fixed
2079        // overhead; a two-line system prompt would leave the tool defs dwarfing
2080        // the prefix and the 25% gate would never open, which is a property of
2081        // the fixture, not of the code under test.
2082        spec.identity = "You answer questions carefully. ".repeat(2_500);
2083
2084        let _ = DeclarativeAgentRunner::new(&spec, &generator, &exec)
2085            .with_model(Some("scripted".into()))
2086            .run("read medium.txt")
2087            .await;
2088
2089        let seen = seen.lock().unwrap();
2090        assert_eq!(seen.len(), 2, "two turns");
2091        seen[1].clone()
2092    }
2093
2094    #[tokio::test]
2095    async fn the_shrink_pass_measures_in_the_same_scale_compaction_decided_on() {
2096        // Compaction runs on the provider's reported prompt size; the fallback
2097        // shrink pass used the raw chars/4 estimate. Once a provider reports
2098        // more than the estimate — 1.4× was the observed case — the second pass
2099        // saw a history that "fits" and left the request over the real budget.
2100        const WINDOW: usize = SCALE_TEST_WINDOW;
2101
2102        // 2×: under budget by the estimate, over it in the provider's tokens.
2103        let scaled = second_turn_under_reported_scale(2).await;
2104        let tool_result = scaled
2105            .iter()
2106            .find_map(|m| match m {
2107                Message::ToolResult { content, .. } => Some(content.clone()),
2108                _ => None,
2109            })
2110            .expect("the second turn carries the tool result");
2111        assert!(
2112            tool_result.contains(TOOL_RESULT_TRUNCATION_MARKER),
2113            "a history that only fits by the unscaled estimate must still be shrunk"
2114        );
2115        assert!(
2116            scaled_prompt_tokens(&scaled, 0, 2.0) <= history_budget(WINDOW),
2117            "and must land under the budget in the SAME scaled tokens: {} > {}",
2118            scaled_prompt_tokens(&scaled, 0, 2.0),
2119            history_budget(WINDOW)
2120        );
2121
2122        // Control at 1×: the same history genuinely fits, so nothing is cut —
2123        // the fix must not shrink what the run can afford to keep.
2124        let unscaled = second_turn_under_reported_scale(1).await;
2125        let tool_result = unscaled
2126            .iter()
2127            .find_map(|m| match m {
2128                Message::ToolResult { content, .. } => Some(content.clone()),
2129                _ => None,
2130            })
2131            .expect("the second turn carries the tool result");
2132        assert!(
2133            scaled_prompt_tokens(&unscaled, 0, 1.0) <= history_budget(WINDOW),
2134            "the control only means something if the raw history genuinely fits: {} > {}",
2135            scaled_prompt_tokens(&unscaled, 0, 1.0),
2136            history_budget(WINDOW)
2137        );
2138        assert!(
2139            !tool_result.contains(TOOL_RESULT_TRUNCATION_MARKER),
2140            "a history that fits must be left alone"
2141        );
2142    }
2143
2144    #[tokio::test]
2145    async fn a_model_that_reuses_call_0_every_turn_still_gets_every_result_truncated() {
2146        // The local tool-call parser restarts its index at every completion, so
2147        // a local model emits `call_0` turn after turn. Keying the shrink guard
2148        // by the model's string then makes turn 2's `call_0` look like turn 1's
2149        // already-shortened result, and every later oversized result ships
2150        // whole — an id collision replacing the content collision it fixed.
2151        struct RepeatIdGen {
2152            /// The messages each request carried.
2153            seen: Arc<StdMutex<Vec<Vec<Message>>>>,
2154        }
2155        #[async_trait]
2156        impl TurnGenerator for RepeatIdGen {
2157            async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
2158                self.seen.lock().unwrap().push(
2159                    req.messages
2160                        .clone()
2161                        .expect("the runner always sets messages"),
2162                );
2163                // The SAME id every turn, exactly as the local parser emits it.
2164                Ok(turn(
2165                    "",
2166                    json!([{"id":"call_0","name":"read_file","arguments":{"path":"big.txt"}}]),
2167                ))
2168            }
2169            fn context_window(&self, _model: &str) -> usize {
2170                8_192
2171            }
2172        }
2173
2174        let dir = tempfile::tempdir().unwrap();
2175        // One read is ~11k tokens — bigger than the whole 6,144-token budget, so
2176        // every turn's fresh result is over on its own.
2177        std::fs::write(dir.path().join("big.txt"), "abcdefghij\n".repeat(4_000)).unwrap();
2178        let exec = WorktreeExecutor::new(dir.path());
2179        let seen = Arc::new(StdMutex::new(Vec::new()));
2180        let generator = RepeatIdGen { seen: seen.clone() };
2181        let spec = spec_with(vec!["read_file"]);
2182
2183        let _ = DeclarativeAgentRunner::new(&spec, &generator, &exec)
2184            .with_model(Some("scripted".into()))
2185            .run("read it again")
2186            .await;
2187
2188        let seen = seen.lock().unwrap();
2189        assert!(seen.len() >= 4, "at least four turns ran");
2190        // Turn 4 carries the three results from turns 1-3, and nothing has been
2191        // dropped yet (8 messages: the pinned head plus the protected tail).
2192        let results: Vec<(&String, &String)> = seen[3]
2193            .iter()
2194            .filter_map(|m| match m {
2195                Message::ToolResult {
2196                    tool_use_id,
2197                    content,
2198                    ..
2199                } => Some((tool_use_id, content)),
2200                _ => None,
2201            })
2202            .collect();
2203        assert_eq!(results.len(), 3, "three tool results by turn 4");
2204        for (id, content) in &results {
2205            assert!(
2206                content.contains(TOOL_RESULT_TRUNCATION_MARKER),
2207                "every oversized result must be truncated, not just the first: {id}"
2208            );
2209            assert_eq!(
2210                content.matches(TOOL_RESULT_TRUNCATION_MARKER).count(),
2211                1,
2212                "and truncated exactly once — the guard still blocks a second cut: {id}"
2213            );
2214        }
2215        let ids: HashSet<&String> = results.iter().map(|(id, _)| *id).collect();
2216        assert_eq!(
2217            ids.len(),
2218            3,
2219            "the runner must give colliding model ids distinct run-unique keys: {ids:?}"
2220        );
2221    }
2222
2223    #[tokio::test]
2224    async fn a_caller_pin_stays_strict_while_a_learned_route_does_not() {
2225        // The two halves of the same switch. A caller who named a backbone
2226        // (`car agent run --model …`, an A/B arm) must get it or a loud error —
2227        // a silent swap to a weaker local model manufactures fake results. A
2228        // route this loop LEARNED carries no such promise, and `strict_model`
2229        // also suppresses the on-device last-resort fallback, so making it
2230        // strict would turn one transient cloud blip into a failed run.
2231        struct CapturingStrict {
2232            seen: Arc<StdMutex<Vec<(Option<String>, bool)>>>,
2233        }
2234        #[async_trait]
2235        impl TurnGenerator for CapturingStrict {
2236            async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
2237                self.seen
2238                    .lock()
2239                    .unwrap()
2240                    .push((req.model.clone(), req.params.strict_model));
2241                Ok(turn("done", json!([])))
2242            }
2243            fn context_window(&self, _model: &str) -> usize {
2244                100_000
2245            }
2246        }
2247
2248        let dir = tempfile::tempdir().unwrap();
2249        let exec = WorktreeExecutor::new(dir.path());
2250        let spec = spec_with(vec![]);
2251
2252        let seen = Arc::new(StdMutex::new(Vec::new()));
2253        let generator = CapturingStrict { seen: seen.clone() };
2254        let _ = DeclarativeAgentRunner::new(&spec, &generator, &exec)
2255            .with_model(Some("pinned-model".into()))
2256            .run("hello")
2257            .await;
2258        assert_eq!(
2259            seen.lock().unwrap().as_slice(),
2260            [(Some("pinned-model".to_string()), true)],
2261            "a caller's pin keeps strict_model"
2262        );
2263
2264        let seen = Arc::new(StdMutex::new(Vec::new()));
2265        let generator = CapturingStrict { seen: seen.clone() };
2266        let _ = DeclarativeAgentRunner::new(&spec, &generator, &exec)
2267            .run("hello")
2268            .await;
2269        assert_eq!(
2270            seen.lock().unwrap().as_slice(),
2271            [(None, false)],
2272            "an unpinned run never sends strict_model"
2273        );
2274    }
2275
2276    #[tokio::test]
2277    async fn an_oversized_tool_result_is_truncated_to_fit_the_window() {
2278        // Compaction cannot save this run: after one tool turn the history is
2279        // [System, User, Assistant, ToolResult] — all of it pinned head or
2280        // protected tail — so the shared function drops nothing and a single
2281        // `read_file` of a large file ships whole. On an 8k window that one
2282        // result is bigger than the entire budget.
2283        struct WindowedCapture {
2284            turns: Vec<InferenceResult>,
2285            cursor: AtomicUsize,
2286            seen: Arc<StdMutex<Vec<Vec<Message>>>>,
2287            window: usize,
2288        }
2289        #[async_trait]
2290        impl TurnGenerator for WindowedCapture {
2291            async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
2292                self.seen.lock().unwrap().push(
2293                    req.messages
2294                        .clone()
2295                        .expect("the runner always sets messages"),
2296                );
2297                let i = self.cursor.fetch_add(1, Ordering::SeqCst);
2298                self.turns
2299                    .get(i)
2300                    .cloned()
2301                    .ok_or_else(|| "script exhausted".into())
2302            }
2303            fn context_window(&self, _model: &str) -> usize {
2304                self.window
2305            }
2306        }
2307
2308        const WINDOW: usize = 8_192;
2309        let dir = tempfile::tempdir().unwrap();
2310        // ~40k characters: an ordinary source file, ~10k tokens, well past the
2311        // 6,144-token budget of an 8k window.
2312        std::fs::write(dir.path().join("big.txt"), "abcdefghij\n".repeat(4_000)).unwrap();
2313        let exec = WorktreeExecutor::new(dir.path());
2314        let seen = Arc::new(StdMutex::new(Vec::new()));
2315        let generator = WindowedCapture {
2316            turns: vec![
2317                turn(
2318                    "",
2319                    json!([{"id":"c1","name":"read_file","arguments":{"path":"big.txt"}}]),
2320                ),
2321                turn("done", json!([])),
2322            ],
2323            cursor: AtomicUsize::new(0),
2324            seen: seen.clone(),
2325            window: WINDOW,
2326        };
2327        let spec = spec_with(vec!["read_file"]);
2328
2329        let result = DeclarativeAgentRunner::new(&spec, &generator, &exec)
2330            .with_model(Some("scripted".into()))
2331            .run("read big.txt")
2332            .await;
2333        assert_eq!(result.output, "done");
2334
2335        let seen = seen.lock().unwrap();
2336        assert_eq!(seen.len(), 2, "two turns");
2337        let second = &seen[1];
2338        let tool_result = second
2339            .iter()
2340            .find_map(|m| match m {
2341                Message::ToolResult { content, .. } => Some(content.clone()),
2342                _ => None,
2343            })
2344            .expect("the second turn carries the tool result");
2345        assert!(
2346            tool_result.contains(TOOL_RESULT_TRUNCATION_MARKER),
2347            "the oversized tool result must say it was truncated"
2348        );
2349        assert!(
2350            tool_result.contains("not recoverable in this run"),
2351            "and must not imply the middle can be recovered: {}",
2352            &tool_result[..tool_result.len().min(400)]
2353        );
2354        let measured = car_inference::media_tokens::request_prompt_tokens(
2355            "",
2356            None,
2357            None,
2358            None,
2359            Some(second.as_slice()),
2360        );
2361        assert!(
2362            measured <= history_budget(WINDOW),
2363            "the second request must fit the budget: {measured} > {}",
2364            history_budget(WINDOW)
2365        );
2366    }
2367
2368    #[tokio::test]
2369    async fn an_unknown_context_window_leaves_the_history_unbounded_and_says_so() {
2370        // A model missing from the catalog resolves to 0, which disables
2371        // compaction — the honest behavior (there is no window to compact
2372        // against), and the reason the runner logs a loud warning on the first
2373        // turn instead of leaving the operator to infer it from degraded
2374        // answers. The warning is log-only; the observable contract is that
2375        // nothing is silently dropped.
2376        let spec = spec_with(vec!["write_file"]);
2377        let seen = run_growing_history(&spec, Some("unknown-model"), 0).await;
2378
2379        assert_eq!(
2380            seen.last().unwrap().0,
2381            UNBOUNDED_TWELFTH_TURN,
2382            "an unknown window must not fabricate a budget"
2383        );
2384    }
2385
2386    fn spec_with(tools: Vec<&str>) -> DeclarativeAgentSpec {
2387        DeclarativeAgentSpec {
2388            id: "t".into(),
2389            name: "T".into(),
2390            identity: "You answer.".into(),
2391            tools: tools.into_iter().map(String::from).collect(),
2392            denied_tools: vec![],
2393            standing_goal: "help".into(),
2394            goal: None,
2395            scenarios: vec![],
2396            enabled: true,
2397            context: ContextPolicy::default(),
2398        }
2399    }
2400
2401    #[test]
2402    fn strict_allowlist_empty_intersection_is_zero_tools() {
2403        let all = WorktreeExecutor::tool_defs();
2404        assert!(!all.is_empty());
2405        // Allowlist that matches nothing → ZERO, never all.
2406        assert!(select_tool_defs_strict(&all, &["nonexistent".into()], &[]).is_empty());
2407        // Empty allowlist → zero.
2408        assert!(select_tool_defs_strict(&all, &[], &[]).is_empty());
2409        // A real name → exactly that one.
2410        let sel = select_tool_defs_strict(&all, &["read_file".into()], &[]);
2411        assert_eq!(sel.len(), 1);
2412        assert_eq!(sel[0]["name"], "read_file");
2413        // Denied even if allowed.
2414        assert!(
2415            select_tool_defs_strict(&all, &["read_file".into()], &["read_file".into()]).is_empty()
2416        );
2417    }
2418
2419    #[tokio::test]
2420    async fn runner_returns_text_answer_with_no_tools() {
2421        let dir = tempfile::tempdir().unwrap();
2422        let exec = WorktreeExecutor::new(dir.path());
2423        let script = Script {
2424            turns: vec![turn("the answer is 42", json!([]))],
2425            cursor: AtomicUsize::new(0),
2426        };
2427        let spec = spec_with(vec![]);
2428        let runner = DeclarativeAgentRunner::new(&spec, &script, &exec);
2429        let r = runner.run("what is the answer?").await;
2430        assert_eq!(r.output, "the answer is 42");
2431        assert_eq!(r.tool_calls, 0);
2432        assert!(r.error.is_none());
2433    }
2434
2435    #[tokio::test]
2436    async fn runner_executes_an_allowed_tool() {
2437        let dir = tempfile::tempdir().unwrap();
2438        std::fs::write(dir.path().join("data.txt"), "secret content").unwrap();
2439        let exec = WorktreeExecutor::new(dir.path());
2440        let script = Script {
2441            turns: vec![
2442                turn(
2443                    "",
2444                    json!([{"id":"c1","name":"read_file","arguments":{"path":"data.txt"}}]),
2445                ),
2446                turn("the file says secret content", json!([])),
2447            ],
2448            cursor: AtomicUsize::new(0),
2449        };
2450        let spec = spec_with(vec!["read_file"]);
2451        let runner = DeclarativeAgentRunner::new(&spec, &script, &exec);
2452        let r = runner.run("read data.txt").await;
2453        assert!(r.output.contains("secret content"));
2454        assert_eq!(r.tool_calls, 1);
2455    }
2456
2457    #[tokio::test]
2458    async fn runner_replays_managed_responses_continuity_on_second_turn() {
2459        let dir = tempfile::tempdir().unwrap();
2460        std::fs::write(dir.path().join("data.txt"), "secret content").unwrap();
2461        let exec = WorktreeExecutor::new(dir.path());
2462        let reasoning = json!({
2463            "type": "reasoning",
2464            "id": "rs_coder",
2465            "status": "completed",
2466            "summary": [{"type": "summary_text", "text": "safe"}],
2467            "encrypted_content": "opaque-coder",
2468        });
2469        let mut first = turn(
2470            "reading",
2471            json!([{"id":"c1","name":"read_file","arguments":{"path":"data.txt"}}]),
2472        );
2473        first.provider_output_items = vec![reasoning.clone()];
2474        let seen = Arc::new(StdMutex::new(Vec::new()));
2475        let script = CapturingScript {
2476            turns: vec![first, turn("done", json!([]))],
2477            cursor: AtomicUsize::new(0),
2478            seen: seen.clone(),
2479        };
2480        let spec = spec_with(vec!["read_file"]);
2481        let runner = DeclarativeAgentRunner::new(&spec, &script, &exec);
2482
2483        let result = runner.run("read data.txt").await;
2484
2485        assert_eq!(result.output, "done");
2486        assert!(!result.output.contains("opaque-coder"));
2487        let seen = seen.lock().unwrap();
2488        let second = seen[1].messages.as_ref().expect("second-turn history");
2489        assert!(matches!(
2490            &second[2],
2491            Message::ProviderOutputItems { protocol, items }
2492                if protocol == car_inference::protocol::OPENAI_RESPONSES_PROTOCOL
2493                    && items == &vec![reasoning]
2494        ));
2495        assert!(matches!(
2496            &second[3],
2497            Message::Assistant { content, .. } if content == "reading"
2498        ));
2499        assert!(matches!(&second[4], Message::ToolResult { .. }));
2500    }
2501
2502    #[tokio::test]
2503    async fn runner_blocks_a_disallowed_tool_even_if_the_model_calls_it() {
2504        let dir = tempfile::tempdir().unwrap();
2505        let exec = WorktreeExecutor::new(dir.path());
2506        // Agent allows only read_file, but the model tries write_file.
2507        let script = Script {
2508            turns: vec![
2509                turn(
2510                    "",
2511                    json!([{"id":"c1","name":"write_file","arguments":{"path":"x","content":"y"}}]),
2512                ),
2513                turn("done", json!([])),
2514            ],
2515            cursor: AtomicUsize::new(0),
2516        };
2517        let spec = spec_with(vec!["read_file"]);
2518        let runner = DeclarativeAgentRunner::new(&spec, &script, &exec);
2519        let _ = runner.run("write a file").await;
2520        // The disallowed write must not have happened.
2521        assert!(!dir.path().join("x").exists(), "disallowed tool executed");
2522    }
2523
2524    #[tokio::test]
2525    async fn runner_redrives_until_manifest_goal_check_passes() {
2526        let dir = tempfile::tempdir().unwrap();
2527        let exec = WorktreeExecutor::new(dir.path());
2528        let script = Script {
2529            turns: vec![
2530                turn("not done yet", json!([])),
2531                turn(
2532                    "",
2533                    json!([{"id":"w1","name":"write_file","arguments":{"path":"done.txt","content":"ok"}}]),
2534                ),
2535                turn("done", json!([])),
2536            ],
2537            cursor: AtomicUsize::new(0),
2538        };
2539        let mut spec = spec_with(vec!["write_file"]);
2540        spec.goal = Some(DeclarativeGoal {
2541            check: crate::coder::test_cmds::file_exists("done.txt"),
2542            max_iterations: 3,
2543        });
2544        let runner = DeclarativeAgentRunner::new(&spec, &script, &exec);
2545        let r = runner.run("create done.txt").await;
2546
2547        assert_eq!(r.output, "done");
2548        assert!(r.error.is_none(), "{:?}", r.error);
2549        assert_eq!(r.turns, 3);
2550        assert_eq!(r.tool_calls, 1);
2551        assert_eq!(
2552            std::fs::read_to_string(dir.path().join("done.txt")).unwrap(),
2553            "ok"
2554        );
2555        let goal = r.goal.expect("goal audit is present");
2556        assert!(goal.met, "{goal:?}");
2557        assert!(goal.grounded, "{goal:?}");
2558        assert_eq!(goal.iterations, 2);
2559        assert_eq!(goal.last_exit_code, Some(0));
2560    }
2561
2562    #[tokio::test]
2563    async fn runner_reports_error_when_manifest_goal_never_passes() {
2564        let dir = tempfile::tempdir().unwrap();
2565        let exec = WorktreeExecutor::new(dir.path());
2566        let script = Script {
2567            turns: vec![
2568                turn("still missing", json!([])),
2569                turn("still missing", json!([])),
2570            ],
2571            cursor: AtomicUsize::new(0),
2572        };
2573        let mut spec = spec_with(vec![]);
2574        spec.goal = Some(DeclarativeGoal {
2575            check: crate::coder::test_cmds::file_exists("done.txt"),
2576            max_iterations: 2,
2577        });
2578        let runner = DeclarativeAgentRunner::new(&spec, &script, &exec);
2579        let r = runner.run("create done.txt").await;
2580
2581        assert!(r.error.as_deref().unwrap_or("").contains("goal_not_met"));
2582        let goal = r.goal.expect("goal audit is present");
2583        assert!(!goal.met);
2584        assert!(
2585            goal.grounded,
2586            "a deterministic nonzero shell exit is grounded evidence, not model judgment"
2587        );
2588        assert_eq!(goal.iterations, 2);
2589        assert_eq!(goal.last_exit_code, Some(1));
2590    }
2591
2592    // Exit 127 is /bin/sh's "command not found". Windows' cmd reports a
2593    // missing command as 9009, which this fix deliberately does not treat
2594    // (car#1523 names it a follow-up), so the fixture is Unix-only.
2595    #[cfg(unix)]
2596    #[tokio::test]
2597    async fn runner_stops_once_when_goal_check_is_not_a_runnable_command() {
2598        let dir = tempfile::tempdir().unwrap();
2599        let exec = WorktreeExecutor::new(dir.path());
2600        // Two turns: a regression that keeps retrying a broken check would
2601        // consume the second turn and fail the cursor assertion below.
2602        let script = Script {
2603            turns: vec![
2604                turn("working on it", json!([])),
2605                turn("must not run", json!([])),
2606            ],
2607            cursor: AtomicUsize::new(0),
2608        };
2609        let mut spec = spec_with(vec![]);
2610        spec.goal = Some(DeclarativeGoal {
2611            check: "definitely-not-a-real-command-xyz".into(),
2612            max_iterations: 8,
2613        });
2614        let runner = DeclarativeAgentRunner::new(&spec, &script, &exec);
2615        let r = runner.run("do the work").await;
2616
2617        let error = r.error.as_deref().unwrap_or_default();
2618        assert!(
2619            error.contains("not a runnable command"),
2620            "error must name the defect: {error}"
2621        );
2622        assert!(error.contains("fix or remove goal.check"));
2623        let goal = r.goal.expect("goal audit is present");
2624        assert!(!goal.met);
2625        assert!(
2626            goal.grounded,
2627            "a deterministic shell exit is grounded evidence, not model judgment"
2628        );
2629        assert_eq!(goal.iterations, 1);
2630        assert_eq!(goal.last_exit_code, Some(127));
2631        assert_eq!(
2632            script.cursor.load(Ordering::SeqCst),
2633            1,
2634            "a broken check must not re-drive the agent"
2635        );
2636    }
2637
2638    // Regression guard: only 126/127-style "cannot run" exits stop early;
2639    // plain exit 1 still means "not done yet" and keeps the full retry budget.
2640    // `exit 1` is a builtin of both sh and cmd (crate::coder::test_cmds::FAIL).
2641    #[tokio::test]
2642    async fn runner_still_retries_a_goal_check_that_exits_1() {
2643        let dir = tempfile::tempdir().unwrap();
2644        let exec = WorktreeExecutor::new(dir.path());
2645        let script = Script {
2646            turns: (0..8).map(|_| turn("not done yet", json!([]))).collect(),
2647            cursor: AtomicUsize::new(0),
2648        };
2649        let mut spec = spec_with(vec![]);
2650        spec.goal = Some(DeclarativeGoal {
2651            check: crate::coder::test_cmds::FAIL.to_string(),
2652            max_iterations: 8,
2653        });
2654        let runner = DeclarativeAgentRunner::new(&spec, &script, &exec);
2655        let r = runner.run("keep working").await;
2656
2657        assert!(r.error.as_deref().unwrap_or("").contains("goal_not_met"));
2658        let goal = r.goal.expect("goal audit is present");
2659        assert!(!goal.met);
2660        assert_eq!(
2661            goal.iterations, 8,
2662            "exit 1 means 'not done yet', not broken config — full retry budget"
2663        );
2664        assert_eq!(goal.last_exit_code, Some(1));
2665    }
2666
2667    #[tokio::test]
2668    async fn runner_honors_cancel_before_manifest_goal_redrive() {
2669        let dir = tempfile::tempdir().unwrap();
2670        let exec = WorktreeExecutor::new(dir.path());
2671        let cancel = Arc::new(AtomicBool::new(false));
2672        let script = Script {
2673            turns: vec![
2674                turn("still missing", json!([])),
2675                turn("should not run", json!([])),
2676            ],
2677            cursor: AtomicUsize::new(0),
2678        };
2679        let mut spec = spec_with(vec![]);
2680        spec.goal = Some(DeclarativeGoal {
2681            check: crate::coder::test_cmds::file_exists("done.txt"),
2682            max_iterations: 3,
2683        });
2684        let runner =
2685            DeclarativeAgentRunner::new(&spec, &script, &exec).with_cancel(Some(cancel.clone()));
2686
2687        cancel.store(true, Ordering::SeqCst);
2688        let r = runner.run("create done.txt").await;
2689
2690        assert_eq!(r.error.as_deref(), Some("cancelled"));
2691        assert_eq!(r.turns, 0);
2692        assert_eq!(script.cursor.load(Ordering::SeqCst), 0);
2693    }
2694
2695    #[test]
2696    fn build_prompt_preserves_a_400_character_description_as_the_spec_source() {
2697        let mut description = "Build an agent whose identity, standing goal, and scenarios follow this complete request: ".to_string();
2698        description.push_str(&"z".repeat(400 - description.len()));
2699        assert_eq!(description.chars().count(), 400);
2700
2701        let prompt = build_prompt(&description, &["read_file".into()], &[]);
2702
2703        assert!(prompt.contains(&format!("User request:\n{description}\n\nAVAILABLE TOOLS")));
2704        assert!(prompt.contains("\"identity\""));
2705        assert!(prompt.contains("\"standing_goal\""));
2706        assert!(prompt.contains("\"scenarios\""));
2707    }
2708
2709    #[tokio::test]
2710    async fn build_agent_generates_then_passes_scenarios() {
2711        let dir = tempfile::tempdir().unwrap();
2712        let exec = WorktreeExecutor::new(dir.path());
2713        // Turn 1: the agent spec (a greeter with one scenario).
2714        // Turn 2: the scenario run — agent answers containing "hello".
2715        let script = Script {
2716            turns: vec![
2717                turn(
2718                    r#"{"name":"Greeter","identity":"You greet people warmly.","tools":[],
2719                        "standing_goal":"greet","scenarios":[{"input":"hi","expect":"hello"}]}"#,
2720                    json!([]),
2721                ),
2722                turn("hello there, friend!", json!([])),
2723            ],
2724            cursor: AtomicUsize::new(0),
2725        };
2726        let cfg = BuildAgentConfig {
2727            agent_id: "greeter".into(),
2728            available_tools: vec!["read_file".into(), "write_file".into()],
2729            max_attempts: 3,
2730        };
2731        let outcome = build_agent("make a friendly greeter", &script, &exec, &cfg).await;
2732        assert!(outcome.passed, "issues: {:?}", outcome.issues);
2733        let spec = outcome.spec.unwrap();
2734        assert_eq!(spec.id, "greeter");
2735        assert_eq!(spec.name, "Greeter");
2736        assert_eq!(spec.scenarios.len(), 1);
2737    }
2738
2739    #[tokio::test]
2740    async fn build_agent_drops_invented_tool_names() {
2741        let dir = tempfile::tempdir().unwrap();
2742        let exec = WorktreeExecutor::new(dir.path());
2743        let script = Script {
2744            turns: vec![
2745                turn(
2746                    r#"{"name":"X","identity":"You help.","tools":["send_email","read_file"],
2747                        "standing_goal":"g","scenarios":[{"input":"q","expect":"a"}]}"#,
2748                    json!([]),
2749                ),
2750                turn("answer: a", json!([])),
2751            ],
2752            cursor: AtomicUsize::new(0),
2753        };
2754        let cfg = BuildAgentConfig {
2755            agent_id: "x".into(),
2756            available_tools: vec!["read_file".into()],
2757            max_attempts: 2,
2758        };
2759        let outcome = build_agent("intent", &script, &exec, &cfg).await;
2760        assert!(outcome.passed);
2761        // send_email isn't a real tool → dropped; read_file kept.
2762        assert_eq!(outcome.spec.unwrap().tools, vec!["read_file".to_string()]);
2763    }
2764
2765    #[tokio::test]
2766    async fn build_agent_parses_optional_goal_contract() {
2767        let dir = tempfile::tempdir().unwrap();
2768        let exec = WorktreeExecutor::new(dir.path());
2769        let script = Script {
2770            turns: vec![
2771                turn(
2772                    &json!({
2773                        "name":"Writer","identity":"You write the requested file.","tools":["write_file"],
2774                        "standing_goal":"write files",
2775                        // Surrounding whitespace exercises the parser's trim.
2776                        "goal":{"check": format!(" {} ", crate::coder::test_cmds::file_exists("done.txt")),
2777                                "max_iterations":99},
2778                        "scenarios":[{"input":"make it","expect":"done"}]
2779                    })
2780                    .to_string(),
2781                    json!([]),
2782                ),
2783                turn(
2784                    "",
2785                    json!([{"id":"w1","name":"write_file","arguments":{"path":"done.txt","content":"ok"}}]),
2786                ),
2787                turn("done", json!([])),
2788            ],
2789            cursor: AtomicUsize::new(0),
2790        };
2791        let cfg = BuildAgentConfig {
2792            agent_id: "writer".into(),
2793            available_tools: vec!["write_file".into()],
2794            max_attempts: 1,
2795        };
2796        let outcome = build_agent("make a file writer", &script, &exec, &cfg).await;
2797        assert!(outcome.passed, "issues: {:?}", outcome.issues);
2798        let goal = outcome.spec.unwrap().goal.expect("goal parsed");
2799        assert_eq!(goal.check, crate::coder::test_cmds::file_exists("done.txt"));
2800        assert_eq!(goal.max_iterations, 50);
2801    }
2802
2803    #[tokio::test]
2804    async fn build_agent_repairs_a_failing_scenario() {
2805        let dir = tempfile::tempdir().unwrap();
2806        let exec = WorktreeExecutor::new(dir.path());
2807        let script = Script {
2808            turns: vec![
2809                // Attempt 1 spec.
2810                turn(
2811                    r#"{"name":"A","identity":"v1","tools":[],"standing_goal":"g","scenarios":[{"input":"q","expect":"RIGHT"}]}"#,
2812                    json!([]),
2813                ),
2814                // Scenario run for attempt 1 → wrong.
2815                turn("WRONG", json!([])),
2816                // Attempt 2 spec (repaired).
2817                turn(
2818                    r#"{"name":"A","identity":"v2","tools":[],"standing_goal":"g","scenarios":[{"input":"q","expect":"RIGHT"}]}"#,
2819                    json!([]),
2820                ),
2821                // Scenario run for attempt 2 → right.
2822                turn("the RIGHT answer", json!([])),
2823            ],
2824            cursor: AtomicUsize::new(0),
2825        };
2826        let cfg = BuildAgentConfig {
2827            agent_id: "a".into(),
2828            available_tools: vec![],
2829            max_attempts: 3,
2830        };
2831        let outcome = build_agent("intent", &script, &exec, &cfg).await;
2832        assert!(outcome.passed);
2833        assert_eq!(outcome.attempts, 2);
2834        assert_eq!(outcome.spec.unwrap().identity, "v2");
2835    }
2836
2837    // Attempt 1's spec carries a goal.check that is not a real command; the
2838    // scenario must fail with the defect error after one iteration, and
2839    // attempt 2's generation prompt must carry that text back to the model.
2840    // Unix-only like the runner fixture above: /bin/sh reports 127, Windows'
2841    // cmd reports 9009.
2842    #[cfg(unix)]
2843    #[tokio::test]
2844    async fn build_agent_feeds_a_not_runnable_goal_back_into_the_next_attempt() {
2845        let dir = tempfile::tempdir().unwrap();
2846        let exec = WorktreeExecutor::new(dir.path());
2847        let seen = Arc::new(StdMutex::new(Vec::new()));
2848        let script = CapturingScript {
2849            turns: vec![
2850                // Attempt 1 spec: goal.check is prose-shaped, not a command.
2851                turn(
2852                    r#"{"name":"A","identity":"v1","tools":[],"standing_goal":"g",
2853                        "goal":{"check":"definitely-not-a-real-command-xyz","max_iterations":8},
2854                        "scenarios":[{"input":"q","expect":"RIGHT"}]}"#,
2855                    json!([]),
2856                ),
2857                // Scenario run for attempt 1: the output matches `expect`, but
2858                // the broken goal check fails the scenario after one iteration.
2859                turn("the RIGHT answer", json!([])),
2860                // Attempt 2 spec (repaired: `goal` removed).
2861                turn(
2862                    r#"{"name":"A","identity":"v2","tools":[],"standing_goal":"g","scenarios":[{"input":"q","expect":"RIGHT"}]}"#,
2863                    json!([]),
2864                ),
2865                // Scenario run for attempt 2.
2866                turn("the RIGHT answer", json!([])),
2867            ],
2868            cursor: AtomicUsize::new(0),
2869            seen: seen.clone(),
2870        };
2871        let cfg = BuildAgentConfig {
2872            agent_id: "a".into(),
2873            available_tools: vec![],
2874            max_attempts: 3,
2875        };
2876        let outcome = build_agent("intent", &script, &exec, &cfg).await;
2877        assert!(outcome.passed, "issues: {:?}", outcome.issues);
2878        assert_eq!(outcome.attempts, 2);
2879
2880        // Spec-generation prompts open with the design preamble; scenario-run
2881        // requests carry the scenario input instead, so this filter separates
2882        // the two request kinds sharing one generator.
2883        let prompts: Vec<String> = seen
2884            .lock()
2885            .unwrap()
2886            .iter()
2887            .map(|req| req.prompt.clone())
2888            .filter(|p| p.starts_with("You are designing"))
2889            .collect();
2890        assert_eq!(prompts.len(), 2, "exactly two spec-generation prompts");
2891        assert!(
2892            !prompts[0].contains("goal check is not a runnable command"),
2893            "attempt 1 has no feedback to carry yet"
2894        );
2895        assert!(
2896            prompts[1].contains("goal check is not a runnable command"),
2897            "attempt 2's generation prompt must carry attempt 1's defect text"
2898        );
2899        assert!(
2900            prompts[1].contains("definitely-not-a-real-command-xyz"),
2901            "the feedback must name the broken check so the model can repair it"
2902        );
2903    }
2904}