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, 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. You cannot edit the spec ({intent}), the checks' configuration or \
40.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 \
51the task is left undone — a check you could not run is no reason.",
52        product = w.product,
53        intent = w.intent_paths,
54    )
55}
56
57/// The final report's schema (the model's claim).
58#[must_use]
59pub fn report_schema() -> Value {
60    let strings = json!({ "type": "array", "items": { "type": "string" } });
61    json!({
62        "type": "object",
63        "properties": {
64            "summary": { "type": "string" },
65            "status": { "type": "string", "enum": ["done", "incomplete"] },
66            "files_changed": strings,
67            "checks_run": strings,
68            "notes": strings,
69        },
70        "required": ["summary", "status", "files_changed", "checks_run", "notes"],
71        "additionalProperties": false,
72    })
73}
74
75fn report_blocked() -> ToolDeclaration {
76    ToolDeclaration::new(
77        REPORT_BLOCKED,
78        "Stop the attempt because the task cannot be done as written; a person will look at the reason.",
79        json!({
80            "type": "object",
81            "properties": { "reason": { "type": "string" } },
82            "required": ["reason"],
83            "additionalProperties": false,
84        }),
85    )
86}
87
88/// The question tool a hosted skill may use.
89pub const ASK_PERSON: &str = "ask_person";
90
91/// The `ask_person` declaration.
92#[must_use]
93pub fn ask_person() -> ToolDeclaration {
94    ToolDeclaration::new(
95        ASK_PERSON,
96        "Ask the person one question and wait for their answer. If nobody can answer, the reply says so.",
97        json!({
98            "type": "object",
99            "properties": { "question": { "type": "string" } },
100            "required": ["question"],
101            "additionalProperties": false,
102        }),
103    )
104}
105
106/// How an attempt waits for a person when a call needs one: on an
107/// agentplane human task the person decides — inline, from an editor, or
108/// later with `speccoder task approve` — while the run is suspended.
109#[derive(Debug, Clone)]
110pub struct Asker {
111    /// `feature/task`, shown to the person.
112    pub task: String,
113    /// The task's title.
114    pub task_title: String,
115    /// What the live run knows for the presentation.
116    pub context: PresentContext,
117    /// How long a call waits before it is denied as expired.
118    pub wait_secs: u64,
119}
120
121impl Asker {
122    /// The asker the journaled settings describe: `asks = "tasks"` waits on
123    /// a task; anything else (`"stop"`, a lane) stops the attempt instead.
124    #[must_use]
125    pub fn from_settings(
126        settings: &Value,
127        task: String,
128        task_title: String,
129        context: PresentContext,
130    ) -> Option<Self> {
131        (settings["asks"] == "tasks").then(|| Self {
132            task,
133            task_title,
134            context,
135            wait_secs: settings["wait_secs"].as_u64().unwrap_or(7 * 86_400),
136        })
137    }
138}
139
140/// Waits for a person's decision on `spec`, registering the `approval`
141/// obligation the first time: the run suspends until someone decides or the
142/// deadline passes. Journaled — replay reads the decision back.
143async fn wait_for_person(
144    cx: &mut StepCtx<'_>,
145    asker: &Asker,
146    obligation: &mut bool,
147    spec: &agentplane::core::TaskSpec,
148) -> Result<agentplane::core::Decision, SkillError> {
149    if !*obligation {
150        let minutes = u32::try_from(asker.wait_secs.div_ceil(60).max(1)).unwrap_or(u32::MAX);
151        cx.deadline(
152            crate::approvals::tasks::DEADLINE,
153            &agentplane::core::DeadlineSpec::minutes(minutes),
154            None,
155        )
156        .await?;
157        *obligation = true;
158    }
159    Ok(cx.task(spec).await?)
160}
161
162/// The coded skill.
163///
164/// Everything its decisions depend on — the bundle, the policy's command
165/// prefixes and the limits — arrives in the run's input, so it is journaled
166/// and strict replay needs no configuration. Only the prompt dialect
167/// is construction-time, and it is part of each model call's effect key.
168#[derive(Debug)]
169pub struct Attempt {
170    /// Prompt dialect.
171    pub style: PromptStyle,
172    /// Cache breakpoint lifetime.
173    pub cache_ttl: CacheTtl,
174    /// Where live progress goes; not journaled, `None` in replay.
175    pub progress: Option<std::sync::Arc<dyn crate::progress::Progress>>,
176    /// The project's spec format.
177    pub format: &'static dyn crate::format::Adapter,
178    /// The sandbox, as a person asked about a command is told; live only.
179    pub present: PresentContext,
180}
181
182/// The run input: `{ bundle, settings: { checks, allow, max_turns,
183/// max_tool_calls, frozen, … }, digest }` — `digest` is the verifier's
184/// account of the previous attempt, `null` on a first attempt.
185///
186/// Labelled by where each part came from: the bundle is the
187/// person's committed spec and plan (trusted); the digest quotes check
188/// output (`speccoder://verifier`); the settings list MCP tool descriptions
189/// (their servers).
190#[must_use]
191pub fn input(
192    bundle: Value,
193    settings: &Settings<'_>,
194    digest: Option<&Value>,
195    instructions: &[crate::instructions::Instruction],
196    test_layout: Option<&str>,
197) -> Tainted<Value> {
198    let digest = match digest.filter(|d| !d.is_null()) {
199        Some(d) => Tainted::with_label(d.clone(), provenance::from(provenance::VERIFIER)),
200        None => Tainted::trusted(Value::Null),
201    };
202    // Each MCP tool's description and schema are its server's words.
203    let mcp = Tainted::array(settings.mcp.iter().map(|t| {
204        Tainted::with_label(
205            serde_json::to_value(t).unwrap_or(Value::Null),
206            agentplane::core::Label::untrusted(provenance::tool(&t.server, &t.tool)),
207        )
208    }));
209    let trusted = |v: Value| Tainted::trusted(v);
210    let autopilot = settings.autopilot;
211    let planning = settings.planning;
212    let settings = vec![
213        ("checks", trusted(json!(settings.checks))),
214        ("allow", trusted(json!(settings.allow))),
215        ("max_turns", trusted(json!(settings.max_turns))),
216        ("max_tool_calls", trusted(json!(settings.max_tool_calls))),
217        ("reasoning", trusted(json!(settings.reasoning))),
218        ("frozen", trusted(json!(settings.frozen))),
219        ("approved", trusted(json!(settings.approved))),
220        ("rejected", trusted(json!(settings.rejected))),
221        ("run_approved", trusted(json!(settings.run_approved))),
222        ("approved_once", trusted(json!(settings.approved_once))),
223        ("nudges", trusted(json!(settings.nudges))),
224        ("mcp", mcp),
225        ("root", trusted(json!(settings.root))),
226        (
227            "frozen_additions",
228            trusted(json!(settings.frozen_additions)),
229        ),
230        ("network_allow", trusted(json!(settings.network_allow))),
231        ("allow_prefix", trusted(json!(settings.allow_prefix))),
232        (
233            "network_allow_prefix",
234            trusted(json!(settings.network_allow_prefix)),
235        ),
236        ("run_id", trusted(json!(settings.run_id))),
237        ("asked", trusted(json!(settings.asked))),
238        ("budget", trusted(json!(settings.budget))),
239        ("prompts", trusted(json!(settings.prompts))),
240        ("asks", trusted(json!(settings.asks))),
241        ("wait_secs", trusted(json!(settings.wait_secs))),
242        ("in_place", trusted(json!(settings.in_place))),
243        ("hidden", trusted(json!(settings.hidden))),
244    ];
245    // Only an autopilot run carries the key: every other input reads as
246    // it was recorded before it existed.
247    let mut settings = settings;
248    if autopilot {
249        settings.push(("autopilot", trusted(json!(true))));
250    }
251    // Likewise only a planning task's.
252    if let Some(dir) = planning {
253        settings.push(("planning", trusted(json!(dir))));
254    }
255    let settings = Tainted::object(settings);
256    Tainted::object([
257        ("bundle", Tainted::trusted(bundle)),
258        ("digest", digest),
259        ("settings", settings),
260        // The repository's text, as if the model had read the files itself;
261        // none is no text, and no label.
262        (
263            "instructions",
264            if instructions.is_empty() {
265                Tainted::trusted(json!([]))
266            } else {
267                Tainted::with_label(json!(instructions), provenance::from(provenance::WORKTREE))
268            },
269        ),
270        // The test layout speccoder detected from the checkout.
271        (
272            "test_layout",
273            match test_layout {
274                Some(t) => Tainted::with_label(json!(t), provenance::from(provenance::WORKTREE)),
275                None => Tainted::trusted(Value::Null),
276            },
277        ),
278    ])
279}
280
281/// A refusal speccoder composes about a call quotes it — the tool, path or
282/// command the model chose — so it carries the call's label; only
283/// constant replies are trusted.
284fn refusal(text: String, call: &agentplane::core::Label) -> Tainted<String> {
285    Tainted::with_label(text, call.clone())
286}
287
288/// The parts of an attempt's input its messages quote.
289const MESSAGE_PARTS: [&str; 7] = [
290    "/bundle",
291    "/instructions",
292    "/test_layout",
293    "/digest",
294    "/settings/nudges",
295    "/settings/rejected",
296    "/settings/mcp",
297];
298
299/// The label of the conversation part: the join of the labels the run was
300/// admitted with for the parts it quotes — read, never inferred. A part
301/// without a label of its own takes the whole input's (fails closed).
302fn conversation_label(input: &Tainted<Value>) -> agentplane::core::Label {
303    MESSAGE_PARTS
304        .iter()
305        .map(|p| input.label_at(p).unwrap_or_else(|| input.label()).clone())
306        .fold(agentplane::core::Label::trusted(), |acc, l| acc.join(&l))
307}
308
309/// What an attempt decides by, besides its bundle (all journaled).
310#[derive(Debug, Clone, Copy)]
311pub struct Settings<'a> {
312    /// Configured checks' argvs (their prefixes are allowed commands).
313    pub checks: &'a [Vec<String>],
314    /// Exact commands allowed (`[commands] allow`).
315    pub allow: &'a [Vec<String>],
316    /// Allowed command prefixes (`[commands] allow_prefix`).
317    pub allow_prefix: &'a [Vec<String>],
318    /// Model turns.
319    pub max_turns: u32,
320    /// Tool calls.
321    pub max_tool_calls: u32,
322    /// Ask for the model's reasoning summary on every turn.
323    pub reasoning: bool,
324    /// Test files frozen for this attempt.
325    pub frozen: &'a [String],
326    /// A person's approvals for this task, in this run.
327    pub approved: &'a [Object],
328    /// A person's rejections for this task.
329    pub rejected: &'a [crate::tools::policy::Rejection],
330    /// Objects a person approved for every task of this run.
331    pub run_approved: &'a [Object],
332    /// Objects a person approved for one call each (a call a lane stopped,
333    /// decided in its run's batch): this attempt may make each one time.
334    pub approved_once: &'a [Object],
335    /// A person's notes for this task (`nudge`).
336    pub nudges: &'a [String],
337    /// The project's MCP tools for this run.
338    pub mcp: &'a [crate::mcp::McpTool],
339    /// The worktree's absolute (canonical) path.
340    pub root: &'a str,
341    /// Frozen tests may gain lines without approval (`[tests] frozen`).
342    pub frozen_additions: bool,
343    /// How a call needing a person is handled: `"tasks"` (the attempt waits
344    /// on a human task) or `"stop"` (it ends `needs_approval`; lanes).
345    pub asks: &'a str,
346    /// How long a call waits for a person before it is denied as expired.
347    pub wait_secs: u64,
348    /// Exact commands that get the network (`[sandbox] network_allow`).
349    pub network_allow: &'a [Vec<String>],
350    /// Prefixes that get it (`[sandbox] network_allow_prefix`).
351    pub network_allow_prefix: &'a [Vec<String>],
352    /// The run the attempt belongs to.
353    pub run_id: &'a str,
354    /// Prompts the run has shown a person so far.
355    pub asked: u32,
356    /// `[approvals] budget`.
357    pub budget: u32,
358    /// A person is prompted inline (not `--no-prompt`).
359    pub prompts: bool,
360    /// An in-place attempt: git that moves the checkout is denied.
361    pub in_place: bool,
362    /// In place: what git ignored at the run's start, hidden from the tools.
363    pub hidden: &'a [String],
364    /// An autopilot run: a call that would ask is refused instead.
365    pub autopilot: bool,
366    /// A planning task: its unit's directory (worktree-relative), whose
367    /// design documents it writes ([`Policy::planning`]).
368    pub planning: Option<&'a str>,
369}
370
371/// The calls consecutive denials of which end an attempt.
372pub const MAX_CONSECUTIVE_DENIALS: u32 = 3;
373
374/// The digest as the model reads it: facts from the verifier only.
375fn digest_text(digest: &Value) -> String {
376    let mut out = format!(
377        "# Previous attempt\n\nAttempt {} at this task was verified by speccoder and FAILED. Its \
378changes were discarded; the worktree is back where that attempt started. You start fresh — use \
379these facts from the verifier, check them yourself, and do not repeat the mistake.\n",
380        digest["attempt"].as_u64().unwrap_or(1)
381    );
382    let strings = |v: &Value| -> Vec<String> {
383        v.as_array()
384            .map(|a| {
385                a.iter()
386                    .filter_map(|x| x.as_str().map(str::to_owned))
387                    .collect()
388            })
389            .unwrap_or_default()
390    };
391    let reasons = strings(&digest["reasons"]);
392    if !reasons.is_empty() {
393        out.push_str("\n## Verdict reasons\n\n");
394        for r in reasons {
395            out.push_str(&format!("- {r}\n"));
396        }
397    }
398    for c in digest["checks"].as_array().into_iter().flatten() {
399        out.push_str(&format!(
400            "\n## Check `{}` ({}): `{}`\n",
401            c["name"].as_str().unwrap_or_default(),
402            c["status"].as_str().unwrap_or_default(),
403            strings(&c["argv"]).join(" ")
404        ));
405        let tests = strings(&c["failing_tests"]);
406        if !tests.is_empty() {
407            out.push_str(&format!("\nFailing tests: {}\n", tests.join(", ")));
408        }
409        out.push_str(&format!(
410            "\n```text\n{}\n```\n",
411            c["excerpt"].as_str().unwrap_or_default()
412        ));
413    }
414    let uncollected = strings(&digest["uncollected"]);
415    if !uncollected.is_empty() {
416        out.push_str("\n## Tests that never ran\n\n");
417        for l in uncollected {
418            out.push_str(&format!("- {l}\n"));
419        }
420    }
421    let discarded = strings(&digest["discarded"]);
422    if !discarded.is_empty() {
423        out.push_str(&format!(
424            "\n## Files the discarded attempt changed\n\n{}\n",
425            discarded.join("\n")
426        ));
427    }
428    out
429}
430
431fn argvs(v: &Value) -> Vec<Vec<String>> {
432    v.as_array()
433        .map(|a| {
434            a.iter()
435                .map(|argv| {
436                    argv.as_array()
437                        .map(|p| {
438                            p.iter()
439                                .filter_map(|x| x.as_str().map(str::to_owned))
440                                .collect()
441                        })
442                        .unwrap_or_default()
443                })
444                .collect()
445        })
446        .unwrap_or_default()
447}
448
449/// A person's decisions of the journaled input at `key`: none when absent,
450/// an error when present and unreadable — never read as "no decisions",
451/// which would drop rejections.
452fn decisions_of<T: serde::de::DeserializeOwned>(
453    settings: &Value,
454    key: &str,
455) -> Result<Vec<T>, SkillError> {
456    match &settings[key] {
457        Value::Null => Ok(Vec::new()),
458        v => serde_json::from_value(v.clone())
459            .map_err(|e| SkillError::Input(format!("settings.{key}: {e}"))),
460    }
461}
462
463/// The policy an attempt's journaled settings decide by: the checks, the
464/// allow lists, the frozen tests, the person's decisions, the MCP tools,
465/// the network rules, in place and autopilot. An external agent's attempt
466/// is decided by the same one.
467pub(crate) fn policy_of(
468    settings: &Value,
469    rules: &'static crate::format::Rules,
470) -> Result<Policy, SkillError> {
471    let frozen: Vec<String> = settings["frozen"]
472        .as_array()
473        .map(|a| {
474            a.iter()
475                .filter_map(|v| v.as_str().map(str::to_owned))
476                .collect()
477        })
478        .unwrap_or_default();
479    let approved: Vec<Object> = decisions_of(settings, "approved")?;
480    let rejected: Vec<crate::tools::policy::Rejection> = decisions_of(settings, "rejected")?;
481    let mcp: Vec<crate::mcp::McpTool> =
482        serde_json::from_value(settings["mcp"].clone()).unwrap_or_default();
483    let policy = Policy::new(&argvs(&settings["checks"]), &argvs(&settings["allow"]))
484        .with_prefixes(argvs(&settings["allow_prefix"]))
485        .with_network_prefixes(argvs(&settings["network_allow_prefix"]))
486        .rules(rules)
487        .with_frozen(frozen)
488        .with_decisions(approved, rejected)
489        .with_mcp(mcp.iter().map(|t| (t.name.clone(), t.decision.clone())))
490        .with_network(argvs(&settings["network_allow"]))
491        .with_hidden(
492            serde_json::from_value::<Vec<String>>(settings["hidden"].clone()).unwrap_or_default(),
493        );
494    let policy = if settings["in_place"] == true {
495        policy.in_place()
496    } else {
497        policy
498    };
499    let policy = if settings["autopilot"] == true {
500        policy.autopilot()
501    } else {
502        policy
503    };
504    Ok(match settings["planning"].as_str() {
505        Some(dir) => policy.planning(dir),
506        None => policy,
507    })
508}
509
510#[async_trait]
511impl Skill for Attempt {
512    fn descriptor(&self) -> SkillDescriptor {
513        SkillDescriptor::new("speccoder.task.attempt").provides(CAPABILITY)
514    }
515
516    async fn invoke(
517        &self,
518        cx: &mut StepCtx<'_>,
519        input: Tainted<Value>,
520    ) -> Result<Outcome, SkillError> {
521        let (ttl, style) = (self.cache_ttl, self.style);
522        let settings = input.peek()["settings"].clone();
523        let task_name = format!(
524            "{}/{}",
525            input.peek()["bundle"]["feature"]
526                .as_str()
527                .unwrap_or_default(),
528            input.peek()["bundle"]["task"]["id"]
529                .as_str()
530                .unwrap_or_default()
531        );
532        let rejected: Vec<crate::tools::policy::Rejection> = decisions_of(&settings, "rejected")?;
533        let told = rejected;
534        let mcp: Vec<crate::mcp::McpTool> =
535            serde_json::from_value(settings["mcp"].clone()).unwrap_or_default();
536        let planning = settings["planning"].as_str().map(str::to_owned);
537        let policy = policy_of(&settings, self.format.rules())?;
538        let max_turns = settings["max_turns"]
539            .as_u64()
540            .map_or(30, |n| u32::try_from(n).unwrap_or(u32::MAX))
541            .max(1);
542        let max_tool_calls = settings["max_tool_calls"]
543            .as_u64()
544            .map_or(100, |n| u32::try_from(n).unwrap_or(u32::MAX));
545        // Same stable prefix as `propose`, with the attempt policy in front;
546        // a retry's digest goes after it, so the cached prefix is shared.
547        let label = conversation_label(&input);
548        let raw = input.peek().clone();
549        let prompt = provenance::labelled_prompt(
550            {
551                let input = raw;
552                let mut p = layout(
553                    &input["bundle"],
554                    ttl,
555                    style,
556                    &self::policy(self.format.wording()),
557                    self.format,
558                );
559                let given: Vec<crate::instructions::Instruction> =
560                    serde_json::from_value(input["instructions"].clone()).unwrap_or_default();
561                if let Some(text) = crate::instructions::section(&given) {
562                    let (key, kind) = match style {
563                        PromptStyle::OpenAi => ("input", "input_text"),
564                        PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
565                            ("messages", "text")
566                        }
567                    };
568                    if let Some(content) = p[key][0]["content"].as_array_mut() {
569                        content.push(json!({ "type": kind, "text": text }));
570                    }
571                }
572                if let Some(text) = input["test_layout"].as_str().filter(|t| !t.is_empty()) {
573                    let (key, kind) = match style {
574                        PromptStyle::OpenAi => ("input", "input_text"),
575                        PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
576                            ("messages", "text")
577                        }
578                    };
579                    if let Some(content) = p[key][0]["content"].as_array_mut() {
580                        content.push(json!({ "type": kind, "text": text }));
581                    }
582                }
583                if !input["digest"].is_null() {
584                    let (key, kind) = match style {
585                        PromptStyle::OpenAi => ("input", "input_text"),
586                        PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
587                            ("messages", "text")
588                        }
589                    };
590                    if let Some(content) = p[key][0]["content"].as_array_mut() {
591                        content
592                            .push(json!({ "type": kind, "text": digest_text(&input["digest"]) }));
593                    }
594                }
595                // A planning task: where its documents go.
596                if let Some(dir) = &planning {
597                    let (key, kind) = match style {
598                        PromptStyle::OpenAi => ("input", "input_text"),
599                        PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
600                            ("messages", "text")
601                        }
602                    };
603                    let text = format!(
604                        "# 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"
605                    );
606                    if let Some(content) = p[key][0]["content"].as_array_mut() {
607                        content.push(json!({ "type": kind, "text": text }));
608                    }
609                }
610                // The checks as configured: the commands that run without asking.
611                let checks: Vec<String> = argvs(&input["settings"]["checks"])
612                    .iter()
613                    .map(|argv| format!("- `{}`", json!(argv)))
614                    .collect();
615                if !checks.is_empty() {
616                    let (key, kind) = match style {
617                        PromptStyle::OpenAi => ("input", "input_text"),
618                        PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
619                            ("messages", "text")
620                        }
621                    };
622                    let text = format!(
623                        "# The checks\n\nRun them with run_command as these argvs, word for word; they need no approval:\n\n{}\n",
624                        checks.join("\n")
625                    );
626                    if let Some(content) = p[key][0]["content"].as_array_mut() {
627                        content.push(json!({ "type": kind, "text": text }));
628                    }
629                }
630                // A person's notes for this task.
631                let notes: Vec<String> = input["settings"]["nudges"]
632                    .as_array()
633                    .map(|a| {
634                        a.iter()
635                            .filter_map(|n| n.as_str().map(|n| format!("- {n}")))
636                            .collect()
637                    })
638                    .unwrap_or_default();
639                if !notes.is_empty() {
640                    let (key, kind) = match style {
641                        PromptStyle::OpenAi => ("input", "input_text"),
642                        PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
643                            ("messages", "text")
644                        }
645                    };
646                    let text = format!("# Notes from a person\n\n{}\n", notes.join("\n"));
647                    if let Some(content) = p[key][0]["content"].as_array_mut() {
648                        content.push(json!({ "type": kind, "text": text }));
649                    }
650                }
651                // A person's rejections, so the model does not ask again.
652                if !told.is_empty() {
653                    let (key, kind) = match style {
654                        PromptStyle::OpenAi => ("input", "input_text"),
655                        PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
656                            ("messages", "text")
657                        }
658                    };
659                    let lines: Vec<String> = told
660                        .iter()
661                        .map(|r| format!("- {}: {}", r.object.describe(), r.reason))
662                        .collect();
663                    let text = format!(
664                        "# Rejected by a person\n\nThese calls were rejected for this task; find another way or call report_blocked:\n\n{}\n",
665                        lines.join("\n")
666                    );
667                    if let Some(content) = p[key][0]["content"].as_array_mut() {
668                        content.push(json!({ "type": kind, "text": text }));
669                    }
670                }
671                if let PromptStyle::Parts { caches } = style {
672                    super::model_config::to_parts(&mut p, caches);
673                }
674                p
675            },
676            &label,
677        );
678        let mut tools = crate::tools::declarations();
679        tools.push(report_blocked());
680        // MCP tools the policy does not deny, as the journaled input lists them.
681        let routes: Vec<(String, String, String)> = mcp
682            .iter()
683            .filter(|t| t.decision != "deny")
684            .map(|t| (t.name.clone(), t.server.clone(), t.tool.clone()))
685            .collect();
686        tools.extend(mcp.iter().filter(|t| t.decision != "deny").map(|t| {
687            ToolDeclaration::new(t.name.clone(), t.description.clone(), t.schema.clone())
688        }));
689        // Journaled: whether this attempt asks inline decides its effects.
690        let ask = Asker::from_settings(
691            &settings,
692            task_name.clone(),
693            input.peek()["bundle"]["task"]["title"]
694                .as_str()
695                .unwrap_or_default()
696                .to_owned(),
697            PresentContext {
698                root: settings["root"].as_str().map(std::path::PathBuf::from),
699                ..self.present.clone()
700            },
701        );
702        let remembered: Vec<Object> = decisions_of(&settings, "run_approved")?;
703        let once: Vec<Object> = decisions_of(&settings, "approved_once")?;
704        // Untrusted text the prompt itself carries.
705        let mut parts = Vec::new();
706        let given: Vec<crate::instructions::Instruction> =
707            serde_json::from_value(input.peek()["instructions"].clone()).unwrap_or_default();
708        if let Some(text) = crate::instructions::section(&given) {
709            parts.push(crate::approvals::derived::Part {
710                from: provenance::WORKTREE.to_owned(),
711                turn: 0,
712                text,
713            });
714        }
715        if !input.peek()["digest"].is_null() {
716            parts.push(crate::approvals::derived::Part {
717                from: provenance::VERIFIER.to_owned(),
718                turn: 0,
719                text: crate::approvals::derived::text_of(&input.peek()["digest"]),
720            });
721        }
722        let number = |k: &str, d: u32| {
723            settings[k]
724                .as_u64()
725                .map_or(d, |n| u32::try_from(n).unwrap_or(u32::MAX))
726        };
727        tool_loop(
728            cx,
729            &prompt,
730            LoopSpec {
731                tools,
732                policy,
733                max_turns,
734                max_tool_calls,
735                reasoning: settings["reasoning"] == true,
736                schema: report_schema(),
737                routes,
738                root: settings["root"].as_str().map(str::to_owned),
739                frozen_additions: settings["frozen_additions"] == true,
740                ask,
741                run_approved: remembered,
742                once,
743                run: settings["run_id"].as_str().map(str::to_owned),
744                asking: Asking {
745                    asked: number("asked", 0),
746                    budget: number("budget", DEFAULT_BUDGET),
747                    prompts: settings["prompts"] == true,
748                },
749                parts,
750                mcp_digests: mcp
751                    .iter()
752                    .map(|t| (t.name.clone(), t.digest.clone()))
753                    .collect(),
754                observer: self.progress.clone().map(|p| {
755                    (
756                        task_name.rsplit('/').next().unwrap_or_default().to_owned(),
757                        p,
758                    )
759                }),
760                finish: |report| {
761                    // The model's own word that it did not finish is believed;
762                    // its word that it did is not (the verifier decides that).
763                    if report["status"] == "incomplete" {
764                        "incomplete"
765                    } else {
766                        "completed"
767                    }
768                },
769            },
770        )
771        .await
772    }
773}
774
775/// Turns an absolute `path` argument inside the workspace `root` into a
776/// workspace-relative one. Tools print absolute paths (Spec Kit's scripts
777/// do), and a model passes them on; outside the root a path stays as it is
778/// — and the policy refuses it.
779fn relativise(arguments: &mut Value, root: Option<&str>) {
780    let (Some(root), Some(path)) = (root, arguments.get("path").and_then(Value::as_str)) else {
781        return;
782    };
783    let root = root.trim_end_matches('/');
784    if let Some(rel) = path.strip_prefix(root).and_then(|r| r.strip_prefix('/')) {
785        let rel = if rel.is_empty() {
786            ".".to_owned()
787        } else {
788            rel.to_owned()
789        };
790        arguments["path"] = Value::String(rel);
791    }
792}
793
794/// Reports one logged tool call to whoever watches.
795fn emit(
796    observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
797    turn: u32,
798    entry: &Value,
799) {
800    emit_with_tail(observer, turn, entry, Vec::new());
801}
802
803/// [`emit`] with the last lines of a failed command's output (live only,
804/// never journaled).
805fn emit_with_tail(
806    observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
807    turn: u32,
808    entry: &Value,
809    tail: Vec<String>,
810) {
811    if let Some((task, p)) = observer {
812        let tool = entry["tool"].as_str().unwrap_or_default();
813        p.event(&crate::progress::Event::ToolCall {
814            task: task.clone(),
815            turn,
816            tool: tool.to_owned(),
817            target: crate::progress::target(tool, &entry["arguments"]),
818            decision: entry["decision"].as_str().unwrap_or_default().to_owned(),
819            ok: entry["ok"].as_bool(),
820            tail,
821        });
822    }
823}
824
825/// A person is needed for `asked`: the run tells whoever watches.
826fn needs_person(
827    observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
828    asked: &agentplane::model::ToolCall,
829    object: Option<&Object>,
830) {
831    if let Some((task, p)) = observer {
832        let (tool, arguments) = (asked.name.as_str(), &asked.arguments);
833        let target = crate::progress::target(tool, arguments);
834        let what: String = format!("{tool} {target}")
835            .chars()
836            .filter(|c| !c.is_control())
837            .take(60)
838            .collect();
839        p.event(&crate::progress::Event::ApprovalRequested {
840            task: task.clone(),
841            call: asked.id.clone(),
842            tool: tool.to_owned(),
843            what,
844            class: object
845                .map(|o| o.class().as_str().to_owned())
846                .unwrap_or_default(),
847            object: object.map(Object::short).unwrap_or_default(),
848        });
849    }
850}
851
852/// A person decided `asked`: the run tells whoever watches.
853fn person_decided(
854    observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
855    asked: &agentplane::model::ToolCall,
856    decision: &str,
857    scope: Option<&str>,
858    who: Option<&Who>,
859    object: Option<&Object>,
860) {
861    if let Some((task, p)) = observer {
862        p.event(&crate::progress::Event::ApprovalDecided {
863            task: task.clone(),
864            call: asked.id.clone(),
865            decision: decision.to_owned(),
866            scope: scope.map(str::to_owned),
867            by: who.map_or_else(|| "nobody (expired)".to_owned(), |w| w.name.clone()),
868            how: who.map_or_else(|| "expired".to_owned(), |w| w.basis.clone()),
869            class: object
870                .map(|o| o.class().as_str().to_owned())
871                .unwrap_or_default(),
872            object: object.map(Object::short).unwrap_or_default(),
873        });
874    }
875}
876
877/// What a call needing a person is asked by.
878struct Question<'a> {
879    asked: &'a agentplane::model::ToolCall,
880    reason: &'a str,
881    turn: u32,
882    said: &'a str,
883    asker: Option<&'a Asker>,
884    policy: &'a Policy,
885    object: Option<&'a Object>,
886    remembered: &'a [(Object, &'static str)],
887    parts: &'a [crate::approvals::derived::Part],
888    run: Option<&'a str>,
889}
890
891/// The journaled question for a call needing a person: its object, what
892/// changed since an approval of the same name, where its arguments were
893/// copied from, and the choices that leaves — all from journaled data.
894fn question(q: &Question<'_>) -> Request {
895    let (tool, arguments) = (q.asked.name.as_str(), &q.asked.arguments);
896    let kind = crate::approvals::kind_of(tool, arguments);
897    let risk = crate::approvals::risk(tool, q.reason);
898    // Always Allow commits an exact entry: judged without the network.
899    let always_risk = if kind == "network" {
900        let mut bare = arguments.clone();
901        if let Some(o) = bare.as_object_mut() {
902            o.remove("network");
903        }
904        match q.policy.base(tool, &bare) {
905            Decision::Allow => "low",
906            Decision::Approval(r) => crate::approvals::risk(tool, &r),
907            Decision::Deny(_) => "high",
908        }
909    } else {
910        risk
911    };
912    let (sources, more) = crate::approvals::derived::sources(tool, arguments, q.parts);
913    let changed = q.object.and_then(|o| {
914        q.remembered
915            .iter()
916            .map(|(r, _)| r)
917            .chain(q.policy.approved())
918            .find_map(|earlier| o.changed_from(earlier))
919    });
920    Request {
921        task: q.asker.map(|a| a.task.clone()).unwrap_or_default(),
922        task_title: q.asker.map(|a| a.task_title.clone()).unwrap_or_default(),
923        turn: q.turn,
924        tool: tool.to_owned(),
925        arguments: arguments.clone(),
926        kind: kind.to_owned(),
927        reason: q.reason.to_owned(),
928        object: q.object.cloned(),
929        changed,
930        risk: risk.to_owned(),
931        claim: crate::approvals::claim(q.said),
932        options: crate::approvals::options(
933            risk,
934            q.object.is_some(),
935            !sources.is_empty(),
936            kind,
937            always_risk,
938        ),
939        sources,
940        more,
941        run: q.run.map(str::to_owned),
942        asked: 0,
943        budget: 0,
944        batched: false,
945    }
946}
947
948/// What a governed tool loop offers the model and asks of it.
949pub(crate) struct LoopSpec {
950    /// The tools declared to the model; any other name is refused.
951    pub tools: Vec<ToolDeclaration>,
952    /// Classifies every call before dispatch.
953    pub policy: Policy,
954    /// Model turns.
955    pub max_turns: u32,
956    /// Tool calls.
957    pub max_tool_calls: u32,
958    /// Ask each turn for the model's reasoning summary.
959    pub reasoning: bool,
960    /// The closing answer's schema.
961    pub schema: Value,
962    /// Declared tools served elsewhere: (name, server, tool) — MCP.
963    pub routes: Vec<(String, String, String)>,
964    /// The workspace's absolute path, from the journaled input: absolute
965    /// paths inside it are made relative before the policy sees them.
966    pub root: Option<String>,
967    /// A write to a frozen test that only adds lines needs no approval.
968    pub frozen_additions: bool,
969    /// The status for a closing answer.
970    pub finish: fn(&Value) -> &'static str,
971    /// Ask a person instead of stopping when a call needs approval.
972    pub ask: Option<Asker>,
973    /// Objects a person approved for this run: allowed without asking.
974    pub run_approved: Vec<Object>,
975    /// Objects a person approved for one call each: the first call of
976    /// each is allowed without asking, later ones ask again.
977    pub once: Vec<Object>,
978    /// Live progress: the task ID and where to report.
979    pub observer: Option<(String, std::sync::Arc<dyn crate::progress::Progress>)>,
980    /// The run the attempt belongs to (`run-<secs>-<hex>`).
981    pub run: Option<String>,
982    /// How a run asks: its count so far, its budget and whether a person is
983    /// prompted at all.
984    pub asking: Asking,
985    /// Untrusted content the prompt carries (inlined worktree text, the
986    /// verifier's digest): what derived arguments are found in.
987    pub parts: Vec<crate::approvals::derived::Part>,
988    /// MCP tools' digests by model-facing name: their objects.
989    pub mcp_digests: std::collections::BTreeMap<String, String>,
990}
991
992/// A run's asking budget as an attempt sees it (all journaled).
993#[derive(Debug, Clone, Copy, PartialEq, Eq)]
994pub struct Asking {
995    /// Prompts shown to a person in the run so far.
996    pub asked: u32,
997    /// `[approvals] budget`: at most this many per run.
998    pub budget: u32,
999    /// A person is prompted inline; `false` with `--no-prompt` (calls wait
1000    /// as ordinary approvals, nothing is counted).
1001    pub prompts: bool,
1002}
1003
1004impl Default for Asking {
1005    fn default() -> Self {
1006        Self {
1007            asked: 0,
1008            budget: DEFAULT_BUDGET,
1009            prompts: false,
1010        }
1011    }
1012}
1013
1014/// `[approvals] budget` unless configured.
1015pub use super::run_config::DEFAULT_BUDGET;
1016
1017/// The governed tool loop shared by attempts and convergence: every call is
1018/// classified first (deny → refused and told; approval → stop; allow →
1019/// dispatched through agentplane); the closing structured answer ends it.
1020pub(crate) async fn tool_loop(
1021    cx: &mut StepCtx<'_>,
1022    prompt: &Tainted<Value>,
1023    spec: LoopSpec,
1024) -> Result<Outcome, SkillError> {
1025    let LoopSpec {
1026        tools,
1027        policy,
1028        max_turns,
1029        max_tool_calls,
1030        reasoning,
1031        schema,
1032        routes,
1033        root,
1034        frozen_additions,
1035        finish,
1036        ask,
1037        run_approved,
1038        mut once,
1039        observer,
1040        run,
1041        asking,
1042        mut parts,
1043        mcp_digests,
1044    } = spec;
1045    // Objects allowed without asking: this run's, then what a person
1046    // answered "for the task" or "for this run" in this attempt.
1047    let mut remembered: Vec<(Object, &'static str)> =
1048        run_approved.into_iter().map(|g| (g, "run-grant")).collect();
1049    let mut prompted = asking.asked;
1050    let mut told_budget = false;
1051    let catalog = crate::tools::catalog();
1052
1053    // What the conversation has read, with its labels: agentplane checks
1054    // every model call against the join.
1055    let mut transcript = Transcript::new(prompt);
1056    let mut conversation: Label = prompt.label().clone();
1057    let mut calls = 0u32;
1058    let mut denied_in_a_row = 0u32;
1059    // Whether this attempt registered the `approval` obligation yet.
1060    let mut obligation = false;
1061    let mut usage = agentplane::model::Usage::default();
1062    let mut log: Vec<Value> = Vec::new();
1063
1064    let end = |status: &str,
1065               extra: Value,
1066               log: &[Value],
1067               usage: agentplane::model::Usage,
1068               conversation: &Label,
1069               prompted: u32| {
1070        let mut out = json!({
1071            "status": status,
1072            "tool_calls": log,
1073            "asked": prompted,
1074            "usage": {
1075                "input_tokens": usage.input_tokens,
1076                "output_tokens": usage.output_tokens,
1077                "cache_read_tokens": usage.cache_read_tokens,
1078                "cache_write_tokens": usage.cache_write_tokens,
1079            },
1080        });
1081        if let (Some(o), Some(e)) = (out.as_object_mut(), extra.as_object()) {
1082            o.extend(e.clone());
1083        }
1084        Ok(Outcome::done(Tainted::with_label(
1085            out,
1086            conversation.clone(),
1087        )))
1088    };
1089
1090    for turn in 0..max_turns {
1091        // The prompt was built from labelled parts (`/system` is speccoder's
1092        // constant instruction); what the conversation read since rides in
1093        // the transcript, so the request is labelled by its real sources.
1094        let tools_now = tools.clone();
1095        let schema_now = schema.clone();
1096        let read_so_far = &transcript;
1097        let completion = cx
1098            .complete_with(prompt, move |call| {
1099                let call = call
1100                    .with_tools(tools_now)
1101                    .continuing(read_so_far)
1102                    .expecting(schema_now);
1103                if reasoning {
1104                    call.with_reasoning_summary()
1105                } else {
1106                    call
1107                }
1108            })
1109            .await?;
1110        conversation = transcript.label().join(completion.label());
1111        usage = usage.plus(completion.peek().usage);
1112        if let Some((task, p)) = &observer {
1113            p.event(&crate::progress::Event::Usage {
1114                task: task.clone(),
1115                input_tokens: usage.input_tokens,
1116                output_tokens: usage.output_tokens,
1117                cache_read_tokens: usage.cache_read_tokens,
1118                cache_write_tokens: usage.cache_write_tokens,
1119                usd: None,
1120            });
1121        }
1122
1123        // The model's summary of its reasoning, when asked and given: its
1124        // account of why, a claim like its words.
1125        if let (Some((task, p)), Some(reasoning)) =
1126            (&observer, completion.peek().reasoning.as_deref())
1127            && !reasoning.trim().is_empty()
1128        {
1129            let text: String = crate::approvals::present::escape(reasoning.trim())
1130                .chars()
1131                .take(4000)
1132                .collect();
1133            p.event(&crate::progress::Event::Reasoning {
1134                task: task.clone(),
1135                turn: turn + 1,
1136                text,
1137            });
1138        }
1139        if completion.peek().truncated {
1140            return Ok(Outcome::fail(
1141                "the model ran out of output budget mid-turn; nothing it was still writing was executed — raise [model] max_output_tokens",
1142            ));
1143        }
1144        let asked_calls = transcript.asked(&completion);
1145        // The model's own words beside its calls, live: its account, a
1146        // claim. A turn without calls is its final answer, reported as such;
1147        // its commentary, where the provider marks one, is still words.
1148        let said = super::said(completion.peek());
1149        if let (Some((task, p)), Some(said)) = (observer.as_ref(), said.as_deref()) {
1150            let text: String = crate::approvals::present::escape(said)
1151                .chars()
1152                .take(2000)
1153                .collect();
1154            p.event(&crate::progress::Event::Said {
1155                task: task.clone(),
1156                turn: turn + 1,
1157                text,
1158            });
1159        }
1160        if asked_calls.is_empty() {
1161            let report = completion
1162                .peek()
1163                .structured
1164                .clone()
1165                .unwrap_or_else(|| json!({ "summary": completion.peek().text, "status": "incomplete", "files_changed": [], "checks_run": [], "notes": ["the answer was not the requested structure"] }));
1166            let status = finish(&report);
1167            return end(
1168                status,
1169                json!({ "report": report }),
1170                &log,
1171                usage,
1172                &conversation,
1173                prompted,
1174            );
1175        }
1176
1177        for mut asked in asked_calls {
1178            relativise(&mut asked.arguments, root.as_deref());
1179            // Only speccoder sets the object a call is checked against.
1180            if let Some(o) = asked.arguments.as_object_mut() {
1181                o.remove(crate::tools::EXPECT);
1182            }
1183            calls += 1;
1184            if calls > max_tool_calls {
1185                return end(
1186                    "budget_exhausted",
1187                    json!({ "reason": format!("more than {max_tool_calls} tool calls (`[budget] max_tool_calls`)") }),
1188                    &log,
1189                    usage,
1190                    &conversation,
1191                    prompted,
1192                );
1193            }
1194            if asked.name == ASK_PERSON && tools.iter().any(|t| t.name == ASK_PERSON) {
1195                let question = asked.arguments["question"]
1196                    .as_str()
1197                    .unwrap_or_default()
1198                    .to_owned();
1199                // A person's answer is their input, recorded with who gave it
1200                // — never speccoder's own trusted text.
1201                let decided = match &ask {
1202                    Some(asker) => Some(
1203                        wait_for_person(
1204                            cx,
1205                            asker,
1206                            &mut obligation,
1207                            &crate::approvals::tasks::question_task(&asker.task, &question),
1208                        )
1209                        .await?,
1210                    ),
1211                    None => None,
1212                };
1213                let read = decided.as_ref().map(crate::approvals::tasks::read);
1214                let by = read.as_ref().and_then(|r| r.by.clone());
1215                let entry = json!({
1216                    "tool": ASK_PERSON,
1217                    "arguments": asked.arguments,
1218                    "decision": "allow",
1219                    "answered": read.as_ref().is_some_and(|r| r.text.is_some()),
1220                    "operator": by.as_ref().map(Who::view),
1221                    "via": by.as_ref().map(|w| w.via.clone()),
1222                });
1223                emit(observer.as_ref(), turn + 1, &entry);
1224                log.push(entry);
1225                let text = match (&read, &by) {
1226                    (Some(r), _) if r.text.is_some() => r.text.clone().unwrap_or_default(),
1227                    (Some(_), Some(w)) => format!("{} declined to answer.", w.name),
1228                    _ => "Nobody can answer now. Record the question in the artifact as [NEEDS CLARIFICATION: <question>] and continue.".to_owned(),
1229                };
1230                let label = match &by {
1231                    Some(_) => provenance::from(provenance::PERSON),
1232                    None => Label::trusted(),
1233                };
1234                transcript.answered(
1235                    asked,
1236                    &Tainted::with_label(json!({ "answer": text }), label),
1237                );
1238                continue;
1239            }
1240            if asked.name == REPORT_BLOCKED && tools.iter().any(|t| t.name == REPORT_BLOCKED) {
1241                let reason = asked.arguments["reason"]
1242                    .as_str()
1243                    .unwrap_or("no reason given")
1244                    .to_owned();
1245                let entry = json!({ "tool": REPORT_BLOCKED, "arguments": asked.arguments, "decision": "stop" });
1246                emit(observer.as_ref(), turn + 1, &entry);
1247                log.push(entry);
1248                return end(
1249                    "blocked",
1250                    json!({ "reason": reason }),
1251                    &log,
1252                    usage,
1253                    &conversation,
1254                    prompted,
1255                );
1256            }
1257            let declared = tools.iter().any(|t| t.name == asked.name);
1258            let target = catalog
1259                .iter()
1260                .find(|t| declared && t.name == asked.name)
1261                .map(|t| ToolId::new(t.server, t.name))
1262                .or_else(|| {
1263                    routes
1264                        .iter()
1265                        .find(|(n, _, _)| declared && *n == asked.name)
1266                        .map(|(_, server, tool)| ToolId::new(server, tool))
1267                });
1268            let Some(target) = target else {
1269                let entry = json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "unknown" });
1270                emit(observer.as_ref(), turn + 1, &entry);
1271                log.push(entry);
1272                transcript.failed(asked, Tainted::trusted("no tool of that name".to_owned()));
1273                continue;
1274            };
1275            let mut decision = policy.base(&asked.name, &asked.arguments);
1276            // `[tests] frozen = "additions"`: read the frozen test (a
1277            // journaled call, so replay sees the same content) and allow a
1278            // write that keeps every existing line.
1279            let mut additions_only = false;
1280            if frozen_additions
1281                && matches!(&decision, Decision::Approval(r) if r.contains("frozen test"))
1282            {
1283                let path = asked.arguments["path"]
1284                    .as_str()
1285                    .unwrap_or_default()
1286                    .to_owned();
1287                let read = cx
1288                    .call_tool(
1289                        ToolId::new("fs", "read_file"),
1290                        Tainted::with_label(json!({ "path": path }), completion.label().clone()),
1291                    )
1292                    .await?;
1293                let current = read.peek().clone();
1294                // `read_file` joins lines with `\n`; the file's last newline is
1295                // restored so an edit's `old_string` matches as on disk.
1296                let lines = current["content"].as_str().unwrap_or_default();
1297                let old_text = if lines.is_empty() {
1298                    String::new()
1299                } else {
1300                    format!("{lines}\n")
1301                };
1302                let old = old_text.as_str();
1303                let readable = current["ok"] == true && current["truncated"] != true;
1304                let new = match asked.name.as_str() {
1305                    "write_file" => asked.arguments["content"].as_str().map(str::to_owned),
1306                    "edit_file" => {
1307                        let (from, to) = (
1308                            asked.arguments["old_string"].as_str().unwrap_or_default(),
1309                            asked.arguments["new_string"].as_str().unwrap_or_default(),
1310                        );
1311                        let all = asked.arguments["replace_all"] == true;
1312                        (!from.is_empty() && (all || old.matches(from).count() == 1))
1313                            .then(|| old.replace(from, to))
1314                    }
1315                    _ => None,
1316                };
1317                if readable && new.is_some_and(|n| crate::integrity::only_additions(old, &n)) {
1318                    decision = Decision::Allow;
1319                    additions_only = true;
1320                }
1321            }
1322            // A person would be needed: the object the call reaches is
1323            // resolved by a journaled step (replay reads the same object
1324            // back), and only a decision on that very object applies.
1325            let mut object: Option<Object> = None;
1326            if matches!(decision, Decision::Approval(_)) {
1327                if asked.name.starts_with(crate::mcp::PREFIX) {
1328                    object = mcp_digests.get(&asked.name).map(|digest| Object::Tool {
1329                        name: asked.name.clone(),
1330                        digest: digest.clone(),
1331                    });
1332                } else {
1333                    let resolved = cx
1334                        .call_tool(
1335                            ToolId::new("fs", "resolve"),
1336                            Tainted::with_label(
1337                                json!({ "tool": asked.name, "arguments": asked.arguments }),
1338                                completion.label().clone(),
1339                            ),
1340                        )
1341                        .await?;
1342                    let out = resolved.peek();
1343                    if out["ok"] == true {
1344                        object = serde_json::from_value(out["object"].clone()).ok();
1345                    } else {
1346                        decision = Decision::Deny(
1347                            out["error"]
1348                                .as_str()
1349                                .unwrap_or("the call's object cannot be resolved")
1350                                .to_owned(),
1351                        );
1352                    }
1353                }
1354                if matches!(decision, Decision::Approval(_)) {
1355                    decision = policy.classify(&asked.name, &asked.arguments, object.as_ref());
1356                }
1357            }
1358            let mut person: Option<Value> = None;
1359            // Allowed by a person's grant for the task (of this run): the
1360            // person's decision, not a rule's.
1361            if decision == Decision::Allow && object.is_some() {
1362                person = Some(json!({
1363                    "approved_by": "person",
1364                    "scope": "task-grant",
1365                    "grant": { "scope": "task", "run": run },
1366                }));
1367            }
1368            let mut note: Option<String> = None;
1369            // An object a person approved for one call: this one.
1370            if matches!(decision, Decision::Approval(_))
1371                && let Some(at) = object
1372                    .as_ref()
1373                    .and_then(|o| once.iter().position(|r| r == o))
1374            {
1375                once.remove(at);
1376                person = Some(json!({
1377                    "approved_by": "person",
1378                    "scope": "once-grant",
1379                    "grant": { "scope": "once", "run": run },
1380                }));
1381                decision = Decision::Allow;
1382            }
1383            // An object a person already approved for this run or task.
1384            if matches!(decision, Decision::Approval(_))
1385                && let Some((_, scope)) = object
1386                    .as_ref()
1387                    .and_then(|o| remembered.iter().find(|(r, _)| r == o))
1388            {
1389                person = Some(json!({
1390                    "approved_by": "person",
1391                    "scope": scope,
1392                    "grant": { "scope": scope.trim_end_matches("-grant"), "run": run },
1393                }));
1394                decision = Decision::Allow;
1395            }
1396            // On autopilot nobody is asked: what would wait for a person is
1397            // refused for the run and listed for them afterwards.
1398            decision = policy.settle(decision);
1399            // Still a person's: the question, with everything it is decided by.
1400            let request = match &decision {
1401                Decision::Approval(reason) => Some(question(&Question {
1402                    asked: &asked,
1403                    reason,
1404                    turn: turn + 1,
1405                    said: said.as_deref().unwrap_or_default(),
1406                    asker: ask.as_ref(),
1407                    policy: &policy,
1408                    object: object.as_ref(),
1409                    remembered: &remembered,
1410                    parts: &parts,
1411                    run: run.as_deref(),
1412                })),
1413                _ => None,
1414            };
1415            // ask a person now, as a journaled effect, instead of stopping.
1416            if let (Some(mut request), Some(asker)) = (request.clone(), &ask) {
1417                // The run's asking budget: within it a person is prompted
1418                // (and counted, whatever they answer); past it the call
1419                // waits in the run's batch, nobody interrupted.
1420                if asking.prompts {
1421                    if prompted < asking.budget {
1422                        prompted += 1;
1423                        request.asked = prompted;
1424                    } else {
1425                        request.batched = true;
1426                    }
1427                    request.budget = asking.budget;
1428                }
1429                if request.batched {
1430                    // The batch item is created: the person is notified
1431                    // once; calls joining it notify no one.
1432                    if !told_budget {
1433                        needs_person(observer.as_ref(), &asked, object.as_ref());
1434                    }
1435                    if !told_budget && let Some((_, p)) = &observer {
1436                        p.event(&crate::progress::Event::Notice {
1437                            text: format!(
1438                                "approval budget ({}) reached: calls wait in Needs You (speccoder approvals)",
1439                                asking.budget
1440                            ),
1441                        });
1442                    }
1443                    told_budget = true;
1444                } else {
1445                    needs_person(observer.as_ref(), &asked, object.as_ref());
1446                }
1447                let decided = wait_for_person(
1448                    cx,
1449                    asker,
1450                    &mut obligation,
1451                    &crate::approvals::tasks::approval_task(&request, &log),
1452                )
1453                .await?;
1454                let read = crate::approvals::tasks::read(&decided);
1455                // A batch decision may start the run's asking again.
1456                if read.resume_asking {
1457                    prompted = 0;
1458                }
1459                let answer: Answer = read.answer.clone();
1460                // Who decided, and how that is known, is journaled with it.
1461                let approver = serde_json::to_value(&answer.by).unwrap_or(Value::Null);
1462                let operator = read.by.as_ref().map(Who::view);
1463                person_decided(
1464                    observer.as_ref(),
1465                    &asked,
1466                    if answer.decision == Said::Approve {
1467                        "approve"
1468                    } else {
1469                        "reject"
1470                    },
1471                    (answer.decision == Said::Approve).then(|| answer.scope.name()),
1472                    read.by.as_ref().filter(|_| !read.expired),
1473                    object.as_ref(),
1474                );
1475                match answer.decision {
1476                    Said::Approve => {
1477                        let mut p = json!({
1478                            "approved_by": "person",
1479                            "scope": answer.scope.name(),
1480                            "note": answer.note,
1481                            "approver": approver,
1482                            "operator": operator,
1483                        });
1484                        if answer.always {
1485                            p["always"] = json!(true);
1486                        }
1487                        if let (Some(o), Scope::Task | Scope::Run) = (&object, answer.scope) {
1488                            let remembered_as = if answer.scope == Scope::Run {
1489                                "run-grant"
1490                            } else {
1491                                "task-grant"
1492                            };
1493                            p["grant"] = json!({ "scope": answer.scope.name(), "run": run });
1494                            remembered.push((o.clone(), remembered_as));
1495                        }
1496                        person = Some(p);
1497                        note = answer.note;
1498                        decision = Decision::Allow;
1499                    }
1500                    // "Later" is a task left undecided: the run stays
1501                    // suspended and never reaches here.
1502                    Said::Reject | Said::Stop => {
1503                        let why = answer
1504                            .reason
1505                            .unwrap_or_else(|| "no reason given".to_owned());
1506                        person = Some(if read.expired {
1507                            json!({ "expired": true, "rejection": why })
1508                        } else {
1509                            json!({ "rejected_by": "person", "approver": approver, "operator": operator, "rejection": why })
1510                        });
1511                        decision = Decision::Deny(format!("a person rejected this: {why}"));
1512                    }
1513                }
1514            }
1515            // Who decided: a person (now, or by a grant they gave), or a rule
1516            // — named, so a rule's decision is never read as a person's.
1517            let rule = person
1518                .is_none()
1519                .then(|| policy.rule(&asked.name, &asked.arguments, object.as_ref()));
1520            let with_person = |mut entry: Value| {
1521                if let Some(o) = entry.as_object_mut() {
1522                    // The object decided on, whoever decided.
1523                    if let Some(obj) = &object {
1524                        o.insert("object".to_owned(), json!(obj));
1525                    }
1526                    match (person.as_ref().and_then(Value::as_object), &rule) {
1527                        (Some(p), _) => {
1528                            // An expiry is nobody's decision.
1529                            let by = if p.contains_key("expired") {
1530                                "expiry"
1531                            } else {
1532                                "person"
1533                            };
1534                            o.insert("by".to_owned(), json!(by));
1535                            o.extend(p.clone());
1536                        }
1537                        (None, Some(r)) => {
1538                            o.insert("by".to_owned(), json!("rule"));
1539                            o.insert("rule".to_owned(), json!(r));
1540                        }
1541                        (None, None) => {}
1542                    }
1543                }
1544                entry
1545            };
1546            match decision {
1547                Decision::Deny(reason) => {
1548                    let entry = with_person(
1549                        json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "deny", "reason": reason }),
1550                    );
1551                    emit(observer.as_ref(), turn + 1, &entry);
1552                    log.push(entry);
1553                    // The reason quotes the call (its tool, path, command, or a
1554                    // person's words about it): it carries the call's label.
1555                    transcript.failed(
1556                        asked,
1557                        refusal(format!("denied: {reason}"), completion.label()),
1558                    );
1559                    // Escalation: a model that keeps asking for what it may
1560                    // not do is stopped for a person.
1561                    denied_in_a_row += 1;
1562                    if denied_in_a_row >= MAX_CONSECUTIVE_DENIALS {
1563                        return end(
1564                            "denied",
1565                            json!({ "reason": format!("{denied_in_a_row} calls in a row were denied; last: {reason}") }),
1566                            &log,
1567                            usage,
1568                            &conversation,
1569                            prompted,
1570                        );
1571                    }
1572                    continue;
1573                }
1574                Decision::Approval(reason) => {
1575                    let mut entry = json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "approval", "reason": reason });
1576                    if let Some(obj) = &object {
1577                        entry["object"] = json!(obj);
1578                    }
1579                    emit(observer.as_ref(), turn + 1, &entry);
1580                    needs_person(observer.as_ref(), &asked, object.as_ref());
1581                    log.push(entry);
1582                    return end(
1583                        "needs_approval",
1584                        json!({ "pending": {
1585                            "tool": asked.name,
1586                            "arguments": asked.arguments,
1587                            "reason": reason,
1588                            "object": object,
1589                            "run": run,
1590                            "request": request,
1591                        } }),
1592                        &log,
1593                        usage,
1594                        &conversation,
1595                        prompted,
1596                    );
1597                }
1598                Decision::Allow => {
1599                    denied_in_a_row = 0;
1600                    // The arguments carry the model's label: agentplane's
1601                    // taint gate checks them against the grant's fields (an
1602                    // argument-less mutating tool is granted exactly `{}`).
1603                    // A command gets the call's id (`SPECCODER_CALL_ID`):
1604                    // a call resumed after a crash has the same one, so a
1605                    // script can tell a repeat from a new call.
1606                    let mut arguments = asked.arguments.clone();
1607                    if asked.name == "run_command"
1608                        && let Some(o) = arguments.as_object_mut()
1609                    {
1610                        o.insert("call_id".to_owned(), Value::String(asked.id.clone()));
1611                    }
1612                    // Decided on an object: the tool checks it again at
1613                    // use, and refuses when the call now reaches another.
1614                    if let (Some(obj), Some(o)) = (&object, arguments.as_object_mut())
1615                        && matches!(
1616                            asked.name.as_str(),
1617                            "write_file" | "edit_file" | "run_command"
1618                        )
1619                    {
1620                        o.insert(crate::tools::EXPECT.to_owned(), json!(obj));
1621                    }
1622                    let args = Tainted::with_label(arguments, completion.label().clone());
1623                    let result = match cx.call_tool(target.clone(), args).await {
1624                        Ok(r) => r,
1625                        // A failure the far side reported is the model's to
1626                        // read, labelled by who wrote it.
1627                        Err(error) => match cx.relayed(&error) {
1628                            Some(detail) => {
1629                                let entry = with_person(
1630                                    json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "allow", "ok": false }),
1631                                );
1632                                emit(observer.as_ref(), turn + 1, &entry);
1633                                log.push(entry);
1634                                transcript.failed(asked, detail);
1635                                continue;
1636                            }
1637                            None => return Err(error.into()),
1638                        },
1639                    };
1640                    let output = result.peek().clone();
1641                    let ok = output["ok"].as_bool() != Some(false);
1642                    let mut entry = with_person(
1643                        json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "allow", "ok": ok }),
1644                    );
1645                    if let Some(changed) = output["object_changed"].as_object() {
1646                        entry["decision"] = json!("refused_changed");
1647                        entry["expected"] = changed.get("expected").cloned().unwrap_or(Value::Null);
1648                        entry["found"] = changed.get("found").cloned().unwrap_or(Value::Null);
1649                    }
1650                    // What the tool returned is content the attempt read:
1651                    // arguments copied from it later are named as such.
1652                    if let Some(part) = crate::approvals::derived::part_of(
1653                        &asked.name,
1654                        &asked.arguments,
1655                        &output,
1656                        turn + 1,
1657                        &format!("tool://{}/{}", target.server, target.tool),
1658                        |p: &str| {
1659                            policy.protected().write_class(p)
1660                                == Some(crate::tools::paths::Class::Intent)
1661                        },
1662                    ) {
1663                        parts.push(part);
1664                    }
1665                    if additions_only {
1666                        entry["frozen_additions"] = json!(true);
1667                    }
1668                    let tail = if asked.name == "run_command" && !ok {
1669                        crate::progress::tail(&format!(
1670                            "{}\n{}",
1671                            output["stdout"].as_str().unwrap_or_default(),
1672                            output["stderr"].as_str().unwrap_or_default()
1673                        ))
1674                    } else {
1675                        Vec::new()
1676                    };
1677                    emit_with_tail(observer.as_ref(), turn + 1, &entry, tail);
1678                    log.push(entry);
1679                    // A command that changed a judge input ends the attempt
1680                    // before the model sees another turn.
1681                    if let Some(drift) = output["integrity"].as_array().filter(|d| !d.is_empty()) {
1682                        let paths: Vec<&str> =
1683                            drift.iter().filter_map(|d| d["path"].as_str()).collect();
1684                        return end(
1685                            "integrity",
1686                            json!({
1687                                "reason": format!("a command changed what judges the task: {}", paths.join(", ")),
1688                                "integrity": drift,
1689                            }),
1690                            &log,
1691                            usage,
1692                            &conversation,
1693                            prompted,
1694                        );
1695                    }
1696                    // A tool that refused (`error`) is a failed call; a
1697                    // command that ran and exited non-zero is a result the
1698                    // model must read in full — its stdout and stderr are
1699                    // the diagnostics (found by the first real evaluation,
1700                    // where the model saw only the word "failed").
1701                    match output["error"].as_str() {
1702                        Some(error) if !ok => transcript.failed(
1703                            asked,
1704                            Tainted::with_label(error.to_owned(), result.label().clone()),
1705                        ),
1706                        // A person's note rides with the result it approved.
1707                        _ => match &note {
1708                            Some(n) => {
1709                                let n = format!("Note from the person who approved this call: {n}");
1710                                transcript.answered(
1711                                    asked,
1712                                    &result.map(|mut v| {
1713                                        v["note_from_person"] = Value::String(n);
1714                                        v
1715                                    }),
1716                                );
1717                            }
1718                            None => transcript.answered(asked, &result),
1719                        },
1720                    }
1721                }
1722            }
1723        }
1724    }
1725    end(
1726        "budget_exhausted",
1727        json!({ "reason": format!("more than {max_turns} model turns (`[budget] max_turns`)") }),
1728        &log,
1729        usage,
1730        &conversation,
1731        prompted,
1732    )
1733}
1734
1735#[cfg(test)]
1736mod tests {
1737    use super::*;
1738
1739    #[test]
1740    fn labels_are_read_from_the_parts_and_refusals_carry_the_call() {
1741        use agentplane::core::{Label, SourceId};
1742        let model = Label::untrusted(SourceId::new("model:x/y"));
1743        // A refusal quoting the call is the call's; a constant is trusted.
1744        assert!(
1745            refusal("denied: `src/x` is protected".to_owned(), &model)
1746                .label()
1747                .is_untrusted()
1748        );
1749        // Parts labelled when admitted: the spec trusted, the digest the verifier's.
1750        let input = Tainted::object([
1751            ("bundle", Tainted::trusted(json!({}))),
1752            (
1753                "digest",
1754                Tainted::with_label(json!({}), provenance::from(provenance::VERIFIER)),
1755            ),
1756            (
1757                "settings",
1758                Tainted::object([("nudges", Tainted::trusted(json!([])))]),
1759            ),
1760        ]);
1761        let label = conversation_label(&input);
1762        assert!(
1763            label
1764                .provenance
1765                .contains(&SourceId::new(provenance::VERIFIER))
1766        );
1767        // No labels of its own: every part takes the whole input's (fails closed).
1768        let flat = Tainted::with_label(json!({ "bundle": {} }), model.clone());
1769        assert_eq!(conversation_label(&flat).provenance, model.provenance);
1770    }
1771
1772    #[test]
1773    fn absolute_paths_inside_the_root_become_relative_and_others_stay() {
1774        let mut a = json!({ "path": "/work/repo/specs/001-x/plan.md" });
1775        relativise(&mut a, Some("/work/repo"));
1776        assert_eq!(a["path"], "specs/001-x/plan.md");
1777        let mut outside = json!({ "path": "/work/repository-two/x" });
1778        relativise(&mut outside, Some("/work/repo"));
1779        assert_eq!(outside["path"], "/work/repository-two/x");
1780        let mut none = json!({ "path": "/etc/passwd" });
1781        relativise(&mut none, None);
1782        assert_eq!(none["path"], "/etc/passwd");
1783    }
1784}