Skip to main content

speccoder_runtime/substrate/attempt/
mod.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 — never work another task of the list is for — and run the project's checks with run_command to see whether it works. Stay inside \
36the task's scope: the task's paths are the plan's guess — where they differ from the code, \
37use the module that holds it; a change elsewhere that the task or its acceptance tests need \
38(an entry point they run, a module they import) is in scope and shown to the reviewer, never \
39a reason to stop: an entry point that bypasses the task's module is rewired to it. You \
40cannot edit the spec ({intent}), the checks' configuration or .git; such calls are refused. Some commands need a person's approval and will end the \
41attempt — prefer configured checks. If the task cannot be done as written — it \
42contradicts the spec, needs information or access you lack, or a test is wrong — call \
43report_blocked with the reason instead of working around it. Test files that existed \
44when the attempt started are frozen: add new tests in new files (or next to the code), \
45never by editing those. Never weaken, skip or delete tests to make checks pass. Never \
46return values copied from tests or examples in place of the specified behaviour (a \
47constant where the spec asks for a computed, fetched or current value is a cheat): if \
48the specified behaviour is impossible under the stated constraints, call report_blocked. \
49You are not the judge: when you finish, speccoder runs the checks itself and decides. \
50Finish with the requested structured report; its status is `incomplete` only when part of \
51this task's own work is left undone — a check you could not run, a test a later task makes \
52pass, or a finding in a frozen test file is no reason.",
53        product = w.product,
54        intent = w.intent_paths,
55    )
56}
57
58/// The final report's schema (the model's claim).
59#[must_use]
60pub fn report_schema() -> Value {
61    super::answers::schema::<super::answers::AttemptReport>()
62}
63
64fn report_blocked() -> ToolDeclaration {
65    ToolDeclaration::new(
66        REPORT_BLOCKED,
67        "Stop the attempt because the task cannot be done as written; a person will look at the \
68reason. `kind`, with the `evidence` speccoder checks: `refused` — a call the task needs was \
69refused (the call); `spec_conflict` — the spec contradicts the task (its exact words); \
70`wrong_test` — a frozen test asserts the wrong thing (its path); `needs_information` — only a \
71person knows (the question). Scope is never a kind: when the tests run code that bypasses the \
72task's module (an entry point, a caller), wiring that code to it is part of the task.",
73        super::answers::schema::<super::answers::Blocked>(),
74    )
75}
76
77mod loop_spec;
78mod person;
79#[cfg(test)]
80mod tests;
81mod tool_loop;
82
83pub use loop_spec::*;
84pub use person::*;
85pub(crate) use tool_loop::*;
86
87/// The coded skill.
88///
89/// Everything its decisions depend on — the bundle, the policy's command
90/// prefixes and the limits — arrives in the run's input, so it is journaled
91/// and strict replay needs no configuration. Only the prompt dialect
92/// is construction-time, and it is part of each model call's effect key.
93#[derive(Debug)]
94pub struct Attempt {
95    /// Prompt dialect.
96    pub style: PromptStyle,
97    /// Cache breakpoint lifetime.
98    pub cache_ttl: CacheTtl,
99    /// Where live progress goes; not journaled, `None` in replay.
100    pub progress: Option<std::sync::Arc<dyn crate::progress::Progress>>,
101    /// The project's spec format.
102    pub format: &'static dyn crate::format::Adapter,
103    /// The sandbox, as a person asked about a command is told; live only.
104    pub present: PresentContext,
105}
106
107/// A turn's progress, said only where no earlier pass said it: dropped
108/// while `replaying` (agentplane's `StepCtx::replaying`, read after each
109/// journaled step) holds.
110#[derive(Debug)]
111struct Quiet {
112    replaying: std::sync::Arc<std::sync::atomic::AtomicBool>,
113    next: std::sync::Arc<dyn crate::progress::Progress>,
114}
115
116impl crate::progress::Progress for Quiet {
117    fn event(&self, event: &crate::progress::Event) {
118        if !self.replaying.load(std::sync::atomic::Ordering::SeqCst) {
119            self.next.event(event);
120        }
121    }
122}
123
124/// The run input: `{ bundle, settings: { checks, allow, max_turns,
125/// max_tool_calls, frozen, … }, digest }` — `digest` is the verifier's
126/// account of the previous attempt, `null` on a first attempt.
127///
128/// Labelled by where each part came from: the bundle is the
129/// person's committed spec and plan (trusted); the digest quotes check
130/// output (`speccoder://verifier`); the settings list MCP tool descriptions
131/// (their servers).
132#[must_use]
133pub fn input(
134    bundle: Value,
135    settings: &Settings<'_>,
136    digest: Option<&Value>,
137    instructions: &[crate::instructions::Instruction],
138    test_layout: Option<&str>,
139) -> Tainted<Value> {
140    let digest = match digest.filter(|d| !d.is_null()) {
141        Some(d) => Tainted::with_label(d.clone(), provenance::from(provenance::VERIFIER)),
142        None => Tainted::trusted(Value::Null),
143    };
144    // Each MCP tool's description and schema are its server's words.
145    let mcp = Tainted::array(settings.mcp.iter().map(|t| {
146        Tainted::with_label(
147            serde_json::to_value(t).unwrap_or(Value::Null),
148            agentplane::core::Label::untrusted(provenance::tool(&t.server, &t.tool)),
149        )
150    }));
151    let trusted = |v: Value| Tainted::trusted(v);
152    let autopilot = settings.autopilot;
153    let planning = settings.planning;
154    let contained = settings.contained;
155    let settings = vec![
156        ("checks", trusted(json!(settings.checks))),
157        ("allow", trusted(json!(settings.allow))),
158        ("max_turns", trusted(json!(settings.max_turns))),
159        ("max_tool_calls", trusted(json!(settings.max_tool_calls))),
160        ("reasoning", trusted(json!(settings.reasoning))),
161        ("frozen", trusted(json!(settings.frozen))),
162        ("approved", trusted(json!(settings.approved))),
163        ("rejected", trusted(json!(settings.rejected))),
164        ("run_approved", trusted(json!(settings.run_approved))),
165        ("approved_once", trusted(json!(settings.approved_once))),
166        ("nudges", trusted(json!(settings.nudges))),
167        ("mcp", mcp),
168        ("root", trusted(json!(settings.root))),
169        (
170            "frozen_additions",
171            trusted(json!(settings.frozen_additions)),
172        ),
173        ("network_allow", trusted(json!(settings.network_allow))),
174        ("allow_prefix", trusted(json!(settings.allow_prefix))),
175        (
176            "network_allow_prefix",
177            trusted(json!(settings.network_allow_prefix)),
178        ),
179        ("run_id", trusted(json!(settings.run_id))),
180        ("asked", trusted(json!(settings.asked))),
181        ("budget", trusted(json!(settings.budget))),
182        ("left", trusted(json!(settings.left))),
183        ("prompts", trusted(json!(settings.prompts))),
184        ("asks", trusted(json!(settings.asks))),
185        ("wait_secs", trusted(json!(settings.wait_secs))),
186        ("in_place", trusted(json!(settings.in_place))),
187        ("hidden", trusted(json!(settings.hidden))),
188    ];
189    // Only an autopilot run carries the key: every other input reads as
190    // it was recorded before it existed.
191    let mut settings = settings;
192    if autopilot {
193        settings.push(("autopilot", trusted(json!(true))));
194    }
195    // Likewise only a planning task's.
196    if let Some(dir) = planning {
197        settings.push(("planning", trusted(json!(dir))));
198    }
199    // And only a run whose sandbox confines what it runs.
200    if let Some(m) = contained {
201        settings.push(("contained", trusted(json!(m))));
202    }
203    let settings = Tainted::object(settings);
204    Tainted::object([
205        ("bundle", Tainted::trusted(bundle)),
206        ("digest", digest),
207        ("settings", settings),
208        // The repository's text, as if the model had read the files itself;
209        // none is no text, and no label.
210        (
211            "instructions",
212            if instructions.is_empty() {
213                Tainted::trusted(json!([]))
214            } else {
215                Tainted::with_label(json!(instructions), provenance::from(provenance::WORKTREE))
216            },
217        ),
218        // The test layout speccoder detected from the checkout.
219        (
220            "test_layout",
221            match test_layout {
222                Some(t) => Tainted::with_label(json!(t), provenance::from(provenance::WORKTREE)),
223                None => Tainted::trusted(Value::Null),
224            },
225        ),
226    ])
227}
228
229/// A refusal speccoder composes about a call quotes it — the tool, path or
230/// command the model chose — so it carries the call's label; only
231/// constant replies are trusted.
232fn refusal(text: String, call: &agentplane::core::Label) -> Tainted<String> {
233    Tainted::with_label(text, call.clone())
234}
235
236/// The parts of an attempt's input its messages quote.
237const MESSAGE_PARTS: [&str; 7] = [
238    "/bundle",
239    "/instructions",
240    "/test_layout",
241    "/digest",
242    "/settings/nudges",
243    "/settings/rejected",
244    "/settings/mcp",
245];
246
247/// The label of the conversation part: the join of the labels the run was
248/// admitted with for the parts it quotes — read, never inferred. A part
249/// without a label of its own takes the whole input's (fails closed).
250fn conversation_label(input: &Tainted<Value>) -> agentplane::core::Label {
251    MESSAGE_PARTS
252        .iter()
253        .map(|p| input.label_at(p).unwrap_or_else(|| input.label()).clone())
254        .fold(agentplane::core::Label::trusted(), |acc, l| acc.join(&l))
255}
256
257/// What an attempt decides by, besides its bundle (all journaled).
258#[derive(Debug, Clone, Copy)]
259pub struct Settings<'a> {
260    /// Configured checks' argvs (their prefixes are allowed commands).
261    pub checks: &'a [Vec<String>],
262    /// Exact commands allowed (`[commands] allow`).
263    pub allow: &'a [Vec<String>],
264    /// Allowed command prefixes (`[commands] allow_prefix`).
265    pub allow_prefix: &'a [Vec<String>],
266    /// Model turns.
267    pub max_turns: u32,
268    /// Tool calls.
269    pub max_tool_calls: u32,
270    /// Ask for the model's reasoning summary on every turn.
271    pub reasoning: bool,
272    /// Test files frozen for this attempt.
273    pub frozen: &'a [String],
274    /// A person's approvals for this task, in this run.
275    pub approved: &'a [Object],
276    /// A person's rejections for this task.
277    pub rejected: &'a [crate::tools::policy::Rejection],
278    /// Objects a person approved for every task of this run.
279    pub run_approved: &'a [Object],
280    /// Objects a person approved for one call each (a call a lane stopped,
281    /// decided in its run's batch): this attempt may make each one time.
282    pub approved_once: &'a [Object],
283    /// A person's notes for this task (`nudge`).
284    pub nudges: &'a [String],
285    /// The project's MCP tools for this run.
286    pub mcp: &'a [crate::mcp::McpTool],
287    /// The worktree's absolute (canonical) path.
288    pub root: &'a str,
289    /// Frozen tests may gain lines without approval (`[tests] frozen`).
290    pub frozen_additions: bool,
291    /// How a call needing a person is handled: `"tasks"` (the attempt waits
292    /// on a human task) or `"stop"` (it ends `needs_approval`; lanes).
293    pub asks: &'a str,
294    /// How long a call waits for a person before it is denied as expired.
295    pub wait_secs: u64,
296    /// Exact commands that get the network (`[sandbox] network_allow`).
297    pub network_allow: &'a [Vec<String>],
298    /// Prefixes that get it (`[sandbox] network_allow_prefix`).
299    pub network_allow_prefix: &'a [Vec<String>],
300    /// The run the attempt belongs to.
301    pub run_id: &'a str,
302    /// Prompts the run has shown a person so far.
303    pub asked: u32,
304    /// `[approvals] budget`.
305    pub budget: u32,
306    /// A person is prompted inline (not `--no-prompt`).
307    pub prompts: bool,
308    /// An in-place attempt: git that moves the checkout is denied.
309    pub in_place: bool,
310    /// In place: what git ignored at the run's start, hidden from the tools.
311    pub hidden: &'a [String],
312    /// An autopilot run: a call that would ask is refused instead.
313    pub autopilot: bool,
314    /// A planning task: its unit's directory (worktree-relative), whose
315    /// design documents it writes ([`Policy::planning`]).
316    pub planning: Option<&'a str>,
317    /// The enforced sandbox's mechanism when contained calls run without
318    /// asking ([`Policy::contained`]).
319    pub contained: Option<&'a str>,
320    /// What is left of the run's cap for this attempt.
321    pub left: Option<Left>,
322}
323
324/// The calls consecutive denials of which end an attempt.
325pub const MAX_CONSECUTIVE_DENIALS: u32 = 3;
326
327/// The digest as the model reads it: facts from the verifier only.
328fn digest_text(digest: &Value) -> String {
329    let mut out = format!(
330        "# Previous attempt\n\nAttempt {} at this task was verified by speccoder and FAILED. Its \
331changes were discarded; the worktree is back where that attempt started. You start fresh — use \
332these facts from the verifier, check them yourself, and do not repeat the mistake.\n",
333        digest["attempt"].as_u64().unwrap_or(1)
334    );
335    let strings = |v: &Value| -> Vec<String> {
336        v.as_array()
337            .map(|a| {
338                a.iter()
339                    .filter_map(|x| x.as_str().map(str::to_owned))
340                    .collect()
341            })
342            .unwrap_or_default()
343    };
344    let reasons = strings(&digest["reasons"]);
345    if !reasons.is_empty() {
346        out.push_str("\n## Verdict reasons\n\n");
347        for r in reasons {
348            out.push_str(&format!("- {r}\n"));
349        }
350    }
351    for c in digest["checks"].as_array().into_iter().flatten() {
352        out.push_str(&format!(
353            "\n## Check `{}` ({}): `{}`\n",
354            c["name"].as_str().unwrap_or_default(),
355            c["status"].as_str().unwrap_or_default(),
356            strings(&c["argv"]).join(" ")
357        ));
358        let tests = strings(&c["failing_tests"]);
359        if !tests.is_empty() {
360            out.push_str(&format!("\nFailing tests: {}\n", tests.join(", ")));
361        }
362        out.push_str(&format!(
363            "\n```text\n{}\n```\n",
364            c["excerpt"].as_str().unwrap_or_default()
365        ));
366    }
367    let uncollected = strings(&digest["uncollected"]);
368    if !uncollected.is_empty() {
369        out.push_str("\n## Tests that never ran\n\n");
370        for l in uncollected {
371            out.push_str(&format!("- {l}\n"));
372        }
373    }
374    let discarded = strings(&digest["discarded"]);
375    if !discarded.is_empty() {
376        out.push_str(&format!(
377            "\n## Files the discarded attempt changed\n\n{}\n",
378            discarded.join("\n")
379        ));
380    }
381    out
382}
383
384fn argvs(v: &Value) -> Vec<Vec<String>> {
385    v.as_array()
386        .map(|a| {
387            a.iter()
388                .map(|argv| {
389                    argv.as_array()
390                        .map(|p| {
391                            p.iter()
392                                .filter_map(|x| x.as_str().map(str::to_owned))
393                                .collect()
394                        })
395                        .unwrap_or_default()
396                })
397                .collect()
398        })
399        .unwrap_or_default()
400}
401
402/// A person's decisions of the journaled input at `key`: none when absent,
403/// an error when present and unreadable — never read as "no decisions",
404/// which would drop rejections.
405fn decisions_of<T: serde::de::DeserializeOwned>(
406    settings: &Value,
407    key: &str,
408) -> Result<Vec<T>, SkillError> {
409    match &settings[key] {
410        Value::Null => Ok(Vec::new()),
411        v => serde_json::from_value(v.clone())
412            .map_err(|e| SkillError::Input(format!("settings.{key}: {e}"))),
413    }
414}
415
416/// The policy an attempt's journaled settings decide by: the checks, the
417/// allow lists, the frozen tests, the person's decisions, the MCP tools,
418/// the network rules, in place and autopilot. An external agent's attempt
419/// is decided by the same one.
420pub(crate) fn policy_of(
421    settings: &Value,
422    rules: &'static crate::format::Rules,
423) -> Result<Policy, SkillError> {
424    let frozen: Vec<String> = settings["frozen"]
425        .as_array()
426        .map(|a| {
427            a.iter()
428                .filter_map(|v| v.as_str().map(str::to_owned))
429                .collect()
430        })
431        .unwrap_or_default();
432    let approved: Vec<Object> = decisions_of(settings, "approved")?;
433    let rejected: Vec<crate::tools::policy::Rejection> = decisions_of(settings, "rejected")?;
434    let mcp: Vec<crate::mcp::McpTool> =
435        serde_json::from_value(settings["mcp"].clone()).unwrap_or_default();
436    let policy = Policy::new(&argvs(&settings["checks"]), &argvs(&settings["allow"]))
437        .with_prefixes(argvs(&settings["allow_prefix"]))
438        .with_network_prefixes(argvs(&settings["network_allow_prefix"]))
439        .rules(rules)
440        .with_frozen(frozen)
441        .with_decisions(approved, rejected)
442        .with_mcp(mcp.iter().map(|t| (t.name.clone(), t.decision.clone())))
443        .with_network(argvs(&settings["network_allow"]))
444        .with_hidden(
445            serde_json::from_value::<Vec<String>>(settings["hidden"].clone()).unwrap_or_default(),
446        );
447    let policy = if settings["in_place"] == true {
448        policy.in_place()
449    } else {
450        policy
451    };
452    let policy = if settings["autopilot"] == true {
453        policy.autopilot()
454    } else {
455        policy
456    };
457    let policy = match settings["contained"].as_str() {
458        Some(m) => policy.contained(m),
459        None => policy,
460    };
461    Ok(match settings["planning"].as_str() {
462        Some(dir) => policy.planning(dir),
463        None => policy,
464    })
465}
466
467#[async_trait]
468impl Skill for Attempt {
469    fn descriptor(&self) -> SkillDescriptor {
470        SkillDescriptor::new("speccoder.task.attempt").provides(CAPABILITY)
471    }
472
473    async fn invoke(
474        &self,
475        cx: &mut StepCtx<'_>,
476        input: Tainted<Value>,
477    ) -> Result<Outcome, SkillError> {
478        let (ttl, style) = (self.cache_ttl, self.style);
479        let settings = input.peek()["settings"].clone();
480        let task_name = format!(
481            "{}/{}",
482            input.peek()["bundle"]["feature"]
483                .as_str()
484                .unwrap_or_default(),
485            input.peek()["bundle"]["task"]["id"]
486                .as_str()
487                .unwrap_or_default()
488        );
489        let rejected: Vec<crate::tools::policy::Rejection> = decisions_of(&settings, "rejected")?;
490        let told = rejected;
491        let bundle = &input.peek()["bundle"];
492        let stop = StopGate {
493            spec: ["story", "plan", "constitution"]
494                .iter()
495                .filter_map(|k| bundle[*k].as_str())
496                .chain(bundle["task"]["title"].as_str())
497                .collect::<Vec<_>>()
498                .join("\n"),
499            frozen: settings["frozen"]
500                .as_array()
501                .into_iter()
502                .flatten()
503                .filter_map(|v| v.as_str().map(str::to_owned))
504                .collect(),
505            rejected: !told.is_empty(),
506        };
507        let mcp: Vec<crate::mcp::McpTool> =
508            serde_json::from_value(settings["mcp"].clone()).unwrap_or_default();
509        let planning = settings["planning"].as_str().map(str::to_owned);
510        let policy = policy_of(&settings, self.format.rules())?;
511        let max_turns = settings["max_turns"]
512            .as_u64()
513            .map_or(30, |n| u32::try_from(n).unwrap_or(u32::MAX))
514            .max(1);
515        let max_tool_calls = settings["max_tool_calls"]
516            .as_u64()
517            .map_or(100, |n| u32::try_from(n).unwrap_or(u32::MAX));
518        // Same stable prefix as `propose`, with the attempt policy in front;
519        // a retry's digest goes after it, so the cached prefix is shared.
520        let label = conversation_label(&input);
521        let raw = input.peek().clone();
522        let prompt = provenance::labelled_prompt(
523            {
524                let input = raw;
525                let mut p = layout(
526                    &input["bundle"],
527                    ttl,
528                    style,
529                    &self::policy(self.format.wording()),
530                    self.format,
531                );
532                let given: Vec<crate::instructions::Instruction> =
533                    serde_json::from_value(input["instructions"].clone()).unwrap_or_default();
534                if let Some(text) = crate::instructions::section(&given) {
535                    let (key, kind) = match style {
536                        PromptStyle::OpenAi => ("input", "input_text"),
537                        PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
538                            ("messages", "text")
539                        }
540                    };
541                    if let Some(content) = p[key][0]["content"].as_array_mut() {
542                        content.push(json!({ "type": kind, "text": text }));
543                    }
544                }
545                if let Some(text) = input["test_layout"].as_str().filter(|t| !t.is_empty()) {
546                    let (key, kind) = match style {
547                        PromptStyle::OpenAi => ("input", "input_text"),
548                        PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
549                            ("messages", "text")
550                        }
551                    };
552                    if let Some(content) = p[key][0]["content"].as_array_mut() {
553                        content.push(json!({ "type": kind, "text": text }));
554                    }
555                }
556                if !input["digest"].is_null() {
557                    let (key, kind) = match style {
558                        PromptStyle::OpenAi => ("input", "input_text"),
559                        PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
560                            ("messages", "text")
561                        }
562                    };
563                    if let Some(content) = p[key][0]["content"].as_array_mut() {
564                        content
565                            .push(json!({ "type": kind, "text": digest_text(&input["digest"]) }));
566                    }
567                }
568                // A planning task: where its documents go.
569                if let Some(dir) = &planning {
570                    let (key, kind) = match style {
571                        PromptStyle::OpenAi => ("input", "input_text"),
572                        PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
573                            ("messages", "text")
574                        }
575                    };
576                    let text = format!(
577                        "# A planning task\n\nThis task writes design documents, not code: write them under `{dir}/` (the plan, contracts, the data model, research and the like are yours to write there). The requirements, the task list and the person's review stay protected, as does everything else of the spec; change no source code. speccoder runs the checks afterwards.\n"
578                    );
579                    if let Some(content) = p[key][0]["content"].as_array_mut() {
580                        content.push(json!({ "type": kind, "text": text }));
581                    }
582                }
583                // The checks as configured: the commands that run without asking.
584                let checks: Vec<String> = argvs(&input["settings"]["checks"])
585                    .iter()
586                    .map(|argv| format!("- `{}`", json!(argv)))
587                    .collect();
588                if !checks.is_empty() {
589                    let (key, kind) = match style {
590                        PromptStyle::OpenAi => ("input", "input_text"),
591                        PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
592                            ("messages", "text")
593                        }
594                    };
595                    let text = format!(
596                        "# The checks\n\nRun them with run_command as these argvs, word for word; they need no approval:\n\n{}\n",
597                        checks.join("\n")
598                    );
599                    if let Some(content) = p[key][0]["content"].as_array_mut() {
600                        content.push(json!({ "type": kind, "text": text }));
601                    }
602                }
603                // A person's notes for this task.
604                let notes: Vec<String> = input["settings"]["nudges"]
605                    .as_array()
606                    .map(|a| {
607                        a.iter()
608                            .filter_map(|n| n.as_str().map(|n| format!("- {n}")))
609                            .collect()
610                    })
611                    .unwrap_or_default();
612                if !notes.is_empty() {
613                    let (key, kind) = match style {
614                        PromptStyle::OpenAi => ("input", "input_text"),
615                        PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
616                            ("messages", "text")
617                        }
618                    };
619                    let text = format!("# Notes from a person\n\n{}\n", notes.join("\n"));
620                    if let Some(content) = p[key][0]["content"].as_array_mut() {
621                        content.push(json!({ "type": kind, "text": text }));
622                    }
623                }
624                // A person's rejections, so the model does not ask again.
625                if !told.is_empty() {
626                    let (key, kind) = match style {
627                        PromptStyle::OpenAi => ("input", "input_text"),
628                        PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
629                            ("messages", "text")
630                        }
631                    };
632                    let lines: Vec<String> = told
633                        .iter()
634                        .map(|r| format!("- {}: {}", r.object.describe(), r.reason))
635                        .collect();
636                    let text = format!(
637                        "# Rejected by a person\n\nThese calls were rejected for this task; find another way or call report_blocked:\n\n{}\n",
638                        lines.join("\n")
639                    );
640                    if let Some(content) = p[key][0]["content"].as_array_mut() {
641                        content.push(json!({ "type": kind, "text": text }));
642                    }
643                }
644                if let PromptStyle::Parts { caches } = style {
645                    super::model_config::to_parts(&mut p, caches);
646                }
647                p
648            },
649            &label,
650        );
651        let mut tools = crate::tools::declarations();
652        tools.push(report_blocked());
653        // MCP tools the policy does not deny, as the journaled input lists them.
654        let routes: Vec<(String, String, String)> = mcp
655            .iter()
656            .filter(|t| t.decision != "deny")
657            .map(|t| (t.name.clone(), t.server.clone(), t.tool.clone()))
658            .collect();
659        tools.extend(mcp.iter().filter(|t| t.decision != "deny").map(|t| {
660            ToolDeclaration::new(t.name.clone(), t.description.clone(), t.schema.clone())
661        }));
662        // Journaled: whether this attempt asks inline decides its effects.
663        let ask = Asker::from_settings(
664            &settings,
665            task_name.clone(),
666            input.peek()["bundle"]["task"]["title"]
667                .as_str()
668                .unwrap_or_default()
669                .to_owned(),
670            PresentContext {
671                root: settings["root"].as_str().map(std::path::PathBuf::from),
672                ..self.present.clone()
673            },
674        );
675        let remembered: Vec<Object> = decisions_of(&settings, "run_approved")?;
676        let once: Vec<Object> = decisions_of(&settings, "approved_once")?;
677        // Untrusted text the prompt itself carries.
678        let mut parts = Vec::new();
679        let given: Vec<crate::instructions::Instruction> =
680            serde_json::from_value(input.peek()["instructions"].clone()).unwrap_or_default();
681        if let Some(text) = crate::instructions::section(&given) {
682            parts.push(crate::approvals::derived::Part {
683                from: provenance::WORKTREE.to_owned(),
684                turn: 0,
685                text,
686            });
687        }
688        if !input.peek()["digest"].is_null() {
689            parts.push(crate::approvals::derived::Part {
690                from: provenance::VERIFIER.to_owned(),
691                turn: 0,
692                text: crate::approvals::derived::text_of(&input.peek()["digest"]),
693            });
694        }
695        let number = |k: &str, d: u32| {
696            settings[k]
697                .as_u64()
698                .map_or(d, |n| u32::try_from(n).unwrap_or(u32::MAX))
699        };
700        tool_loop(
701            cx,
702            &prompt,
703            LoopSpec {
704                tools,
705                policy,
706                max_turns,
707                max_tool_calls,
708                reasoning: settings["reasoning"] == true,
709                schema: Some(report_schema()),
710                max_tokens: None,
711                left: serde_json::from_value(settings["left"].clone()).ok(),
712                reads: None,
713                halt: None,
714                routes,
715                root: settings["root"].as_str().map(str::to_owned),
716                frozen_additions: settings["frozen_additions"] == true,
717                ask,
718                run_approved: remembered,
719                once,
720                run: settings["run_id"].as_str().map(str::to_owned),
721                asking: Asking {
722                    asked: number("asked", 0),
723                    budget: number("budget", DEFAULT_BUDGET),
724                    prompts: settings["prompts"] == true,
725                },
726                parts,
727                mcp_digests: mcp
728                    .iter()
729                    .map(|t| (t.name.clone(), t.digest.clone()))
730                    .collect(),
731                stop,
732                observer: self.progress.clone().map(|p| {
733                    (
734                        task_name.rsplit('/').next().unwrap_or_default().to_owned(),
735                        p,
736                    )
737                }),
738                finish: |report| {
739                    // The model's own word that it did not finish is believed;
740                    // its word that it did is not (the verifier decides that).
741                    if report["status"] == "incomplete" {
742                        "incomplete"
743                    } else {
744                        "completed"
745                    }
746                },
747            },
748        )
749        .await
750    }
751}