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, 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) -> Tainted<Value> {
193    let digest = match digest.filter(|d| !d.is_null()) {
194        Some(d) => Tainted::with_label(d.clone(), provenance::from(provenance::VERIFIER)),
195        None => Tainted::trusted(Value::Null),
196    };
197    // Each MCP tool's description and schema are its server's words.
198    let mcp = Tainted::array(settings.mcp.iter().map(|t| {
199        Tainted::with_label(
200            serde_json::to_value(t).unwrap_or(Value::Null),
201            agentplane::core::Label::untrusted(provenance::tool(&t.server, &t.tool)),
202        )
203    }));
204    let trusted = |v: Value| Tainted::trusted(v);
205    let settings = Tainted::object([
206        ("checks", trusted(json!(settings.checks))),
207        ("allow", trusted(json!(settings.allow))),
208        ("max_turns", trusted(json!(settings.max_turns))),
209        ("max_tool_calls", trusted(json!(settings.max_tool_calls))),
210        ("frozen", trusted(json!(settings.frozen))),
211        ("approved", trusted(json!(settings.approved))),
212        ("rejected", trusted(json!(settings.rejected))),
213        ("run_approved", trusted(json!(settings.run_approved))),
214        ("nudges", trusted(json!(settings.nudges))),
215        ("mcp", mcp),
216        ("root", trusted(json!(settings.root))),
217        (
218            "frozen_additions",
219            trusted(json!(settings.frozen_additions)),
220        ),
221        ("network_allow", trusted(json!(settings.network_allow))),
222        ("asks", trusted(json!(settings.asks))),
223        ("wait_secs", trusted(json!(settings.wait_secs))),
224        ("in_place", trusted(json!(settings.in_place))),
225        ("hidden", trusted(json!(settings.hidden))),
226    ]);
227    Tainted::object([
228        ("bundle", Tainted::trusted(bundle)),
229        ("digest", digest),
230        ("settings", settings),
231        // The repository's text, as if the model had read the files itself;
232        // none is no text, and no label.
233        (
234            "instructions",
235            if instructions.is_empty() {
236                Tainted::trusted(json!([]))
237            } else {
238                Tainted::with_label(json!(instructions), provenance::from(provenance::WORKTREE))
239            },
240        ),
241    ])
242}
243
244/// A refusal speccoder composes about a call quotes it — the tool, path or
245/// command the model chose — so it carries the call's label; only
246/// constant replies are trusted.
247fn refusal(text: String, call: &agentplane::core::Label) -> Tainted<String> {
248    Tainted::with_label(text, call.clone())
249}
250
251/// The parts of an attempt's input its messages quote.
252const MESSAGE_PARTS: [&str; 6] = [
253    "/bundle",
254    "/instructions",
255    "/digest",
256    "/settings/nudges",
257    "/settings/rejected",
258    "/settings/mcp",
259];
260
261/// The label of the conversation part: the join of the labels the run was
262/// admitted with for the parts it quotes — read, never inferred. A part
263/// without a label of its own takes the whole input's (fails closed).
264fn conversation_label(input: &Tainted<Value>) -> agentplane::core::Label {
265    MESSAGE_PARTS
266        .iter()
267        .map(|p| input.label_at(p).unwrap_or_else(|| input.label()).clone())
268        .fold(agentplane::core::Label::trusted(), |acc, l| acc.join(&l))
269}
270
271/// What an attempt decides by, besides its bundle (all journaled).
272#[derive(Debug, Clone, Copy)]
273pub struct Settings<'a> {
274    /// Configured checks' argvs (their prefixes are allowed commands).
275    pub checks: &'a [Vec<String>],
276    /// Extra allowed command prefixes.
277    pub allow: &'a [Vec<String>],
278    /// Model turns.
279    pub max_turns: u32,
280    /// Tool calls.
281    pub max_tool_calls: u32,
282    /// Test files frozen for this attempt.
283    pub frozen: &'a [String],
284    /// A person's approvals for this task.
285    pub approved: &'a [crate::tools::policy::Grant],
286    /// A person's rejections for this task.
287    pub rejected: &'a [crate::tools::policy::Rejection],
288    /// Classes a person approved for every task of this run.
289    pub run_approved: &'a [crate::tools::policy::Grant],
290    /// A person's notes for this task (`nudge`).
291    pub nudges: &'a [String],
292    /// The project's MCP tools for this run.
293    pub mcp: &'a [crate::mcp::McpTool],
294    /// The worktree's absolute (canonical) path.
295    pub root: &'a str,
296    /// Frozen tests may gain lines without approval (`[tests] frozen`).
297    pub frozen_additions: bool,
298    /// How a call needing a person is handled: `"tasks"` (the attempt waits
299    /// on a human task) or `"stop"` (it ends `needs_approval`; lanes).
300    pub asks: &'a str,
301    /// How long a call waits for a person before it is denied as expired.
302    pub wait_secs: u64,
303    /// Command prefixes that get the network (`[sandbox] network_allow`).
304    pub network_allow: &'a [Vec<String>],
305    /// An in-place attempt: git that moves the checkout is denied.
306    pub in_place: bool,
307    /// In place: what git ignored at the run's start, hidden from the tools.
308    pub hidden: &'a [String],
309}
310
311/// The calls consecutive denials of which end an attempt.
312pub const MAX_CONSECUTIVE_DENIALS: u32 = 3;
313
314/// The digest as the model reads it: facts from the verifier only.
315fn digest_text(digest: &Value) -> String {
316    let mut out = format!(
317        "# Previous attempt\n\nAttempt {} at this task was verified by speccoder and FAILED. Its \
318changes were discarded; the worktree is back where that attempt started. You start fresh — use \
319these facts from the verifier, check them yourself, and do not repeat the mistake.\n",
320        digest["attempt"].as_u64().unwrap_or(1)
321    );
322    let strings = |v: &Value| -> Vec<String> {
323        v.as_array()
324            .map(|a| {
325                a.iter()
326                    .filter_map(|x| x.as_str().map(str::to_owned))
327                    .collect()
328            })
329            .unwrap_or_default()
330    };
331    let reasons = strings(&digest["reasons"]);
332    if !reasons.is_empty() {
333        out.push_str("\n## Verdict reasons\n\n");
334        for r in reasons {
335            out.push_str(&format!("- {r}\n"));
336        }
337    }
338    for c in digest["checks"].as_array().into_iter().flatten() {
339        out.push_str(&format!(
340            "\n## Check `{}` ({}): `{}`\n",
341            c["name"].as_str().unwrap_or_default(),
342            c["status"].as_str().unwrap_or_default(),
343            strings(&c["argv"]).join(" ")
344        ));
345        let tests = strings(&c["failing_tests"]);
346        if !tests.is_empty() {
347            out.push_str(&format!("\nFailing tests: {}\n", tests.join(", ")));
348        }
349        out.push_str(&format!(
350            "\n```text\n{}\n```\n",
351            c["excerpt"].as_str().unwrap_or_default()
352        ));
353    }
354    let discarded = strings(&digest["discarded"]);
355    if !discarded.is_empty() {
356        out.push_str(&format!(
357            "\n## Files the discarded attempt changed\n\n{}\n",
358            discarded.join("\n")
359        ));
360    }
361    out
362}
363
364fn argvs(v: &Value) -> Vec<Vec<String>> {
365    v.as_array()
366        .map(|a| {
367            a.iter()
368                .map(|argv| {
369                    argv.as_array()
370                        .map(|p| {
371                            p.iter()
372                                .filter_map(|x| x.as_str().map(str::to_owned))
373                                .collect()
374                        })
375                        .unwrap_or_default()
376                })
377                .collect()
378        })
379        .unwrap_or_default()
380}
381
382fn usage_sum(total: &mut [u64; 4], usage: &Value) {
383    for (i, key) in [
384        "input_tokens",
385        "output_tokens",
386        "cache_read_tokens",
387        "cache_write_tokens",
388    ]
389    .iter()
390    .enumerate()
391    {
392        total[i] += usage[*key].as_u64().unwrap_or(0);
393    }
394}
395
396#[async_trait]
397impl Skill for Attempt {
398    fn descriptor(&self) -> SkillDescriptor {
399        SkillDescriptor::new("speccoder.task.attempt").provides(CAPABILITY)
400    }
401
402    async fn invoke(
403        &self,
404        cx: &mut StepCtx<'_>,
405        input: Tainted<Value>,
406    ) -> Result<Outcome, SkillError> {
407        let (ttl, style) = (self.cache_ttl, self.style);
408        let settings = input.peek()["settings"].clone();
409        let task_name = format!(
410            "{}/{}",
411            input.peek()["bundle"]["feature"]
412                .as_str()
413                .unwrap_or_default(),
414            input.peek()["bundle"]["task"]["id"]
415                .as_str()
416                .unwrap_or_default()
417        );
418        let frozen: Vec<String> = settings["frozen"]
419            .as_array()
420            .map(|a| {
421                a.iter()
422                    .filter_map(|v| v.as_str().map(str::to_owned))
423                    .collect()
424            })
425            .unwrap_or_default();
426        let decisions = |key: &str| settings[key].clone();
427        let approved: Vec<crate::tools::policy::Grant> =
428            serde_json::from_value(decisions("approved")).unwrap_or_default();
429        let rejected: Vec<crate::tools::policy::Rejection> =
430            serde_json::from_value(decisions("rejected")).unwrap_or_default();
431        let told = rejected.clone();
432        let mcp: Vec<crate::mcp::McpTool> =
433            serde_json::from_value(settings["mcp"].clone()).unwrap_or_default();
434        let policy = Policy::new(&argvs(&settings["checks"]), &argvs(&settings["allow"]))
435            .rules(self.format.rules())
436            .with_frozen(frozen)
437            .with_decisions(approved, rejected)
438            .with_mcp(mcp.iter().map(|t| (t.name.clone(), t.decision.clone())))
439            .with_network(argvs(&settings["network_allow"]))
440            .with_hidden(
441                serde_json::from_value::<Vec<String>>(settings["hidden"].clone())
442                    .unwrap_or_default(),
443            );
444        let policy = if settings["in_place"] == true {
445            policy.in_place()
446        } else {
447            policy
448        };
449        let max_turns = settings["max_turns"]
450            .as_u64()
451            .map_or(30, |n| u32::try_from(n).unwrap_or(u32::MAX))
452            .max(1);
453        let max_tool_calls = settings["max_tool_calls"]
454            .as_u64()
455            .map_or(100, |n| u32::try_from(n).unwrap_or(u32::MAX));
456        // Same stable prefix as `propose`, with the attempt policy in front;
457        // a retry's digest goes after it, so the cached prefix is shared.
458        let label = conversation_label(&input);
459        let raw = input.peek().clone();
460        let prompt = provenance::labelled_prompt(
461            {
462                let input = raw;
463                let mut p = layout(
464                    &input["bundle"],
465                    ttl,
466                    style,
467                    &self::policy(self.format.wording()),
468                    self.format,
469                );
470                let given: Vec<crate::instructions::Instruction> =
471                    serde_json::from_value(input["instructions"].clone()).unwrap_or_default();
472                if let Some(text) = crate::instructions::section(&given) {
473                    let (key, kind) = match style {
474                        PromptStyle::OpenAi => ("input", "input_text"),
475                        PromptStyle::Anthropic | PromptStyle::Chat => ("messages", "text"),
476                    };
477                    if let Some(content) = p[key][0]["content"].as_array_mut() {
478                        content.push(json!({ "type": kind, "text": text }));
479                    }
480                }
481                if !input["digest"].is_null() {
482                    let (key, kind) = match style {
483                        PromptStyle::OpenAi => ("input", "input_text"),
484                        PromptStyle::Anthropic | PromptStyle::Chat => ("messages", "text"),
485                    };
486                    if let Some(content) = p[key][0]["content"].as_array_mut() {
487                        content
488                            .push(json!({ "type": kind, "text": digest_text(&input["digest"]) }));
489                    }
490                }
491                // A person's notes for this task.
492                let notes: Vec<String> = input["settings"]["nudges"]
493                    .as_array()
494                    .map(|a| {
495                        a.iter()
496                            .filter_map(|n| n.as_str().map(|n| format!("- {n}")))
497                            .collect()
498                    })
499                    .unwrap_or_default();
500                if !notes.is_empty() {
501                    let (key, kind) = match style {
502                        PromptStyle::OpenAi => ("input", "input_text"),
503                        PromptStyle::Anthropic | PromptStyle::Chat => ("messages", "text"),
504                    };
505                    let text = format!("# Notes from a person\n\n{}\n", notes.join("\n"));
506                    if let Some(content) = p[key][0]["content"].as_array_mut() {
507                        content.push(json!({ "type": kind, "text": text }));
508                    }
509                }
510                // A person's rejections, so the model does not ask again.
511                if !told.is_empty() {
512                    let (key, kind) = match style {
513                        PromptStyle::OpenAi => ("input", "input_text"),
514                        PromptStyle::Anthropic | PromptStyle::Chat => ("messages", "text"),
515                    };
516                    let lines: Vec<String> = told
517                        .iter()
518                        .map(|r| format!("- {}: {}", r.grant.describe(), r.reason))
519                        .collect();
520                    let text = format!(
521                        "# Rejected by a person\n\nThese calls were rejected for this task; find another way or call report_blocked:\n\n{}\n",
522                        lines.join("\n")
523                    );
524                    if let Some(content) = p[key][0]["content"].as_array_mut() {
525                        content.push(json!({ "type": kind, "text": text }));
526                    }
527                }
528                p
529            },
530            &label,
531        );
532        let mut tools = crate::tools::declarations();
533        tools.push(report_blocked());
534        // MCP tools the policy does not deny, as the journaled input lists them.
535        let routes: Vec<(String, String, String)> = mcp
536            .iter()
537            .filter(|t| t.decision != "deny")
538            .map(|t| (t.name.clone(), t.server.clone(), t.tool.clone()))
539            .collect();
540        tools.extend(mcp.iter().filter(|t| t.decision != "deny").map(|t| {
541            ToolDeclaration::new(t.name.clone(), t.description.clone(), t.schema.clone())
542        }));
543        // Journaled: whether this attempt asks inline decides its effects.
544        let ask = Asker::from_settings(
545            &settings,
546            task_name.clone(),
547            input.peek()["bundle"]["task"]["title"]
548                .as_str()
549                .unwrap_or_default()
550                .to_owned(),
551            PresentContext {
552                root: settings["root"].as_str().map(std::path::PathBuf::from),
553                ..self.present.clone()
554            },
555        );
556        let remembered: Vec<crate::tools::policy::Grant> =
557            serde_json::from_value(settings["run_approved"].clone()).unwrap_or_default();
558        tool_loop(
559            cx,
560            &prompt,
561            LoopSpec {
562                tools,
563                policy,
564                max_turns,
565                max_tool_calls,
566                schema: report_schema(),
567                routes,
568                root: settings["root"].as_str().map(str::to_owned),
569                frozen_additions: settings["frozen_additions"] == true,
570                ask,
571                run_approved: remembered,
572                observer: self.progress.clone().map(|p| {
573                    (
574                        task_name.rsplit('/').next().unwrap_or_default().to_owned(),
575                        p,
576                    )
577                }),
578                finish: |report| {
579                    // The model's own word that it did not finish is believed;
580                    // its word that it did is not (the verifier decides that).
581                    if report["status"] == "incomplete" {
582                        "incomplete"
583                    } else {
584                        "completed"
585                    }
586                },
587            },
588        )
589        .await
590    }
591}
592
593/// Turns an absolute `path` argument inside the workspace `root` into a
594/// workspace-relative one. Tools print absolute paths (Spec Kit's scripts
595/// do), and a model passes them on; outside the root a path stays as it is
596/// — and the policy refuses it.
597fn relativise(arguments: &mut Value, root: Option<&str>) {
598    let (Some(root), Some(path)) = (root, arguments.get("path").and_then(Value::as_str)) else {
599        return;
600    };
601    let root = root.trim_end_matches('/');
602    if let Some(rel) = path.strip_prefix(root).and_then(|r| r.strip_prefix('/')) {
603        let rel = if rel.is_empty() {
604            ".".to_owned()
605        } else {
606            rel.to_owned()
607        };
608        arguments["path"] = Value::String(rel);
609    }
610}
611
612/// Reports one logged tool call to whoever watches.
613fn emit(
614    observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
615    turn: u32,
616    entry: &Value,
617) {
618    emit_with_tail(observer, turn, entry, Vec::new());
619}
620
621/// [`emit`] with the last lines of a failed command's output (live only,
622/// never journaled).
623fn emit_with_tail(
624    observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
625    turn: u32,
626    entry: &Value,
627    tail: Vec<String>,
628) {
629    if let Some((task, p)) = observer {
630        let tool = entry["tool"].as_str().unwrap_or_default();
631        p.event(&crate::progress::Event::ToolCall {
632            task: task.clone(),
633            turn,
634            tool: tool.to_owned(),
635            target: crate::progress::target(tool, &entry["arguments"]),
636            decision: entry["decision"].as_str().unwrap_or_default().to_owned(),
637            ok: entry["ok"].as_bool(),
638            tail,
639        });
640    }
641}
642
643/// A person is needed for `asked`: the run tells whoever watches.
644fn needs_person(
645    observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
646    asked: &agentplane::model::ToolCall,
647) {
648    if let Some((task, p)) = observer {
649        let (tool, arguments) = (asked.name.as_str(), &asked.arguments);
650        let target = crate::progress::target(tool, arguments);
651        let what: String = format!("{tool} {target}")
652            .chars()
653            .filter(|c| !c.is_control())
654            .take(60)
655            .collect();
656        p.event(&crate::progress::Event::ApprovalRequested {
657            task: task.clone(),
658            call: asked.id.clone(),
659            tool: tool.to_owned(),
660            what,
661            class: crate::tools::policy::Grant::of(tool, arguments)
662                .map(|g| g.describe())
663                .unwrap_or_default(),
664        });
665    }
666}
667
668/// A person decided `asked`: the run tells whoever watches.
669fn person_decided(
670    observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
671    asked: &agentplane::model::ToolCall,
672    decision: &str,
673    scope: Option<&str>,
674    who: Option<&Who>,
675) {
676    if let Some((task, p)) = observer {
677        p.event(&crate::progress::Event::ApprovalDecided {
678            task: task.clone(),
679            call: asked.id.clone(),
680            decision: decision.to_owned(),
681            scope: scope.map(str::to_owned),
682            by: who.map_or_else(|| "nobody (expired)".to_owned(), |w| w.name.clone()),
683            how: who.map_or_else(|| "expired".to_owned(), |w| w.basis.clone()),
684        });
685    }
686}
687
688/// What a governed tool loop offers the model and asks of it.
689pub(crate) struct LoopSpec {
690    /// The tools declared to the model; any other name is refused.
691    pub tools: Vec<ToolDeclaration>,
692    /// Classifies every call before dispatch.
693    pub policy: Policy,
694    /// Model turns.
695    pub max_turns: u32,
696    /// Tool calls.
697    pub max_tool_calls: u32,
698    /// The closing answer's schema.
699    pub schema: Value,
700    /// Declared tools served elsewhere: (name, server, tool) — MCP.
701    pub routes: Vec<(String, String, String)>,
702    /// The workspace's absolute path, from the journaled input: absolute
703    /// paths inside it are made relative before the policy sees them.
704    pub root: Option<String>,
705    /// A write to a frozen test that only adds lines needs no approval.
706    pub frozen_additions: bool,
707    /// The status for a closing answer.
708    pub finish: fn(&Value) -> &'static str,
709    /// Ask a person instead of stopping when a call needs approval.
710    pub ask: Option<Asker>,
711    /// Classes a person approved for this run: allowed without asking.
712    pub run_approved: Vec<crate::tools::policy::Grant>,
713    /// Live progress: the task ID and where to report.
714    pub observer: Option<(String, std::sync::Arc<dyn crate::progress::Progress>)>,
715}
716
717/// The governed tool loop shared by attempts and convergence: every call is
718/// classified first (deny → refused and told; approval → stop; allow →
719/// dispatched through agentplane); the closing structured answer ends it.
720pub(crate) async fn tool_loop(
721    cx: &mut StepCtx<'_>,
722    prompt: &Tainted<Value>,
723    spec: LoopSpec,
724) -> Result<Outcome, SkillError> {
725    let LoopSpec {
726        tools,
727        policy,
728        max_turns,
729        max_tool_calls,
730        schema,
731        routes,
732        root,
733        frozen_additions,
734        finish,
735        ask,
736        run_approved,
737        observer,
738    } = spec;
739    // Classes allowed without asking: this run's, then what a person
740    // answered "for the task" or "for this run" in this attempt.
741    let mut remembered: Vec<(crate::tools::policy::Grant, &'static str)> =
742        run_approved.into_iter().map(|g| (g, "run-grant")).collect();
743    let catalog = crate::tools::catalog();
744
745    // What the conversation has read, with its labels: agentplane checks
746    // every model call against the join.
747    let mut transcript = Transcript::new(prompt);
748    let mut conversation: Label = prompt.label().clone();
749    let mut calls = 0u32;
750    let mut denied_in_a_row = 0u32;
751    // Whether this attempt registered the `approval` obligation yet.
752    let mut obligation = false;
753    let mut usage = [0u64; 4];
754    let mut log: Vec<Value> = Vec::new();
755
756    let end = |status: &str, extra: Value, log: &[Value], usage: [u64; 4], conversation: &Label| {
757        let mut out = json!({
758            "status": status,
759            "tool_calls": log,
760            "usage": {
761                "input_tokens": usage[0],
762                "output_tokens": usage[1],
763                "cache_read_tokens": usage[2],
764                "cache_write_tokens": usage[3],
765            },
766        });
767        if let (Some(o), Some(e)) = (out.as_object_mut(), extra.as_object()) {
768            o.extend(e.clone());
769        }
770        Ok(Outcome::done(Tainted::with_label(
771            out,
772            conversation.clone(),
773        )))
774    };
775
776    for turn in 0..max_turns {
777        // The prompt was built from labelled parts (`/system` is speccoder's
778        // constant instruction); what the conversation read since rides in
779        // the transcript, so the request is labelled by its real sources.
780        let tools_now = tools.clone();
781        let schema_now = schema.clone();
782        let read_so_far = &transcript;
783        let completion = cx
784            .complete_with(prompt, move |call| {
785                call.with_tools(tools_now)
786                    .continuing(read_so_far)
787                    .expecting(schema_now)
788            })
789            .await?;
790        conversation = transcript.label().join(completion.label());
791        usage_sum(
792            &mut usage,
793            &serde_json::to_value(completion.peek().usage).unwrap_or(Value::Null),
794        );
795        if let Some((task, p)) = &observer {
796            p.event(&crate::progress::Event::Usage {
797                task: task.clone(),
798                input_tokens: usage[0],
799                output_tokens: usage[1],
800                cache_read_tokens: usage[2],
801                cache_write_tokens: usage[3],
802                usd: None,
803            });
804        }
805
806        if completion.peek().truncated {
807            return Ok(Outcome::fail(
808                "the model ran out of output budget mid-turn; nothing it was still writing was executed — raise [model] max_output_tokens",
809            ));
810        }
811        let asked_calls = transcript.asked(&completion);
812        // The model's own words beside its calls, live: its account, a
813        // claim. A turn without calls is its final answer, reported as such.
814        if let Some((task, p)) = observer.as_ref().filter(|_| !asked_calls.is_empty()) {
815            let said = completion.peek().text.trim();
816            if !said.is_empty() {
817                let text: String = crate::approvals::present::escape(said)
818                    .chars()
819                    .take(2000)
820                    .collect();
821                p.event(&crate::progress::Event::Said {
822                    task: task.clone(),
823                    turn: turn + 1,
824                    text,
825                });
826            }
827        }
828        if asked_calls.is_empty() {
829            let report = completion
830                .peek()
831                .structured
832                .clone()
833                .unwrap_or_else(|| json!({ "summary": completion.peek().text, "status": "incomplete", "files_changed": [], "checks_run": [], "notes": ["the answer was not the requested structure"] }));
834            let status = finish(&report);
835            return end(
836                status,
837                json!({ "report": report }),
838                &log,
839                usage,
840                &conversation,
841            );
842        }
843
844        for mut asked in asked_calls {
845            relativise(&mut asked.arguments, root.as_deref());
846            calls += 1;
847            if calls > max_tool_calls {
848                return end(
849                    "budget_exhausted",
850                    json!({ "reason": format!("more than {max_tool_calls} tool calls") }),
851                    &log,
852                    usage,
853                    &conversation,
854                );
855            }
856            if asked.name == ASK_PERSON && tools.iter().any(|t| t.name == ASK_PERSON) {
857                let question = asked.arguments["question"]
858                    .as_str()
859                    .unwrap_or_default()
860                    .to_owned();
861                // A person's answer is their input, recorded with who gave it
862                // — never speccoder's own trusted text.
863                let decided = match &ask {
864                    Some(asker) => Some(
865                        wait_for_person(
866                            cx,
867                            asker,
868                            &mut obligation,
869                            &crate::approvals::tasks::question_task(&asker.task, &question),
870                        )
871                        .await?,
872                    ),
873                    None => None,
874                };
875                let read = decided.as_ref().map(crate::approvals::tasks::read);
876                let by = read.as_ref().and_then(|r| r.by.clone());
877                let entry = json!({
878                    "tool": ASK_PERSON,
879                    "arguments": asked.arguments,
880                    "decision": "allow",
881                    "answered": read.as_ref().is_some_and(|r| r.text.is_some()),
882                    "operator": by.as_ref().map(Who::view),
883                    "via": by.as_ref().map(|w| w.via.clone()),
884                });
885                emit(observer.as_ref(), turn + 1, &entry);
886                log.push(entry);
887                let text = match (&read, &by) {
888                    (Some(r), _) if r.text.is_some() => r.text.clone().unwrap_or_default(),
889                    (Some(_), Some(w)) => format!("{} declined to answer.", w.name),
890                    _ => "Nobody can answer now. Record the question in the artifact as [NEEDS CLARIFICATION: <question>] and continue.".to_owned(),
891                };
892                let label = match &by {
893                    Some(_) => provenance::from(provenance::PERSON),
894                    None => Label::trusted(),
895                };
896                transcript.answered(
897                    asked,
898                    &Tainted::with_label(json!({ "answer": text }), label),
899                );
900                continue;
901            }
902            if asked.name == REPORT_BLOCKED && tools.iter().any(|t| t.name == REPORT_BLOCKED) {
903                let reason = asked.arguments["reason"]
904                    .as_str()
905                    .unwrap_or("no reason given")
906                    .to_owned();
907                let entry = json!({ "tool": REPORT_BLOCKED, "arguments": asked.arguments, "decision": "stop" });
908                emit(observer.as_ref(), turn + 1, &entry);
909                log.push(entry);
910                return end(
911                    "blocked",
912                    json!({ "reason": reason }),
913                    &log,
914                    usage,
915                    &conversation,
916                );
917            }
918            let declared = tools.iter().any(|t| t.name == asked.name);
919            let target = catalog
920                .iter()
921                .find(|t| declared && t.name == asked.name)
922                .map(|t| ToolId::new(t.server, t.name))
923                .or_else(|| {
924                    routes
925                        .iter()
926                        .find(|(n, _, _)| declared && *n == asked.name)
927                        .map(|(_, server, tool)| ToolId::new(server, tool))
928                });
929            let Some(target) = target else {
930                let entry = json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "unknown" });
931                emit(observer.as_ref(), turn + 1, &entry);
932                log.push(entry);
933                transcript.failed(asked, Tainted::trusted("no tool of that name".to_owned()));
934                continue;
935            };
936            let mut decision = policy.classify(&asked.name, &asked.arguments);
937            // `[tests] frozen = "additions"`: read the frozen test (a
938            // journaled call, so replay sees the same content) and allow a
939            // write that keeps every existing line.
940            let mut additions_only = false;
941            if frozen_additions
942                && matches!(&decision, Decision::Approval(r) if r.contains("frozen test"))
943            {
944                let path = asked.arguments["path"]
945                    .as_str()
946                    .unwrap_or_default()
947                    .to_owned();
948                let read = cx
949                    .call_tool(
950                        ToolId::new("fs", "read_file"),
951                        Tainted::with_label(json!({ "path": path }), completion.label().clone()),
952                    )
953                    .await?;
954                let current = read.peek().clone();
955                // `read_file` joins lines with `\n`; the file's last newline is
956                // restored so an edit's `old_string` matches as on disk.
957                let lines = current["content"].as_str().unwrap_or_default();
958                let old_text = if lines.is_empty() {
959                    String::new()
960                } else {
961                    format!("{lines}\n")
962                };
963                let old = old_text.as_str();
964                let readable = current["ok"] == true && current["truncated"] != true;
965                let new = match asked.name.as_str() {
966                    "write_file" => asked.arguments["content"].as_str().map(str::to_owned),
967                    "edit_file" => {
968                        let (from, to) = (
969                            asked.arguments["old_string"].as_str().unwrap_or_default(),
970                            asked.arguments["new_string"].as_str().unwrap_or_default(),
971                        );
972                        let all = asked.arguments["replace_all"] == true;
973                        (!from.is_empty() && (all || old.matches(from).count() == 1))
974                            .then(|| old.replace(from, to))
975                    }
976                    _ => None,
977                };
978                if readable && new.is_some_and(|n| crate::integrity::only_additions(old, &n)) {
979                    decision = Decision::Allow;
980                    additions_only = true;
981                }
982            }
983            // A class a person already approved for this run or task.
984            let grant = crate::tools::policy::Grant::of(&asked.name, &asked.arguments);
985            let mut person: Option<Value> = None;
986            let mut note: Option<String> = None;
987            if matches!(decision, Decision::Approval(_))
988                && let Some((_, scope)) = grant
989                    .as_ref()
990                    .and_then(|g| remembered.iter().find(|(r, _)| r == g))
991            {
992                person = Some(json!({ "approved_by": "person", "scope": scope }));
993                decision = Decision::Allow;
994            }
995            // ask a person now, as a journaled effect, instead of stopping.
996            if let (Decision::Approval(reason), Some(asker)) = (&decision, &ask) {
997                let risk = crate::approvals::risk(&asked.name, reason);
998                let claim = crate::approvals::claim(&completion.peek().text);
999                let request = Request {
1000                    task: asker.task.clone(),
1001                    task_title: asker.task_title.clone(),
1002                    turn: turn + 1,
1003                    tool: asked.name.clone(),
1004                    arguments: asked.arguments.clone(),
1005                    kind: crate::approvals::kind_of(&asked.name, &asked.arguments).to_owned(),
1006                    reason: reason.clone(),
1007                    covers: grant.as_ref().map(|g| g.describe()).unwrap_or_default(),
1008                    risk: risk.to_owned(),
1009                    claim: claim.clone(),
1010                    options: crate::approvals::options(risk, grant.is_some()),
1011                };
1012                needs_person(observer.as_ref(), &asked);
1013                let decided = wait_for_person(
1014                    cx,
1015                    asker,
1016                    &mut obligation,
1017                    &crate::approvals::tasks::approval_task(&request, grant.as_ref(), &log),
1018                )
1019                .await?;
1020                let read = crate::approvals::tasks::read(&decided);
1021                let answer: Answer = read.answer.clone();
1022                // Who decided, and how that is known, is journaled with it.
1023                let approver = serde_json::to_value(&answer.by).unwrap_or(Value::Null);
1024                let operator = read.by.as_ref().map(Who::view);
1025                person_decided(
1026                    observer.as_ref(),
1027                    &asked,
1028                    if answer.decision == Said::Approve {
1029                        "approve"
1030                    } else {
1031                        "reject"
1032                    },
1033                    (answer.decision == Said::Approve).then(|| answer.scope.name()),
1034                    read.by.as_ref().filter(|_| !read.expired),
1035                );
1036                match answer.decision {
1037                    Said::Approve => {
1038                        person = Some(json!({
1039                            "approved_by": "person",
1040                            "scope": answer.scope.name(),
1041                            "note": answer.note,
1042                            "approver": approver,
1043                            "operator": operator,
1044                        }));
1045                        if let (Some(g), Scope::Task | Scope::Run) = (&grant, answer.scope) {
1046                            let remembered_as = if answer.scope == Scope::Run {
1047                                "run-grant"
1048                            } else {
1049                                "task-grant"
1050                            };
1051                            remembered.push((g.clone(), remembered_as));
1052                        }
1053                        note = answer.note;
1054                        decision = Decision::Allow;
1055                    }
1056                    // "Later" is a task left undecided: the run stays
1057                    // suspended and never reaches here.
1058                    Said::Reject | Said::Stop => {
1059                        let why = answer
1060                            .reason
1061                            .unwrap_or_else(|| "no reason given".to_owned());
1062                        person = Some(if read.expired {
1063                            json!({ "expired": true, "rejection": why })
1064                        } else {
1065                            json!({ "rejected_by": "person", "approver": approver, "operator": operator, "rejection": why })
1066                        });
1067                        decision = Decision::Deny(format!("a person rejected this: {why}"));
1068                    }
1069                }
1070            }
1071            // Who decided: a person (now, or by a grant they gave), or a rule
1072            // — named, so a rule's decision is never read as a person's.
1073            let rule = person
1074                .is_none()
1075                .then(|| policy.rule(&asked.name, &asked.arguments));
1076            let with_person = |mut entry: Value| {
1077                if let Some(o) = entry.as_object_mut() {
1078                    match (person.as_ref().and_then(Value::as_object), &rule) {
1079                        (Some(p), _) => {
1080                            // An expiry is nobody's decision.
1081                            let by = if p.contains_key("expired") {
1082                                "expiry"
1083                            } else {
1084                                "person"
1085                            };
1086                            o.insert("by".to_owned(), json!(by));
1087                            o.extend(p.clone());
1088                        }
1089                        (None, Some(r)) => {
1090                            o.insert("by".to_owned(), json!("rule"));
1091                            o.insert("rule".to_owned(), json!(r));
1092                        }
1093                        (None, None) => {}
1094                    }
1095                }
1096                entry
1097            };
1098            match decision {
1099                Decision::Deny(reason) => {
1100                    let entry = with_person(
1101                        json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "deny", "reason": reason }),
1102                    );
1103                    emit(observer.as_ref(), turn + 1, &entry);
1104                    log.push(entry);
1105                    // The reason quotes the call (its tool, path, command, or a
1106                    // person's words about it): it carries the call's label.
1107                    transcript.failed(
1108                        asked,
1109                        refusal(format!("denied: {reason}"), completion.label()),
1110                    );
1111                    // Escalation: a model that keeps asking for what it may
1112                    // not do is stopped for a person.
1113                    denied_in_a_row += 1;
1114                    if denied_in_a_row >= MAX_CONSECUTIVE_DENIALS {
1115                        return end(
1116                            "denied",
1117                            json!({ "reason": format!("{denied_in_a_row} calls in a row were denied; last: {reason}") }),
1118                            &log,
1119                            usage,
1120                            &conversation,
1121                        );
1122                    }
1123                    continue;
1124                }
1125                Decision::Approval(reason) => {
1126                    let entry = json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "approval", "reason": reason });
1127                    emit(observer.as_ref(), turn + 1, &entry);
1128                    needs_person(observer.as_ref(), &asked);
1129                    log.push(entry);
1130                    return end(
1131                        "needs_approval",
1132                        json!({ "pending": { "tool": asked.name, "arguments": asked.arguments, "reason": reason } }),
1133                        &log,
1134                        usage,
1135                        &conversation,
1136                    );
1137                }
1138                Decision::Allow => {
1139                    denied_in_a_row = 0;
1140                    // The arguments carry the model's label: agentplane's
1141                    // taint gate checks them against the grant's fields (an
1142                    // argument-less mutating tool is granted exactly `{}`).
1143                    // A command gets the call's id (`SPECCODER_CALL_ID`):
1144                    // a call resumed after a crash has the same one, so a
1145                    // script can tell a repeat from a new call.
1146                    let mut arguments = asked.arguments.clone();
1147                    if asked.name == "run_command"
1148                        && let Some(o) = arguments.as_object_mut()
1149                    {
1150                        o.insert("call_id".to_owned(), Value::String(asked.id.clone()));
1151                    }
1152                    let args = Tainted::with_label(arguments, completion.label().clone());
1153                    let result = match cx.call_tool(target.clone(), args).await {
1154                        Ok(r) => r,
1155                        // A failure the far side reported is the model's to
1156                        // read, labelled by who wrote it.
1157                        Err(error) => match cx.relayed(&error) {
1158                            Some(detail) => {
1159                                let entry = with_person(
1160                                    json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "allow", "ok": false }),
1161                                );
1162                                emit(observer.as_ref(), turn + 1, &entry);
1163                                log.push(entry);
1164                                transcript.failed(asked, detail);
1165                                continue;
1166                            }
1167                            None => return Err(error.into()),
1168                        },
1169                    };
1170                    let output = result.peek().clone();
1171                    let ok = output["ok"].as_bool() != Some(false);
1172                    let mut entry = with_person(
1173                        json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "allow", "ok": ok }),
1174                    );
1175                    if additions_only {
1176                        entry["frozen_additions"] = json!(true);
1177                    }
1178                    let tail = if asked.name == "run_command" && !ok {
1179                        crate::progress::tail(&format!(
1180                            "{}\n{}",
1181                            output["stdout"].as_str().unwrap_or_default(),
1182                            output["stderr"].as_str().unwrap_or_default()
1183                        ))
1184                    } else {
1185                        Vec::new()
1186                    };
1187                    emit_with_tail(observer.as_ref(), turn + 1, &entry, tail);
1188                    log.push(entry);
1189                    // A command that changed a judge input ends the attempt
1190                    // before the model sees another turn.
1191                    if let Some(drift) = output["integrity"].as_array().filter(|d| !d.is_empty()) {
1192                        let paths: Vec<&str> =
1193                            drift.iter().filter_map(|d| d["path"].as_str()).collect();
1194                        return end(
1195                            "integrity",
1196                            json!({
1197                                "reason": format!("a command changed what judges the task: {}", paths.join(", ")),
1198                                "integrity": drift,
1199                            }),
1200                            &log,
1201                            usage,
1202                            &conversation,
1203                        );
1204                    }
1205                    // A tool that refused (`error`) is a failed call; a
1206                    // command that ran and exited non-zero is a result the
1207                    // model must read in full — its stdout and stderr are
1208                    // the diagnostics (found by the first real evaluation,
1209                    // where the model saw only the word "failed").
1210                    match output["error"].as_str() {
1211                        Some(error) if !ok => transcript.failed(
1212                            asked,
1213                            Tainted::with_label(error.to_owned(), result.label().clone()),
1214                        ),
1215                        // A person's note rides with the result it approved.
1216                        _ => match &note {
1217                            Some(n) => {
1218                                let n = format!("Note from the person who approved this call: {n}");
1219                                transcript.answered(
1220                                    asked,
1221                                    &result.map(|mut v| {
1222                                        v["note_from_person"] = Value::String(n);
1223                                        v
1224                                    }),
1225                                );
1226                            }
1227                            None => transcript.answered(asked, &result),
1228                        },
1229                    }
1230                }
1231            }
1232        }
1233    }
1234    end(
1235        "budget_exhausted",
1236        json!({ "reason": format!("more than {max_turns} model turns") }),
1237        &log,
1238        usage,
1239        &conversation,
1240    )
1241}
1242
1243#[cfg(test)]
1244mod tests {
1245    use super::*;
1246
1247    #[test]
1248    fn labels_are_read_from_the_parts_and_refusals_carry_the_call() {
1249        use agentplane::core::{Label, SourceId};
1250        let model = Label::untrusted(SourceId::new("model:x/y"));
1251        // A refusal quoting the call is the call's; a constant is trusted.
1252        assert!(
1253            refusal("denied: `src/x` is protected".to_owned(), &model)
1254                .label()
1255                .is_untrusted()
1256        );
1257        // Parts labelled when admitted: the spec trusted, the digest the verifier's.
1258        let input = Tainted::object([
1259            ("bundle", Tainted::trusted(json!({}))),
1260            (
1261                "digest",
1262                Tainted::with_label(json!({}), provenance::from(provenance::VERIFIER)),
1263            ),
1264            (
1265                "settings",
1266                Tainted::object([("nudges", Tainted::trusted(json!([])))]),
1267            ),
1268        ]);
1269        let label = conversation_label(&input);
1270        assert!(
1271            label
1272                .provenance
1273                .contains(&SourceId::new(provenance::VERIFIER))
1274        );
1275        // No labels of its own: every part takes the whole input's (fails closed).
1276        let flat = Tainted::with_label(json!({ "bundle": {} }), model.clone());
1277        assert_eq!(conversation_label(&flat).provenance, model.provenance);
1278    }
1279
1280    #[test]
1281    fn absolute_paths_inside_the_root_become_relative_and_others_stay() {
1282        let mut a = json!({ "path": "/work/repo/specs/001-x/plan.md" });
1283        relativise(&mut a, Some("/work/repo"));
1284        assert_eq!(a["path"], "specs/001-x/plan.md");
1285        let mut outside = json!({ "path": "/work/repository-two/x" });
1286        relativise(&mut outside, Some("/work/repo"));
1287        assert_eq!(outside["path"], "/work/repository-two/x");
1288        let mut none = json!({ "path": "/etc/passwd" });
1289        relativise(&mut none, None);
1290        assert_eq!(none["path"], "/etc/passwd");
1291    }
1292}