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