1use 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
23pub const CAPABILITY: &str = "speccoder.task.attempt";
25
26pub const REPORT_BLOCKED: &str = "report_blocked";
29
30pub 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#[must_use]
60pub fn report_schema() -> Value {
61 let strings = json!({ "type": "array", "items": { "type": "string" } });
62 json!({
63 "type": "object",
64 "properties": {
65 "summary": { "type": "string" },
66 "status": {
67 "type": "string",
68 "enum": ["done", "incomplete"],
69 "description": "done: this task's work is finished — even while tests that later tasks make pass still fail. incomplete: part of this task is left undone."
70 },
71 "files_changed": strings,
72 "checks_run": strings,
73 "notes": strings,
74 },
75 "required": ["summary", "status", "files_changed", "checks_run", "notes"],
76 "additionalProperties": false,
77 })
78}
79
80fn report_blocked() -> ToolDeclaration {
81 ToolDeclaration::new(
82 REPORT_BLOCKED,
83 "Stop the attempt because the task cannot be done as written; a person will look at the \
84reason. `kind`, with the `evidence` speccoder checks: `refused` — a call the task needs was \
85refused (the call); `spec_conflict` — the spec contradicts the task (its exact words); \
86`wrong_test` — a frozen test asserts the wrong thing (its path); `needs_information` — only a \
87person knows (the question). Scope is never a kind: when the tests run code that bypasses the \
88task's module (an entry point, a caller), wiring that code to it is part of the task.",
89 json!({
90 "type": "object",
91 "properties": {
92 "kind": { "type": "string", "enum": ["refused", "spec_conflict", "wrong_test", "needs_information"] },
93 "evidence": { "type": "string" },
94 "reason": { "type": "string" },
95 },
96 "required": ["kind", "evidence", "reason"],
97 "additionalProperties": false,
98 }),
99 )
100}
101
102#[derive(Default)]
105pub(crate) struct StopGate {
106 pub spec: String,
108 pub frozen: Vec<String>,
110 pub rejected: bool,
112}
113
114#[derive(Debug, PartialEq, Eq)]
116enum Stop {
117 Refused(String),
119 Asked(String),
121 Stopped(String),
123}
124
125fn judge_stop(
128 gate: &StopGate,
129 arguments: &Value,
130 refused: bool,
131 asked: bool,
132 turns_left: u32,
133) -> Stop {
134 let text = |k: &str| arguments[k].as_str().unwrap_or_default().trim();
135 let (kind, evidence, reason) = (text("kind"), text("evidence"), text("reason"));
136 let words = |t: &str| t.split_whitespace().collect::<Vec<_>>().join(" ");
137 let checked = match kind {
138 "refused" if refused => format!("refused call `{evidence}`"),
139 "refused" => return Stop::Refused("not stopped: no call in this attempt was refused or rejected".to_owned()),
140 "spec_conflict" if evidence.chars().count() >= 12 && words(&gate.spec).contains(&words(evidence)) => {
141 format!("the spec says “{evidence}”")
142 }
143 "spec_conflict" => {
144 return Stop::Refused("not stopped: the evidence must be the spec's exact words, at least a phrase; these are not in it".to_owned());
145 }
146 "wrong_test" if gate.frozen.iter().any(|f| f == evidence.trim_start_matches("./")) => {
147 format!("frozen test `{evidence}`")
148 }
149 "wrong_test" => return Stop::Refused("not stopped: the evidence must be the path of a test that existed when the attempt started".to_owned()),
150 "needs_information" if !evidence.is_empty() => format!("question: {evidence}"),
151 _ => return Stop::Refused("not stopped: `kind` is one of refused, spec_conflict, wrong_test, needs_information, with its `evidence`".to_owned()),
152 };
153 let said = match reason {
155 "" => checked,
156 r if r == evidence => r.to_owned(),
157 r => format!("{r} — {checked}"),
158 };
159 if kind == "refused" || asked || turns_left == 0 {
161 return Stop::Stopped(said);
162 }
163 Stop::Asked(format!(
164 "Not stopped yet: speccoder asks once before a stop. {turns_left} turns are left. The task's paths are the plan's guess: wiring an entry point, caller or import the tests run to the task's module is part of the task, and a failing check is a reason to keep going. If the task still cannot be done as written, call report_blocked again."
165 ))
166}
167
168pub const ASK_PERSON: &str = "ask_person";
170
171#[must_use]
173pub fn ask_person() -> ToolDeclaration {
174 ToolDeclaration::new(
175 ASK_PERSON,
176 "Ask the person one question and wait for their answer. If nobody can answer, the reply says so.",
177 json!({
178 "type": "object",
179 "properties": { "question": { "type": "string" } },
180 "required": ["question"],
181 "additionalProperties": false,
182 }),
183 )
184}
185
186#[derive(Debug, Clone)]
190pub struct Asker {
191 pub task: String,
193 pub task_title: String,
195 pub context: PresentContext,
197 pub wait_secs: u64,
199}
200
201impl Asker {
202 #[must_use]
205 pub fn from_settings(
206 settings: &Value,
207 task: String,
208 task_title: String,
209 context: PresentContext,
210 ) -> Option<Self> {
211 (settings["asks"] == "tasks").then(|| Self {
212 task,
213 task_title,
214 context,
215 wait_secs: settings["wait_secs"].as_u64().unwrap_or(7 * 86_400),
216 })
217 }
218}
219
220async fn wait_for_person(
224 cx: &mut StepCtx<'_>,
225 asker: &Asker,
226 obligation: &mut bool,
227 spec: &agentplane::core::TaskSpec,
228) -> Result<agentplane::core::Decision, SkillError> {
229 if !*obligation {
230 let minutes = u32::try_from(asker.wait_secs.div_ceil(60).max(1)).unwrap_or(u32::MAX);
231 cx.deadline(
232 crate::approvals::tasks::DEADLINE,
233 &agentplane::core::DeadlineSpec::minutes(minutes),
234 None,
235 )
236 .await?;
237 *obligation = true;
238 }
239 Ok(cx.task(spec).await?)
240}
241
242#[derive(Debug)]
249pub struct Attempt {
250 pub style: PromptStyle,
252 pub cache_ttl: CacheTtl,
254 pub progress: Option<std::sync::Arc<dyn crate::progress::Progress>>,
256 pub format: &'static dyn crate::format::Adapter,
258 pub present: PresentContext,
260}
261
262#[derive(Debug)]
266struct Quiet {
267 replaying: std::sync::Arc<std::sync::atomic::AtomicBool>,
268 next: std::sync::Arc<dyn crate::progress::Progress>,
269}
270
271impl crate::progress::Progress for Quiet {
272 fn event(&self, event: &crate::progress::Event) {
273 if !self.replaying.load(std::sync::atomic::Ordering::SeqCst) {
274 self.next.event(event);
275 }
276 }
277}
278
279#[must_use]
288pub fn input(
289 bundle: Value,
290 settings: &Settings<'_>,
291 digest: Option<&Value>,
292 instructions: &[crate::instructions::Instruction],
293 test_layout: Option<&str>,
294) -> Tainted<Value> {
295 let digest = match digest.filter(|d| !d.is_null()) {
296 Some(d) => Tainted::with_label(d.clone(), provenance::from(provenance::VERIFIER)),
297 None => Tainted::trusted(Value::Null),
298 };
299 let mcp = Tainted::array(settings.mcp.iter().map(|t| {
301 Tainted::with_label(
302 serde_json::to_value(t).unwrap_or(Value::Null),
303 agentplane::core::Label::untrusted(provenance::tool(&t.server, &t.tool)),
304 )
305 }));
306 let trusted = |v: Value| Tainted::trusted(v);
307 let autopilot = settings.autopilot;
308 let planning = settings.planning;
309 let contained = settings.contained;
310 let settings = vec![
311 ("checks", trusted(json!(settings.checks))),
312 ("allow", trusted(json!(settings.allow))),
313 ("max_turns", trusted(json!(settings.max_turns))),
314 ("max_tool_calls", trusted(json!(settings.max_tool_calls))),
315 ("reasoning", trusted(json!(settings.reasoning))),
316 ("frozen", trusted(json!(settings.frozen))),
317 ("approved", trusted(json!(settings.approved))),
318 ("rejected", trusted(json!(settings.rejected))),
319 ("run_approved", trusted(json!(settings.run_approved))),
320 ("approved_once", trusted(json!(settings.approved_once))),
321 ("nudges", trusted(json!(settings.nudges))),
322 ("mcp", mcp),
323 ("root", trusted(json!(settings.root))),
324 (
325 "frozen_additions",
326 trusted(json!(settings.frozen_additions)),
327 ),
328 ("network_allow", trusted(json!(settings.network_allow))),
329 ("allow_prefix", trusted(json!(settings.allow_prefix))),
330 (
331 "network_allow_prefix",
332 trusted(json!(settings.network_allow_prefix)),
333 ),
334 ("run_id", trusted(json!(settings.run_id))),
335 ("asked", trusted(json!(settings.asked))),
336 ("budget", trusted(json!(settings.budget))),
337 ("left", trusted(json!(settings.left))),
338 ("prompts", trusted(json!(settings.prompts))),
339 ("asks", trusted(json!(settings.asks))),
340 ("wait_secs", trusted(json!(settings.wait_secs))),
341 ("in_place", trusted(json!(settings.in_place))),
342 ("hidden", trusted(json!(settings.hidden))),
343 ];
344 let mut settings = settings;
347 if autopilot {
348 settings.push(("autopilot", trusted(json!(true))));
349 }
350 if let Some(dir) = planning {
352 settings.push(("planning", trusted(json!(dir))));
353 }
354 if let Some(m) = contained {
356 settings.push(("contained", trusted(json!(m))));
357 }
358 let settings = Tainted::object(settings);
359 Tainted::object([
360 ("bundle", Tainted::trusted(bundle)),
361 ("digest", digest),
362 ("settings", settings),
363 (
366 "instructions",
367 if instructions.is_empty() {
368 Tainted::trusted(json!([]))
369 } else {
370 Tainted::with_label(json!(instructions), provenance::from(provenance::WORKTREE))
371 },
372 ),
373 (
375 "test_layout",
376 match test_layout {
377 Some(t) => Tainted::with_label(json!(t), provenance::from(provenance::WORKTREE)),
378 None => Tainted::trusted(Value::Null),
379 },
380 ),
381 ])
382}
383
384fn refusal(text: String, call: &agentplane::core::Label) -> Tainted<String> {
388 Tainted::with_label(text, call.clone())
389}
390
391const MESSAGE_PARTS: [&str; 7] = [
393 "/bundle",
394 "/instructions",
395 "/test_layout",
396 "/digest",
397 "/settings/nudges",
398 "/settings/rejected",
399 "/settings/mcp",
400];
401
402fn conversation_label(input: &Tainted<Value>) -> agentplane::core::Label {
406 MESSAGE_PARTS
407 .iter()
408 .map(|p| input.label_at(p).unwrap_or_else(|| input.label()).clone())
409 .fold(agentplane::core::Label::trusted(), |acc, l| acc.join(&l))
410}
411
412#[derive(Debug, Clone, Copy)]
414pub struct Settings<'a> {
415 pub checks: &'a [Vec<String>],
417 pub allow: &'a [Vec<String>],
419 pub allow_prefix: &'a [Vec<String>],
421 pub max_turns: u32,
423 pub max_tool_calls: u32,
425 pub reasoning: bool,
427 pub frozen: &'a [String],
429 pub approved: &'a [Object],
431 pub rejected: &'a [crate::tools::policy::Rejection],
433 pub run_approved: &'a [Object],
435 pub approved_once: &'a [Object],
438 pub nudges: &'a [String],
440 pub mcp: &'a [crate::mcp::McpTool],
442 pub root: &'a str,
444 pub frozen_additions: bool,
446 pub asks: &'a str,
449 pub wait_secs: u64,
451 pub network_allow: &'a [Vec<String>],
453 pub network_allow_prefix: &'a [Vec<String>],
455 pub run_id: &'a str,
457 pub asked: u32,
459 pub budget: u32,
461 pub prompts: bool,
463 pub in_place: bool,
465 pub hidden: &'a [String],
467 pub autopilot: bool,
469 pub planning: Option<&'a str>,
472 pub contained: Option<&'a str>,
475 pub left: Option<Left>,
477}
478
479pub const MAX_CONSECUTIVE_DENIALS: u32 = 3;
481
482fn digest_text(digest: &Value) -> String {
484 let mut out = format!(
485 "# Previous attempt\n\nAttempt {} at this task was verified by speccoder and FAILED. Its \
486changes were discarded; the worktree is back where that attempt started. You start fresh — use \
487these facts from the verifier, check them yourself, and do not repeat the mistake.\n",
488 digest["attempt"].as_u64().unwrap_or(1)
489 );
490 let strings = |v: &Value| -> Vec<String> {
491 v.as_array()
492 .map(|a| {
493 a.iter()
494 .filter_map(|x| x.as_str().map(str::to_owned))
495 .collect()
496 })
497 .unwrap_or_default()
498 };
499 let reasons = strings(&digest["reasons"]);
500 if !reasons.is_empty() {
501 out.push_str("\n## Verdict reasons\n\n");
502 for r in reasons {
503 out.push_str(&format!("- {r}\n"));
504 }
505 }
506 for c in digest["checks"].as_array().into_iter().flatten() {
507 out.push_str(&format!(
508 "\n## Check `{}` ({}): `{}`\n",
509 c["name"].as_str().unwrap_or_default(),
510 c["status"].as_str().unwrap_or_default(),
511 strings(&c["argv"]).join(" ")
512 ));
513 let tests = strings(&c["failing_tests"]);
514 if !tests.is_empty() {
515 out.push_str(&format!("\nFailing tests: {}\n", tests.join(", ")));
516 }
517 out.push_str(&format!(
518 "\n```text\n{}\n```\n",
519 c["excerpt"].as_str().unwrap_or_default()
520 ));
521 }
522 let uncollected = strings(&digest["uncollected"]);
523 if !uncollected.is_empty() {
524 out.push_str("\n## Tests that never ran\n\n");
525 for l in uncollected {
526 out.push_str(&format!("- {l}\n"));
527 }
528 }
529 let discarded = strings(&digest["discarded"]);
530 if !discarded.is_empty() {
531 out.push_str(&format!(
532 "\n## Files the discarded attempt changed\n\n{}\n",
533 discarded.join("\n")
534 ));
535 }
536 out
537}
538
539fn argvs(v: &Value) -> Vec<Vec<String>> {
540 v.as_array()
541 .map(|a| {
542 a.iter()
543 .map(|argv| {
544 argv.as_array()
545 .map(|p| {
546 p.iter()
547 .filter_map(|x| x.as_str().map(str::to_owned))
548 .collect()
549 })
550 .unwrap_or_default()
551 })
552 .collect()
553 })
554 .unwrap_or_default()
555}
556
557fn decisions_of<T: serde::de::DeserializeOwned>(
561 settings: &Value,
562 key: &str,
563) -> Result<Vec<T>, SkillError> {
564 match &settings[key] {
565 Value::Null => Ok(Vec::new()),
566 v => serde_json::from_value(v.clone())
567 .map_err(|e| SkillError::Input(format!("settings.{key}: {e}"))),
568 }
569}
570
571pub(crate) fn policy_of(
576 settings: &Value,
577 rules: &'static crate::format::Rules,
578) -> Result<Policy, SkillError> {
579 let frozen: Vec<String> = settings["frozen"]
580 .as_array()
581 .map(|a| {
582 a.iter()
583 .filter_map(|v| v.as_str().map(str::to_owned))
584 .collect()
585 })
586 .unwrap_or_default();
587 let approved: Vec<Object> = decisions_of(settings, "approved")?;
588 let rejected: Vec<crate::tools::policy::Rejection> = decisions_of(settings, "rejected")?;
589 let mcp: Vec<crate::mcp::McpTool> =
590 serde_json::from_value(settings["mcp"].clone()).unwrap_or_default();
591 let policy = Policy::new(&argvs(&settings["checks"]), &argvs(&settings["allow"]))
592 .with_prefixes(argvs(&settings["allow_prefix"]))
593 .with_network_prefixes(argvs(&settings["network_allow_prefix"]))
594 .rules(rules)
595 .with_frozen(frozen)
596 .with_decisions(approved, rejected)
597 .with_mcp(mcp.iter().map(|t| (t.name.clone(), t.decision.clone())))
598 .with_network(argvs(&settings["network_allow"]))
599 .with_hidden(
600 serde_json::from_value::<Vec<String>>(settings["hidden"].clone()).unwrap_or_default(),
601 );
602 let policy = if settings["in_place"] == true {
603 policy.in_place()
604 } else {
605 policy
606 };
607 let policy = if settings["autopilot"] == true {
608 policy.autopilot()
609 } else {
610 policy
611 };
612 let policy = match settings["contained"].as_str() {
613 Some(m) => policy.contained(m),
614 None => policy,
615 };
616 Ok(match settings["planning"].as_str() {
617 Some(dir) => policy.planning(dir),
618 None => policy,
619 })
620}
621
622#[async_trait]
623impl Skill for Attempt {
624 fn descriptor(&self) -> SkillDescriptor {
625 SkillDescriptor::new("speccoder.task.attempt").provides(CAPABILITY)
626 }
627
628 async fn invoke(
629 &self,
630 cx: &mut StepCtx<'_>,
631 input: Tainted<Value>,
632 ) -> Result<Outcome, SkillError> {
633 let (ttl, style) = (self.cache_ttl, self.style);
634 let settings = input.peek()["settings"].clone();
635 let task_name = format!(
636 "{}/{}",
637 input.peek()["bundle"]["feature"]
638 .as_str()
639 .unwrap_or_default(),
640 input.peek()["bundle"]["task"]["id"]
641 .as_str()
642 .unwrap_or_default()
643 );
644 let rejected: Vec<crate::tools::policy::Rejection> = decisions_of(&settings, "rejected")?;
645 let told = rejected;
646 let bundle = &input.peek()["bundle"];
647 let stop = StopGate {
648 spec: ["story", "plan", "constitution"]
649 .iter()
650 .filter_map(|k| bundle[*k].as_str())
651 .chain(bundle["task"]["title"].as_str())
652 .collect::<Vec<_>>()
653 .join("\n"),
654 frozen: settings["frozen"]
655 .as_array()
656 .into_iter()
657 .flatten()
658 .filter_map(|v| v.as_str().map(str::to_owned))
659 .collect(),
660 rejected: !told.is_empty(),
661 };
662 let mcp: Vec<crate::mcp::McpTool> =
663 serde_json::from_value(settings["mcp"].clone()).unwrap_or_default();
664 let planning = settings["planning"].as_str().map(str::to_owned);
665 let policy = policy_of(&settings, self.format.rules())?;
666 let max_turns = settings["max_turns"]
667 .as_u64()
668 .map_or(30, |n| u32::try_from(n).unwrap_or(u32::MAX))
669 .max(1);
670 let max_tool_calls = settings["max_tool_calls"]
671 .as_u64()
672 .map_or(100, |n| u32::try_from(n).unwrap_or(u32::MAX));
673 let label = conversation_label(&input);
676 let raw = input.peek().clone();
677 let prompt = provenance::labelled_prompt(
678 {
679 let input = raw;
680 let mut p = layout(
681 &input["bundle"],
682 ttl,
683 style,
684 &self::policy(self.format.wording()),
685 self.format,
686 );
687 let given: Vec<crate::instructions::Instruction> =
688 serde_json::from_value(input["instructions"].clone()).unwrap_or_default();
689 if let Some(text) = crate::instructions::section(&given) {
690 let (key, kind) = match style {
691 PromptStyle::OpenAi => ("input", "input_text"),
692 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
693 ("messages", "text")
694 }
695 };
696 if let Some(content) = p[key][0]["content"].as_array_mut() {
697 content.push(json!({ "type": kind, "text": text }));
698 }
699 }
700 if let Some(text) = input["test_layout"].as_str().filter(|t| !t.is_empty()) {
701 let (key, kind) = match style {
702 PromptStyle::OpenAi => ("input", "input_text"),
703 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
704 ("messages", "text")
705 }
706 };
707 if let Some(content) = p[key][0]["content"].as_array_mut() {
708 content.push(json!({ "type": kind, "text": text }));
709 }
710 }
711 if !input["digest"].is_null() {
712 let (key, kind) = match style {
713 PromptStyle::OpenAi => ("input", "input_text"),
714 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
715 ("messages", "text")
716 }
717 };
718 if let Some(content) = p[key][0]["content"].as_array_mut() {
719 content
720 .push(json!({ "type": kind, "text": digest_text(&input["digest"]) }));
721 }
722 }
723 if let Some(dir) = &planning {
725 let (key, kind) = match style {
726 PromptStyle::OpenAi => ("input", "input_text"),
727 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
728 ("messages", "text")
729 }
730 };
731 let text = format!(
732 "# 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"
733 );
734 if let Some(content) = p[key][0]["content"].as_array_mut() {
735 content.push(json!({ "type": kind, "text": text }));
736 }
737 }
738 let checks: Vec<String> = argvs(&input["settings"]["checks"])
740 .iter()
741 .map(|argv| format!("- `{}`", json!(argv)))
742 .collect();
743 if !checks.is_empty() {
744 let (key, kind) = match style {
745 PromptStyle::OpenAi => ("input", "input_text"),
746 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
747 ("messages", "text")
748 }
749 };
750 let text = format!(
751 "# The checks\n\nRun them with run_command as these argvs, word for word; they need no approval:\n\n{}\n",
752 checks.join("\n")
753 );
754 if let Some(content) = p[key][0]["content"].as_array_mut() {
755 content.push(json!({ "type": kind, "text": text }));
756 }
757 }
758 let notes: Vec<String> = input["settings"]["nudges"]
760 .as_array()
761 .map(|a| {
762 a.iter()
763 .filter_map(|n| n.as_str().map(|n| format!("- {n}")))
764 .collect()
765 })
766 .unwrap_or_default();
767 if !notes.is_empty() {
768 let (key, kind) = match style {
769 PromptStyle::OpenAi => ("input", "input_text"),
770 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
771 ("messages", "text")
772 }
773 };
774 let text = format!("# Notes from a person\n\n{}\n", notes.join("\n"));
775 if let Some(content) = p[key][0]["content"].as_array_mut() {
776 content.push(json!({ "type": kind, "text": text }));
777 }
778 }
779 if !told.is_empty() {
781 let (key, kind) = match style {
782 PromptStyle::OpenAi => ("input", "input_text"),
783 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
784 ("messages", "text")
785 }
786 };
787 let lines: Vec<String> = told
788 .iter()
789 .map(|r| format!("- {}: {}", r.object.describe(), r.reason))
790 .collect();
791 let text = format!(
792 "# Rejected by a person\n\nThese calls were rejected for this task; find another way or call report_blocked:\n\n{}\n",
793 lines.join("\n")
794 );
795 if let Some(content) = p[key][0]["content"].as_array_mut() {
796 content.push(json!({ "type": kind, "text": text }));
797 }
798 }
799 if let PromptStyle::Parts { caches } = style {
800 super::model_config::to_parts(&mut p, caches);
801 }
802 p
803 },
804 &label,
805 );
806 let mut tools = crate::tools::declarations();
807 tools.push(report_blocked());
808 let routes: Vec<(String, String, String)> = mcp
810 .iter()
811 .filter(|t| t.decision != "deny")
812 .map(|t| (t.name.clone(), t.server.clone(), t.tool.clone()))
813 .collect();
814 tools.extend(mcp.iter().filter(|t| t.decision != "deny").map(|t| {
815 ToolDeclaration::new(t.name.clone(), t.description.clone(), t.schema.clone())
816 }));
817 let ask = Asker::from_settings(
819 &settings,
820 task_name.clone(),
821 input.peek()["bundle"]["task"]["title"]
822 .as_str()
823 .unwrap_or_default()
824 .to_owned(),
825 PresentContext {
826 root: settings["root"].as_str().map(std::path::PathBuf::from),
827 ..self.present.clone()
828 },
829 );
830 let remembered: Vec<Object> = decisions_of(&settings, "run_approved")?;
831 let once: Vec<Object> = decisions_of(&settings, "approved_once")?;
832 let mut parts = Vec::new();
834 let given: Vec<crate::instructions::Instruction> =
835 serde_json::from_value(input.peek()["instructions"].clone()).unwrap_or_default();
836 if let Some(text) = crate::instructions::section(&given) {
837 parts.push(crate::approvals::derived::Part {
838 from: provenance::WORKTREE.to_owned(),
839 turn: 0,
840 text,
841 });
842 }
843 if !input.peek()["digest"].is_null() {
844 parts.push(crate::approvals::derived::Part {
845 from: provenance::VERIFIER.to_owned(),
846 turn: 0,
847 text: crate::approvals::derived::text_of(&input.peek()["digest"]),
848 });
849 }
850 let number = |k: &str, d: u32| {
851 settings[k]
852 .as_u64()
853 .map_or(d, |n| u32::try_from(n).unwrap_or(u32::MAX))
854 };
855 tool_loop(
856 cx,
857 &prompt,
858 LoopSpec {
859 tools,
860 policy,
861 max_turns,
862 max_tool_calls,
863 reasoning: settings["reasoning"] == true,
864 schema: Some(report_schema()),
865 max_tokens: None,
866 left: serde_json::from_value(settings["left"].clone()).ok(),
867 reads: None,
868 halt: None,
869 routes,
870 root: settings["root"].as_str().map(str::to_owned),
871 frozen_additions: settings["frozen_additions"] == true,
872 ask,
873 run_approved: remembered,
874 once,
875 run: settings["run_id"].as_str().map(str::to_owned),
876 asking: Asking {
877 asked: number("asked", 0),
878 budget: number("budget", DEFAULT_BUDGET),
879 prompts: settings["prompts"] == true,
880 },
881 parts,
882 mcp_digests: mcp
883 .iter()
884 .map(|t| (t.name.clone(), t.digest.clone()))
885 .collect(),
886 stop,
887 observer: self.progress.clone().map(|p| {
888 (
889 task_name.rsplit('/').next().unwrap_or_default().to_owned(),
890 p,
891 )
892 }),
893 finish: |report| {
894 if report["status"] == "incomplete" {
897 "incomplete"
898 } else {
899 "completed"
900 }
901 },
902 },
903 )
904 .await
905 }
906}
907
908fn relativise(arguments: &mut Value, root: Option<&str>) {
913 let (Some(root), Some(path)) = (root, arguments.get("path").and_then(Value::as_str)) else {
914 return;
915 };
916 let root = root.trim_end_matches('/');
917 if let Some(rel) = path.strip_prefix(root).and_then(|r| r.strip_prefix('/')) {
918 let rel = if rel.is_empty() {
919 ".".to_owned()
920 } else {
921 rel.to_owned()
922 };
923 arguments["path"] = Value::String(rel);
924 }
925}
926
927fn emit(
929 observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
930 turn: u32,
931 entry: &Value,
932) {
933 emit_with_tail(observer, turn, entry, Vec::new());
934}
935
936fn emit_with_tail(
939 observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
940 turn: u32,
941 entry: &Value,
942 tail: Vec<String>,
943) {
944 if let Some((task, p)) = observer {
945 let tool = entry["tool"].as_str().unwrap_or_default();
946 p.event(&crate::progress::Event::ToolCall {
947 task: task.clone(),
948 turn,
949 tool: tool.to_owned(),
950 target: crate::progress::target(tool, &entry["arguments"]),
951 decision: entry["decision"].as_str().unwrap_or_default().to_owned(),
952 ok: entry["ok"].as_bool(),
953 tail,
954 contained: entry["contained"] == true,
955 });
956 }
957}
958
959fn needs_person(
961 observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
962 asked: &agentplane::model::ToolCall,
963 object: Option<&Object>,
964) {
965 if let Some((task, p)) = observer {
966 let (tool, arguments) = (asked.name.as_str(), &asked.arguments);
967 let target = crate::progress::target(tool, arguments);
968 let what: String = format!("{tool} {target}")
969 .chars()
970 .filter(|c| !c.is_control())
971 .take(60)
972 .collect();
973 p.event(&crate::progress::Event::ApprovalRequested {
974 task: task.clone(),
975 call: asked.id.clone(),
976 tool: tool.to_owned(),
977 what,
978 class: object
979 .map(|o| o.class().as_str().to_owned())
980 .unwrap_or_default(),
981 object: object.map(Object::short).unwrap_or_default(),
982 });
983 }
984}
985
986fn person_decided(
988 observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
989 asked: &agentplane::model::ToolCall,
990 decision: &str,
991 scope: Option<&str>,
992 who: Option<&Who>,
993 object: Option<&Object>,
994) {
995 if let Some((task, p)) = observer {
996 p.event(&crate::progress::Event::ApprovalDecided {
997 task: task.clone(),
998 call: asked.id.clone(),
999 decision: decision.to_owned(),
1000 scope: scope.map(str::to_owned),
1001 by: who.map_or_else(|| "nobody (expired)".to_owned(), |w| w.name.clone()),
1002 how: who.map_or_else(|| "expired".to_owned(), |w| w.basis.clone()),
1003 class: object
1004 .map(|o| o.class().as_str().to_owned())
1005 .unwrap_or_default(),
1006 object: object.map(Object::short).unwrap_or_default(),
1007 });
1008 }
1009}
1010
1011struct Question<'a> {
1013 asked: &'a agentplane::model::ToolCall,
1014 reason: &'a str,
1015 turn: u32,
1016 said: &'a str,
1017 asker: Option<&'a Asker>,
1018 policy: &'a Policy,
1019 object: Option<&'a Object>,
1020 remembered: &'a [(Object, &'static str)],
1021 parts: &'a [crate::approvals::derived::Part],
1022 run: Option<&'a str>,
1023}
1024
1025fn question(q: &Question<'_>) -> Request {
1029 let (tool, arguments) = (q.asked.name.as_str(), &q.asked.arguments);
1030 let kind = crate::approvals::kind_of(tool, arguments);
1031 let risk = crate::approvals::risk(tool, q.reason);
1032 let always_risk = if kind == "network" {
1034 let mut bare = arguments.clone();
1035 if let Some(o) = bare.as_object_mut() {
1036 o.remove("network");
1037 }
1038 match q.policy.base(tool, &bare) {
1039 Decision::Allow => "low",
1040 Decision::Approval(r) => crate::approvals::risk(tool, &r),
1041 Decision::Deny(_) => "high",
1042 }
1043 } else {
1044 risk
1045 };
1046 let (sources, more) = crate::approvals::derived::sources(tool, arguments, q.parts);
1047 let changed = q.object.and_then(|o| {
1048 q.remembered
1049 .iter()
1050 .map(|(r, _)| r)
1051 .chain(q.policy.approved())
1052 .find_map(|earlier| o.changed_from(earlier))
1053 });
1054 Request {
1055 task: q.asker.map(|a| a.task.clone()).unwrap_or_default(),
1056 task_title: q.asker.map(|a| a.task_title.clone()).unwrap_or_default(),
1057 turn: q.turn,
1058 tool: tool.to_owned(),
1059 arguments: arguments.clone(),
1060 kind: kind.to_owned(),
1061 reason: q.reason.to_owned(),
1062 object: q.object.cloned(),
1063 changed,
1064 risk: risk.to_owned(),
1065 claim: crate::approvals::claim(q.said),
1066 options: crate::approvals::options(
1067 risk,
1068 q.object.is_some(),
1069 !sources.is_empty(),
1070 kind,
1071 always_risk,
1072 ),
1073 sources,
1074 more,
1075 run: q.run.map(str::to_owned),
1076 asked: 0,
1077 budget: 0,
1078 batched: false,
1079 }
1080}
1081
1082pub(crate) struct LoopSpec {
1084 pub tools: Vec<ToolDeclaration>,
1086 pub policy: Policy,
1088 pub max_turns: u32,
1090 pub max_tool_calls: u32,
1092 pub reasoning: bool,
1094 pub schema: Option<Value>,
1096 pub routes: Vec<(String, String, String)>,
1098 pub root: Option<String>,
1101 pub frozen_additions: bool,
1103 pub finish: fn(&Value) -> &'static str,
1105 pub ask: Option<Asker>,
1107 pub run_approved: Vec<Object>,
1109 pub once: Vec<Object>,
1112 pub observer: Option<(String, std::sync::Arc<dyn crate::progress::Progress>)>,
1114 pub run: Option<String>,
1116 pub asking: Asking,
1119 pub parts: Vec<crate::approvals::derived::Part>,
1122 pub mcp_digests: std::collections::BTreeMap<String, String>,
1124 pub stop: StopGate,
1126 pub max_tokens: Option<u64>,
1128 pub left: Option<Left>,
1130 pub reads: Option<u32>,
1132 pub halt: Option<agentplane::model::Stop>,
1136}
1137
1138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1140pub struct Asking {
1141 pub asked: u32,
1143 pub budget: u32,
1145 pub prompts: bool,
1148}
1149
1150impl Default for Asking {
1151 fn default() -> Self {
1152 Self {
1153 asked: 0,
1154 budget: DEFAULT_BUDGET,
1155 prompts: false,
1156 }
1157 }
1158}
1159
1160pub use super::run_config::DEFAULT_BUDGET;
1162
1163#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
1166pub struct Left {
1167 pub cap: super::run_config::Cap,
1169 pub prices: Option<super::model_config::Prices>,
1171}
1172
1173impl Left {
1174 #[must_use]
1176 pub fn spent(&self, usage: &agentplane::model::Usage) -> u64 {
1177 match self.cap {
1178 super::run_config::Cap::Tokens(_) => usage.input_tokens + usage.output_tokens,
1179 super::run_config::Cap::Usd(_) => self.prices.map_or(0, |p| {
1180 micros(p.usd(
1181 usage.input_tokens,
1182 usage.output_tokens,
1183 usage.cache_read_tokens,
1184 usage.cache_write_tokens,
1185 ))
1186 }),
1187 }
1188 }
1189
1190 #[must_use]
1192 pub fn reached(&self, usage: &agentplane::model::Usage) -> bool {
1193 self.spent(usage) >= self.cap.amount()
1194 }
1195
1196 #[must_use]
1198 pub fn reason(&self, usage: &agentplane::model::Usage) -> String {
1199 format!(
1200 "the run's cap: this attempt used {} of the {} left (`run --cap`, `[budget] cap`)",
1201 self.cap.with(self.spent(usage)).rounded(),
1202 self.cap.rounded()
1203 )
1204 }
1205}
1206
1207#[must_use]
1209pub fn usage_of(v: Option<&Value>) -> agentplane::model::Usage {
1210 let n = |k: &str| v.and_then(|v| v[k].as_u64()).unwrap_or(0);
1211 agentplane::model::Usage {
1212 input_tokens: n("input_tokens"),
1213 output_tokens: n("output_tokens"),
1214 cache_read_tokens: n("cache_read_tokens"),
1215 cache_write_tokens: n("cache_write_tokens"),
1216 ..agentplane::model::Usage::default()
1217 }
1218}
1219
1220#[must_use]
1222#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1223pub fn micros(usd: f64) -> u64 {
1224 (usd * 1e6).round().max(0.0) as u64
1225}
1226
1227pub(crate) async fn tool_loop(
1231 cx: &mut StepCtx<'_>,
1232 prompt: &Tainted<Value>,
1233 spec: LoopSpec,
1234) -> Result<Outcome, SkillError> {
1235 let mut transcript = Transcript::new(prompt);
1238 tool_turn(cx, prompt, spec, &mut transcript).await
1239}
1240
1241pub(crate) async fn tool_turn(
1244 cx: &mut StepCtx<'_>,
1245 prompt: &Tainted<Value>,
1246 spec: LoopSpec,
1247 transcript: &mut Transcript,
1248) -> Result<Outcome, SkillError> {
1249 let LoopSpec {
1250 tools,
1251 policy,
1252 max_turns,
1253 max_tool_calls,
1254 reasoning,
1255 schema,
1256 routes,
1257 root,
1258 frozen_additions,
1259 finish,
1260 ask,
1261 run_approved,
1262 mut once,
1263 observer,
1264 run,
1265 asking,
1266 mut parts,
1267 mcp_digests,
1268 stop,
1269 max_tokens,
1270 left,
1271 reads,
1272 halt,
1273 } = spec;
1274 let replaying = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(cx.replaying()));
1278 let observer = observer.map(|(task, next)| {
1279 let quiet: std::sync::Arc<dyn crate::progress::Progress> = std::sync::Arc::new(Quiet {
1280 replaying: std::sync::Arc::clone(&replaying),
1281 next,
1282 });
1283 (task, quiet)
1284 });
1285 let seen = |cx: &StepCtx<'_>| {
1286 replaying.store(cx.replaying(), std::sync::atomic::Ordering::SeqCst);
1287 };
1288 let mut next_read = reads;
1290 let mut proposed = false;
1291 let mut remembered: Vec<(Object, &'static str)> =
1294 run_approved.into_iter().map(|g| (g, "run-grant")).collect();
1295 let mut prompted = asking.asked;
1296 let mut told_budget = false;
1297 let catalog = crate::tools::catalog();
1298
1299 let mut conversation: Label = transcript.label().clone();
1300 let mut calls = 0u32;
1301 let mut denied_in_a_row = 0u32;
1302 let mut asked_to_stop = false;
1304 let mut obligation = false;
1306 let mut usage = agentplane::model::Usage::default();
1307 let mut log: Vec<Value> = Vec::new();
1308
1309 let end = |status: &str,
1310 extra: Value,
1311 log: &[Value],
1312 usage: agentplane::model::Usage,
1313 conversation: &Label,
1314 prompted: u32| {
1315 let mut out = json!({
1316 "status": status,
1317 "tool_calls": log,
1318 "asked": prompted,
1319 "usage": {
1320 "input_tokens": usage.input_tokens,
1321 "output_tokens": usage.output_tokens,
1322 "cache_read_tokens": usage.cache_read_tokens,
1323 "cache_write_tokens": usage.cache_write_tokens,
1324 },
1325 });
1326 if let (Some(o), Some(e)) = (out.as_object_mut(), extra.as_object()) {
1327 o.extend(e.clone());
1328 }
1329 Ok(Outcome::done(Tainted::with_label(
1330 out,
1331 conversation.clone(),
1332 )))
1333 };
1334
1335 for turn in 0..max_turns {
1336 if let Some(max) = max_tokens
1338 && usage.input_tokens + usage.output_tokens >= max
1339 {
1340 return end(
1341 "budget",
1342 json!({ "reason": format!("this turn used {} of its {max} tokens", usage.input_tokens + usage.output_tokens) }),
1343 &log,
1344 usage,
1345 &conversation,
1346 prompted,
1347 );
1348 }
1349 if let Some(l) = left
1351 && l.reached(&usage)
1352 {
1353 return end(
1354 "budget_exhausted",
1355 json!({ "reason": l.reason(&usage) }),
1356 &log,
1357 usage,
1358 &conversation,
1359 prompted,
1360 );
1361 }
1362 let tools_now = tools.clone();
1366 let schema_now = schema.clone();
1367 let read_so_far = &*transcript;
1368 let stop_now = halt.clone();
1369 let completion = cx
1370 .complete_with(prompt, move |call| {
1371 let call = call.with_tools(tools_now).continuing(read_so_far);
1372 let call = match stop_now {
1373 Some(stop) => call.stopped_by(stop),
1374 None => call,
1375 };
1376 let call = match schema_now {
1377 Some(schema) => call.expecting(schema),
1378 None => call,
1379 };
1380 if reasoning {
1381 call.with_reasoning_summary()
1382 } else {
1383 call
1384 }
1385 })
1386 .await?;
1387 seen(cx);
1388 conversation = transcript.label().join(completion.label());
1389 usage = usage.plus(completion.peek().usage);
1390 if let Some((task, p)) = &observer {
1391 p.event(&crate::progress::Event::Usage {
1392 task: task.clone(),
1393 input_tokens: usage.input_tokens,
1394 output_tokens: usage.output_tokens,
1395 cache_read_tokens: usage.cache_read_tokens,
1396 cache_write_tokens: usage.cache_write_tokens,
1397 usd: None,
1398 });
1399 }
1400
1401 if let (Some((task, p)), Some(reasoning)) =
1404 (&observer, completion.peek().reasoning.as_deref())
1405 && !reasoning.trim().is_empty()
1406 {
1407 let text: String = crate::approvals::present::escape(reasoning.trim())
1408 .chars()
1409 .take(4000)
1410 .collect();
1411 p.event(&crate::progress::Event::Reasoning {
1412 task: task.clone(),
1413 turn: turn + 1,
1414 text,
1415 });
1416 }
1417 if completion.peek().stopped
1418 || halt
1419 .as_ref()
1420 .is_some_and(agentplane::model::Stop::is_stopped)
1421 {
1422 return end(
1423 "stopped",
1424 json!({ "text": completion.peek().text }),
1425 &log,
1426 usage,
1427 &conversation,
1428 prompted,
1429 );
1430 }
1431 if completion.peek().truncated {
1432 return Ok(Outcome::fail(
1433 "the model ran out of output budget mid-turn; nothing it was still writing was executed — raise [model] max_output_tokens",
1434 ));
1435 }
1436 let asked_calls = transcript.asked(&completion);
1437 let said = super::said(completion.peek());
1441 if let (Some((task, p)), Some(said)) = (observer.as_ref(), said.as_deref()) {
1442 let text: String = crate::approvals::present::escape(said)
1443 .chars()
1444 .take(2000)
1445 .collect();
1446 p.event(&crate::progress::Event::Said {
1447 task: task.clone(),
1448 turn: turn + 1,
1449 text,
1450 });
1451 }
1452 if asked_calls.is_empty() && schema.is_none() {
1453 return end(
1454 "answered",
1455 json!({ "text": completion.peek().text }),
1456 &log,
1457 usage,
1458 &conversation,
1459 prompted,
1460 );
1461 }
1462 if asked_calls.is_empty() {
1463 let report = completion
1464 .peek()
1465 .structured
1466 .clone()
1467 .unwrap_or_else(|| json!({ "summary": completion.peek().text, "status": "incomplete", "files_changed": [], "checks_run": [], "notes": ["the answer was not the requested structure"] }));
1468 let status = finish(&report);
1469 return end(
1470 status,
1471 json!({ "report": report }),
1472 &log,
1473 usage,
1474 &conversation,
1475 prompted,
1476 );
1477 }
1478
1479 for mut asked in asked_calls {
1480 relativise(&mut asked.arguments, root.as_deref());
1481 if let Some(o) = asked.arguments.as_object_mut() {
1483 o.remove(crate::tools::EXPECT);
1484 }
1485 calls += 1;
1486 if calls > max_tool_calls {
1487 return end(
1488 "budget_exhausted",
1489 json!({ "reason": format!("more than {max_tool_calls} tool calls (`[budget] max_tool_calls`)") }),
1490 &log,
1491 usage,
1492 &conversation,
1493 prompted,
1494 );
1495 }
1496 if asked.name == ASK_PERSON && tools.iter().any(|t| t.name == ASK_PERSON) {
1497 let question = asked.arguments["question"]
1498 .as_str()
1499 .unwrap_or_default()
1500 .to_owned();
1501 let decided = match &ask {
1504 Some(asker) => Some(
1505 wait_for_person(
1506 cx,
1507 asker,
1508 &mut obligation,
1509 &crate::approvals::tasks::question_task(&asker.task, &question),
1510 )
1511 .await?,
1512 ),
1513 None => None,
1514 };
1515 let read = decided.as_ref().map(crate::approvals::tasks::read);
1516 let by = read.as_ref().and_then(|r| r.by.clone());
1517 let entry = json!({
1518 "tool": ASK_PERSON,
1519 "arguments": asked.arguments,
1520 "decision": "allow",
1521 "answered": read.as_ref().is_some_and(|r| r.text.is_some()),
1522 "answer": read.as_ref().and_then(|r| r.text.clone()),
1524 "operator": by.as_ref().map(Who::view),
1525 "via": by.as_ref().map(|w| w.via.clone()),
1526 });
1527 emit(observer.as_ref(), turn + 1, &entry);
1528 log.push(entry);
1529 let text = match (&read, &by) {
1530 (Some(r), _) if r.text.is_some() => r.text.clone().unwrap_or_default(),
1531 (Some(_), Some(w)) => format!("{} declined to answer.", w.name),
1532 _ => "Nobody can answer now. Record the question in the artifact as [NEEDS CLARIFICATION: <question>] and continue.".to_owned(),
1533 };
1534 let label = match &by {
1535 Some(_) => provenance::from(provenance::PERSON),
1536 None => Label::trusted(),
1537 };
1538 transcript.answered(
1539 asked,
1540 &Tainted::with_label(json!({ "answer": text }), label),
1541 );
1542 continue;
1543 }
1544 if asked.name == REPORT_BLOCKED && tools.iter().any(|t| t.name == REPORT_BLOCKED) {
1545 let refused = stop.rejected || log.iter().any(|e| e["decision"] == "deny");
1546 let left = max_turns - turn - 1;
1547 match judge_stop(&stop, &asked.arguments, refused, asked_to_stop, left) {
1548 Stop::Stopped(reason) => {
1549 let entry = json!({ "tool": REPORT_BLOCKED, "arguments": asked.arguments, "decision": "stop" });
1550 emit(observer.as_ref(), turn + 1, &entry);
1551 log.push(entry);
1552 return end(
1553 "blocked",
1554 json!({ "reason": reason }),
1555 &log,
1556 usage,
1557 &conversation,
1558 prompted,
1559 );
1560 }
1561 Stop::Asked(text) => {
1562 asked_to_stop = true;
1563 let entry = json!({ "tool": REPORT_BLOCKED, "arguments": asked.arguments, "decision": "challenged" });
1564 emit(observer.as_ref(), turn + 1, &entry);
1565 log.push(entry);
1566 transcript.answered(
1567 asked,
1568 &Tainted::trusted(json!({ "stopped": false, "note": text })),
1569 );
1570 }
1571 Stop::Refused(text) => {
1572 let entry = json!({ "tool": REPORT_BLOCKED, "arguments": asked.arguments, "decision": "unproven", "reason": text });
1573 emit(observer.as_ref(), turn + 1, &entry);
1574 log.push(entry);
1575 transcript.failed(asked, Tainted::trusted(text));
1576 }
1577 }
1578 continue;
1579 }
1580 if asked.name == crate::ask::PROPOSE && tools.iter().any(|t| t.name == asked.name) {
1581 let entry = json!({ "tool": asked.name, "arguments": asked.arguments, "decision": if proposed { "deny" } else { "proposed" } });
1583 emit(observer.as_ref(), turn + 1, &entry);
1584 log.push(entry);
1585 if proposed {
1586 transcript.failed(
1587 asked,
1588 Tainted::trusted(
1589 "one proposal per turn; this one was not shown".to_owned(),
1590 ),
1591 );
1592 } else {
1593 proposed = true;
1594 transcript.answered(asked, &Tainted::trusted(json!({ "shown": true, "note": "shown to the person as a sheet; nothing ran, and only the person can confirm it" })));
1595 }
1596 continue;
1597 }
1598 let declared = tools.iter().any(|t| t.name == asked.name);
1599 let target = catalog
1600 .iter()
1601 .find(|t| declared && t.name == asked.name)
1602 .map(|t| ToolId::new(t.server, t.name))
1603 .or_else(|| {
1604 routes
1605 .iter()
1606 .find(|(n, _, _)| declared && *n == asked.name)
1607 .map(|(_, server, tool)| ToolId::new(server, tool))
1608 });
1609 let Some(target) = target else {
1610 let entry = json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "unknown" });
1611 emit(observer.as_ref(), turn + 1, &entry);
1612 log.push(entry);
1613 transcript.failed(asked, Tainted::trusted("no tool of that name".to_owned()));
1614 continue;
1615 };
1616 let mut decision = policy.base(&asked.name, &asked.arguments);
1617 let mut additions_only = false;
1621 if frozen_additions
1622 && matches!(&decision, Decision::Approval(r) if r.contains("frozen test"))
1623 {
1624 let path = asked.arguments["path"]
1625 .as_str()
1626 .unwrap_or_default()
1627 .to_owned();
1628 let read = cx
1629 .call_tool(
1630 ToolId::new("fs", "read_file"),
1631 Tainted::with_label(json!({ "path": path }), completion.label().clone()),
1632 )
1633 .await?;
1634 seen(cx);
1635 let current = read.peek().clone();
1636 let lines = current["content"].as_str().unwrap_or_default();
1639 let old_text = if lines.is_empty() {
1640 String::new()
1641 } else {
1642 format!("{lines}\n")
1643 };
1644 let old = old_text.as_str();
1645 let readable = current["ok"] == true && current["truncated"] != true;
1646 let new = match asked.name.as_str() {
1647 "write_file" => asked.arguments["content"].as_str().map(str::to_owned),
1648 "edit_file" => {
1649 let (from, to) = (
1650 asked.arguments["old_string"].as_str().unwrap_or_default(),
1651 asked.arguments["new_string"].as_str().unwrap_or_default(),
1652 );
1653 let all = asked.arguments["replace_all"] == true;
1654 (!from.is_empty() && (all || old.matches(from).count() == 1))
1655 .then(|| old.replace(from, to))
1656 }
1657 _ => None,
1658 };
1659 if readable && new.is_some_and(|n| crate::integrity::only_additions(old, &n)) {
1660 decision = Decision::Allow;
1661 additions_only = true;
1662 }
1663 }
1664 let mut object: Option<Object> = None;
1668 if matches!(decision, Decision::Approval(_)) {
1669 if asked.name.starts_with(crate::mcp::PREFIX) {
1670 object = mcp_digests.get(&asked.name).map(|digest| Object::Tool {
1671 name: asked.name.clone(),
1672 digest: digest.clone(),
1673 });
1674 } else {
1675 let resolved = cx
1676 .call_tool(
1677 ToolId::new("fs", "resolve"),
1678 Tainted::with_label(
1679 json!({ "tool": asked.name, "arguments": asked.arguments }),
1680 completion.label().clone(),
1681 ),
1682 )
1683 .await?;
1684 seen(cx);
1685 let out = resolved.peek();
1686 if out["ok"] == true {
1687 object = serde_json::from_value(out["object"].clone()).ok();
1688 } else {
1689 decision = Decision::Deny(
1690 out["error"]
1691 .as_str()
1692 .unwrap_or("the call's object cannot be resolved")
1693 .to_owned(),
1694 );
1695 }
1696 }
1697 if matches!(decision, Decision::Approval(_)) {
1698 decision = policy.classify(&asked.name, &asked.arguments, object.as_ref());
1699 }
1700 }
1701 let mut person: Option<Value> = None;
1702 if decision == Decision::Allow
1705 && object
1706 .as_ref()
1707 .is_some_and(|o| policy.approved().contains(o))
1708 {
1709 person = Some(json!({
1710 "approved_by": "person",
1711 "scope": "task-grant",
1712 "grant": { "scope": "task", "run": run },
1713 }));
1714 }
1715 let mut note: Option<String> = None;
1716 if matches!(decision, Decision::Approval(_))
1718 && let Some(at) = object
1719 .as_ref()
1720 .and_then(|o| once.iter().position(|r| r == o))
1721 {
1722 once.remove(at);
1723 person = Some(json!({
1724 "approved_by": "person",
1725 "scope": "once-grant",
1726 "grant": { "scope": "once", "run": run },
1727 }));
1728 decision = Decision::Allow;
1729 }
1730 if matches!(decision, Decision::Approval(_))
1732 && let Some((_, scope)) = object
1733 .as_ref()
1734 .and_then(|o| remembered.iter().find(|(r, _)| r == o))
1735 {
1736 person = Some(json!({
1737 "approved_by": "person",
1738 "scope": scope,
1739 "grant": { "scope": scope.trim_end_matches("-grant"), "run": run },
1740 }));
1741 decision = Decision::Allow;
1742 }
1743 decision = policy.settle(decision);
1746 let request = match &decision {
1748 Decision::Approval(reason) => Some(question(&Question {
1749 asked: &asked,
1750 reason,
1751 turn: turn + 1,
1752 said: said.as_deref().unwrap_or_default(),
1753 asker: ask.as_ref(),
1754 policy: &policy,
1755 object: object.as_ref(),
1756 remembered: &remembered,
1757 parts: &parts,
1758 run: run.as_deref(),
1759 })),
1760 _ => None,
1761 };
1762 if let (Some(mut request), Some(asker)) = (request.clone(), &ask) {
1764 if asking.prompts {
1768 if prompted < asking.budget {
1769 prompted += 1;
1770 request.asked = prompted;
1771 } else {
1772 request.batched = true;
1773 }
1774 request.budget = asking.budget;
1775 }
1776 if request.batched {
1777 if !told_budget {
1780 needs_person(observer.as_ref(), &asked, object.as_ref());
1781 }
1782 if !told_budget && let Some((_, p)) = &observer {
1783 p.event(&crate::progress::Event::Notice {
1784 text: format!(
1785 "approval budget ({}) reached: calls wait in Needs You (speccoder approvals)",
1786 asking.budget
1787 ),
1788 });
1789 }
1790 told_budget = true;
1791 } else {
1792 needs_person(observer.as_ref(), &asked, object.as_ref());
1793 }
1794 let decided = wait_for_person(
1795 cx,
1796 asker,
1797 &mut obligation,
1798 &crate::approvals::tasks::approval_task(&request, &log),
1799 )
1800 .await?;
1801 seen(cx);
1802 let read = crate::approvals::tasks::read(&decided);
1803 if read.resume_asking {
1805 prompted = 0;
1806 }
1807 let answer: Answer = read.answer.clone();
1808 let approver = serde_json::to_value(&answer.by).unwrap_or(Value::Null);
1810 let operator = read.by.as_ref().map(Who::view);
1811 person_decided(
1812 observer.as_ref(),
1813 &asked,
1814 if answer.decision == Said::Approve {
1815 "approve"
1816 } else {
1817 "reject"
1818 },
1819 (answer.decision == Said::Approve).then(|| answer.scope.name()),
1820 read.by.as_ref().filter(|_| !read.expired),
1821 object.as_ref(),
1822 );
1823 match answer.decision {
1824 Said::Approve => {
1825 let mut p = json!({
1826 "approved_by": "person",
1827 "scope": answer.scope.name(),
1828 "note": answer.note,
1829 "approver": approver,
1830 "operator": operator,
1831 });
1832 if answer.always {
1833 p["always"] = json!(true);
1834 }
1835 if let (Some(o), Scope::Task | Scope::Run) = (&object, answer.scope) {
1836 let remembered_as = if answer.scope == Scope::Run {
1837 "run-grant"
1838 } else {
1839 "task-grant"
1840 };
1841 p["grant"] = json!({ "scope": answer.scope.name(), "run": run });
1842 remembered.push((o.clone(), remembered_as));
1843 }
1844 person = Some(p);
1845 note = answer.note;
1846 decision = Decision::Allow;
1847 }
1848 Said::Reject | Said::Stop => {
1851 let why = answer
1852 .reason
1853 .unwrap_or_else(|| "no reason given".to_owned());
1854 person = Some(if read.expired {
1855 json!({ "expired": true, "rejection": why })
1856 } else {
1857 json!({ "rejected_by": "person", "approver": approver, "operator": operator, "rejection": why })
1858 });
1859 decision = Decision::Deny(format!("a person rejected this: {why}"));
1860 }
1861 }
1862 }
1863 let rule = person
1866 .is_none()
1867 .then(|| policy.rule(&asked.name, &asked.arguments, object.as_ref()));
1868 let with_person = |mut entry: Value| {
1869 if let Some(o) = entry.as_object_mut() {
1870 if let Some(obj) = &object {
1872 o.insert("object".to_owned(), json!(obj));
1873 }
1874 match (person.as_ref().and_then(Value::as_object), &rule) {
1875 (Some(p), _) => {
1876 let by = if p.contains_key("expired") {
1878 "expiry"
1879 } else {
1880 "person"
1881 };
1882 o.insert("by".to_owned(), json!(by));
1883 o.extend(p.clone());
1884 }
1885 (None, Some(r)) => {
1886 o.insert("by".to_owned(), json!("rule"));
1887 o.insert("rule".to_owned(), json!(r));
1888 if r.starts_with("contained (") {
1890 o.insert("contained".to_owned(), json!(true));
1891 }
1892 }
1893 (None, None) => {}
1894 }
1895 }
1896 entry
1897 };
1898 match decision {
1899 Decision::Deny(reason) => {
1900 let entry = with_person(
1901 json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "deny", "reason": reason }),
1902 );
1903 emit(observer.as_ref(), turn + 1, &entry);
1904 log.push(entry);
1905 transcript.failed(
1908 asked,
1909 refusal(format!("denied: {reason}"), completion.label()),
1910 );
1911 denied_in_a_row += 1;
1914 if denied_in_a_row >= MAX_CONSECUTIVE_DENIALS {
1915 return end(
1916 "denied",
1917 json!({ "reason": format!("{denied_in_a_row} calls in a row were denied; last: {reason}") }),
1918 &log,
1919 usage,
1920 &conversation,
1921 prompted,
1922 );
1923 }
1924 continue;
1925 }
1926 Decision::Approval(reason) => {
1927 let mut entry = json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "approval", "reason": reason });
1928 if let Some(obj) = &object {
1929 entry["object"] = json!(obj);
1930 }
1931 emit(observer.as_ref(), turn + 1, &entry);
1932 needs_person(observer.as_ref(), &asked, object.as_ref());
1933 log.push(entry);
1934 return end(
1935 "needs_approval",
1936 json!({ "pending": {
1937 "tool": asked.name,
1938 "arguments": asked.arguments,
1939 "reason": reason,
1940 "object": object,
1941 "run": run,
1942 "request": request,
1943 } }),
1944 &log,
1945 usage,
1946 &conversation,
1947 prompted,
1948 );
1949 }
1950 Decision::Allow => {
1951 denied_in_a_row = 0;
1952 let mut arguments = asked.arguments.clone();
1959 if asked.name == "run_command"
1960 && let Some(o) = arguments.as_object_mut()
1961 {
1962 o.insert("call_id".to_owned(), Value::String(asked.id.clone()));
1963 }
1964 if let (Some(obj), Some(o)) = (&object, arguments.as_object_mut())
1967 && matches!(
1968 asked.name.as_str(),
1969 "write_file" | "edit_file" | "run_command"
1970 )
1971 {
1972 o.insert(crate::tools::EXPECT.to_owned(), json!(obj));
1973 }
1974 let args = Tainted::with_label(arguments, completion.label().clone());
1975 let result = cx.call_tool(target.clone(), args).await;
1976 seen(cx);
1977 let result = match result {
1978 Ok(r) => r,
1979 Err(error) => match cx.relayed(&error) {
1982 Some(detail) => {
1983 let entry = with_person(
1984 json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "allow", "ok": false }),
1985 );
1986 emit(observer.as_ref(), turn + 1, &entry);
1987 log.push(entry);
1988 transcript.failed(asked, detail);
1989 continue;
1990 }
1991 None => return Err(error.into()),
1992 },
1993 };
1994 let output = result.peek().clone();
1995 let ok = output["ok"].as_bool() != Some(false);
1996 let mut entry = with_person(
1997 json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "allow", "ok": ok }),
1998 );
1999 let result = match next_read.as_mut() {
2001 Some(n) if ok => {
2002 let id = format!("r{n}");
2003 *n += 1;
2004 entry["read"] = json!(crate::ask::read_of(
2005 &id,
2006 &asked.name,
2007 &asked.arguments,
2008 &output
2009 ));
2010 result.map(|mut v| {
2011 if let Some(o) = v.as_object_mut() {
2012 o.insert("read".to_owned(), json!(id));
2013 }
2014 v
2015 })
2016 }
2017 _ => result,
2018 };
2019 if let Some(changed) = output["object_changed"].as_object() {
2020 entry["decision"] = json!("refused_changed");
2021 entry["expected"] = changed.get("expected").cloned().unwrap_or(Value::Null);
2022 entry["found"] = changed.get("found").cloned().unwrap_or(Value::Null);
2023 }
2024 if let Some(part) = crate::approvals::derived::part_of(
2027 &asked.name,
2028 &asked.arguments,
2029 &output,
2030 turn + 1,
2031 &format!("tool://{}/{}", target.server, target.tool),
2032 |p: &str| {
2033 policy.protected().write_class(p)
2034 == Some(crate::tools::paths::Class::Intent)
2035 },
2036 ) {
2037 parts.push(part);
2038 }
2039 if additions_only {
2040 entry["frozen_additions"] = json!(true);
2041 }
2042 let tail = if asked.name == "run_command" && !ok {
2043 crate::progress::tail(&format!(
2044 "{}\n{}",
2045 output["stdout"].as_str().unwrap_or_default(),
2046 output["stderr"].as_str().unwrap_or_default()
2047 ))
2048 } else {
2049 Vec::new()
2050 };
2051 emit_with_tail(observer.as_ref(), turn + 1, &entry, tail);
2052 log.push(entry);
2053 if let Some(drift) = output["integrity"].as_array().filter(|d| !d.is_empty()) {
2056 let paths: Vec<&str> =
2057 drift.iter().filter_map(|d| d["path"].as_str()).collect();
2058 return end(
2059 "integrity",
2060 json!({
2061 "reason": format!("a command changed what judges the task: {}", paths.join(", ")),
2062 "integrity": drift,
2063 }),
2064 &log,
2065 usage,
2066 &conversation,
2067 prompted,
2068 );
2069 }
2070 match output["error"].as_str() {
2076 Some(error) if !ok => transcript.failed(
2077 asked,
2078 Tainted::with_label(error.to_owned(), result.label().clone()),
2079 ),
2080 _ => match ¬e {
2082 Some(n) => {
2083 let n = format!("Note from the person who approved this call: {n}");
2084 transcript.answered(
2085 asked,
2086 &result.map(|mut v| {
2087 v["note_from_person"] = Value::String(n);
2088 v
2089 }),
2090 );
2091 }
2092 None => transcript.answered(asked, &result),
2093 },
2094 }
2095 }
2096 }
2097 }
2098 }
2099 end(
2100 "budget_exhausted",
2101 json!({ "reason": format!("more than {max_turns} model turns (`[budget] max_turns`)") }),
2102 &log,
2103 usage,
2104 &conversation,
2105 prompted,
2106 )
2107}
2108
2109#[cfg(test)]
2110mod tests {
2111 use super::*;
2112
2113 #[test]
2114 fn labels_are_read_from_the_parts_and_refusals_carry_the_call() {
2115 use agentplane::core::{Label, SourceId};
2116 let model = Label::untrusted(SourceId::new("model:x/y"));
2117 assert!(
2119 refusal("denied: `src/x` is protected".to_owned(), &model)
2120 .label()
2121 .is_untrusted()
2122 );
2123 let input = Tainted::object([
2125 ("bundle", Tainted::trusted(json!({}))),
2126 (
2127 "digest",
2128 Tainted::with_label(json!({}), provenance::from(provenance::VERIFIER)),
2129 ),
2130 (
2131 "settings",
2132 Tainted::object([("nudges", Tainted::trusted(json!([])))]),
2133 ),
2134 ]);
2135 let label = conversation_label(&input);
2136 assert!(
2137 label
2138 .provenance
2139 .contains(&SourceId::new(provenance::VERIFIER))
2140 );
2141 let flat = Tainted::with_label(json!({ "bundle": {} }), model.clone());
2143 assert_eq!(conversation_label(&flat).provenance, model.provenance);
2144 }
2145
2146 #[test]
2147 fn absolute_paths_inside_the_root_become_relative_and_others_stay() {
2148 let mut a = json!({ "path": "/work/repo/specs/001-x/plan.md" });
2149 relativise(&mut a, Some("/work/repo"));
2150 assert_eq!(a["path"], "specs/001-x/plan.md");
2151 let mut outside = json!({ "path": "/work/repository-two/x" });
2152 relativise(&mut outside, Some("/work/repo"));
2153 assert_eq!(outside["path"], "/work/repository-two/x");
2154 let mut none = json!({ "path": "/etc/passwd" });
2155 relativise(&mut none, None);
2156 assert_eq!(none["path"], "/etc/passwd");
2157 }
2158
2159 #[test]
2162 fn a_stop_stands_on_evidence_speccoder_checks() {
2163 let gate = StopGate {
2164 spec: "Rows are sorted\n ascending by name.".to_owned(),
2165 frozen: vec!["tests/test_us3.py".to_owned()],
2166 rejected: false,
2167 };
2168 let call = |kind: &str, evidence: &str| json!({ "kind": kind, "evidence": evidence, "reason": "why" });
2169 let judge =
2170 |args: &Value, refused: bool, asked: bool| judge_stop(&gate, args, refused, asked, 5);
2171 let unproven = |s: Stop| matches!(s, Stop::Refused(_));
2172
2173 assert!(unproven(judge(
2175 &json!({ "reason": "beyond the task's module scope" }),
2176 false,
2177 false
2178 )));
2179 assert!(unproven(judge(&call("scope", "src/cli"), false, false)));
2180 assert!(unproven(judge(
2181 &call("wrong_test", "tests/new_test.py"),
2182 false,
2183 false
2184 )));
2185 assert!(unproven(judge(
2186 &call("spec_conflict", "sorted descending by name"),
2187 false,
2188 false
2189 )));
2190 assert!(unproven(judge(
2191 &call("spec_conflict", "sorted"),
2192 false,
2193 false
2194 )));
2195 assert!(unproven(judge(
2196 &call("refused", "cargo add x"),
2197 false,
2198 false
2199 )));
2200
2201 let quote = call("spec_conflict", "sorted ascending by name");
2204 assert!(
2205 matches!(judge("e, false, false), Stop::Asked(t) if t.contains("5 turns are left"))
2206 );
2207 assert_eq!(
2208 judge("e, false, true),
2209 Stop::Stopped("why — the spec says “sorted ascending by name”".to_owned())
2210 );
2211 assert_eq!(
2212 judge(&call("wrong_test", "./tests/test_us3.py"), false, true),
2213 Stop::Stopped("why — frozen test `./tests/test_us3.py`".to_owned())
2214 );
2215 assert!(matches!(
2217 judge(&call("refused", "cargo add x"), true, false),
2218 Stop::Stopped(_)
2219 ));
2220 let q = call("needs_information", "which locale?");
2221 assert!(matches!(
2222 judge_stop(&gate, &q, false, false, 0),
2223 Stop::Stopped(_)
2224 ));
2225 }
2226
2227 #[test]
2230 fn a_replayed_step_says_nothing() {
2231 #[derive(Debug, Default)]
2232 struct Seen(std::sync::Mutex<Vec<u32>>);
2233 impl crate::progress::Progress for Seen {
2234 fn event(&self, e: &crate::progress::Event) {
2235 if let crate::progress::Event::ToolCall { turn, .. } = e {
2236 self.0.lock().unwrap().push(*turn);
2237 }
2238 }
2239 }
2240 let seen = std::sync::Arc::new(Seen::default());
2241 let replaying = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
2242 let quiet = Quiet {
2243 replaying: std::sync::Arc::clone(&replaying),
2244 next: seen.clone(),
2245 };
2246 let call = |turn| crate::progress::Event::ToolCall {
2247 task: "T012".to_owned(),
2248 turn,
2249 tool: "run_command".to_owned(),
2250 target: String::new(),
2251 decision: "allow".to_owned(),
2252 ok: Some(true),
2253 tail: Vec::new(),
2254 contained: false,
2255 };
2256 crate::progress::Progress::event(&quiet, &call(1));
2257 replaying.store(false, std::sync::atomic::Ordering::SeqCst);
2258 crate::progress::Progress::event(&quiet, &call(2));
2259 assert_eq!(*seen.0.lock().unwrap(), [2]);
2260 }
2261}