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 ("prompts", trusted(json!(settings.prompts))),
338 ("asks", trusted(json!(settings.asks))),
339 ("wait_secs", trusted(json!(settings.wait_secs))),
340 ("in_place", trusted(json!(settings.in_place))),
341 ("hidden", trusted(json!(settings.hidden))),
342 ];
343 let mut settings = settings;
346 if autopilot {
347 settings.push(("autopilot", trusted(json!(true))));
348 }
349 if let Some(dir) = planning {
351 settings.push(("planning", trusted(json!(dir))));
352 }
353 if let Some(m) = contained {
355 settings.push(("contained", trusted(json!(m))));
356 }
357 let settings = Tainted::object(settings);
358 Tainted::object([
359 ("bundle", Tainted::trusted(bundle)),
360 ("digest", digest),
361 ("settings", settings),
362 (
365 "instructions",
366 if instructions.is_empty() {
367 Tainted::trusted(json!([]))
368 } else {
369 Tainted::with_label(json!(instructions), provenance::from(provenance::WORKTREE))
370 },
371 ),
372 (
374 "test_layout",
375 match test_layout {
376 Some(t) => Tainted::with_label(json!(t), provenance::from(provenance::WORKTREE)),
377 None => Tainted::trusted(Value::Null),
378 },
379 ),
380 ])
381}
382
383fn refusal(text: String, call: &agentplane::core::Label) -> Tainted<String> {
387 Tainted::with_label(text, call.clone())
388}
389
390const MESSAGE_PARTS: [&str; 7] = [
392 "/bundle",
393 "/instructions",
394 "/test_layout",
395 "/digest",
396 "/settings/nudges",
397 "/settings/rejected",
398 "/settings/mcp",
399];
400
401fn conversation_label(input: &Tainted<Value>) -> agentplane::core::Label {
405 MESSAGE_PARTS
406 .iter()
407 .map(|p| input.label_at(p).unwrap_or_else(|| input.label()).clone())
408 .fold(agentplane::core::Label::trusted(), |acc, l| acc.join(&l))
409}
410
411#[derive(Debug, Clone, Copy)]
413pub struct Settings<'a> {
414 pub checks: &'a [Vec<String>],
416 pub allow: &'a [Vec<String>],
418 pub allow_prefix: &'a [Vec<String>],
420 pub max_turns: u32,
422 pub max_tool_calls: u32,
424 pub reasoning: bool,
426 pub frozen: &'a [String],
428 pub approved: &'a [Object],
430 pub rejected: &'a [crate::tools::policy::Rejection],
432 pub run_approved: &'a [Object],
434 pub approved_once: &'a [Object],
437 pub nudges: &'a [String],
439 pub mcp: &'a [crate::mcp::McpTool],
441 pub root: &'a str,
443 pub frozen_additions: bool,
445 pub asks: &'a str,
448 pub wait_secs: u64,
450 pub network_allow: &'a [Vec<String>],
452 pub network_allow_prefix: &'a [Vec<String>],
454 pub run_id: &'a str,
456 pub asked: u32,
458 pub budget: u32,
460 pub prompts: bool,
462 pub in_place: bool,
464 pub hidden: &'a [String],
466 pub autopilot: bool,
468 pub planning: Option<&'a str>,
471 pub contained: Option<&'a str>,
474}
475
476pub const MAX_CONSECUTIVE_DENIALS: u32 = 3;
478
479fn digest_text(digest: &Value) -> String {
481 let mut out = format!(
482 "# Previous attempt\n\nAttempt {} at this task was verified by speccoder and FAILED. Its \
483changes were discarded; the worktree is back where that attempt started. You start fresh — use \
484these facts from the verifier, check them yourself, and do not repeat the mistake.\n",
485 digest["attempt"].as_u64().unwrap_or(1)
486 );
487 let strings = |v: &Value| -> Vec<String> {
488 v.as_array()
489 .map(|a| {
490 a.iter()
491 .filter_map(|x| x.as_str().map(str::to_owned))
492 .collect()
493 })
494 .unwrap_or_default()
495 };
496 let reasons = strings(&digest["reasons"]);
497 if !reasons.is_empty() {
498 out.push_str("\n## Verdict reasons\n\n");
499 for r in reasons {
500 out.push_str(&format!("- {r}\n"));
501 }
502 }
503 for c in digest["checks"].as_array().into_iter().flatten() {
504 out.push_str(&format!(
505 "\n## Check `{}` ({}): `{}`\n",
506 c["name"].as_str().unwrap_or_default(),
507 c["status"].as_str().unwrap_or_default(),
508 strings(&c["argv"]).join(" ")
509 ));
510 let tests = strings(&c["failing_tests"]);
511 if !tests.is_empty() {
512 out.push_str(&format!("\nFailing tests: {}\n", tests.join(", ")));
513 }
514 out.push_str(&format!(
515 "\n```text\n{}\n```\n",
516 c["excerpt"].as_str().unwrap_or_default()
517 ));
518 }
519 let uncollected = strings(&digest["uncollected"]);
520 if !uncollected.is_empty() {
521 out.push_str("\n## Tests that never ran\n\n");
522 for l in uncollected {
523 out.push_str(&format!("- {l}\n"));
524 }
525 }
526 let discarded = strings(&digest["discarded"]);
527 if !discarded.is_empty() {
528 out.push_str(&format!(
529 "\n## Files the discarded attempt changed\n\n{}\n",
530 discarded.join("\n")
531 ));
532 }
533 out
534}
535
536fn argvs(v: &Value) -> Vec<Vec<String>> {
537 v.as_array()
538 .map(|a| {
539 a.iter()
540 .map(|argv| {
541 argv.as_array()
542 .map(|p| {
543 p.iter()
544 .filter_map(|x| x.as_str().map(str::to_owned))
545 .collect()
546 })
547 .unwrap_or_default()
548 })
549 .collect()
550 })
551 .unwrap_or_default()
552}
553
554fn decisions_of<T: serde::de::DeserializeOwned>(
558 settings: &Value,
559 key: &str,
560) -> Result<Vec<T>, SkillError> {
561 match &settings[key] {
562 Value::Null => Ok(Vec::new()),
563 v => serde_json::from_value(v.clone())
564 .map_err(|e| SkillError::Input(format!("settings.{key}: {e}"))),
565 }
566}
567
568pub(crate) fn policy_of(
573 settings: &Value,
574 rules: &'static crate::format::Rules,
575) -> Result<Policy, SkillError> {
576 let frozen: Vec<String> = settings["frozen"]
577 .as_array()
578 .map(|a| {
579 a.iter()
580 .filter_map(|v| v.as_str().map(str::to_owned))
581 .collect()
582 })
583 .unwrap_or_default();
584 let approved: Vec<Object> = decisions_of(settings, "approved")?;
585 let rejected: Vec<crate::tools::policy::Rejection> = decisions_of(settings, "rejected")?;
586 let mcp: Vec<crate::mcp::McpTool> =
587 serde_json::from_value(settings["mcp"].clone()).unwrap_or_default();
588 let policy = Policy::new(&argvs(&settings["checks"]), &argvs(&settings["allow"]))
589 .with_prefixes(argvs(&settings["allow_prefix"]))
590 .with_network_prefixes(argvs(&settings["network_allow_prefix"]))
591 .rules(rules)
592 .with_frozen(frozen)
593 .with_decisions(approved, rejected)
594 .with_mcp(mcp.iter().map(|t| (t.name.clone(), t.decision.clone())))
595 .with_network(argvs(&settings["network_allow"]))
596 .with_hidden(
597 serde_json::from_value::<Vec<String>>(settings["hidden"].clone()).unwrap_or_default(),
598 );
599 let policy = if settings["in_place"] == true {
600 policy.in_place()
601 } else {
602 policy
603 };
604 let policy = if settings["autopilot"] == true {
605 policy.autopilot()
606 } else {
607 policy
608 };
609 let policy = match settings["contained"].as_str() {
610 Some(m) => policy.contained(m),
611 None => policy,
612 };
613 Ok(match settings["planning"].as_str() {
614 Some(dir) => policy.planning(dir),
615 None => policy,
616 })
617}
618
619#[async_trait]
620impl Skill for Attempt {
621 fn descriptor(&self) -> SkillDescriptor {
622 SkillDescriptor::new("speccoder.task.attempt").provides(CAPABILITY)
623 }
624
625 async fn invoke(
626 &self,
627 cx: &mut StepCtx<'_>,
628 input: Tainted<Value>,
629 ) -> Result<Outcome, SkillError> {
630 let (ttl, style) = (self.cache_ttl, self.style);
631 let settings = input.peek()["settings"].clone();
632 let task_name = format!(
633 "{}/{}",
634 input.peek()["bundle"]["feature"]
635 .as_str()
636 .unwrap_or_default(),
637 input.peek()["bundle"]["task"]["id"]
638 .as_str()
639 .unwrap_or_default()
640 );
641 let rejected: Vec<crate::tools::policy::Rejection> = decisions_of(&settings, "rejected")?;
642 let told = rejected;
643 let bundle = &input.peek()["bundle"];
644 let stop = StopGate {
645 spec: ["story", "plan", "constitution"]
646 .iter()
647 .filter_map(|k| bundle[*k].as_str())
648 .chain(bundle["task"]["title"].as_str())
649 .collect::<Vec<_>>()
650 .join("\n"),
651 frozen: settings["frozen"]
652 .as_array()
653 .into_iter()
654 .flatten()
655 .filter_map(|v| v.as_str().map(str::to_owned))
656 .collect(),
657 rejected: !told.is_empty(),
658 };
659 let mcp: Vec<crate::mcp::McpTool> =
660 serde_json::from_value(settings["mcp"].clone()).unwrap_or_default();
661 let planning = settings["planning"].as_str().map(str::to_owned);
662 let policy = policy_of(&settings, self.format.rules())?;
663 let max_turns = settings["max_turns"]
664 .as_u64()
665 .map_or(30, |n| u32::try_from(n).unwrap_or(u32::MAX))
666 .max(1);
667 let max_tool_calls = settings["max_tool_calls"]
668 .as_u64()
669 .map_or(100, |n| u32::try_from(n).unwrap_or(u32::MAX));
670 let label = conversation_label(&input);
673 let raw = input.peek().clone();
674 let prompt = provenance::labelled_prompt(
675 {
676 let input = raw;
677 let mut p = layout(
678 &input["bundle"],
679 ttl,
680 style,
681 &self::policy(self.format.wording()),
682 self.format,
683 );
684 let given: Vec<crate::instructions::Instruction> =
685 serde_json::from_value(input["instructions"].clone()).unwrap_or_default();
686 if let Some(text) = crate::instructions::section(&given) {
687 let (key, kind) = match style {
688 PromptStyle::OpenAi => ("input", "input_text"),
689 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
690 ("messages", "text")
691 }
692 };
693 if let Some(content) = p[key][0]["content"].as_array_mut() {
694 content.push(json!({ "type": kind, "text": text }));
695 }
696 }
697 if let Some(text) = input["test_layout"].as_str().filter(|t| !t.is_empty()) {
698 let (key, kind) = match style {
699 PromptStyle::OpenAi => ("input", "input_text"),
700 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
701 ("messages", "text")
702 }
703 };
704 if let Some(content) = p[key][0]["content"].as_array_mut() {
705 content.push(json!({ "type": kind, "text": text }));
706 }
707 }
708 if !input["digest"].is_null() {
709 let (key, kind) = match style {
710 PromptStyle::OpenAi => ("input", "input_text"),
711 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
712 ("messages", "text")
713 }
714 };
715 if let Some(content) = p[key][0]["content"].as_array_mut() {
716 content
717 .push(json!({ "type": kind, "text": digest_text(&input["digest"]) }));
718 }
719 }
720 if let Some(dir) = &planning {
722 let (key, kind) = match style {
723 PromptStyle::OpenAi => ("input", "input_text"),
724 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
725 ("messages", "text")
726 }
727 };
728 let text = format!(
729 "# 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"
730 );
731 if let Some(content) = p[key][0]["content"].as_array_mut() {
732 content.push(json!({ "type": kind, "text": text }));
733 }
734 }
735 let checks: Vec<String> = argvs(&input["settings"]["checks"])
737 .iter()
738 .map(|argv| format!("- `{}`", json!(argv)))
739 .collect();
740 if !checks.is_empty() {
741 let (key, kind) = match style {
742 PromptStyle::OpenAi => ("input", "input_text"),
743 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
744 ("messages", "text")
745 }
746 };
747 let text = format!(
748 "# The checks\n\nRun them with run_command as these argvs, word for word; they need no approval:\n\n{}\n",
749 checks.join("\n")
750 );
751 if let Some(content) = p[key][0]["content"].as_array_mut() {
752 content.push(json!({ "type": kind, "text": text }));
753 }
754 }
755 let notes: Vec<String> = input["settings"]["nudges"]
757 .as_array()
758 .map(|a| {
759 a.iter()
760 .filter_map(|n| n.as_str().map(|n| format!("- {n}")))
761 .collect()
762 })
763 .unwrap_or_default();
764 if !notes.is_empty() {
765 let (key, kind) = match style {
766 PromptStyle::OpenAi => ("input", "input_text"),
767 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
768 ("messages", "text")
769 }
770 };
771 let text = format!("# Notes from a person\n\n{}\n", notes.join("\n"));
772 if let Some(content) = p[key][0]["content"].as_array_mut() {
773 content.push(json!({ "type": kind, "text": text }));
774 }
775 }
776 if !told.is_empty() {
778 let (key, kind) = match style {
779 PromptStyle::OpenAi => ("input", "input_text"),
780 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
781 ("messages", "text")
782 }
783 };
784 let lines: Vec<String> = told
785 .iter()
786 .map(|r| format!("- {}: {}", r.object.describe(), r.reason))
787 .collect();
788 let text = format!(
789 "# Rejected by a person\n\nThese calls were rejected for this task; find another way or call report_blocked:\n\n{}\n",
790 lines.join("\n")
791 );
792 if let Some(content) = p[key][0]["content"].as_array_mut() {
793 content.push(json!({ "type": kind, "text": text }));
794 }
795 }
796 if let PromptStyle::Parts { caches } = style {
797 super::model_config::to_parts(&mut p, caches);
798 }
799 p
800 },
801 &label,
802 );
803 let mut tools = crate::tools::declarations();
804 tools.push(report_blocked());
805 let routes: Vec<(String, String, String)> = mcp
807 .iter()
808 .filter(|t| t.decision != "deny")
809 .map(|t| (t.name.clone(), t.server.clone(), t.tool.clone()))
810 .collect();
811 tools.extend(mcp.iter().filter(|t| t.decision != "deny").map(|t| {
812 ToolDeclaration::new(t.name.clone(), t.description.clone(), t.schema.clone())
813 }));
814 let ask = Asker::from_settings(
816 &settings,
817 task_name.clone(),
818 input.peek()["bundle"]["task"]["title"]
819 .as_str()
820 .unwrap_or_default()
821 .to_owned(),
822 PresentContext {
823 root: settings["root"].as_str().map(std::path::PathBuf::from),
824 ..self.present.clone()
825 },
826 );
827 let remembered: Vec<Object> = decisions_of(&settings, "run_approved")?;
828 let once: Vec<Object> = decisions_of(&settings, "approved_once")?;
829 let mut parts = Vec::new();
831 let given: Vec<crate::instructions::Instruction> =
832 serde_json::from_value(input.peek()["instructions"].clone()).unwrap_or_default();
833 if let Some(text) = crate::instructions::section(&given) {
834 parts.push(crate::approvals::derived::Part {
835 from: provenance::WORKTREE.to_owned(),
836 turn: 0,
837 text,
838 });
839 }
840 if !input.peek()["digest"].is_null() {
841 parts.push(crate::approvals::derived::Part {
842 from: provenance::VERIFIER.to_owned(),
843 turn: 0,
844 text: crate::approvals::derived::text_of(&input.peek()["digest"]),
845 });
846 }
847 let number = |k: &str, d: u32| {
848 settings[k]
849 .as_u64()
850 .map_or(d, |n| u32::try_from(n).unwrap_or(u32::MAX))
851 };
852 tool_loop(
853 cx,
854 &prompt,
855 LoopSpec {
856 tools,
857 policy,
858 max_turns,
859 max_tool_calls,
860 reasoning: settings["reasoning"] == true,
861 schema: Some(report_schema()),
862 max_tokens: None,
863 reads: None,
864 halt: None,
865 routes,
866 root: settings["root"].as_str().map(str::to_owned),
867 frozen_additions: settings["frozen_additions"] == true,
868 ask,
869 run_approved: remembered,
870 once,
871 run: settings["run_id"].as_str().map(str::to_owned),
872 asking: Asking {
873 asked: number("asked", 0),
874 budget: number("budget", DEFAULT_BUDGET),
875 prompts: settings["prompts"] == true,
876 },
877 parts,
878 mcp_digests: mcp
879 .iter()
880 .map(|t| (t.name.clone(), t.digest.clone()))
881 .collect(),
882 stop,
883 observer: self.progress.clone().map(|p| {
884 (
885 task_name.rsplit('/').next().unwrap_or_default().to_owned(),
886 p,
887 )
888 }),
889 finish: |report| {
890 if report["status"] == "incomplete" {
893 "incomplete"
894 } else {
895 "completed"
896 }
897 },
898 },
899 )
900 .await
901 }
902}
903
904fn relativise(arguments: &mut Value, root: Option<&str>) {
909 let (Some(root), Some(path)) = (root, arguments.get("path").and_then(Value::as_str)) else {
910 return;
911 };
912 let root = root.trim_end_matches('/');
913 if let Some(rel) = path.strip_prefix(root).and_then(|r| r.strip_prefix('/')) {
914 let rel = if rel.is_empty() {
915 ".".to_owned()
916 } else {
917 rel.to_owned()
918 };
919 arguments["path"] = Value::String(rel);
920 }
921}
922
923fn emit(
925 observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
926 turn: u32,
927 entry: &Value,
928) {
929 emit_with_tail(observer, turn, entry, Vec::new());
930}
931
932fn emit_with_tail(
935 observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
936 turn: u32,
937 entry: &Value,
938 tail: Vec<String>,
939) {
940 if let Some((task, p)) = observer {
941 let tool = entry["tool"].as_str().unwrap_or_default();
942 p.event(&crate::progress::Event::ToolCall {
943 task: task.clone(),
944 turn,
945 tool: tool.to_owned(),
946 target: crate::progress::target(tool, &entry["arguments"]),
947 decision: entry["decision"].as_str().unwrap_or_default().to_owned(),
948 ok: entry["ok"].as_bool(),
949 tail,
950 contained: entry["contained"] == true,
951 });
952 }
953}
954
955fn needs_person(
957 observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
958 asked: &agentplane::model::ToolCall,
959 object: Option<&Object>,
960) {
961 if let Some((task, p)) = observer {
962 let (tool, arguments) = (asked.name.as_str(), &asked.arguments);
963 let target = crate::progress::target(tool, arguments);
964 let what: String = format!("{tool} {target}")
965 .chars()
966 .filter(|c| !c.is_control())
967 .take(60)
968 .collect();
969 p.event(&crate::progress::Event::ApprovalRequested {
970 task: task.clone(),
971 call: asked.id.clone(),
972 tool: tool.to_owned(),
973 what,
974 class: object
975 .map(|o| o.class().as_str().to_owned())
976 .unwrap_or_default(),
977 object: object.map(Object::short).unwrap_or_default(),
978 });
979 }
980}
981
982fn person_decided(
984 observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
985 asked: &agentplane::model::ToolCall,
986 decision: &str,
987 scope: Option<&str>,
988 who: Option<&Who>,
989 object: Option<&Object>,
990) {
991 if let Some((task, p)) = observer {
992 p.event(&crate::progress::Event::ApprovalDecided {
993 task: task.clone(),
994 call: asked.id.clone(),
995 decision: decision.to_owned(),
996 scope: scope.map(str::to_owned),
997 by: who.map_or_else(|| "nobody (expired)".to_owned(), |w| w.name.clone()),
998 how: who.map_or_else(|| "expired".to_owned(), |w| w.basis.clone()),
999 class: object
1000 .map(|o| o.class().as_str().to_owned())
1001 .unwrap_or_default(),
1002 object: object.map(Object::short).unwrap_or_default(),
1003 });
1004 }
1005}
1006
1007struct Question<'a> {
1009 asked: &'a agentplane::model::ToolCall,
1010 reason: &'a str,
1011 turn: u32,
1012 said: &'a str,
1013 asker: Option<&'a Asker>,
1014 policy: &'a Policy,
1015 object: Option<&'a Object>,
1016 remembered: &'a [(Object, &'static str)],
1017 parts: &'a [crate::approvals::derived::Part],
1018 run: Option<&'a str>,
1019}
1020
1021fn question(q: &Question<'_>) -> Request {
1025 let (tool, arguments) = (q.asked.name.as_str(), &q.asked.arguments);
1026 let kind = crate::approvals::kind_of(tool, arguments);
1027 let risk = crate::approvals::risk(tool, q.reason);
1028 let always_risk = if kind == "network" {
1030 let mut bare = arguments.clone();
1031 if let Some(o) = bare.as_object_mut() {
1032 o.remove("network");
1033 }
1034 match q.policy.base(tool, &bare) {
1035 Decision::Allow => "low",
1036 Decision::Approval(r) => crate::approvals::risk(tool, &r),
1037 Decision::Deny(_) => "high",
1038 }
1039 } else {
1040 risk
1041 };
1042 let (sources, more) = crate::approvals::derived::sources(tool, arguments, q.parts);
1043 let changed = q.object.and_then(|o| {
1044 q.remembered
1045 .iter()
1046 .map(|(r, _)| r)
1047 .chain(q.policy.approved())
1048 .find_map(|earlier| o.changed_from(earlier))
1049 });
1050 Request {
1051 task: q.asker.map(|a| a.task.clone()).unwrap_or_default(),
1052 task_title: q.asker.map(|a| a.task_title.clone()).unwrap_or_default(),
1053 turn: q.turn,
1054 tool: tool.to_owned(),
1055 arguments: arguments.clone(),
1056 kind: kind.to_owned(),
1057 reason: q.reason.to_owned(),
1058 object: q.object.cloned(),
1059 changed,
1060 risk: risk.to_owned(),
1061 claim: crate::approvals::claim(q.said),
1062 options: crate::approvals::options(
1063 risk,
1064 q.object.is_some(),
1065 !sources.is_empty(),
1066 kind,
1067 always_risk,
1068 ),
1069 sources,
1070 more,
1071 run: q.run.map(str::to_owned),
1072 asked: 0,
1073 budget: 0,
1074 batched: false,
1075 }
1076}
1077
1078pub(crate) struct LoopSpec {
1080 pub tools: Vec<ToolDeclaration>,
1082 pub policy: Policy,
1084 pub max_turns: u32,
1086 pub max_tool_calls: u32,
1088 pub reasoning: bool,
1090 pub schema: Option<Value>,
1092 pub routes: Vec<(String, String, String)>,
1094 pub root: Option<String>,
1097 pub frozen_additions: bool,
1099 pub finish: fn(&Value) -> &'static str,
1101 pub ask: Option<Asker>,
1103 pub run_approved: Vec<Object>,
1105 pub once: Vec<Object>,
1108 pub observer: Option<(String, std::sync::Arc<dyn crate::progress::Progress>)>,
1110 pub run: Option<String>,
1112 pub asking: Asking,
1115 pub parts: Vec<crate::approvals::derived::Part>,
1118 pub mcp_digests: std::collections::BTreeMap<String, String>,
1120 pub stop: StopGate,
1122 pub max_tokens: Option<u64>,
1124 pub reads: Option<u32>,
1126 pub halt: Option<agentplane::model::Stop>,
1130}
1131
1132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1134pub struct Asking {
1135 pub asked: u32,
1137 pub budget: u32,
1139 pub prompts: bool,
1142}
1143
1144impl Default for Asking {
1145 fn default() -> Self {
1146 Self {
1147 asked: 0,
1148 budget: DEFAULT_BUDGET,
1149 prompts: false,
1150 }
1151 }
1152}
1153
1154pub use super::run_config::DEFAULT_BUDGET;
1156
1157pub(crate) async fn tool_loop(
1161 cx: &mut StepCtx<'_>,
1162 prompt: &Tainted<Value>,
1163 spec: LoopSpec,
1164) -> Result<Outcome, SkillError> {
1165 let mut transcript = Transcript::new(prompt);
1168 tool_turn(cx, prompt, spec, &mut transcript).await
1169}
1170
1171pub(crate) async fn tool_turn(
1174 cx: &mut StepCtx<'_>,
1175 prompt: &Tainted<Value>,
1176 spec: LoopSpec,
1177 transcript: &mut Transcript,
1178) -> Result<Outcome, SkillError> {
1179 let LoopSpec {
1180 tools,
1181 policy,
1182 max_turns,
1183 max_tool_calls,
1184 reasoning,
1185 schema,
1186 routes,
1187 root,
1188 frozen_additions,
1189 finish,
1190 ask,
1191 run_approved,
1192 mut once,
1193 observer,
1194 run,
1195 asking,
1196 mut parts,
1197 mcp_digests,
1198 stop,
1199 max_tokens,
1200 reads,
1201 halt,
1202 } = spec;
1203 let replaying = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(cx.replaying()));
1207 let observer = observer.map(|(task, next)| {
1208 let quiet: std::sync::Arc<dyn crate::progress::Progress> = std::sync::Arc::new(Quiet {
1209 replaying: std::sync::Arc::clone(&replaying),
1210 next,
1211 });
1212 (task, quiet)
1213 });
1214 let seen = |cx: &StepCtx<'_>| {
1215 replaying.store(cx.replaying(), std::sync::atomic::Ordering::SeqCst);
1216 };
1217 let mut next_read = reads;
1219 let mut proposed = false;
1220 let mut remembered: Vec<(Object, &'static str)> =
1223 run_approved.into_iter().map(|g| (g, "run-grant")).collect();
1224 let mut prompted = asking.asked;
1225 let mut told_budget = false;
1226 let catalog = crate::tools::catalog();
1227
1228 let mut conversation: Label = transcript.label().clone();
1229 let mut calls = 0u32;
1230 let mut denied_in_a_row = 0u32;
1231 let mut asked_to_stop = false;
1233 let mut obligation = false;
1235 let mut usage = agentplane::model::Usage::default();
1236 let mut log: Vec<Value> = Vec::new();
1237
1238 let end = |status: &str,
1239 extra: Value,
1240 log: &[Value],
1241 usage: agentplane::model::Usage,
1242 conversation: &Label,
1243 prompted: u32| {
1244 let mut out = json!({
1245 "status": status,
1246 "tool_calls": log,
1247 "asked": prompted,
1248 "usage": {
1249 "input_tokens": usage.input_tokens,
1250 "output_tokens": usage.output_tokens,
1251 "cache_read_tokens": usage.cache_read_tokens,
1252 "cache_write_tokens": usage.cache_write_tokens,
1253 },
1254 });
1255 if let (Some(o), Some(e)) = (out.as_object_mut(), extra.as_object()) {
1256 o.extend(e.clone());
1257 }
1258 Ok(Outcome::done(Tainted::with_label(
1259 out,
1260 conversation.clone(),
1261 )))
1262 };
1263
1264 for turn in 0..max_turns {
1265 if let Some(max) = max_tokens
1267 && usage.input_tokens + usage.output_tokens >= max
1268 {
1269 return end(
1270 "budget",
1271 json!({ "reason": format!("this turn used {} of its {max} tokens", usage.input_tokens + usage.output_tokens) }),
1272 &log,
1273 usage,
1274 &conversation,
1275 prompted,
1276 );
1277 }
1278 let tools_now = tools.clone();
1282 let schema_now = schema.clone();
1283 let read_so_far = &*transcript;
1284 let stop_now = halt.clone();
1285 let completion = cx
1286 .complete_with(prompt, move |call| {
1287 let call = call.with_tools(tools_now).continuing(read_so_far);
1288 let call = match stop_now {
1289 Some(stop) => call.stopped_by(stop),
1290 None => call,
1291 };
1292 let call = match schema_now {
1293 Some(schema) => call.expecting(schema),
1294 None => call,
1295 };
1296 if reasoning {
1297 call.with_reasoning_summary()
1298 } else {
1299 call
1300 }
1301 })
1302 .await?;
1303 seen(cx);
1304 conversation = transcript.label().join(completion.label());
1305 usage = usage.plus(completion.peek().usage);
1306 if let Some((task, p)) = &observer {
1307 p.event(&crate::progress::Event::Usage {
1308 task: task.clone(),
1309 input_tokens: usage.input_tokens,
1310 output_tokens: usage.output_tokens,
1311 cache_read_tokens: usage.cache_read_tokens,
1312 cache_write_tokens: usage.cache_write_tokens,
1313 usd: None,
1314 });
1315 }
1316
1317 if let (Some((task, p)), Some(reasoning)) =
1320 (&observer, completion.peek().reasoning.as_deref())
1321 && !reasoning.trim().is_empty()
1322 {
1323 let text: String = crate::approvals::present::escape(reasoning.trim())
1324 .chars()
1325 .take(4000)
1326 .collect();
1327 p.event(&crate::progress::Event::Reasoning {
1328 task: task.clone(),
1329 turn: turn + 1,
1330 text,
1331 });
1332 }
1333 if completion.peek().stopped
1334 || halt
1335 .as_ref()
1336 .is_some_and(agentplane::model::Stop::is_stopped)
1337 {
1338 return end(
1339 "stopped",
1340 json!({ "text": completion.peek().text }),
1341 &log,
1342 usage,
1343 &conversation,
1344 prompted,
1345 );
1346 }
1347 if completion.peek().truncated {
1348 return Ok(Outcome::fail(
1349 "the model ran out of output budget mid-turn; nothing it was still writing was executed — raise [model] max_output_tokens",
1350 ));
1351 }
1352 let asked_calls = transcript.asked(&completion);
1353 let said = super::said(completion.peek());
1357 if let (Some((task, p)), Some(said)) = (observer.as_ref(), said.as_deref()) {
1358 let text: String = crate::approvals::present::escape(said)
1359 .chars()
1360 .take(2000)
1361 .collect();
1362 p.event(&crate::progress::Event::Said {
1363 task: task.clone(),
1364 turn: turn + 1,
1365 text,
1366 });
1367 }
1368 if asked_calls.is_empty() && schema.is_none() {
1369 return end(
1370 "answered",
1371 json!({ "text": completion.peek().text }),
1372 &log,
1373 usage,
1374 &conversation,
1375 prompted,
1376 );
1377 }
1378 if asked_calls.is_empty() {
1379 let report = completion
1380 .peek()
1381 .structured
1382 .clone()
1383 .unwrap_or_else(|| json!({ "summary": completion.peek().text, "status": "incomplete", "files_changed": [], "checks_run": [], "notes": ["the answer was not the requested structure"] }));
1384 let status = finish(&report);
1385 return end(
1386 status,
1387 json!({ "report": report }),
1388 &log,
1389 usage,
1390 &conversation,
1391 prompted,
1392 );
1393 }
1394
1395 for mut asked in asked_calls {
1396 relativise(&mut asked.arguments, root.as_deref());
1397 if let Some(o) = asked.arguments.as_object_mut() {
1399 o.remove(crate::tools::EXPECT);
1400 }
1401 calls += 1;
1402 if calls > max_tool_calls {
1403 return end(
1404 "budget_exhausted",
1405 json!({ "reason": format!("more than {max_tool_calls} tool calls (`[budget] max_tool_calls`)") }),
1406 &log,
1407 usage,
1408 &conversation,
1409 prompted,
1410 );
1411 }
1412 if asked.name == ASK_PERSON && tools.iter().any(|t| t.name == ASK_PERSON) {
1413 let question = asked.arguments["question"]
1414 .as_str()
1415 .unwrap_or_default()
1416 .to_owned();
1417 let decided = match &ask {
1420 Some(asker) => Some(
1421 wait_for_person(
1422 cx,
1423 asker,
1424 &mut obligation,
1425 &crate::approvals::tasks::question_task(&asker.task, &question),
1426 )
1427 .await?,
1428 ),
1429 None => None,
1430 };
1431 let read = decided.as_ref().map(crate::approvals::tasks::read);
1432 let by = read.as_ref().and_then(|r| r.by.clone());
1433 let entry = json!({
1434 "tool": ASK_PERSON,
1435 "arguments": asked.arguments,
1436 "decision": "allow",
1437 "answered": read.as_ref().is_some_and(|r| r.text.is_some()),
1438 "answer": read.as_ref().and_then(|r| r.text.clone()),
1440 "operator": by.as_ref().map(Who::view),
1441 "via": by.as_ref().map(|w| w.via.clone()),
1442 });
1443 emit(observer.as_ref(), turn + 1, &entry);
1444 log.push(entry);
1445 let text = match (&read, &by) {
1446 (Some(r), _) if r.text.is_some() => r.text.clone().unwrap_or_default(),
1447 (Some(_), Some(w)) => format!("{} declined to answer.", w.name),
1448 _ => "Nobody can answer now. Record the question in the artifact as [NEEDS CLARIFICATION: <question>] and continue.".to_owned(),
1449 };
1450 let label = match &by {
1451 Some(_) => provenance::from(provenance::PERSON),
1452 None => Label::trusted(),
1453 };
1454 transcript.answered(
1455 asked,
1456 &Tainted::with_label(json!({ "answer": text }), label),
1457 );
1458 continue;
1459 }
1460 if asked.name == REPORT_BLOCKED && tools.iter().any(|t| t.name == REPORT_BLOCKED) {
1461 let refused = stop.rejected || log.iter().any(|e| e["decision"] == "deny");
1462 let left = max_turns - turn - 1;
1463 match judge_stop(&stop, &asked.arguments, refused, asked_to_stop, left) {
1464 Stop::Stopped(reason) => {
1465 let entry = json!({ "tool": REPORT_BLOCKED, "arguments": asked.arguments, "decision": "stop" });
1466 emit(observer.as_ref(), turn + 1, &entry);
1467 log.push(entry);
1468 return end(
1469 "blocked",
1470 json!({ "reason": reason }),
1471 &log,
1472 usage,
1473 &conversation,
1474 prompted,
1475 );
1476 }
1477 Stop::Asked(text) => {
1478 asked_to_stop = true;
1479 let entry = json!({ "tool": REPORT_BLOCKED, "arguments": asked.arguments, "decision": "challenged" });
1480 emit(observer.as_ref(), turn + 1, &entry);
1481 log.push(entry);
1482 transcript.answered(
1483 asked,
1484 &Tainted::trusted(json!({ "stopped": false, "note": text })),
1485 );
1486 }
1487 Stop::Refused(text) => {
1488 let entry = json!({ "tool": REPORT_BLOCKED, "arguments": asked.arguments, "decision": "unproven", "reason": text });
1489 emit(observer.as_ref(), turn + 1, &entry);
1490 log.push(entry);
1491 transcript.failed(asked, Tainted::trusted(text));
1492 }
1493 }
1494 continue;
1495 }
1496 if asked.name == crate::ask::PROPOSE && tools.iter().any(|t| t.name == asked.name) {
1497 let entry = json!({ "tool": asked.name, "arguments": asked.arguments, "decision": if proposed { "deny" } else { "proposed" } });
1499 emit(observer.as_ref(), turn + 1, &entry);
1500 log.push(entry);
1501 if proposed {
1502 transcript.failed(
1503 asked,
1504 Tainted::trusted(
1505 "one proposal per turn; this one was not shown".to_owned(),
1506 ),
1507 );
1508 } else {
1509 proposed = true;
1510 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" })));
1511 }
1512 continue;
1513 }
1514 let declared = tools.iter().any(|t| t.name == asked.name);
1515 let target = catalog
1516 .iter()
1517 .find(|t| declared && t.name == asked.name)
1518 .map(|t| ToolId::new(t.server, t.name))
1519 .or_else(|| {
1520 routes
1521 .iter()
1522 .find(|(n, _, _)| declared && *n == asked.name)
1523 .map(|(_, server, tool)| ToolId::new(server, tool))
1524 });
1525 let Some(target) = target else {
1526 let entry = json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "unknown" });
1527 emit(observer.as_ref(), turn + 1, &entry);
1528 log.push(entry);
1529 transcript.failed(asked, Tainted::trusted("no tool of that name".to_owned()));
1530 continue;
1531 };
1532 let mut decision = policy.base(&asked.name, &asked.arguments);
1533 let mut additions_only = false;
1537 if frozen_additions
1538 && matches!(&decision, Decision::Approval(r) if r.contains("frozen test"))
1539 {
1540 let path = asked.arguments["path"]
1541 .as_str()
1542 .unwrap_or_default()
1543 .to_owned();
1544 let read = cx
1545 .call_tool(
1546 ToolId::new("fs", "read_file"),
1547 Tainted::with_label(json!({ "path": path }), completion.label().clone()),
1548 )
1549 .await?;
1550 seen(cx);
1551 let current = read.peek().clone();
1552 let lines = current["content"].as_str().unwrap_or_default();
1555 let old_text = if lines.is_empty() {
1556 String::new()
1557 } else {
1558 format!("{lines}\n")
1559 };
1560 let old = old_text.as_str();
1561 let readable = current["ok"] == true && current["truncated"] != true;
1562 let new = match asked.name.as_str() {
1563 "write_file" => asked.arguments["content"].as_str().map(str::to_owned),
1564 "edit_file" => {
1565 let (from, to) = (
1566 asked.arguments["old_string"].as_str().unwrap_or_default(),
1567 asked.arguments["new_string"].as_str().unwrap_or_default(),
1568 );
1569 let all = asked.arguments["replace_all"] == true;
1570 (!from.is_empty() && (all || old.matches(from).count() == 1))
1571 .then(|| old.replace(from, to))
1572 }
1573 _ => None,
1574 };
1575 if readable && new.is_some_and(|n| crate::integrity::only_additions(old, &n)) {
1576 decision = Decision::Allow;
1577 additions_only = true;
1578 }
1579 }
1580 let mut object: Option<Object> = None;
1584 if matches!(decision, Decision::Approval(_)) {
1585 if asked.name.starts_with(crate::mcp::PREFIX) {
1586 object = mcp_digests.get(&asked.name).map(|digest| Object::Tool {
1587 name: asked.name.clone(),
1588 digest: digest.clone(),
1589 });
1590 } else {
1591 let resolved = cx
1592 .call_tool(
1593 ToolId::new("fs", "resolve"),
1594 Tainted::with_label(
1595 json!({ "tool": asked.name, "arguments": asked.arguments }),
1596 completion.label().clone(),
1597 ),
1598 )
1599 .await?;
1600 seen(cx);
1601 let out = resolved.peek();
1602 if out["ok"] == true {
1603 object = serde_json::from_value(out["object"].clone()).ok();
1604 } else {
1605 decision = Decision::Deny(
1606 out["error"]
1607 .as_str()
1608 .unwrap_or("the call's object cannot be resolved")
1609 .to_owned(),
1610 );
1611 }
1612 }
1613 if matches!(decision, Decision::Approval(_)) {
1614 decision = policy.classify(&asked.name, &asked.arguments, object.as_ref());
1615 }
1616 }
1617 let mut person: Option<Value> = None;
1618 if decision == Decision::Allow
1621 && object
1622 .as_ref()
1623 .is_some_and(|o| policy.approved().contains(o))
1624 {
1625 person = Some(json!({
1626 "approved_by": "person",
1627 "scope": "task-grant",
1628 "grant": { "scope": "task", "run": run },
1629 }));
1630 }
1631 let mut note: Option<String> = None;
1632 if matches!(decision, Decision::Approval(_))
1634 && let Some(at) = object
1635 .as_ref()
1636 .and_then(|o| once.iter().position(|r| r == o))
1637 {
1638 once.remove(at);
1639 person = Some(json!({
1640 "approved_by": "person",
1641 "scope": "once-grant",
1642 "grant": { "scope": "once", "run": run },
1643 }));
1644 decision = Decision::Allow;
1645 }
1646 if matches!(decision, Decision::Approval(_))
1648 && let Some((_, scope)) = object
1649 .as_ref()
1650 .and_then(|o| remembered.iter().find(|(r, _)| r == o))
1651 {
1652 person = Some(json!({
1653 "approved_by": "person",
1654 "scope": scope,
1655 "grant": { "scope": scope.trim_end_matches("-grant"), "run": run },
1656 }));
1657 decision = Decision::Allow;
1658 }
1659 decision = policy.settle(decision);
1662 let request = match &decision {
1664 Decision::Approval(reason) => Some(question(&Question {
1665 asked: &asked,
1666 reason,
1667 turn: turn + 1,
1668 said: said.as_deref().unwrap_or_default(),
1669 asker: ask.as_ref(),
1670 policy: &policy,
1671 object: object.as_ref(),
1672 remembered: &remembered,
1673 parts: &parts,
1674 run: run.as_deref(),
1675 })),
1676 _ => None,
1677 };
1678 if let (Some(mut request), Some(asker)) = (request.clone(), &ask) {
1680 if asking.prompts {
1684 if prompted < asking.budget {
1685 prompted += 1;
1686 request.asked = prompted;
1687 } else {
1688 request.batched = true;
1689 }
1690 request.budget = asking.budget;
1691 }
1692 if request.batched {
1693 if !told_budget {
1696 needs_person(observer.as_ref(), &asked, object.as_ref());
1697 }
1698 if !told_budget && let Some((_, p)) = &observer {
1699 p.event(&crate::progress::Event::Notice {
1700 text: format!(
1701 "approval budget ({}) reached: calls wait in Needs You (speccoder approvals)",
1702 asking.budget
1703 ),
1704 });
1705 }
1706 told_budget = true;
1707 } else {
1708 needs_person(observer.as_ref(), &asked, object.as_ref());
1709 }
1710 let decided = wait_for_person(
1711 cx,
1712 asker,
1713 &mut obligation,
1714 &crate::approvals::tasks::approval_task(&request, &log),
1715 )
1716 .await?;
1717 seen(cx);
1718 let read = crate::approvals::tasks::read(&decided);
1719 if read.resume_asking {
1721 prompted = 0;
1722 }
1723 let answer: Answer = read.answer.clone();
1724 let approver = serde_json::to_value(&answer.by).unwrap_or(Value::Null);
1726 let operator = read.by.as_ref().map(Who::view);
1727 person_decided(
1728 observer.as_ref(),
1729 &asked,
1730 if answer.decision == Said::Approve {
1731 "approve"
1732 } else {
1733 "reject"
1734 },
1735 (answer.decision == Said::Approve).then(|| answer.scope.name()),
1736 read.by.as_ref().filter(|_| !read.expired),
1737 object.as_ref(),
1738 );
1739 match answer.decision {
1740 Said::Approve => {
1741 let mut p = json!({
1742 "approved_by": "person",
1743 "scope": answer.scope.name(),
1744 "note": answer.note,
1745 "approver": approver,
1746 "operator": operator,
1747 });
1748 if answer.always {
1749 p["always"] = json!(true);
1750 }
1751 if let (Some(o), Scope::Task | Scope::Run) = (&object, answer.scope) {
1752 let remembered_as = if answer.scope == Scope::Run {
1753 "run-grant"
1754 } else {
1755 "task-grant"
1756 };
1757 p["grant"] = json!({ "scope": answer.scope.name(), "run": run });
1758 remembered.push((o.clone(), remembered_as));
1759 }
1760 person = Some(p);
1761 note = answer.note;
1762 decision = Decision::Allow;
1763 }
1764 Said::Reject | Said::Stop => {
1767 let why = answer
1768 .reason
1769 .unwrap_or_else(|| "no reason given".to_owned());
1770 person = Some(if read.expired {
1771 json!({ "expired": true, "rejection": why })
1772 } else {
1773 json!({ "rejected_by": "person", "approver": approver, "operator": operator, "rejection": why })
1774 });
1775 decision = Decision::Deny(format!("a person rejected this: {why}"));
1776 }
1777 }
1778 }
1779 let rule = person
1782 .is_none()
1783 .then(|| policy.rule(&asked.name, &asked.arguments, object.as_ref()));
1784 let with_person = |mut entry: Value| {
1785 if let Some(o) = entry.as_object_mut() {
1786 if let Some(obj) = &object {
1788 o.insert("object".to_owned(), json!(obj));
1789 }
1790 match (person.as_ref().and_then(Value::as_object), &rule) {
1791 (Some(p), _) => {
1792 let by = if p.contains_key("expired") {
1794 "expiry"
1795 } else {
1796 "person"
1797 };
1798 o.insert("by".to_owned(), json!(by));
1799 o.extend(p.clone());
1800 }
1801 (None, Some(r)) => {
1802 o.insert("by".to_owned(), json!("rule"));
1803 o.insert("rule".to_owned(), json!(r));
1804 if r.starts_with("contained (") {
1806 o.insert("contained".to_owned(), json!(true));
1807 }
1808 }
1809 (None, None) => {}
1810 }
1811 }
1812 entry
1813 };
1814 match decision {
1815 Decision::Deny(reason) => {
1816 let entry = with_person(
1817 json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "deny", "reason": reason }),
1818 );
1819 emit(observer.as_ref(), turn + 1, &entry);
1820 log.push(entry);
1821 transcript.failed(
1824 asked,
1825 refusal(format!("denied: {reason}"), completion.label()),
1826 );
1827 denied_in_a_row += 1;
1830 if denied_in_a_row >= MAX_CONSECUTIVE_DENIALS {
1831 return end(
1832 "denied",
1833 json!({ "reason": format!("{denied_in_a_row} calls in a row were denied; last: {reason}") }),
1834 &log,
1835 usage,
1836 &conversation,
1837 prompted,
1838 );
1839 }
1840 continue;
1841 }
1842 Decision::Approval(reason) => {
1843 let mut entry = json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "approval", "reason": reason });
1844 if let Some(obj) = &object {
1845 entry["object"] = json!(obj);
1846 }
1847 emit(observer.as_ref(), turn + 1, &entry);
1848 needs_person(observer.as_ref(), &asked, object.as_ref());
1849 log.push(entry);
1850 return end(
1851 "needs_approval",
1852 json!({ "pending": {
1853 "tool": asked.name,
1854 "arguments": asked.arguments,
1855 "reason": reason,
1856 "object": object,
1857 "run": run,
1858 "request": request,
1859 } }),
1860 &log,
1861 usage,
1862 &conversation,
1863 prompted,
1864 );
1865 }
1866 Decision::Allow => {
1867 denied_in_a_row = 0;
1868 let mut arguments = asked.arguments.clone();
1875 if asked.name == "run_command"
1876 && let Some(o) = arguments.as_object_mut()
1877 {
1878 o.insert("call_id".to_owned(), Value::String(asked.id.clone()));
1879 }
1880 if let (Some(obj), Some(o)) = (&object, arguments.as_object_mut())
1883 && matches!(
1884 asked.name.as_str(),
1885 "write_file" | "edit_file" | "run_command"
1886 )
1887 {
1888 o.insert(crate::tools::EXPECT.to_owned(), json!(obj));
1889 }
1890 let args = Tainted::with_label(arguments, completion.label().clone());
1891 let result = cx.call_tool(target.clone(), args).await;
1892 seen(cx);
1893 let result = match result {
1894 Ok(r) => r,
1895 Err(error) => match cx.relayed(&error) {
1898 Some(detail) => {
1899 let entry = with_person(
1900 json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "allow", "ok": false }),
1901 );
1902 emit(observer.as_ref(), turn + 1, &entry);
1903 log.push(entry);
1904 transcript.failed(asked, detail);
1905 continue;
1906 }
1907 None => return Err(error.into()),
1908 },
1909 };
1910 let output = result.peek().clone();
1911 let ok = output["ok"].as_bool() != Some(false);
1912 let mut entry = with_person(
1913 json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "allow", "ok": ok }),
1914 );
1915 let result = match next_read.as_mut() {
1917 Some(n) if ok => {
1918 let id = format!("r{n}");
1919 *n += 1;
1920 entry["read"] = json!(crate::ask::read_of(
1921 &id,
1922 &asked.name,
1923 &asked.arguments,
1924 &output
1925 ));
1926 result.map(|mut v| {
1927 if let Some(o) = v.as_object_mut() {
1928 o.insert("read".to_owned(), json!(id));
1929 }
1930 v
1931 })
1932 }
1933 _ => result,
1934 };
1935 if let Some(changed) = output["object_changed"].as_object() {
1936 entry["decision"] = json!("refused_changed");
1937 entry["expected"] = changed.get("expected").cloned().unwrap_or(Value::Null);
1938 entry["found"] = changed.get("found").cloned().unwrap_or(Value::Null);
1939 }
1940 if let Some(part) = crate::approvals::derived::part_of(
1943 &asked.name,
1944 &asked.arguments,
1945 &output,
1946 turn + 1,
1947 &format!("tool://{}/{}", target.server, target.tool),
1948 |p: &str| {
1949 policy.protected().write_class(p)
1950 == Some(crate::tools::paths::Class::Intent)
1951 },
1952 ) {
1953 parts.push(part);
1954 }
1955 if additions_only {
1956 entry["frozen_additions"] = json!(true);
1957 }
1958 let tail = if asked.name == "run_command" && !ok {
1959 crate::progress::tail(&format!(
1960 "{}\n{}",
1961 output["stdout"].as_str().unwrap_or_default(),
1962 output["stderr"].as_str().unwrap_or_default()
1963 ))
1964 } else {
1965 Vec::new()
1966 };
1967 emit_with_tail(observer.as_ref(), turn + 1, &entry, tail);
1968 log.push(entry);
1969 if let Some(drift) = output["integrity"].as_array().filter(|d| !d.is_empty()) {
1972 let paths: Vec<&str> =
1973 drift.iter().filter_map(|d| d["path"].as_str()).collect();
1974 return end(
1975 "integrity",
1976 json!({
1977 "reason": format!("a command changed what judges the task: {}", paths.join(", ")),
1978 "integrity": drift,
1979 }),
1980 &log,
1981 usage,
1982 &conversation,
1983 prompted,
1984 );
1985 }
1986 match output["error"].as_str() {
1992 Some(error) if !ok => transcript.failed(
1993 asked,
1994 Tainted::with_label(error.to_owned(), result.label().clone()),
1995 ),
1996 _ => match ¬e {
1998 Some(n) => {
1999 let n = format!("Note from the person who approved this call: {n}");
2000 transcript.answered(
2001 asked,
2002 &result.map(|mut v| {
2003 v["note_from_person"] = Value::String(n);
2004 v
2005 }),
2006 );
2007 }
2008 None => transcript.answered(asked, &result),
2009 },
2010 }
2011 }
2012 }
2013 }
2014 }
2015 end(
2016 "budget_exhausted",
2017 json!({ "reason": format!("more than {max_turns} model turns (`[budget] max_turns`)") }),
2018 &log,
2019 usage,
2020 &conversation,
2021 prompted,
2022 )
2023}
2024
2025#[cfg(test)]
2026mod tests {
2027 use super::*;
2028
2029 #[test]
2030 fn labels_are_read_from_the_parts_and_refusals_carry_the_call() {
2031 use agentplane::core::{Label, SourceId};
2032 let model = Label::untrusted(SourceId::new("model:x/y"));
2033 assert!(
2035 refusal("denied: `src/x` is protected".to_owned(), &model)
2036 .label()
2037 .is_untrusted()
2038 );
2039 let input = Tainted::object([
2041 ("bundle", Tainted::trusted(json!({}))),
2042 (
2043 "digest",
2044 Tainted::with_label(json!({}), provenance::from(provenance::VERIFIER)),
2045 ),
2046 (
2047 "settings",
2048 Tainted::object([("nudges", Tainted::trusted(json!([])))]),
2049 ),
2050 ]);
2051 let label = conversation_label(&input);
2052 assert!(
2053 label
2054 .provenance
2055 .contains(&SourceId::new(provenance::VERIFIER))
2056 );
2057 let flat = Tainted::with_label(json!({ "bundle": {} }), model.clone());
2059 assert_eq!(conversation_label(&flat).provenance, model.provenance);
2060 }
2061
2062 #[test]
2063 fn absolute_paths_inside_the_root_become_relative_and_others_stay() {
2064 let mut a = json!({ "path": "/work/repo/specs/001-x/plan.md" });
2065 relativise(&mut a, Some("/work/repo"));
2066 assert_eq!(a["path"], "specs/001-x/plan.md");
2067 let mut outside = json!({ "path": "/work/repository-two/x" });
2068 relativise(&mut outside, Some("/work/repo"));
2069 assert_eq!(outside["path"], "/work/repository-two/x");
2070 let mut none = json!({ "path": "/etc/passwd" });
2071 relativise(&mut none, None);
2072 assert_eq!(none["path"], "/etc/passwd");
2073 }
2074
2075 #[test]
2078 fn a_stop_stands_on_evidence_speccoder_checks() {
2079 let gate = StopGate {
2080 spec: "Rows are sorted\n ascending by name.".to_owned(),
2081 frozen: vec!["tests/test_us3.py".to_owned()],
2082 rejected: false,
2083 };
2084 let call = |kind: &str, evidence: &str| json!({ "kind": kind, "evidence": evidence, "reason": "why" });
2085 let judge =
2086 |args: &Value, refused: bool, asked: bool| judge_stop(&gate, args, refused, asked, 5);
2087 let unproven = |s: Stop| matches!(s, Stop::Refused(_));
2088
2089 assert!(unproven(judge(
2091 &json!({ "reason": "beyond the task's module scope" }),
2092 false,
2093 false
2094 )));
2095 assert!(unproven(judge(&call("scope", "src/cli"), false, false)));
2096 assert!(unproven(judge(
2097 &call("wrong_test", "tests/new_test.py"),
2098 false,
2099 false
2100 )));
2101 assert!(unproven(judge(
2102 &call("spec_conflict", "sorted descending by name"),
2103 false,
2104 false
2105 )));
2106 assert!(unproven(judge(
2107 &call("spec_conflict", "sorted"),
2108 false,
2109 false
2110 )));
2111 assert!(unproven(judge(
2112 &call("refused", "cargo add x"),
2113 false,
2114 false
2115 )));
2116
2117 let quote = call("spec_conflict", "sorted ascending by name");
2120 assert!(
2121 matches!(judge("e, false, false), Stop::Asked(t) if t.contains("5 turns are left"))
2122 );
2123 assert_eq!(
2124 judge("e, false, true),
2125 Stop::Stopped("why — the spec says “sorted ascending by name”".to_owned())
2126 );
2127 assert_eq!(
2128 judge(&call("wrong_test", "./tests/test_us3.py"), false, true),
2129 Stop::Stopped("why — frozen test `./tests/test_us3.py`".to_owned())
2130 );
2131 assert!(matches!(
2133 judge(&call("refused", "cargo add x"), true, false),
2134 Stop::Stopped(_)
2135 ));
2136 let q = call("needs_information", "which locale?");
2137 assert!(matches!(
2138 judge_stop(&gate, &q, false, false, 0),
2139 Stop::Stopped(_)
2140 ));
2141 }
2142
2143 #[test]
2146 fn a_replayed_step_says_nothing() {
2147 #[derive(Debug, Default)]
2148 struct Seen(std::sync::Mutex<Vec<u32>>);
2149 impl crate::progress::Progress for Seen {
2150 fn event(&self, e: &crate::progress::Event) {
2151 if let crate::progress::Event::ToolCall { turn, .. } = e {
2152 self.0.lock().unwrap().push(*turn);
2153 }
2154 }
2155 }
2156 let seen = std::sync::Arc::new(Seen::default());
2157 let replaying = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
2158 let quiet = Quiet {
2159 replaying: std::sync::Arc::clone(&replaying),
2160 next: seen.clone(),
2161 };
2162 let call = |turn| crate::progress::Event::ToolCall {
2163 task: "T012".to_owned(),
2164 turn,
2165 tool: "run_command".to_owned(),
2166 target: String::new(),
2167 decision: "allow".to_owned(),
2168 ok: Some(true),
2169 tail: Vec::new(),
2170 contained: false,
2171 };
2172 crate::progress::Progress::event(&quiet, &call(1));
2173 replaying.store(false, std::sync::atomic::Ordering::SeqCst);
2174 crate::progress::Progress::event(&quiet, &call(2));
2175 assert_eq!(*seen.0.lock().unwrap(), [2]);
2176 }
2177}