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