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