Skip to main content

speccoder_runtime/substrate/
attempt.rs

1//! `speccoder.task.attempt`: one bounded, governed attempt at a task
2//!.
3//!
4//! The model works through speccoder's tools; every call it asks for is
5//! classified by the policy first (deny → refused and told; approval → the
6//! attempt stops for a person; allow → dispatched through agentplane). The
7//! model's closing report is recorded as its claim — the verdict is computed
8//! afterwards by the verifier, never here.
9
10use crate::format::Wording;
11use agentplane::core::Label;
12use agentplane::model::{ToolDeclaration, Transcript};
13use agentplane::prelude::*;
14use agentplane::tools::ToolId;
15use serde_json::{Value, json};
16
17use super::model_config::{CacheTtl, PromptStyle};
18use super::propose::layout;
19use super::provenance;
20use crate::approvals::{Answer, Decision as Said, PresentContext, Request, Scope, Who};
21use crate::tools::policy::{Decision, Object, Policy};
22
23/// The capability the skill provides.
24pub const CAPABILITY: &str = "speccoder.task.attempt";
25
26/// The control tool for an honest exit. Handled by the skill, not
27/// dispatched anywhere.
28pub const REPORT_BLOCKED: &str = "report_blocked";
29
30/// What speccoder tells the model about an attempt; stable for the project.
31pub fn policy(w: &Wording) -> String {
32    format!(
33        "You are speccoder, a coding agent implementing exactly one {product} task in a git \
34worktree. Use the tools to inspect the code, make the smallest change that completes the \
35task — never work another task of the list is for — and run the project's checks with run_command to see whether it works. Stay inside \
36the task's scope: the task's paths are the plan's guess — where they differ from the code, \
37use the module that holds it; a change elsewhere that the task or its acceptance tests need \
38(an entry point they run, a module they import) is in scope and shown to the reviewer, never \
39a reason to stop: an entry point that bypasses the task's module is rewired to it. You \
40cannot edit the spec ({intent}), the checks' configuration or .git; such calls are refused. Some commands need a person's approval and will end the \
41attempt — prefer configured checks. If the task cannot be done as written — it \
42contradicts the spec, needs information or access you lack, or a test is wrong — call \
43report_blocked with the reason instead of working around it. Test files that existed \
44when the attempt started are frozen: add new tests in new files (or next to the code), \
45never by editing those. Never weaken, skip or delete tests to make checks pass. Never \
46return values copied from tests or examples in place of the specified behaviour (a \
47constant where the spec asks for a computed, fetched or current value is a cheat): if \
48the specified behaviour is impossible under the stated constraints, call report_blocked. \
49You are not the judge: when you finish, speccoder runs the checks itself and decides. \
50Finish with the requested structured report; its status is `incomplete` only when part of \
51this task's own work is left undone — a check you could not run, a test a later task makes \
52pass, or a finding in a frozen test file is no reason.",
53        product = w.product,
54        intent = w.intent_paths,
55    )
56}
57
58/// The final report's schema (the model's claim).
59#[must_use]
60pub fn report_schema() -> Value {
61    let strings = json!({ "type": "array", "items": { "type": "string" } });
62    json!({
63        "type": "object",
64        "properties": {
65            "summary": { "type": "string" },
66            "status": {
67                "type": "string",
68                "enum": ["done", "incomplete"],
69                "description": "done: this task's work is finished — even while tests that later tasks make pass still fail. incomplete: part of this task is left undone."
70            },
71            "files_changed": strings,
72            "checks_run": strings,
73            "notes": strings,
74        },
75        "required": ["summary", "status", "files_changed", "checks_run", "notes"],
76        "additionalProperties": false,
77    })
78}
79
80fn report_blocked() -> ToolDeclaration {
81    ToolDeclaration::new(
82        REPORT_BLOCKED,
83        "Stop the attempt because the task cannot be done as written; a person will look at the \
84reason. `kind`, with the `evidence` speccoder checks: `refused` — a call the task needs was \
85refused (the call); `spec_conflict` — the spec contradicts the task (its exact words); \
86`wrong_test` — a frozen test asserts the wrong thing (its path); `needs_information` — only a \
87person knows (the question). Scope is never a kind: when the tests run code that bypasses the \
88task's module (an entry point, a caller), wiring that code to it is part of the task.",
89        json!({
90            "type": "object",
91            "properties": {
92                "kind": { "type": "string", "enum": ["refused", "spec_conflict", "wrong_test", "needs_information"] },
93                "evidence": { "type": "string" },
94                "reason": { "type": "string" },
95            },
96            "required": ["kind", "evidence", "reason"],
97            "additionalProperties": false,
98        }),
99    )
100}
101
102/// What a `report_blocked` call is checked against: its evidence must be
103/// there, never the model's word alone.
104#[derive(Default)]
105pub(crate) struct StopGate {
106    /// The spec text the prompt carried: what a `spec_conflict` quotes.
107    pub spec: String,
108    /// The tests frozen when the attempt started: what a `wrong_test` names.
109    pub frozen: Vec<String>,
110    /// A person rejected a call for this task before.
111    pub rejected: bool,
112}
113
114/// What speccoder does with a `report_blocked` call.
115#[derive(Debug, PartialEq, Eq)]
116enum Stop {
117    /// The evidence is not there: the attempt goes on, told why.
118    Refused(String),
119    /// Asked once before stopping, with the turns left.
120    Asked(String),
121    /// The attempt stops; the reason the person reads.
122    Stopped(String),
123}
124
125/// Judges a stop by its evidence: a refused call stops at once; a quote,
126/// a frozen test or a question is asked about once first.
127fn judge_stop(
128    gate: &StopGate,
129    arguments: &Value,
130    refused: bool,
131    asked: bool,
132    turns_left: u32,
133) -> Stop {
134    let text = |k: &str| arguments[k].as_str().unwrap_or_default().trim();
135    let (kind, evidence, reason) = (text("kind"), text("evidence"), text("reason"));
136    let words = |t: &str| t.split_whitespace().collect::<Vec<_>>().join(" ");
137    let checked = match kind {
138        "refused" if refused => format!("refused call `{evidence}`"),
139        "refused" => return Stop::Refused("not stopped: no call in this attempt was refused or rejected".to_owned()),
140        "spec_conflict" if evidence.chars().count() >= 12 && words(&gate.spec).contains(&words(evidence)) => {
141            format!("the spec says “{evidence}”")
142        }
143        "spec_conflict" => {
144            return Stop::Refused("not stopped: the evidence must be the spec's exact words, at least a phrase; these are not in it".to_owned());
145        }
146        "wrong_test" if gate.frozen.iter().any(|f| f == evidence.trim_start_matches("./")) => {
147            format!("frozen test `{evidence}`")
148        }
149        "wrong_test" => return Stop::Refused("not stopped: the evidence must be the path of a test that existed when the attempt started".to_owned()),
150        "needs_information" if !evidence.is_empty() => format!("question: {evidence}"),
151        _ => return Stop::Refused("not stopped: `kind` is one of refused, spec_conflict, wrong_test, needs_information, with its `evidence`".to_owned()),
152    };
153    // The model's reason first, then what speccoder checked.
154    let said = match reason {
155        "" => checked,
156        r if r == evidence => r.to_owned(),
157        r => format!("{r} — {checked}"),
158    };
159    // Asked once only, and never on the last turn: no answer could follow.
160    if kind == "refused" || asked || turns_left == 0 {
161        return Stop::Stopped(said);
162    }
163    Stop::Asked(format!(
164        "Not stopped yet: speccoder asks once before a stop. {turns_left} turns are left. The task's paths are the plan's guess: wiring an entry point, caller or import the tests run to the task's module is part of the task, and a failing check is a reason to keep going. If the task still cannot be done as written, call report_blocked again."
165    ))
166}
167
168/// The question tool a hosted skill may use.
169pub const ASK_PERSON: &str = "ask_person";
170
171/// The `ask_person` declaration.
172#[must_use]
173pub fn ask_person() -> ToolDeclaration {
174    ToolDeclaration::new(
175        ASK_PERSON,
176        "Ask the person one question and wait for their answer. If nobody can answer, the reply says so.",
177        json!({
178            "type": "object",
179            "properties": { "question": { "type": "string" } },
180            "required": ["question"],
181            "additionalProperties": false,
182        }),
183    )
184}
185
186/// How an attempt waits for a person when a call needs one: on an
187/// agentplane human task the person decides — inline, from an editor, or
188/// later with `speccoder task approve` — while the run is suspended.
189#[derive(Debug, Clone)]
190pub struct Asker {
191    /// `feature/task`, shown to the person.
192    pub task: String,
193    /// The task's title.
194    pub task_title: String,
195    /// What the live run knows for the presentation.
196    pub context: PresentContext,
197    /// How long a call waits before it is denied as expired.
198    pub wait_secs: u64,
199}
200
201impl Asker {
202    /// The asker the journaled settings describe: `asks = "tasks"` waits on
203    /// a task; anything else (`"stop"`, a lane) stops the attempt instead.
204    #[must_use]
205    pub fn from_settings(
206        settings: &Value,
207        task: String,
208        task_title: String,
209        context: PresentContext,
210    ) -> Option<Self> {
211        (settings["asks"] == "tasks").then(|| Self {
212            task,
213            task_title,
214            context,
215            wait_secs: settings["wait_secs"].as_u64().unwrap_or(7 * 86_400),
216        })
217    }
218}
219
220/// Waits for a person's decision on `spec`, registering the `approval`
221/// obligation the first time: the run suspends until someone decides or the
222/// deadline passes. Journaled — replay reads the decision back.
223async fn wait_for_person(
224    cx: &mut StepCtx<'_>,
225    asker: &Asker,
226    obligation: &mut bool,
227    spec: &agentplane::core::TaskSpec,
228) -> Result<agentplane::core::Decision, SkillError> {
229    if !*obligation {
230        let minutes = u32::try_from(asker.wait_secs.div_ceil(60).max(1)).unwrap_or(u32::MAX);
231        cx.deadline(
232            crate::approvals::tasks::DEADLINE,
233            &agentplane::core::DeadlineSpec::minutes(minutes),
234            None,
235        )
236        .await?;
237        *obligation = true;
238    }
239    Ok(cx.task(spec).await?)
240}
241
242/// The coded skill.
243///
244/// Everything its decisions depend on — the bundle, the policy's command
245/// prefixes and the limits — arrives in the run's input, so it is journaled
246/// and strict replay needs no configuration. Only the prompt dialect
247/// is construction-time, and it is part of each model call's effect key.
248#[derive(Debug)]
249pub struct Attempt {
250    /// Prompt dialect.
251    pub style: PromptStyle,
252    /// Cache breakpoint lifetime.
253    pub cache_ttl: CacheTtl,
254    /// Where live progress goes; not journaled, `None` in replay.
255    pub progress: Option<std::sync::Arc<dyn crate::progress::Progress>>,
256    /// The project's spec format.
257    pub format: &'static dyn crate::format::Adapter,
258    /// The sandbox, as a person asked about a command is told; live only.
259    pub present: PresentContext,
260}
261
262/// A turn's progress, said only where no earlier pass said it: dropped
263/// while `replaying` (agentplane's `StepCtx::replaying`, read after each
264/// journaled step) holds.
265#[derive(Debug)]
266struct Quiet {
267    replaying: std::sync::Arc<std::sync::atomic::AtomicBool>,
268    next: std::sync::Arc<dyn crate::progress::Progress>,
269}
270
271impl crate::progress::Progress for Quiet {
272    fn event(&self, event: &crate::progress::Event) {
273        if !self.replaying.load(std::sync::atomic::Ordering::SeqCst) {
274            self.next.event(event);
275        }
276    }
277}
278
279/// The run input: `{ bundle, settings: { checks, allow, max_turns,
280/// max_tool_calls, frozen, … }, digest }` — `digest` is the verifier's
281/// account of the previous attempt, `null` on a first attempt.
282///
283/// Labelled by where each part came from: the bundle is the
284/// person's committed spec and plan (trusted); the digest quotes check
285/// output (`speccoder://verifier`); the settings list MCP tool descriptions
286/// (their servers).
287#[must_use]
288pub fn input(
289    bundle: Value,
290    settings: &Settings<'_>,
291    digest: Option<&Value>,
292    instructions: &[crate::instructions::Instruction],
293    test_layout: Option<&str>,
294) -> Tainted<Value> {
295    let digest = match digest.filter(|d| !d.is_null()) {
296        Some(d) => Tainted::with_label(d.clone(), provenance::from(provenance::VERIFIER)),
297        None => Tainted::trusted(Value::Null),
298    };
299    // Each MCP tool's description and schema are its server's words.
300    let mcp = Tainted::array(settings.mcp.iter().map(|t| {
301        Tainted::with_label(
302            serde_json::to_value(t).unwrap_or(Value::Null),
303            agentplane::core::Label::untrusted(provenance::tool(&t.server, &t.tool)),
304        )
305    }));
306    let trusted = |v: Value| Tainted::trusted(v);
307    let autopilot = settings.autopilot;
308    let planning = settings.planning;
309    let contained = settings.contained;
310    let settings = vec![
311        ("checks", trusted(json!(settings.checks))),
312        ("allow", trusted(json!(settings.allow))),
313        ("max_turns", trusted(json!(settings.max_turns))),
314        ("max_tool_calls", trusted(json!(settings.max_tool_calls))),
315        ("reasoning", trusted(json!(settings.reasoning))),
316        ("frozen", trusted(json!(settings.frozen))),
317        ("approved", trusted(json!(settings.approved))),
318        ("rejected", trusted(json!(settings.rejected))),
319        ("run_approved", trusted(json!(settings.run_approved))),
320        ("approved_once", trusted(json!(settings.approved_once))),
321        ("nudges", trusted(json!(settings.nudges))),
322        ("mcp", mcp),
323        ("root", trusted(json!(settings.root))),
324        (
325            "frozen_additions",
326            trusted(json!(settings.frozen_additions)),
327        ),
328        ("network_allow", trusted(json!(settings.network_allow))),
329        ("allow_prefix", trusted(json!(settings.allow_prefix))),
330        (
331            "network_allow_prefix",
332            trusted(json!(settings.network_allow_prefix)),
333        ),
334        ("run_id", trusted(json!(settings.run_id))),
335        ("asked", trusted(json!(settings.asked))),
336        ("budget", trusted(json!(settings.budget))),
337        ("left", trusted(json!(settings.left))),
338        ("prompts", trusted(json!(settings.prompts))),
339        ("asks", trusted(json!(settings.asks))),
340        ("wait_secs", trusted(json!(settings.wait_secs))),
341        ("in_place", trusted(json!(settings.in_place))),
342        ("hidden", trusted(json!(settings.hidden))),
343    ];
344    // Only an autopilot run carries the key: every other input reads as
345    // it was recorded before it existed.
346    let mut settings = settings;
347    if autopilot {
348        settings.push(("autopilot", trusted(json!(true))));
349    }
350    // Likewise only a planning task's.
351    if let Some(dir) = planning {
352        settings.push(("planning", trusted(json!(dir))));
353    }
354    // And only a run whose sandbox confines what it runs.
355    if let Some(m) = contained {
356        settings.push(("contained", trusted(json!(m))));
357    }
358    let settings = Tainted::object(settings);
359    Tainted::object([
360        ("bundle", Tainted::trusted(bundle)),
361        ("digest", digest),
362        ("settings", settings),
363        // The repository's text, as if the model had read the files itself;
364        // none is no text, and no label.
365        (
366            "instructions",
367            if instructions.is_empty() {
368                Tainted::trusted(json!([]))
369            } else {
370                Tainted::with_label(json!(instructions), provenance::from(provenance::WORKTREE))
371            },
372        ),
373        // The test layout speccoder detected from the checkout.
374        (
375            "test_layout",
376            match test_layout {
377                Some(t) => Tainted::with_label(json!(t), provenance::from(provenance::WORKTREE)),
378                None => Tainted::trusted(Value::Null),
379            },
380        ),
381    ])
382}
383
384/// A refusal speccoder composes about a call quotes it — the tool, path or
385/// command the model chose — so it carries the call's label; only
386/// constant replies are trusted.
387fn refusal(text: String, call: &agentplane::core::Label) -> Tainted<String> {
388    Tainted::with_label(text, call.clone())
389}
390
391/// The parts of an attempt's input its messages quote.
392const MESSAGE_PARTS: [&str; 7] = [
393    "/bundle",
394    "/instructions",
395    "/test_layout",
396    "/digest",
397    "/settings/nudges",
398    "/settings/rejected",
399    "/settings/mcp",
400];
401
402/// The label of the conversation part: the join of the labels the run was
403/// admitted with for the parts it quotes — read, never inferred. A part
404/// without a label of its own takes the whole input's (fails closed).
405fn conversation_label(input: &Tainted<Value>) -> agentplane::core::Label {
406    MESSAGE_PARTS
407        .iter()
408        .map(|p| input.label_at(p).unwrap_or_else(|| input.label()).clone())
409        .fold(agentplane::core::Label::trusted(), |acc, l| acc.join(&l))
410}
411
412/// What an attempt decides by, besides its bundle (all journaled).
413#[derive(Debug, Clone, Copy)]
414pub struct Settings<'a> {
415    /// Configured checks' argvs (their prefixes are allowed commands).
416    pub checks: &'a [Vec<String>],
417    /// Exact commands allowed (`[commands] allow`).
418    pub allow: &'a [Vec<String>],
419    /// Allowed command prefixes (`[commands] allow_prefix`).
420    pub allow_prefix: &'a [Vec<String>],
421    /// Model turns.
422    pub max_turns: u32,
423    /// Tool calls.
424    pub max_tool_calls: u32,
425    /// Ask for the model's reasoning summary on every turn.
426    pub reasoning: bool,
427    /// Test files frozen for this attempt.
428    pub frozen: &'a [String],
429    /// A person's approvals for this task, in this run.
430    pub approved: &'a [Object],
431    /// A person's rejections for this task.
432    pub rejected: &'a [crate::tools::policy::Rejection],
433    /// Objects a person approved for every task of this run.
434    pub run_approved: &'a [Object],
435    /// Objects a person approved for one call each (a call a lane stopped,
436    /// decided in its run's batch): this attempt may make each one time.
437    pub approved_once: &'a [Object],
438    /// A person's notes for this task (`nudge`).
439    pub nudges: &'a [String],
440    /// The project's MCP tools for this run.
441    pub mcp: &'a [crate::mcp::McpTool],
442    /// The worktree's absolute (canonical) path.
443    pub root: &'a str,
444    /// Frozen tests may gain lines without approval (`[tests] frozen`).
445    pub frozen_additions: bool,
446    /// How a call needing a person is handled: `"tasks"` (the attempt waits
447    /// on a human task) or `"stop"` (it ends `needs_approval`; lanes).
448    pub asks: &'a str,
449    /// How long a call waits for a person before it is denied as expired.
450    pub wait_secs: u64,
451    /// Exact commands that get the network (`[sandbox] network_allow`).
452    pub network_allow: &'a [Vec<String>],
453    /// Prefixes that get it (`[sandbox] network_allow_prefix`).
454    pub network_allow_prefix: &'a [Vec<String>],
455    /// The run the attempt belongs to.
456    pub run_id: &'a str,
457    /// Prompts the run has shown a person so far.
458    pub asked: u32,
459    /// `[approvals] budget`.
460    pub budget: u32,
461    /// A person is prompted inline (not `--no-prompt`).
462    pub prompts: bool,
463    /// An in-place attempt: git that moves the checkout is denied.
464    pub in_place: bool,
465    /// In place: what git ignored at the run's start, hidden from the tools.
466    pub hidden: &'a [String],
467    /// An autopilot run: a call that would ask is refused instead.
468    pub autopilot: bool,
469    /// A planning task: its unit's directory (worktree-relative), whose
470    /// design documents it writes ([`Policy::planning`]).
471    pub planning: Option<&'a str>,
472    /// The enforced sandbox's mechanism when contained calls run without
473    /// asking ([`Policy::contained`]).
474    pub contained: Option<&'a str>,
475    /// What is left of the run's cap for this attempt.
476    pub left: Option<Left>,
477}
478
479/// The calls consecutive denials of which end an attempt.
480pub const MAX_CONSECUTIVE_DENIALS: u32 = 3;
481
482/// The digest as the model reads it: facts from the verifier only.
483fn digest_text(digest: &Value) -> String {
484    let mut out = format!(
485        "# Previous attempt\n\nAttempt {} at this task was verified by speccoder and FAILED. Its \
486changes were discarded; the worktree is back where that attempt started. You start fresh — use \
487these facts from the verifier, check them yourself, and do not repeat the mistake.\n",
488        digest["attempt"].as_u64().unwrap_or(1)
489    );
490    let strings = |v: &Value| -> Vec<String> {
491        v.as_array()
492            .map(|a| {
493                a.iter()
494                    .filter_map(|x| x.as_str().map(str::to_owned))
495                    .collect()
496            })
497            .unwrap_or_default()
498    };
499    let reasons = strings(&digest["reasons"]);
500    if !reasons.is_empty() {
501        out.push_str("\n## Verdict reasons\n\n");
502        for r in reasons {
503            out.push_str(&format!("- {r}\n"));
504        }
505    }
506    for c in digest["checks"].as_array().into_iter().flatten() {
507        out.push_str(&format!(
508            "\n## Check `{}` ({}): `{}`\n",
509            c["name"].as_str().unwrap_or_default(),
510            c["status"].as_str().unwrap_or_default(),
511            strings(&c["argv"]).join(" ")
512        ));
513        let tests = strings(&c["failing_tests"]);
514        if !tests.is_empty() {
515            out.push_str(&format!("\nFailing tests: {}\n", tests.join(", ")));
516        }
517        out.push_str(&format!(
518            "\n```text\n{}\n```\n",
519            c["excerpt"].as_str().unwrap_or_default()
520        ));
521    }
522    let uncollected = strings(&digest["uncollected"]);
523    if !uncollected.is_empty() {
524        out.push_str("\n## Tests that never ran\n\n");
525        for l in uncollected {
526            out.push_str(&format!("- {l}\n"));
527        }
528    }
529    let discarded = strings(&digest["discarded"]);
530    if !discarded.is_empty() {
531        out.push_str(&format!(
532            "\n## Files the discarded attempt changed\n\n{}\n",
533            discarded.join("\n")
534        ));
535    }
536    out
537}
538
539fn argvs(v: &Value) -> Vec<Vec<String>> {
540    v.as_array()
541        .map(|a| {
542            a.iter()
543                .map(|argv| {
544                    argv.as_array()
545                        .map(|p| {
546                            p.iter()
547                                .filter_map(|x| x.as_str().map(str::to_owned))
548                                .collect()
549                        })
550                        .unwrap_or_default()
551                })
552                .collect()
553        })
554        .unwrap_or_default()
555}
556
557/// A person's decisions of the journaled input at `key`: none when absent,
558/// an error when present and unreadable — never read as "no decisions",
559/// which would drop rejections.
560fn decisions_of<T: serde::de::DeserializeOwned>(
561    settings: &Value,
562    key: &str,
563) -> Result<Vec<T>, SkillError> {
564    match &settings[key] {
565        Value::Null => Ok(Vec::new()),
566        v => serde_json::from_value(v.clone())
567            .map_err(|e| SkillError::Input(format!("settings.{key}: {e}"))),
568    }
569}
570
571/// The policy an attempt's journaled settings decide by: the checks, the
572/// allow lists, the frozen tests, the person's decisions, the MCP tools,
573/// the network rules, in place and autopilot. An external agent's attempt
574/// is decided by the same one.
575pub(crate) fn policy_of(
576    settings: &Value,
577    rules: &'static crate::format::Rules,
578) -> Result<Policy, SkillError> {
579    let frozen: Vec<String> = settings["frozen"]
580        .as_array()
581        .map(|a| {
582            a.iter()
583                .filter_map(|v| v.as_str().map(str::to_owned))
584                .collect()
585        })
586        .unwrap_or_default();
587    let approved: Vec<Object> = decisions_of(settings, "approved")?;
588    let rejected: Vec<crate::tools::policy::Rejection> = decisions_of(settings, "rejected")?;
589    let mcp: Vec<crate::mcp::McpTool> =
590        serde_json::from_value(settings["mcp"].clone()).unwrap_or_default();
591    let policy = Policy::new(&argvs(&settings["checks"]), &argvs(&settings["allow"]))
592        .with_prefixes(argvs(&settings["allow_prefix"]))
593        .with_network_prefixes(argvs(&settings["network_allow_prefix"]))
594        .rules(rules)
595        .with_frozen(frozen)
596        .with_decisions(approved, rejected)
597        .with_mcp(mcp.iter().map(|t| (t.name.clone(), t.decision.clone())))
598        .with_network(argvs(&settings["network_allow"]))
599        .with_hidden(
600            serde_json::from_value::<Vec<String>>(settings["hidden"].clone()).unwrap_or_default(),
601        );
602    let policy = if settings["in_place"] == true {
603        policy.in_place()
604    } else {
605        policy
606    };
607    let policy = if settings["autopilot"] == true {
608        policy.autopilot()
609    } else {
610        policy
611    };
612    let policy = match settings["contained"].as_str() {
613        Some(m) => policy.contained(m),
614        None => policy,
615    };
616    Ok(match settings["planning"].as_str() {
617        Some(dir) => policy.planning(dir),
618        None => policy,
619    })
620}
621
622#[async_trait]
623impl Skill for Attempt {
624    fn descriptor(&self) -> SkillDescriptor {
625        SkillDescriptor::new("speccoder.task.attempt").provides(CAPABILITY)
626    }
627
628    async fn invoke(
629        &self,
630        cx: &mut StepCtx<'_>,
631        input: Tainted<Value>,
632    ) -> Result<Outcome, SkillError> {
633        let (ttl, style) = (self.cache_ttl, self.style);
634        let settings = input.peek()["settings"].clone();
635        let task_name = format!(
636            "{}/{}",
637            input.peek()["bundle"]["feature"]
638                .as_str()
639                .unwrap_or_default(),
640            input.peek()["bundle"]["task"]["id"]
641                .as_str()
642                .unwrap_or_default()
643        );
644        let rejected: Vec<crate::tools::policy::Rejection> = decisions_of(&settings, "rejected")?;
645        let told = rejected;
646        let bundle = &input.peek()["bundle"];
647        let stop = StopGate {
648            spec: ["story", "plan", "constitution"]
649                .iter()
650                .filter_map(|k| bundle[*k].as_str())
651                .chain(bundle["task"]["title"].as_str())
652                .collect::<Vec<_>>()
653                .join("\n"),
654            frozen: settings["frozen"]
655                .as_array()
656                .into_iter()
657                .flatten()
658                .filter_map(|v| v.as_str().map(str::to_owned))
659                .collect(),
660            rejected: !told.is_empty(),
661        };
662        let mcp: Vec<crate::mcp::McpTool> =
663            serde_json::from_value(settings["mcp"].clone()).unwrap_or_default();
664        let planning = settings["planning"].as_str().map(str::to_owned);
665        let policy = policy_of(&settings, self.format.rules())?;
666        let max_turns = settings["max_turns"]
667            .as_u64()
668            .map_or(30, |n| u32::try_from(n).unwrap_or(u32::MAX))
669            .max(1);
670        let max_tool_calls = settings["max_tool_calls"]
671            .as_u64()
672            .map_or(100, |n| u32::try_from(n).unwrap_or(u32::MAX));
673        // Same stable prefix as `propose`, with the attempt policy in front;
674        // a retry's digest goes after it, so the cached prefix is shared.
675        let label = conversation_label(&input);
676        let raw = input.peek().clone();
677        let prompt = provenance::labelled_prompt(
678            {
679                let input = raw;
680                let mut p = layout(
681                    &input["bundle"],
682                    ttl,
683                    style,
684                    &self::policy(self.format.wording()),
685                    self.format,
686                );
687                let given: Vec<crate::instructions::Instruction> =
688                    serde_json::from_value(input["instructions"].clone()).unwrap_or_default();
689                if let Some(text) = crate::instructions::section(&given) {
690                    let (key, kind) = match style {
691                        PromptStyle::OpenAi => ("input", "input_text"),
692                        PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
693                            ("messages", "text")
694                        }
695                    };
696                    if let Some(content) = p[key][0]["content"].as_array_mut() {
697                        content.push(json!({ "type": kind, "text": text }));
698                    }
699                }
700                if let Some(text) = input["test_layout"].as_str().filter(|t| !t.is_empty()) {
701                    let (key, kind) = match style {
702                        PromptStyle::OpenAi => ("input", "input_text"),
703                        PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
704                            ("messages", "text")
705                        }
706                    };
707                    if let Some(content) = p[key][0]["content"].as_array_mut() {
708                        content.push(json!({ "type": kind, "text": text }));
709                    }
710                }
711                if !input["digest"].is_null() {
712                    let (key, kind) = match style {
713                        PromptStyle::OpenAi => ("input", "input_text"),
714                        PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
715                            ("messages", "text")
716                        }
717                    };
718                    if let Some(content) = p[key][0]["content"].as_array_mut() {
719                        content
720                            .push(json!({ "type": kind, "text": digest_text(&input["digest"]) }));
721                    }
722                }
723                // A planning task: where its documents go.
724                if let Some(dir) = &planning {
725                    let (key, kind) = match style {
726                        PromptStyle::OpenAi => ("input", "input_text"),
727                        PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
728                            ("messages", "text")
729                        }
730                    };
731                    let text = format!(
732                        "# A planning task\n\nThis task writes design documents, not code: write them under `{dir}/` (the plan, contracts, the data model, research and the like are yours to write there). The requirements, the task list and the person's review stay protected, as does everything else of the spec; change no source code. speccoder runs the checks afterwards.\n"
733                    );
734                    if let Some(content) = p[key][0]["content"].as_array_mut() {
735                        content.push(json!({ "type": kind, "text": text }));
736                    }
737                }
738                // The checks as configured: the commands that run without asking.
739                let checks: Vec<String> = argvs(&input["settings"]["checks"])
740                    .iter()
741                    .map(|argv| format!("- `{}`", json!(argv)))
742                    .collect();
743                if !checks.is_empty() {
744                    let (key, kind) = match style {
745                        PromptStyle::OpenAi => ("input", "input_text"),
746                        PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
747                            ("messages", "text")
748                        }
749                    };
750                    let text = format!(
751                        "# The checks\n\nRun them with run_command as these argvs, word for word; they need no approval:\n\n{}\n",
752                        checks.join("\n")
753                    );
754                    if let Some(content) = p[key][0]["content"].as_array_mut() {
755                        content.push(json!({ "type": kind, "text": text }));
756                    }
757                }
758                // A person's notes for this task.
759                let notes: Vec<String> = input["settings"]["nudges"]
760                    .as_array()
761                    .map(|a| {
762                        a.iter()
763                            .filter_map(|n| n.as_str().map(|n| format!("- {n}")))
764                            .collect()
765                    })
766                    .unwrap_or_default();
767                if !notes.is_empty() {
768                    let (key, kind) = match style {
769                        PromptStyle::OpenAi => ("input", "input_text"),
770                        PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
771                            ("messages", "text")
772                        }
773                    };
774                    let text = format!("# Notes from a person\n\n{}\n", notes.join("\n"));
775                    if let Some(content) = p[key][0]["content"].as_array_mut() {
776                        content.push(json!({ "type": kind, "text": text }));
777                    }
778                }
779                // A person's rejections, so the model does not ask again.
780                if !told.is_empty() {
781                    let (key, kind) = match style {
782                        PromptStyle::OpenAi => ("input", "input_text"),
783                        PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
784                            ("messages", "text")
785                        }
786                    };
787                    let lines: Vec<String> = told
788                        .iter()
789                        .map(|r| format!("- {}: {}", r.object.describe(), r.reason))
790                        .collect();
791                    let text = format!(
792                        "# Rejected by a person\n\nThese calls were rejected for this task; find another way or call report_blocked:\n\n{}\n",
793                        lines.join("\n")
794                    );
795                    if let Some(content) = p[key][0]["content"].as_array_mut() {
796                        content.push(json!({ "type": kind, "text": text }));
797                    }
798                }
799                if let PromptStyle::Parts { caches } = style {
800                    super::model_config::to_parts(&mut p, caches);
801                }
802                p
803            },
804            &label,
805        );
806        let mut tools = crate::tools::declarations();
807        tools.push(report_blocked());
808        // MCP tools the policy does not deny, as the journaled input lists them.
809        let routes: Vec<(String, String, String)> = mcp
810            .iter()
811            .filter(|t| t.decision != "deny")
812            .map(|t| (t.name.clone(), t.server.clone(), t.tool.clone()))
813            .collect();
814        tools.extend(mcp.iter().filter(|t| t.decision != "deny").map(|t| {
815            ToolDeclaration::new(t.name.clone(), t.description.clone(), t.schema.clone())
816        }));
817        // Journaled: whether this attempt asks inline decides its effects.
818        let ask = Asker::from_settings(
819            &settings,
820            task_name.clone(),
821            input.peek()["bundle"]["task"]["title"]
822                .as_str()
823                .unwrap_or_default()
824                .to_owned(),
825            PresentContext {
826                root: settings["root"].as_str().map(std::path::PathBuf::from),
827                ..self.present.clone()
828            },
829        );
830        let remembered: Vec<Object> = decisions_of(&settings, "run_approved")?;
831        let once: Vec<Object> = decisions_of(&settings, "approved_once")?;
832        // Untrusted text the prompt itself carries.
833        let mut parts = Vec::new();
834        let given: Vec<crate::instructions::Instruction> =
835            serde_json::from_value(input.peek()["instructions"].clone()).unwrap_or_default();
836        if let Some(text) = crate::instructions::section(&given) {
837            parts.push(crate::approvals::derived::Part {
838                from: provenance::WORKTREE.to_owned(),
839                turn: 0,
840                text,
841            });
842        }
843        if !input.peek()["digest"].is_null() {
844            parts.push(crate::approvals::derived::Part {
845                from: provenance::VERIFIER.to_owned(),
846                turn: 0,
847                text: crate::approvals::derived::text_of(&input.peek()["digest"]),
848            });
849        }
850        let number = |k: &str, d: u32| {
851            settings[k]
852                .as_u64()
853                .map_or(d, |n| u32::try_from(n).unwrap_or(u32::MAX))
854        };
855        tool_loop(
856            cx,
857            &prompt,
858            LoopSpec {
859                tools,
860                policy,
861                max_turns,
862                max_tool_calls,
863                reasoning: settings["reasoning"] == true,
864                schema: Some(report_schema()),
865                max_tokens: None,
866                left: serde_json::from_value(settings["left"].clone()).ok(),
867                reads: None,
868                halt: None,
869                routes,
870                root: settings["root"].as_str().map(str::to_owned),
871                frozen_additions: settings["frozen_additions"] == true,
872                ask,
873                run_approved: remembered,
874                once,
875                run: settings["run_id"].as_str().map(str::to_owned),
876                asking: Asking {
877                    asked: number("asked", 0),
878                    budget: number("budget", DEFAULT_BUDGET),
879                    prompts: settings["prompts"] == true,
880                },
881                parts,
882                mcp_digests: mcp
883                    .iter()
884                    .map(|t| (t.name.clone(), t.digest.clone()))
885                    .collect(),
886                stop,
887                observer: self.progress.clone().map(|p| {
888                    (
889                        task_name.rsplit('/').next().unwrap_or_default().to_owned(),
890                        p,
891                    )
892                }),
893                finish: |report| {
894                    // The model's own word that it did not finish is believed;
895                    // its word that it did is not (the verifier decides that).
896                    if report["status"] == "incomplete" {
897                        "incomplete"
898                    } else {
899                        "completed"
900                    }
901                },
902            },
903        )
904        .await
905    }
906}
907
908/// Turns an absolute `path` argument inside the workspace `root` into a
909/// workspace-relative one. Tools print absolute paths (Spec Kit's scripts
910/// do), and a model passes them on; outside the root a path stays as it is
911/// — and the policy refuses it.
912fn relativise(arguments: &mut Value, root: Option<&str>) {
913    let (Some(root), Some(path)) = (root, arguments.get("path").and_then(Value::as_str)) else {
914        return;
915    };
916    let root = root.trim_end_matches('/');
917    if let Some(rel) = path.strip_prefix(root).and_then(|r| r.strip_prefix('/')) {
918        let rel = if rel.is_empty() {
919            ".".to_owned()
920        } else {
921            rel.to_owned()
922        };
923        arguments["path"] = Value::String(rel);
924    }
925}
926
927/// Reports one logged tool call to whoever watches.
928fn emit(
929    observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
930    turn: u32,
931    entry: &Value,
932) {
933    emit_with_tail(observer, turn, entry, Vec::new());
934}
935
936/// [`emit`] with the last lines of a failed command's output (live only,
937/// never journaled).
938fn emit_with_tail(
939    observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
940    turn: u32,
941    entry: &Value,
942    tail: Vec<String>,
943) {
944    if let Some((task, p)) = observer {
945        let tool = entry["tool"].as_str().unwrap_or_default();
946        p.event(&crate::progress::Event::ToolCall {
947            task: task.clone(),
948            turn,
949            tool: tool.to_owned(),
950            target: crate::progress::target(tool, &entry["arguments"]),
951            decision: entry["decision"].as_str().unwrap_or_default().to_owned(),
952            ok: entry["ok"].as_bool(),
953            tail,
954            contained: entry["contained"] == true,
955        });
956    }
957}
958
959/// A person is needed for `asked`: the run tells whoever watches.
960fn needs_person(
961    observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
962    asked: &agentplane::model::ToolCall,
963    object: Option<&Object>,
964) {
965    if let Some((task, p)) = observer {
966        let (tool, arguments) = (asked.name.as_str(), &asked.arguments);
967        let target = crate::progress::target(tool, arguments);
968        let what: String = format!("{tool} {target}")
969            .chars()
970            .filter(|c| !c.is_control())
971            .take(60)
972            .collect();
973        p.event(&crate::progress::Event::ApprovalRequested {
974            task: task.clone(),
975            call: asked.id.clone(),
976            tool: tool.to_owned(),
977            what,
978            class: object
979                .map(|o| o.class().as_str().to_owned())
980                .unwrap_or_default(),
981            object: object.map(Object::short).unwrap_or_default(),
982        });
983    }
984}
985
986/// A person decided `asked`: the run tells whoever watches.
987fn person_decided(
988    observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
989    asked: &agentplane::model::ToolCall,
990    decision: &str,
991    scope: Option<&str>,
992    who: Option<&Who>,
993    object: Option<&Object>,
994) {
995    if let Some((task, p)) = observer {
996        p.event(&crate::progress::Event::ApprovalDecided {
997            task: task.clone(),
998            call: asked.id.clone(),
999            decision: decision.to_owned(),
1000            scope: scope.map(str::to_owned),
1001            by: who.map_or_else(|| "nobody (expired)".to_owned(), |w| w.name.clone()),
1002            how: who.map_or_else(|| "expired".to_owned(), |w| w.basis.clone()),
1003            class: object
1004                .map(|o| o.class().as_str().to_owned())
1005                .unwrap_or_default(),
1006            object: object.map(Object::short).unwrap_or_default(),
1007        });
1008    }
1009}
1010
1011/// What a call needing a person is asked by.
1012struct Question<'a> {
1013    asked: &'a agentplane::model::ToolCall,
1014    reason: &'a str,
1015    turn: u32,
1016    said: &'a str,
1017    asker: Option<&'a Asker>,
1018    policy: &'a Policy,
1019    object: Option<&'a Object>,
1020    remembered: &'a [(Object, &'static str)],
1021    parts: &'a [crate::approvals::derived::Part],
1022    run: Option<&'a str>,
1023}
1024
1025/// The journaled question for a call needing a person: its object, what
1026/// changed since an approval of the same name, where its arguments were
1027/// copied from, and the choices that leaves — all from journaled data.
1028fn question(q: &Question<'_>) -> Request {
1029    let (tool, arguments) = (q.asked.name.as_str(), &q.asked.arguments);
1030    let kind = crate::approvals::kind_of(tool, arguments);
1031    let risk = crate::approvals::risk(tool, q.reason);
1032    // Always Allow commits an exact entry: judged without the network.
1033    let always_risk = if kind == "network" {
1034        let mut bare = arguments.clone();
1035        if let Some(o) = bare.as_object_mut() {
1036            o.remove("network");
1037        }
1038        match q.policy.base(tool, &bare) {
1039            Decision::Allow => "low",
1040            Decision::Approval(r) => crate::approvals::risk(tool, &r),
1041            Decision::Deny(_) => "high",
1042        }
1043    } else {
1044        risk
1045    };
1046    let (sources, more) = crate::approvals::derived::sources(tool, arguments, q.parts);
1047    let changed = q.object.and_then(|o| {
1048        q.remembered
1049            .iter()
1050            .map(|(r, _)| r)
1051            .chain(q.policy.approved())
1052            .find_map(|earlier| o.changed_from(earlier))
1053    });
1054    Request {
1055        task: q.asker.map(|a| a.task.clone()).unwrap_or_default(),
1056        task_title: q.asker.map(|a| a.task_title.clone()).unwrap_or_default(),
1057        turn: q.turn,
1058        tool: tool.to_owned(),
1059        arguments: arguments.clone(),
1060        kind: kind.to_owned(),
1061        reason: q.reason.to_owned(),
1062        object: q.object.cloned(),
1063        changed,
1064        risk: risk.to_owned(),
1065        claim: crate::approvals::claim(q.said),
1066        options: crate::approvals::options(
1067            risk,
1068            q.object.is_some(),
1069            !sources.is_empty(),
1070            kind,
1071            always_risk,
1072        ),
1073        sources,
1074        more,
1075        run: q.run.map(str::to_owned),
1076        asked: 0,
1077        budget: 0,
1078        batched: false,
1079    }
1080}
1081
1082/// What a governed tool loop offers the model and asks of it.
1083pub(crate) struct LoopSpec {
1084    /// The tools declared to the model; any other name is refused.
1085    pub tools: Vec<ToolDeclaration>,
1086    /// Classifies every call before dispatch.
1087    pub policy: Policy,
1088    /// Model turns.
1089    pub max_turns: u32,
1090    /// Tool calls.
1091    pub max_tool_calls: u32,
1092    /// Ask each turn for the model's reasoning summary.
1093    pub reasoning: bool,
1094    /// The closing answer's schema; `None`: a text answer ends the turn.
1095    pub schema: Option<Value>,
1096    /// Declared tools served elsewhere: (name, server, tool) — MCP.
1097    pub routes: Vec<(String, String, String)>,
1098    /// The workspace's absolute path, from the journaled input: absolute
1099    /// paths inside it are made relative before the policy sees them.
1100    pub root: Option<String>,
1101    /// A write to a frozen test that only adds lines needs no approval.
1102    pub frozen_additions: bool,
1103    /// The status for a closing answer.
1104    pub finish: fn(&Value) -> &'static str,
1105    /// Ask a person instead of stopping when a call needs approval.
1106    pub ask: Option<Asker>,
1107    /// Objects a person approved for this run: allowed without asking.
1108    pub run_approved: Vec<Object>,
1109    /// Objects a person approved for one call each: the first call of
1110    /// each is allowed without asking, later ones ask again.
1111    pub once: Vec<Object>,
1112    /// Live progress: the task ID and where to report.
1113    pub observer: Option<(String, std::sync::Arc<dyn crate::progress::Progress>)>,
1114    /// The run the attempt belongs to (`run-<secs>-<hex>`).
1115    pub run: Option<String>,
1116    /// How a run asks: its count so far, its budget and whether a person is
1117    /// prompted at all.
1118    pub asking: Asking,
1119    /// Untrusted content the prompt carries (inlined worktree text, the
1120    /// verifier's digest): what derived arguments are found in.
1121    pub parts: Vec<crate::approvals::derived::Part>,
1122    /// MCP tools' digests by model-facing name: their objects.
1123    pub mcp_digests: std::collections::BTreeMap<String, String>,
1124    /// What a `report_blocked` call's evidence is checked against.
1125    pub stop: StopGate,
1126    /// Tokens a turn may use (Ask); `None`: the plane's budget alone.
1127    pub max_tokens: Option<u64>,
1128    /// What is left of the run's cap for this attempt.
1129    pub left: Option<Left>,
1130    /// The first read id to issue (`r<n>`), when the answer cites reads.
1131    pub reads: Option<u32>,
1132    /// Pressed when a person stops the turn (Ask): the model call streaming
1133    /// ends where it is (a recorded, metered completion) and none of its
1134    /// calls run. Not effect identity — replay returns the recorded call.
1135    pub halt: Option<agentplane::model::Stop>,
1136}
1137
1138/// A run's asking budget as an attempt sees it (all journaled).
1139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1140pub struct Asking {
1141    /// Prompts shown to a person in the run so far.
1142    pub asked: u32,
1143    /// `[approvals] budget`: at most this many per run.
1144    pub budget: u32,
1145    /// A person is prompted inline; `false` with `--no-prompt` (calls wait
1146    /// as ordinary approvals, nothing is counted).
1147    pub prompts: bool,
1148}
1149
1150impl Default for Asking {
1151    fn default() -> Self {
1152        Self {
1153            asked: 0,
1154            budget: DEFAULT_BUDGET,
1155            prompts: false,
1156        }
1157    }
1158}
1159
1160/// `[approvals] budget` unless configured.
1161pub use super::run_config::DEFAULT_BUDGET;
1162
1163/// What is left of the run's cap for one attempt, and the prices a dollar
1164/// cap is counted at (journaled with the attempt's settings).
1165#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
1166pub struct Left {
1167    /// Tokens or dollars left.
1168    pub cap: super::run_config::Cap,
1169    /// `[model]`'s prices; a dollar cap is refused without them.
1170    pub prices: Option<super::model_config::Prices>,
1171}
1172
1173impl Left {
1174    /// What `usage` spent, in the cap's own unit (tokens, or millionths of a dollar).
1175    #[must_use]
1176    pub fn spent(&self, usage: &agentplane::model::Usage) -> u64 {
1177        match self.cap {
1178            super::run_config::Cap::Tokens(_) => usage.input_tokens + usage.output_tokens,
1179            super::run_config::Cap::Usd(_) => self.prices.map_or(0, |p| {
1180                micros(p.usd(
1181                    usage.input_tokens,
1182                    usage.output_tokens,
1183                    usage.cache_read_tokens,
1184                    usage.cache_write_tokens,
1185                ))
1186            }),
1187        }
1188    }
1189
1190    /// Whether `usage` used all that is left.
1191    #[must_use]
1192    pub fn reached(&self, usage: &agentplane::model::Usage) -> bool {
1193        self.spent(usage) >= self.cap.amount()
1194    }
1195
1196    /// The stop's words: what the attempt used of what was left.
1197    #[must_use]
1198    pub fn reason(&self, usage: &agentplane::model::Usage) -> String {
1199        format!(
1200            "the run's cap: this attempt used {} of the {} left (`run --cap`, `[budget] cap`)",
1201            self.cap.with(self.spent(usage)).rounded(),
1202            self.cap.rounded()
1203        )
1204    }
1205}
1206
1207/// A recorded usage object (`input_tokens`, …) as the model's usage.
1208#[must_use]
1209pub fn usage_of(v: Option<&Value>) -> agentplane::model::Usage {
1210    let n = |k: &str| v.and_then(|v| v[k].as_u64()).unwrap_or(0);
1211    agentplane::model::Usage {
1212        input_tokens: n("input_tokens"),
1213        output_tokens: n("output_tokens"),
1214        cache_read_tokens: n("cache_read_tokens"),
1215        cache_write_tokens: n("cache_write_tokens"),
1216        ..agentplane::model::Usage::default()
1217    }
1218}
1219
1220/// Dollars as millionths, rounded.
1221#[must_use]
1222#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1223pub fn micros(usd: f64) -> u64 {
1224    (usd * 1e6).round().max(0.0) as u64
1225}
1226
1227/// The governed tool loop shared by attempts and convergence: every call is
1228/// classified first (deny → refused and told; approval → stop; allow →
1229/// dispatched through agentplane); the closing structured answer ends it.
1230pub(crate) async fn tool_loop(
1231    cx: &mut StepCtx<'_>,
1232    prompt: &Tainted<Value>,
1233    spec: LoopSpec,
1234) -> Result<Outcome, SkillError> {
1235    // What the conversation has read, with its labels: agentplane checks
1236    // every model call against the join.
1237    let mut transcript = Transcript::new(prompt);
1238    tool_turn(cx, prompt, spec, &mut transcript).await
1239}
1240
1241/// [`tool_loop`] continuing a conversation the caller holds: Ask's turns
1242/// share one transcript, each person's message told before the turn.
1243pub(crate) async fn tool_turn(
1244    cx: &mut StepCtx<'_>,
1245    prompt: &Tainted<Value>,
1246    spec: LoopSpec,
1247    transcript: &mut Transcript,
1248) -> Result<Outcome, SkillError> {
1249    let LoopSpec {
1250        tools,
1251        policy,
1252        max_turns,
1253        max_tool_calls,
1254        reasoning,
1255        schema,
1256        routes,
1257        root,
1258        frozen_additions,
1259        finish,
1260        ask,
1261        run_approved,
1262        mut once,
1263        observer,
1264        run,
1265        asking,
1266        mut parts,
1267        mcp_digests,
1268        stop,
1269        max_tokens,
1270        left,
1271        reads,
1272        halt,
1273    } = spec;
1274    // What an earlier pass already showed is not shown again: after a
1275    // person's answer the run is invoked again and every journaled call
1276    // returns again (set after each journaled step, from `cx.replaying()`).
1277    let replaying = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(cx.replaying()));
1278    let observer = observer.map(|(task, next)| {
1279        let quiet: std::sync::Arc<dyn crate::progress::Progress> = std::sync::Arc::new(Quiet {
1280            replaying: std::sync::Arc::clone(&replaying),
1281            next,
1282        });
1283        (task, quiet)
1284    });
1285    let seen = |cx: &StepCtx<'_>| {
1286        replaying.store(cx.replaying(), std::sync::atomic::Ordering::SeqCst);
1287    };
1288    // The next read id this turn issues, when reads are cited.
1289    let mut next_read = reads;
1290    let mut proposed = false;
1291    // Objects allowed without asking: this run's, then what a person
1292    // answered "for the task" or "for this run" in this attempt.
1293    let mut remembered: Vec<(Object, &'static str)> =
1294        run_approved.into_iter().map(|g| (g, "run-grant")).collect();
1295    let mut prompted = asking.asked;
1296    let mut told_budget = false;
1297    let catalog = crate::tools::catalog();
1298
1299    let mut conversation: Label = transcript.label().clone();
1300    let mut calls = 0u32;
1301    let mut denied_in_a_row = 0u32;
1302    // Whether the model was asked once before a stop.
1303    let mut asked_to_stop = false;
1304    // Whether this attempt registered the `approval` obligation yet.
1305    let mut obligation = false;
1306    let mut usage = agentplane::model::Usage::default();
1307    let mut log: Vec<Value> = Vec::new();
1308
1309    let end = |status: &str,
1310               extra: Value,
1311               log: &[Value],
1312               usage: agentplane::model::Usage,
1313               conversation: &Label,
1314               prompted: u32| {
1315        let mut out = json!({
1316            "status": status,
1317            "tool_calls": log,
1318            "asked": prompted,
1319            "usage": {
1320                "input_tokens": usage.input_tokens,
1321                "output_tokens": usage.output_tokens,
1322                "cache_read_tokens": usage.cache_read_tokens,
1323                "cache_write_tokens": usage.cache_write_tokens,
1324            },
1325        });
1326        if let (Some(o), Some(e)) = (out.as_object_mut(), extra.as_object()) {
1327            o.extend(e.clone());
1328        }
1329        Ok(Outcome::done(Tainted::with_label(
1330            out,
1331            conversation.clone(),
1332        )))
1333    };
1334
1335    for turn in 0..max_turns {
1336        // A turn's own token limit, checked before each call.
1337        if let Some(max) = max_tokens
1338            && usage.input_tokens + usage.output_tokens >= max
1339        {
1340            return end(
1341                "budget",
1342                json!({ "reason": format!("this turn used {} of its {max} tokens", usage.input_tokens + usage.output_tokens) }),
1343                &log,
1344                usage,
1345                &conversation,
1346                prompted,
1347            );
1348        }
1349        // The run's cap: what is left of it, checked before each call.
1350        if let Some(l) = left
1351            && l.reached(&usage)
1352        {
1353            return end(
1354                "budget_exhausted",
1355                json!({ "reason": l.reason(&usage) }),
1356                &log,
1357                usage,
1358                &conversation,
1359                prompted,
1360            );
1361        }
1362        // The prompt was built from labelled parts (`/system` is speccoder's
1363        // constant instruction); what the conversation read since rides in
1364        // the transcript, so the request is labelled by its real sources.
1365        let tools_now = tools.clone();
1366        let schema_now = schema.clone();
1367        let read_so_far = &*transcript;
1368        let stop_now = halt.clone();
1369        let completion = cx
1370            .complete_with(prompt, move |call| {
1371                let call = call.with_tools(tools_now).continuing(read_so_far);
1372                let call = match stop_now {
1373                    Some(stop) => call.stopped_by(stop),
1374                    None => call,
1375                };
1376                let call = match schema_now {
1377                    Some(schema) => call.expecting(schema),
1378                    None => call,
1379                };
1380                if reasoning {
1381                    call.with_reasoning_summary()
1382                } else {
1383                    call
1384                }
1385            })
1386            .await?;
1387        seen(cx);
1388        conversation = transcript.label().join(completion.label());
1389        usage = usage.plus(completion.peek().usage);
1390        if let Some((task, p)) = &observer {
1391            p.event(&crate::progress::Event::Usage {
1392                task: task.clone(),
1393                input_tokens: usage.input_tokens,
1394                output_tokens: usage.output_tokens,
1395                cache_read_tokens: usage.cache_read_tokens,
1396                cache_write_tokens: usage.cache_write_tokens,
1397                usd: None,
1398            });
1399        }
1400
1401        // The model's summary of its reasoning, when asked and given: its
1402        // account of why, a claim like its words.
1403        if let (Some((task, p)), Some(reasoning)) =
1404            (&observer, completion.peek().reasoning.as_deref())
1405            && !reasoning.trim().is_empty()
1406        {
1407            let text: String = crate::approvals::present::escape(reasoning.trim())
1408                .chars()
1409                .take(4000)
1410                .collect();
1411            p.event(&crate::progress::Event::Reasoning {
1412                task: task.clone(),
1413                turn: turn + 1,
1414                text,
1415            });
1416        }
1417        if completion.peek().stopped
1418            || halt
1419                .as_ref()
1420                .is_some_and(agentplane::model::Stop::is_stopped)
1421        {
1422            return end(
1423                "stopped",
1424                json!({ "text": completion.peek().text }),
1425                &log,
1426                usage,
1427                &conversation,
1428                prompted,
1429            );
1430        }
1431        if completion.peek().truncated {
1432            return Ok(Outcome::fail(
1433                "the model ran out of output budget mid-turn; nothing it was still writing was executed — raise [model] max_output_tokens",
1434            ));
1435        }
1436        let asked_calls = transcript.asked(&completion);
1437        // The model's own words beside its calls, live: its account, a
1438        // claim. A turn without calls is its final answer, reported as such;
1439        // its commentary, where the provider marks one, is still words.
1440        let said = super::said(completion.peek());
1441        if let (Some((task, p)), Some(said)) = (observer.as_ref(), said.as_deref()) {
1442            let text: String = crate::approvals::present::escape(said)
1443                .chars()
1444                .take(2000)
1445                .collect();
1446            p.event(&crate::progress::Event::Said {
1447                task: task.clone(),
1448                turn: turn + 1,
1449                text,
1450            });
1451        }
1452        if asked_calls.is_empty() && schema.is_none() {
1453            return end(
1454                "answered",
1455                json!({ "text": completion.peek().text }),
1456                &log,
1457                usage,
1458                &conversation,
1459                prompted,
1460            );
1461        }
1462        if asked_calls.is_empty() {
1463            let report = completion
1464                .peek()
1465                .structured
1466                .clone()
1467                .unwrap_or_else(|| json!({ "summary": completion.peek().text, "status": "incomplete", "files_changed": [], "checks_run": [], "notes": ["the answer was not the requested structure"] }));
1468            let status = finish(&report);
1469            return end(
1470                status,
1471                json!({ "report": report }),
1472                &log,
1473                usage,
1474                &conversation,
1475                prompted,
1476            );
1477        }
1478
1479        for mut asked in asked_calls {
1480            relativise(&mut asked.arguments, root.as_deref());
1481            // Only speccoder sets the object a call is checked against.
1482            if let Some(o) = asked.arguments.as_object_mut() {
1483                o.remove(crate::tools::EXPECT);
1484            }
1485            calls += 1;
1486            if calls > max_tool_calls {
1487                return end(
1488                    "budget_exhausted",
1489                    json!({ "reason": format!("more than {max_tool_calls} tool calls (`[budget] max_tool_calls`)") }),
1490                    &log,
1491                    usage,
1492                    &conversation,
1493                    prompted,
1494                );
1495            }
1496            if asked.name == ASK_PERSON && tools.iter().any(|t| t.name == ASK_PERSON) {
1497                let question = asked.arguments["question"]
1498                    .as_str()
1499                    .unwrap_or_default()
1500                    .to_owned();
1501                // A person's answer is their input, recorded with who gave it
1502                // — never speccoder's own trusted text.
1503                let decided = match &ask {
1504                    Some(asker) => Some(
1505                        wait_for_person(
1506                            cx,
1507                            asker,
1508                            &mut obligation,
1509                            &crate::approvals::tasks::question_task(&asker.task, &question),
1510                        )
1511                        .await?,
1512                    ),
1513                    None => None,
1514                };
1515                let read = decided.as_ref().map(crate::approvals::tasks::read);
1516                let by = read.as_ref().and_then(|r| r.by.clone());
1517                let entry = json!({
1518                    "tool": ASK_PERSON,
1519                    "arguments": asked.arguments,
1520                    "decision": "allow",
1521                    "answered": read.as_ref().is_some_and(|r| r.text.is_some()),
1522                    // The person's words, as the record of what they said.
1523                    "answer": read.as_ref().and_then(|r| r.text.clone()),
1524                    "operator": by.as_ref().map(Who::view),
1525                    "via": by.as_ref().map(|w| w.via.clone()),
1526                });
1527                emit(observer.as_ref(), turn + 1, &entry);
1528                log.push(entry);
1529                let text = match (&read, &by) {
1530                    (Some(r), _) if r.text.is_some() => r.text.clone().unwrap_or_default(),
1531                    (Some(_), Some(w)) => format!("{} declined to answer.", w.name),
1532                    _ => "Nobody can answer now. Record the question in the artifact as [NEEDS CLARIFICATION: <question>] and continue.".to_owned(),
1533                };
1534                let label = match &by {
1535                    Some(_) => provenance::from(provenance::PERSON),
1536                    None => Label::trusted(),
1537                };
1538                transcript.answered(
1539                    asked,
1540                    &Tainted::with_label(json!({ "answer": text }), label),
1541                );
1542                continue;
1543            }
1544            if asked.name == REPORT_BLOCKED && tools.iter().any(|t| t.name == REPORT_BLOCKED) {
1545                let refused = stop.rejected || log.iter().any(|e| e["decision"] == "deny");
1546                let left = max_turns - turn - 1;
1547                match judge_stop(&stop, &asked.arguments, refused, asked_to_stop, left) {
1548                    Stop::Stopped(reason) => {
1549                        let entry = json!({ "tool": REPORT_BLOCKED, "arguments": asked.arguments, "decision": "stop" });
1550                        emit(observer.as_ref(), turn + 1, &entry);
1551                        log.push(entry);
1552                        return end(
1553                            "blocked",
1554                            json!({ "reason": reason }),
1555                            &log,
1556                            usage,
1557                            &conversation,
1558                            prompted,
1559                        );
1560                    }
1561                    Stop::Asked(text) => {
1562                        asked_to_stop = true;
1563                        let entry = json!({ "tool": REPORT_BLOCKED, "arguments": asked.arguments, "decision": "challenged" });
1564                        emit(observer.as_ref(), turn + 1, &entry);
1565                        log.push(entry);
1566                        transcript.answered(
1567                            asked,
1568                            &Tainted::trusted(json!({ "stopped": false, "note": text })),
1569                        );
1570                    }
1571                    Stop::Refused(text) => {
1572                        let entry = json!({ "tool": REPORT_BLOCKED, "arguments": asked.arguments, "decision": "unproven", "reason": text });
1573                        emit(observer.as_ref(), turn + 1, &entry);
1574                        log.push(entry);
1575                        transcript.failed(asked, Tainted::trusted(text));
1576                    }
1577                }
1578                continue;
1579            }
1580            if asked.name == crate::ask::PROPOSE && tools.iter().any(|t| t.name == asked.name) {
1581                // A suggestion for the person: nothing runs, one per turn.
1582                let entry = json!({ "tool": asked.name, "arguments": asked.arguments, "decision": if proposed { "deny" } else { "proposed" } });
1583                emit(observer.as_ref(), turn + 1, &entry);
1584                log.push(entry);
1585                if proposed {
1586                    transcript.failed(
1587                        asked,
1588                        Tainted::trusted(
1589                            "one proposal per turn; this one was not shown".to_owned(),
1590                        ),
1591                    );
1592                } else {
1593                    proposed = true;
1594                    transcript.answered(asked, &Tainted::trusted(json!({ "shown": true, "note": "shown to the person as a sheet; nothing ran, and only the person can confirm it" })));
1595                }
1596                continue;
1597            }
1598            let declared = tools.iter().any(|t| t.name == asked.name);
1599            let target = catalog
1600                .iter()
1601                .find(|t| declared && t.name == asked.name)
1602                .map(|t| ToolId::new(t.server, t.name))
1603                .or_else(|| {
1604                    routes
1605                        .iter()
1606                        .find(|(n, _, _)| declared && *n == asked.name)
1607                        .map(|(_, server, tool)| ToolId::new(server, tool))
1608                });
1609            let Some(target) = target else {
1610                let entry = json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "unknown" });
1611                emit(observer.as_ref(), turn + 1, &entry);
1612                log.push(entry);
1613                transcript.failed(asked, Tainted::trusted("no tool of that name".to_owned()));
1614                continue;
1615            };
1616            let mut decision = policy.base(&asked.name, &asked.arguments);
1617            // `[tests] frozen = "additions"`: read the frozen test (a
1618            // journaled call, so replay sees the same content) and allow a
1619            // write that keeps every existing line.
1620            let mut additions_only = false;
1621            if frozen_additions
1622                && matches!(&decision, Decision::Approval(r) if r.contains("frozen test"))
1623            {
1624                let path = asked.arguments["path"]
1625                    .as_str()
1626                    .unwrap_or_default()
1627                    .to_owned();
1628                let read = cx
1629                    .call_tool(
1630                        ToolId::new("fs", "read_file"),
1631                        Tainted::with_label(json!({ "path": path }), completion.label().clone()),
1632                    )
1633                    .await?;
1634                seen(cx);
1635                let current = read.peek().clone();
1636                // `read_file` joins lines with `\n`; the file's last newline is
1637                // restored so an edit's `old_string` matches as on disk.
1638                let lines = current["content"].as_str().unwrap_or_default();
1639                let old_text = if lines.is_empty() {
1640                    String::new()
1641                } else {
1642                    format!("{lines}\n")
1643                };
1644                let old = old_text.as_str();
1645                let readable = current["ok"] == true && current["truncated"] != true;
1646                let new = match asked.name.as_str() {
1647                    "write_file" => asked.arguments["content"].as_str().map(str::to_owned),
1648                    "edit_file" => {
1649                        let (from, to) = (
1650                            asked.arguments["old_string"].as_str().unwrap_or_default(),
1651                            asked.arguments["new_string"].as_str().unwrap_or_default(),
1652                        );
1653                        let all = asked.arguments["replace_all"] == true;
1654                        (!from.is_empty() && (all || old.matches(from).count() == 1))
1655                            .then(|| old.replace(from, to))
1656                    }
1657                    _ => None,
1658                };
1659                if readable && new.is_some_and(|n| crate::integrity::only_additions(old, &n)) {
1660                    decision = Decision::Allow;
1661                    additions_only = true;
1662                }
1663            }
1664            // A person would be needed: the object the call reaches is
1665            // resolved by a journaled step (replay reads the same object
1666            // back), and only a decision on that very object applies.
1667            let mut object: Option<Object> = None;
1668            if matches!(decision, Decision::Approval(_)) {
1669                if asked.name.starts_with(crate::mcp::PREFIX) {
1670                    object = mcp_digests.get(&asked.name).map(|digest| Object::Tool {
1671                        name: asked.name.clone(),
1672                        digest: digest.clone(),
1673                    });
1674                } else {
1675                    let resolved = cx
1676                        .call_tool(
1677                            ToolId::new("fs", "resolve"),
1678                            Tainted::with_label(
1679                                json!({ "tool": asked.name, "arguments": asked.arguments }),
1680                                completion.label().clone(),
1681                            ),
1682                        )
1683                        .await?;
1684                    seen(cx);
1685                    let out = resolved.peek();
1686                    if out["ok"] == true {
1687                        object = serde_json::from_value(out["object"].clone()).ok();
1688                    } else {
1689                        decision = Decision::Deny(
1690                            out["error"]
1691                                .as_str()
1692                                .unwrap_or("the call's object cannot be resolved")
1693                                .to_owned(),
1694                        );
1695                    }
1696                }
1697                if matches!(decision, Decision::Approval(_)) {
1698                    decision = policy.classify(&asked.name, &asked.arguments, object.as_ref());
1699                }
1700            }
1701            let mut person: Option<Value> = None;
1702            // Allowed by a person's grant for the task (of this run): the
1703            // person's decision, not a rule's (a contained call is a rule's).
1704            if decision == Decision::Allow
1705                && object
1706                    .as_ref()
1707                    .is_some_and(|o| policy.approved().contains(o))
1708            {
1709                person = Some(json!({
1710                    "approved_by": "person",
1711                    "scope": "task-grant",
1712                    "grant": { "scope": "task", "run": run },
1713                }));
1714            }
1715            let mut note: Option<String> = None;
1716            // An object a person approved for one call: this one.
1717            if matches!(decision, Decision::Approval(_))
1718                && let Some(at) = object
1719                    .as_ref()
1720                    .and_then(|o| once.iter().position(|r| r == o))
1721            {
1722                once.remove(at);
1723                person = Some(json!({
1724                    "approved_by": "person",
1725                    "scope": "once-grant",
1726                    "grant": { "scope": "once", "run": run },
1727                }));
1728                decision = Decision::Allow;
1729            }
1730            // An object a person already approved for this run or task.
1731            if matches!(decision, Decision::Approval(_))
1732                && let Some((_, scope)) = object
1733                    .as_ref()
1734                    .and_then(|o| remembered.iter().find(|(r, _)| r == o))
1735            {
1736                person = Some(json!({
1737                    "approved_by": "person",
1738                    "scope": scope,
1739                    "grant": { "scope": scope.trim_end_matches("-grant"), "run": run },
1740                }));
1741                decision = Decision::Allow;
1742            }
1743            // On autopilot nobody is asked: what would wait for a person is
1744            // refused for the run and listed for them afterwards.
1745            decision = policy.settle(decision);
1746            // Still a person's: the question, with everything it is decided by.
1747            let request = match &decision {
1748                Decision::Approval(reason) => Some(question(&Question {
1749                    asked: &asked,
1750                    reason,
1751                    turn: turn + 1,
1752                    said: said.as_deref().unwrap_or_default(),
1753                    asker: ask.as_ref(),
1754                    policy: &policy,
1755                    object: object.as_ref(),
1756                    remembered: &remembered,
1757                    parts: &parts,
1758                    run: run.as_deref(),
1759                })),
1760                _ => None,
1761            };
1762            // ask a person now, as a journaled effect, instead of stopping.
1763            if let (Some(mut request), Some(asker)) = (request.clone(), &ask) {
1764                // The run's asking budget: within it a person is prompted
1765                // (and counted, whatever they answer); past it the call
1766                // waits in the run's batch, nobody interrupted.
1767                if asking.prompts {
1768                    if prompted < asking.budget {
1769                        prompted += 1;
1770                        request.asked = prompted;
1771                    } else {
1772                        request.batched = true;
1773                    }
1774                    request.budget = asking.budget;
1775                }
1776                if request.batched {
1777                    // The batch item is created: the person is notified
1778                    // once; calls joining it notify no one.
1779                    if !told_budget {
1780                        needs_person(observer.as_ref(), &asked, object.as_ref());
1781                    }
1782                    if !told_budget && let Some((_, p)) = &observer {
1783                        p.event(&crate::progress::Event::Notice {
1784                            text: format!(
1785                                "approval budget ({}) reached: calls wait in Needs You (speccoder approvals)",
1786                                asking.budget
1787                            ),
1788                        });
1789                    }
1790                    told_budget = true;
1791                } else {
1792                    needs_person(observer.as_ref(), &asked, object.as_ref());
1793                }
1794                let decided = wait_for_person(
1795                    cx,
1796                    asker,
1797                    &mut obligation,
1798                    &crate::approvals::tasks::approval_task(&request, &log),
1799                )
1800                .await?;
1801                seen(cx);
1802                let read = crate::approvals::tasks::read(&decided);
1803                // A batch decision may start the run's asking again.
1804                if read.resume_asking {
1805                    prompted = 0;
1806                }
1807                let answer: Answer = read.answer.clone();
1808                // Who decided, and how that is known, is journaled with it.
1809                let approver = serde_json::to_value(&answer.by).unwrap_or(Value::Null);
1810                let operator = read.by.as_ref().map(Who::view);
1811                person_decided(
1812                    observer.as_ref(),
1813                    &asked,
1814                    if answer.decision == Said::Approve {
1815                        "approve"
1816                    } else {
1817                        "reject"
1818                    },
1819                    (answer.decision == Said::Approve).then(|| answer.scope.name()),
1820                    read.by.as_ref().filter(|_| !read.expired),
1821                    object.as_ref(),
1822                );
1823                match answer.decision {
1824                    Said::Approve => {
1825                        let mut p = json!({
1826                            "approved_by": "person",
1827                            "scope": answer.scope.name(),
1828                            "note": answer.note,
1829                            "approver": approver,
1830                            "operator": operator,
1831                        });
1832                        if answer.always {
1833                            p["always"] = json!(true);
1834                        }
1835                        if let (Some(o), Scope::Task | Scope::Run) = (&object, answer.scope) {
1836                            let remembered_as = if answer.scope == Scope::Run {
1837                                "run-grant"
1838                            } else {
1839                                "task-grant"
1840                            };
1841                            p["grant"] = json!({ "scope": answer.scope.name(), "run": run });
1842                            remembered.push((o.clone(), remembered_as));
1843                        }
1844                        person = Some(p);
1845                        note = answer.note;
1846                        decision = Decision::Allow;
1847                    }
1848                    // "Later" is a task left undecided: the run stays
1849                    // suspended and never reaches here.
1850                    Said::Reject | Said::Stop => {
1851                        let why = answer
1852                            .reason
1853                            .unwrap_or_else(|| "no reason given".to_owned());
1854                        person = Some(if read.expired {
1855                            json!({ "expired": true, "rejection": why })
1856                        } else {
1857                            json!({ "rejected_by": "person", "approver": approver, "operator": operator, "rejection": why })
1858                        });
1859                        decision = Decision::Deny(format!("a person rejected this: {why}"));
1860                    }
1861                }
1862            }
1863            // Who decided: a person (now, or by a grant they gave), or a rule
1864            // — named, so a rule's decision is never read as a person's.
1865            let rule = person
1866                .is_none()
1867                .then(|| policy.rule(&asked.name, &asked.arguments, object.as_ref()));
1868            let with_person = |mut entry: Value| {
1869                if let Some(o) = entry.as_object_mut() {
1870                    // The object decided on, whoever decided.
1871                    if let Some(obj) = &object {
1872                        o.insert("object".to_owned(), json!(obj));
1873                    }
1874                    match (person.as_ref().and_then(Value::as_object), &rule) {
1875                        (Some(p), _) => {
1876                            // An expiry is nobody's decision.
1877                            let by = if p.contains_key("expired") {
1878                                "expiry"
1879                            } else {
1880                                "person"
1881                            };
1882                            o.insert("by".to_owned(), json!(by));
1883                            o.extend(p.clone());
1884                        }
1885                        (None, Some(r)) => {
1886                            o.insert("by".to_owned(), json!("rule"));
1887                            o.insert("rule".to_owned(), json!(r));
1888                            // Ran without asking: the sandbox confined it.
1889                            if r.starts_with("contained (") {
1890                                o.insert("contained".to_owned(), json!(true));
1891                            }
1892                        }
1893                        (None, None) => {}
1894                    }
1895                }
1896                entry
1897            };
1898            match decision {
1899                Decision::Deny(reason) => {
1900                    let entry = with_person(
1901                        json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "deny", "reason": reason }),
1902                    );
1903                    emit(observer.as_ref(), turn + 1, &entry);
1904                    log.push(entry);
1905                    // The reason quotes the call (its tool, path, command, or a
1906                    // person's words about it): it carries the call's label.
1907                    transcript.failed(
1908                        asked,
1909                        refusal(format!("denied: {reason}"), completion.label()),
1910                    );
1911                    // Escalation: a model that keeps asking for what it may
1912                    // not do is stopped for a person.
1913                    denied_in_a_row += 1;
1914                    if denied_in_a_row >= MAX_CONSECUTIVE_DENIALS {
1915                        return end(
1916                            "denied",
1917                            json!({ "reason": format!("{denied_in_a_row} calls in a row were denied; last: {reason}") }),
1918                            &log,
1919                            usage,
1920                            &conversation,
1921                            prompted,
1922                        );
1923                    }
1924                    continue;
1925                }
1926                Decision::Approval(reason) => {
1927                    let mut entry = json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "approval", "reason": reason });
1928                    if let Some(obj) = &object {
1929                        entry["object"] = json!(obj);
1930                    }
1931                    emit(observer.as_ref(), turn + 1, &entry);
1932                    needs_person(observer.as_ref(), &asked, object.as_ref());
1933                    log.push(entry);
1934                    return end(
1935                        "needs_approval",
1936                        json!({ "pending": {
1937                            "tool": asked.name,
1938                            "arguments": asked.arguments,
1939                            "reason": reason,
1940                            "object": object,
1941                            "run": run,
1942                            "request": request,
1943                        } }),
1944                        &log,
1945                        usage,
1946                        &conversation,
1947                        prompted,
1948                    );
1949                }
1950                Decision::Allow => {
1951                    denied_in_a_row = 0;
1952                    // The arguments carry the model's label: agentplane's
1953                    // taint gate checks them against the grant's fields (an
1954                    // argument-less mutating tool is granted exactly `{}`).
1955                    // A command gets the call's id (`SPECCODER_CALL_ID`):
1956                    // a call resumed after a crash has the same one, so a
1957                    // script can tell a repeat from a new call.
1958                    let mut arguments = asked.arguments.clone();
1959                    if asked.name == "run_command"
1960                        && let Some(o) = arguments.as_object_mut()
1961                    {
1962                        o.insert("call_id".to_owned(), Value::String(asked.id.clone()));
1963                    }
1964                    // Decided on an object: the tool checks it again at
1965                    // use, and refuses when the call now reaches another.
1966                    if let (Some(obj), Some(o)) = (&object, arguments.as_object_mut())
1967                        && matches!(
1968                            asked.name.as_str(),
1969                            "write_file" | "edit_file" | "run_command"
1970                        )
1971                    {
1972                        o.insert(crate::tools::EXPECT.to_owned(), json!(obj));
1973                    }
1974                    let args = Tainted::with_label(arguments, completion.label().clone());
1975                    let result = cx.call_tool(target.clone(), args).await;
1976                    seen(cx);
1977                    let result = match result {
1978                        Ok(r) => r,
1979                        // A failure the far side reported is the model's to
1980                        // read, labelled by who wrote it.
1981                        Err(error) => match cx.relayed(&error) {
1982                            Some(detail) => {
1983                                let entry = with_person(
1984                                    json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "allow", "ok": false }),
1985                                );
1986                                emit(observer.as_ref(), turn + 1, &entry);
1987                                log.push(entry);
1988                                transcript.failed(asked, detail);
1989                                continue;
1990                            }
1991                            None => return Err(error.into()),
1992                        },
1993                    };
1994                    let output = result.peek().clone();
1995                    let ok = output["ok"].as_bool() != Some(false);
1996                    let mut entry = with_person(
1997                        json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "allow", "ok": ok }),
1998                    );
1999                    // A read the answer may cite: its id rides on the result.
2000                    let result = match next_read.as_mut() {
2001                        Some(n) if ok => {
2002                            let id = format!("r{n}");
2003                            *n += 1;
2004                            entry["read"] = json!(crate::ask::read_of(
2005                                &id,
2006                                &asked.name,
2007                                &asked.arguments,
2008                                &output
2009                            ));
2010                            result.map(|mut v| {
2011                                if let Some(o) = v.as_object_mut() {
2012                                    o.insert("read".to_owned(), json!(id));
2013                                }
2014                                v
2015                            })
2016                        }
2017                        _ => result,
2018                    };
2019                    if let Some(changed) = output["object_changed"].as_object() {
2020                        entry["decision"] = json!("refused_changed");
2021                        entry["expected"] = changed.get("expected").cloned().unwrap_or(Value::Null);
2022                        entry["found"] = changed.get("found").cloned().unwrap_or(Value::Null);
2023                    }
2024                    // What the tool returned is content the attempt read:
2025                    // arguments copied from it later are named as such.
2026                    if let Some(part) = crate::approvals::derived::part_of(
2027                        &asked.name,
2028                        &asked.arguments,
2029                        &output,
2030                        turn + 1,
2031                        &format!("tool://{}/{}", target.server, target.tool),
2032                        |p: &str| {
2033                            policy.protected().write_class(p)
2034                                == Some(crate::tools::paths::Class::Intent)
2035                        },
2036                    ) {
2037                        parts.push(part);
2038                    }
2039                    if additions_only {
2040                        entry["frozen_additions"] = json!(true);
2041                    }
2042                    let tail = if asked.name == "run_command" && !ok {
2043                        crate::progress::tail(&format!(
2044                            "{}\n{}",
2045                            output["stdout"].as_str().unwrap_or_default(),
2046                            output["stderr"].as_str().unwrap_or_default()
2047                        ))
2048                    } else {
2049                        Vec::new()
2050                    };
2051                    emit_with_tail(observer.as_ref(), turn + 1, &entry, tail);
2052                    log.push(entry);
2053                    // A command that changed a judge input ends the attempt
2054                    // before the model sees another turn.
2055                    if let Some(drift) = output["integrity"].as_array().filter(|d| !d.is_empty()) {
2056                        let paths: Vec<&str> =
2057                            drift.iter().filter_map(|d| d["path"].as_str()).collect();
2058                        return end(
2059                            "integrity",
2060                            json!({
2061                                "reason": format!("a command changed what judges the task: {}", paths.join(", ")),
2062                                "integrity": drift,
2063                            }),
2064                            &log,
2065                            usage,
2066                            &conversation,
2067                            prompted,
2068                        );
2069                    }
2070                    // A tool that refused (`error`) is a failed call; a
2071                    // command that ran and exited non-zero is a result the
2072                    // model must read in full — its stdout and stderr are
2073                    // the diagnostics (found by the first real evaluation,
2074                    // where the model saw only the word "failed").
2075                    match output["error"].as_str() {
2076                        Some(error) if !ok => transcript.failed(
2077                            asked,
2078                            Tainted::with_label(error.to_owned(), result.label().clone()),
2079                        ),
2080                        // A person's note rides with the result it approved.
2081                        _ => match &note {
2082                            Some(n) => {
2083                                let n = format!("Note from the person who approved this call: {n}");
2084                                transcript.answered(
2085                                    asked,
2086                                    &result.map(|mut v| {
2087                                        v["note_from_person"] = Value::String(n);
2088                                        v
2089                                    }),
2090                                );
2091                            }
2092                            None => transcript.answered(asked, &result),
2093                        },
2094                    }
2095                }
2096            }
2097        }
2098    }
2099    end(
2100        "budget_exhausted",
2101        json!({ "reason": format!("more than {max_turns} model turns (`[budget] max_turns`)") }),
2102        &log,
2103        usage,
2104        &conversation,
2105        prompted,
2106    )
2107}
2108
2109#[cfg(test)]
2110mod tests {
2111    use super::*;
2112
2113    #[test]
2114    fn labels_are_read_from_the_parts_and_refusals_carry_the_call() {
2115        use agentplane::core::{Label, SourceId};
2116        let model = Label::untrusted(SourceId::new("model:x/y"));
2117        // A refusal quoting the call is the call's; a constant is trusted.
2118        assert!(
2119            refusal("denied: `src/x` is protected".to_owned(), &model)
2120                .label()
2121                .is_untrusted()
2122        );
2123        // Parts labelled when admitted: the spec trusted, the digest the verifier's.
2124        let input = Tainted::object([
2125            ("bundle", Tainted::trusted(json!({}))),
2126            (
2127                "digest",
2128                Tainted::with_label(json!({}), provenance::from(provenance::VERIFIER)),
2129            ),
2130            (
2131                "settings",
2132                Tainted::object([("nudges", Tainted::trusted(json!([])))]),
2133            ),
2134        ]);
2135        let label = conversation_label(&input);
2136        assert!(
2137            label
2138                .provenance
2139                .contains(&SourceId::new(provenance::VERIFIER))
2140        );
2141        // No labels of its own: every part takes the whole input's (fails closed).
2142        let flat = Tainted::with_label(json!({ "bundle": {} }), model.clone());
2143        assert_eq!(conversation_label(&flat).provenance, model.provenance);
2144    }
2145
2146    #[test]
2147    fn absolute_paths_inside_the_root_become_relative_and_others_stay() {
2148        let mut a = json!({ "path": "/work/repo/specs/001-x/plan.md" });
2149        relativise(&mut a, Some("/work/repo"));
2150        assert_eq!(a["path"], "specs/001-x/plan.md");
2151        let mut outside = json!({ "path": "/work/repository-two/x" });
2152        relativise(&mut outside, Some("/work/repo"));
2153        assert_eq!(outside["path"], "/work/repository-two/x");
2154        let mut none = json!({ "path": "/etc/passwd" });
2155        relativise(&mut none, None);
2156        assert_eq!(none["path"], "/etc/passwd");
2157    }
2158
2159    /// A stop is its evidence: what speccoder cannot check goes on, the
2160    /// rest is asked once, a refused call stops at once.
2161    #[test]
2162    fn a_stop_stands_on_evidence_speccoder_checks() {
2163        let gate = StopGate {
2164            spec: "Rows are sorted\n  ascending by name.".to_owned(),
2165            frozen: vec!["tests/test_us3.py".to_owned()],
2166            rejected: false,
2167        };
2168        let call = |kind: &str, evidence: &str| json!({ "kind": kind, "evidence": evidence, "reason": "why" });
2169        let judge =
2170            |args: &Value, refused: bool, asked: bool| judge_stop(&gate, args, refused, asked, 5);
2171        let unproven = |s: Stop| matches!(s, Stop::Refused(_));
2172
2173        // The T018 stop: scope is no kind, an entry point is no evidence.
2174        assert!(unproven(judge(
2175            &json!({ "reason": "beyond the task's module scope" }),
2176            false,
2177            false
2178        )));
2179        assert!(unproven(judge(&call("scope", "src/cli"), false, false)));
2180        assert!(unproven(judge(
2181            &call("wrong_test", "tests/new_test.py"),
2182            false,
2183            false
2184        )));
2185        assert!(unproven(judge(
2186            &call("spec_conflict", "sorted descending by name"),
2187            false,
2188            false
2189        )));
2190        assert!(unproven(judge(
2191            &call("spec_conflict", "sorted"),
2192            false,
2193            false
2194        )));
2195        assert!(unproven(judge(
2196            &call("refused", "cargo add x"),
2197            false,
2198            false
2199        )));
2200
2201        // Checked evidence is asked about once, with the turns left; the
2202        // second call stops, its reason first.
2203        let quote = call("spec_conflict", "sorted ascending by name");
2204        assert!(
2205            matches!(judge(&quote, false, false), Stop::Asked(t) if t.contains("5 turns are left"))
2206        );
2207        assert_eq!(
2208            judge(&quote, false, true),
2209            Stop::Stopped("why — the spec says “sorted ascending by name”".to_owned())
2210        );
2211        assert_eq!(
2212            judge(&call("wrong_test", "./tests/test_us3.py"), false, true),
2213            Stop::Stopped("why — frozen test `./tests/test_us3.py`".to_owned())
2214        );
2215        // A refused call needs no second word, nor does the last turn.
2216        assert!(matches!(
2217            judge(&call("refused", "cargo add x"), true, false),
2218            Stop::Stopped(_)
2219        ));
2220        let q = call("needs_information", "which locale?");
2221        assert!(matches!(
2222            judge_stop(&gate, &q, false, false, 0),
2223            Stop::Stopped(_)
2224        ));
2225    }
2226
2227    /// The md2csv example (2026-11-04): after each answer the attempt's
2228    /// whole turn log was printed again. What a replayed step says is not.
2229    #[test]
2230    fn a_replayed_step_says_nothing() {
2231        #[derive(Debug, Default)]
2232        struct Seen(std::sync::Mutex<Vec<u32>>);
2233        impl crate::progress::Progress for Seen {
2234            fn event(&self, e: &crate::progress::Event) {
2235                if let crate::progress::Event::ToolCall { turn, .. } = e {
2236                    self.0.lock().unwrap().push(*turn);
2237                }
2238            }
2239        }
2240        let seen = std::sync::Arc::new(Seen::default());
2241        let replaying = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
2242        let quiet = Quiet {
2243            replaying: std::sync::Arc::clone(&replaying),
2244            next: seen.clone(),
2245        };
2246        let call = |turn| crate::progress::Event::ToolCall {
2247            task: "T012".to_owned(),
2248            turn,
2249            tool: "run_command".to_owned(),
2250            target: String::new(),
2251            decision: "allow".to_owned(),
2252            ok: Some(true),
2253            tail: Vec::new(),
2254            contained: false,
2255        };
2256        crate::progress::Progress::event(&quiet, &call(1));
2257        replaying.store(false, std::sync::atomic::Ordering::SeqCst);
2258        crate::progress::Progress::event(&quiet, &call(2));
2259        assert_eq!(*seen.0.lock().unwrap(), [2]);
2260    }
2261}