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