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