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, 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 \
51the task is left undone — a check you could not run is no reason.",
52 product = w.product,
53 intent = w.intent_paths,
54 )
55}
56
57#[must_use]
59pub fn report_schema() -> Value {
60 let strings = json!({ "type": "array", "items": { "type": "string" } });
61 json!({
62 "type": "object",
63 "properties": {
64 "summary": { "type": "string" },
65 "status": { "type": "string", "enum": ["done", "incomplete"] },
66 "files_changed": strings,
67 "checks_run": strings,
68 "notes": strings,
69 },
70 "required": ["summary", "status", "files_changed", "checks_run", "notes"],
71 "additionalProperties": false,
72 })
73}
74
75fn report_blocked() -> ToolDeclaration {
76 ToolDeclaration::new(
77 REPORT_BLOCKED,
78 "Stop the attempt because the task cannot be done as written; a person will look at the \
79reason. `kind`, with the `evidence` speccoder checks: `refused` — a call the task needs was \
80refused (the call); `spec_conflict` — the spec contradicts the task (its exact words); \
81`wrong_test` — a frozen test asserts the wrong thing (its path); `needs_information` — only a \
82person knows (the question). Scope is never a kind: when the tests run code that bypasses the \
83task's module (an entry point, a caller), wiring that code to it is part of the task.",
84 json!({
85 "type": "object",
86 "properties": {
87 "kind": { "type": "string", "enum": ["refused", "spec_conflict", "wrong_test", "needs_information"] },
88 "evidence": { "type": "string" },
89 "reason": { "type": "string" },
90 },
91 "required": ["kind", "evidence", "reason"],
92 "additionalProperties": false,
93 }),
94 )
95}
96
97#[derive(Default)]
100pub(crate) struct StopGate {
101 pub spec: String,
103 pub frozen: Vec<String>,
105 pub rejected: bool,
107}
108
109#[derive(Debug, PartialEq, Eq)]
111enum Stop {
112 Refused(String),
114 Asked(String),
116 Stopped(String),
118}
119
120fn judge_stop(
123 gate: &StopGate,
124 arguments: &Value,
125 refused: bool,
126 asked: bool,
127 turns_left: u32,
128) -> Stop {
129 let text = |k: &str| arguments[k].as_str().unwrap_or_default().trim();
130 let (kind, evidence, reason) = (text("kind"), text("evidence"), text("reason"));
131 let words = |t: &str| t.split_whitespace().collect::<Vec<_>>().join(" ");
132 let checked = match kind {
133 "refused" if refused => format!("refused call `{evidence}`"),
134 "refused" => return Stop::Refused("not stopped: no call in this attempt was refused or rejected".to_owned()),
135 "spec_conflict" if evidence.chars().count() >= 12 && words(&gate.spec).contains(&words(evidence)) => {
136 format!("the spec says “{evidence}”")
137 }
138 "spec_conflict" => {
139 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());
140 }
141 "wrong_test" if gate.frozen.iter().any(|f| f == evidence.trim_start_matches("./")) => {
142 format!("frozen test `{evidence}`")
143 }
144 "wrong_test" => return Stop::Refused("not stopped: the evidence must be the path of a test that existed when the attempt started".to_owned()),
145 "needs_information" if !evidence.is_empty() => format!("question: {evidence}"),
146 _ => return Stop::Refused("not stopped: `kind` is one of refused, spec_conflict, wrong_test, needs_information, with its `evidence`".to_owned()),
147 };
148 let said = match reason {
150 "" => checked,
151 r if r == evidence => r.to_owned(),
152 r => format!("{r} — {checked}"),
153 };
154 if kind == "refused" || asked || turns_left == 0 {
156 return Stop::Stopped(said);
157 }
158 Stop::Asked(format!(
159 "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."
160 ))
161}
162
163pub const ASK_PERSON: &str = "ask_person";
165
166#[must_use]
168pub fn ask_person() -> ToolDeclaration {
169 ToolDeclaration::new(
170 ASK_PERSON,
171 "Ask the person one question and wait for their answer. If nobody can answer, the reply says so.",
172 json!({
173 "type": "object",
174 "properties": { "question": { "type": "string" } },
175 "required": ["question"],
176 "additionalProperties": false,
177 }),
178 )
179}
180
181#[derive(Debug, Clone)]
185pub struct Asker {
186 pub task: String,
188 pub task_title: String,
190 pub context: PresentContext,
192 pub wait_secs: u64,
194}
195
196impl Asker {
197 #[must_use]
200 pub fn from_settings(
201 settings: &Value,
202 task: String,
203 task_title: String,
204 context: PresentContext,
205 ) -> Option<Self> {
206 (settings["asks"] == "tasks").then(|| Self {
207 task,
208 task_title,
209 context,
210 wait_secs: settings["wait_secs"].as_u64().unwrap_or(7 * 86_400),
211 })
212 }
213}
214
215async fn wait_for_person(
219 cx: &mut StepCtx<'_>,
220 asker: &Asker,
221 obligation: &mut bool,
222 spec: &agentplane::core::TaskSpec,
223) -> Result<agentplane::core::Decision, SkillError> {
224 if !*obligation {
225 let minutes = u32::try_from(asker.wait_secs.div_ceil(60).max(1)).unwrap_or(u32::MAX);
226 cx.deadline(
227 crate::approvals::tasks::DEADLINE,
228 &agentplane::core::DeadlineSpec::minutes(minutes),
229 None,
230 )
231 .await?;
232 *obligation = true;
233 }
234 Ok(cx.task(spec).await?)
235}
236
237#[derive(Debug)]
244pub struct Attempt {
245 pub style: PromptStyle,
247 pub cache_ttl: CacheTtl,
249 pub progress: Option<std::sync::Arc<dyn crate::progress::Progress>>,
251 pub format: &'static dyn crate::format::Adapter,
253 pub present: PresentContext,
255}
256
257#[must_use]
266pub fn input(
267 bundle: Value,
268 settings: &Settings<'_>,
269 digest: Option<&Value>,
270 instructions: &[crate::instructions::Instruction],
271 test_layout: Option<&str>,
272) -> Tainted<Value> {
273 let digest = match digest.filter(|d| !d.is_null()) {
274 Some(d) => Tainted::with_label(d.clone(), provenance::from(provenance::VERIFIER)),
275 None => Tainted::trusted(Value::Null),
276 };
277 let mcp = Tainted::array(settings.mcp.iter().map(|t| {
279 Tainted::with_label(
280 serde_json::to_value(t).unwrap_or(Value::Null),
281 agentplane::core::Label::untrusted(provenance::tool(&t.server, &t.tool)),
282 )
283 }));
284 let trusted = |v: Value| Tainted::trusted(v);
285 let autopilot = settings.autopilot;
286 let planning = settings.planning;
287 let settings = vec![
288 ("checks", trusted(json!(settings.checks))),
289 ("allow", trusted(json!(settings.allow))),
290 ("max_turns", trusted(json!(settings.max_turns))),
291 ("max_tool_calls", trusted(json!(settings.max_tool_calls))),
292 ("reasoning", trusted(json!(settings.reasoning))),
293 ("frozen", trusted(json!(settings.frozen))),
294 ("approved", trusted(json!(settings.approved))),
295 ("rejected", trusted(json!(settings.rejected))),
296 ("run_approved", trusted(json!(settings.run_approved))),
297 ("approved_once", trusted(json!(settings.approved_once))),
298 ("nudges", trusted(json!(settings.nudges))),
299 ("mcp", mcp),
300 ("root", trusted(json!(settings.root))),
301 (
302 "frozen_additions",
303 trusted(json!(settings.frozen_additions)),
304 ),
305 ("network_allow", trusted(json!(settings.network_allow))),
306 ("allow_prefix", trusted(json!(settings.allow_prefix))),
307 (
308 "network_allow_prefix",
309 trusted(json!(settings.network_allow_prefix)),
310 ),
311 ("run_id", trusted(json!(settings.run_id))),
312 ("asked", trusted(json!(settings.asked))),
313 ("budget", trusted(json!(settings.budget))),
314 ("prompts", trusted(json!(settings.prompts))),
315 ("asks", trusted(json!(settings.asks))),
316 ("wait_secs", trusted(json!(settings.wait_secs))),
317 ("in_place", trusted(json!(settings.in_place))),
318 ("hidden", trusted(json!(settings.hidden))),
319 ];
320 let mut settings = settings;
323 if autopilot {
324 settings.push(("autopilot", trusted(json!(true))));
325 }
326 if let Some(dir) = planning {
328 settings.push(("planning", trusted(json!(dir))));
329 }
330 let settings = Tainted::object(settings);
331 Tainted::object([
332 ("bundle", Tainted::trusted(bundle)),
333 ("digest", digest),
334 ("settings", settings),
335 (
338 "instructions",
339 if instructions.is_empty() {
340 Tainted::trusted(json!([]))
341 } else {
342 Tainted::with_label(json!(instructions), provenance::from(provenance::WORKTREE))
343 },
344 ),
345 (
347 "test_layout",
348 match test_layout {
349 Some(t) => Tainted::with_label(json!(t), provenance::from(provenance::WORKTREE)),
350 None => Tainted::trusted(Value::Null),
351 },
352 ),
353 ])
354}
355
356fn refusal(text: String, call: &agentplane::core::Label) -> Tainted<String> {
360 Tainted::with_label(text, call.clone())
361}
362
363const MESSAGE_PARTS: [&str; 7] = [
365 "/bundle",
366 "/instructions",
367 "/test_layout",
368 "/digest",
369 "/settings/nudges",
370 "/settings/rejected",
371 "/settings/mcp",
372];
373
374fn conversation_label(input: &Tainted<Value>) -> agentplane::core::Label {
378 MESSAGE_PARTS
379 .iter()
380 .map(|p| input.label_at(p).unwrap_or_else(|| input.label()).clone())
381 .fold(agentplane::core::Label::trusted(), |acc, l| acc.join(&l))
382}
383
384#[derive(Debug, Clone, Copy)]
386pub struct Settings<'a> {
387 pub checks: &'a [Vec<String>],
389 pub allow: &'a [Vec<String>],
391 pub allow_prefix: &'a [Vec<String>],
393 pub max_turns: u32,
395 pub max_tool_calls: u32,
397 pub reasoning: bool,
399 pub frozen: &'a [String],
401 pub approved: &'a [Object],
403 pub rejected: &'a [crate::tools::policy::Rejection],
405 pub run_approved: &'a [Object],
407 pub approved_once: &'a [Object],
410 pub nudges: &'a [String],
412 pub mcp: &'a [crate::mcp::McpTool],
414 pub root: &'a str,
416 pub frozen_additions: bool,
418 pub asks: &'a str,
421 pub wait_secs: u64,
423 pub network_allow: &'a [Vec<String>],
425 pub network_allow_prefix: &'a [Vec<String>],
427 pub run_id: &'a str,
429 pub asked: u32,
431 pub budget: u32,
433 pub prompts: bool,
435 pub in_place: bool,
437 pub hidden: &'a [String],
439 pub autopilot: bool,
441 pub planning: Option<&'a str>,
444}
445
446pub const MAX_CONSECUTIVE_DENIALS: u32 = 3;
448
449fn digest_text(digest: &Value) -> String {
451 let mut out = format!(
452 "# Previous attempt\n\nAttempt {} at this task was verified by speccoder and FAILED. Its \
453changes were discarded; the worktree is back where that attempt started. You start fresh — use \
454these facts from the verifier, check them yourself, and do not repeat the mistake.\n",
455 digest["attempt"].as_u64().unwrap_or(1)
456 );
457 let strings = |v: &Value| -> Vec<String> {
458 v.as_array()
459 .map(|a| {
460 a.iter()
461 .filter_map(|x| x.as_str().map(str::to_owned))
462 .collect()
463 })
464 .unwrap_or_default()
465 };
466 let reasons = strings(&digest["reasons"]);
467 if !reasons.is_empty() {
468 out.push_str("\n## Verdict reasons\n\n");
469 for r in reasons {
470 out.push_str(&format!("- {r}\n"));
471 }
472 }
473 for c in digest["checks"].as_array().into_iter().flatten() {
474 out.push_str(&format!(
475 "\n## Check `{}` ({}): `{}`\n",
476 c["name"].as_str().unwrap_or_default(),
477 c["status"].as_str().unwrap_or_default(),
478 strings(&c["argv"]).join(" ")
479 ));
480 let tests = strings(&c["failing_tests"]);
481 if !tests.is_empty() {
482 out.push_str(&format!("\nFailing tests: {}\n", tests.join(", ")));
483 }
484 out.push_str(&format!(
485 "\n```text\n{}\n```\n",
486 c["excerpt"].as_str().unwrap_or_default()
487 ));
488 }
489 let uncollected = strings(&digest["uncollected"]);
490 if !uncollected.is_empty() {
491 out.push_str("\n## Tests that never ran\n\n");
492 for l in uncollected {
493 out.push_str(&format!("- {l}\n"));
494 }
495 }
496 let discarded = strings(&digest["discarded"]);
497 if !discarded.is_empty() {
498 out.push_str(&format!(
499 "\n## Files the discarded attempt changed\n\n{}\n",
500 discarded.join("\n")
501 ));
502 }
503 out
504}
505
506fn argvs(v: &Value) -> Vec<Vec<String>> {
507 v.as_array()
508 .map(|a| {
509 a.iter()
510 .map(|argv| {
511 argv.as_array()
512 .map(|p| {
513 p.iter()
514 .filter_map(|x| x.as_str().map(str::to_owned))
515 .collect()
516 })
517 .unwrap_or_default()
518 })
519 .collect()
520 })
521 .unwrap_or_default()
522}
523
524fn decisions_of<T: serde::de::DeserializeOwned>(
528 settings: &Value,
529 key: &str,
530) -> Result<Vec<T>, SkillError> {
531 match &settings[key] {
532 Value::Null => Ok(Vec::new()),
533 v => serde_json::from_value(v.clone())
534 .map_err(|e| SkillError::Input(format!("settings.{key}: {e}"))),
535 }
536}
537
538pub(crate) fn policy_of(
543 settings: &Value,
544 rules: &'static crate::format::Rules,
545) -> Result<Policy, SkillError> {
546 let frozen: Vec<String> = settings["frozen"]
547 .as_array()
548 .map(|a| {
549 a.iter()
550 .filter_map(|v| v.as_str().map(str::to_owned))
551 .collect()
552 })
553 .unwrap_or_default();
554 let approved: Vec<Object> = decisions_of(settings, "approved")?;
555 let rejected: Vec<crate::tools::policy::Rejection> = decisions_of(settings, "rejected")?;
556 let mcp: Vec<crate::mcp::McpTool> =
557 serde_json::from_value(settings["mcp"].clone()).unwrap_or_default();
558 let policy = Policy::new(&argvs(&settings["checks"]), &argvs(&settings["allow"]))
559 .with_prefixes(argvs(&settings["allow_prefix"]))
560 .with_network_prefixes(argvs(&settings["network_allow_prefix"]))
561 .rules(rules)
562 .with_frozen(frozen)
563 .with_decisions(approved, rejected)
564 .with_mcp(mcp.iter().map(|t| (t.name.clone(), t.decision.clone())))
565 .with_network(argvs(&settings["network_allow"]))
566 .with_hidden(
567 serde_json::from_value::<Vec<String>>(settings["hidden"].clone()).unwrap_or_default(),
568 );
569 let policy = if settings["in_place"] == true {
570 policy.in_place()
571 } else {
572 policy
573 };
574 let policy = if settings["autopilot"] == true {
575 policy.autopilot()
576 } else {
577 policy
578 };
579 Ok(match settings["planning"].as_str() {
580 Some(dir) => policy.planning(dir),
581 None => policy,
582 })
583}
584
585#[async_trait]
586impl Skill for Attempt {
587 fn descriptor(&self) -> SkillDescriptor {
588 SkillDescriptor::new("speccoder.task.attempt").provides(CAPABILITY)
589 }
590
591 async fn invoke(
592 &self,
593 cx: &mut StepCtx<'_>,
594 input: Tainted<Value>,
595 ) -> Result<Outcome, SkillError> {
596 let (ttl, style) = (self.cache_ttl, self.style);
597 let settings = input.peek()["settings"].clone();
598 let task_name = format!(
599 "{}/{}",
600 input.peek()["bundle"]["feature"]
601 .as_str()
602 .unwrap_or_default(),
603 input.peek()["bundle"]["task"]["id"]
604 .as_str()
605 .unwrap_or_default()
606 );
607 let rejected: Vec<crate::tools::policy::Rejection> = decisions_of(&settings, "rejected")?;
608 let told = rejected;
609 let bundle = &input.peek()["bundle"];
610 let stop = StopGate {
611 spec: ["story", "plan", "constitution"]
612 .iter()
613 .filter_map(|k| bundle[*k].as_str())
614 .chain(bundle["task"]["title"].as_str())
615 .collect::<Vec<_>>()
616 .join("\n"),
617 frozen: settings["frozen"]
618 .as_array()
619 .into_iter()
620 .flatten()
621 .filter_map(|v| v.as_str().map(str::to_owned))
622 .collect(),
623 rejected: !told.is_empty(),
624 };
625 let mcp: Vec<crate::mcp::McpTool> =
626 serde_json::from_value(settings["mcp"].clone()).unwrap_or_default();
627 let planning = settings["planning"].as_str().map(str::to_owned);
628 let policy = policy_of(&settings, self.format.rules())?;
629 let max_turns = settings["max_turns"]
630 .as_u64()
631 .map_or(30, |n| u32::try_from(n).unwrap_or(u32::MAX))
632 .max(1);
633 let max_tool_calls = settings["max_tool_calls"]
634 .as_u64()
635 .map_or(100, |n| u32::try_from(n).unwrap_or(u32::MAX));
636 let label = conversation_label(&input);
639 let raw = input.peek().clone();
640 let prompt = provenance::labelled_prompt(
641 {
642 let input = raw;
643 let mut p = layout(
644 &input["bundle"],
645 ttl,
646 style,
647 &self::policy(self.format.wording()),
648 self.format,
649 );
650 let given: Vec<crate::instructions::Instruction> =
651 serde_json::from_value(input["instructions"].clone()).unwrap_or_default();
652 if let Some(text) = crate::instructions::section(&given) {
653 let (key, kind) = match style {
654 PromptStyle::OpenAi => ("input", "input_text"),
655 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
656 ("messages", "text")
657 }
658 };
659 if let Some(content) = p[key][0]["content"].as_array_mut() {
660 content.push(json!({ "type": kind, "text": text }));
661 }
662 }
663 if let Some(text) = input["test_layout"].as_str().filter(|t| !t.is_empty()) {
664 let (key, kind) = match style {
665 PromptStyle::OpenAi => ("input", "input_text"),
666 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
667 ("messages", "text")
668 }
669 };
670 if let Some(content) = p[key][0]["content"].as_array_mut() {
671 content.push(json!({ "type": kind, "text": text }));
672 }
673 }
674 if !input["digest"].is_null() {
675 let (key, kind) = match style {
676 PromptStyle::OpenAi => ("input", "input_text"),
677 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
678 ("messages", "text")
679 }
680 };
681 if let Some(content) = p[key][0]["content"].as_array_mut() {
682 content
683 .push(json!({ "type": kind, "text": digest_text(&input["digest"]) }));
684 }
685 }
686 if let Some(dir) = &planning {
688 let (key, kind) = match style {
689 PromptStyle::OpenAi => ("input", "input_text"),
690 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
691 ("messages", "text")
692 }
693 };
694 let text = format!(
695 "# 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"
696 );
697 if let Some(content) = p[key][0]["content"].as_array_mut() {
698 content.push(json!({ "type": kind, "text": text }));
699 }
700 }
701 let checks: Vec<String> = argvs(&input["settings"]["checks"])
703 .iter()
704 .map(|argv| format!("- `{}`", json!(argv)))
705 .collect();
706 if !checks.is_empty() {
707 let (key, kind) = match style {
708 PromptStyle::OpenAi => ("input", "input_text"),
709 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
710 ("messages", "text")
711 }
712 };
713 let text = format!(
714 "# The checks\n\nRun them with run_command as these argvs, word for word; they need no approval:\n\n{}\n",
715 checks.join("\n")
716 );
717 if let Some(content) = p[key][0]["content"].as_array_mut() {
718 content.push(json!({ "type": kind, "text": text }));
719 }
720 }
721 let notes: Vec<String> = input["settings"]["nudges"]
723 .as_array()
724 .map(|a| {
725 a.iter()
726 .filter_map(|n| n.as_str().map(|n| format!("- {n}")))
727 .collect()
728 })
729 .unwrap_or_default();
730 if !notes.is_empty() {
731 let (key, kind) = match style {
732 PromptStyle::OpenAi => ("input", "input_text"),
733 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
734 ("messages", "text")
735 }
736 };
737 let text = format!("# Notes from a person\n\n{}\n", notes.join("\n"));
738 if let Some(content) = p[key][0]["content"].as_array_mut() {
739 content.push(json!({ "type": kind, "text": text }));
740 }
741 }
742 if !told.is_empty() {
744 let (key, kind) = match style {
745 PromptStyle::OpenAi => ("input", "input_text"),
746 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
747 ("messages", "text")
748 }
749 };
750 let lines: Vec<String> = told
751 .iter()
752 .map(|r| format!("- {}: {}", r.object.describe(), r.reason))
753 .collect();
754 let text = format!(
755 "# Rejected by a person\n\nThese calls were rejected for this task; find another way or call report_blocked:\n\n{}\n",
756 lines.join("\n")
757 );
758 if let Some(content) = p[key][0]["content"].as_array_mut() {
759 content.push(json!({ "type": kind, "text": text }));
760 }
761 }
762 if let PromptStyle::Parts { caches } = style {
763 super::model_config::to_parts(&mut p, caches);
764 }
765 p
766 },
767 &label,
768 );
769 let mut tools = crate::tools::declarations();
770 tools.push(report_blocked());
771 let routes: Vec<(String, String, String)> = mcp
773 .iter()
774 .filter(|t| t.decision != "deny")
775 .map(|t| (t.name.clone(), t.server.clone(), t.tool.clone()))
776 .collect();
777 tools.extend(mcp.iter().filter(|t| t.decision != "deny").map(|t| {
778 ToolDeclaration::new(t.name.clone(), t.description.clone(), t.schema.clone())
779 }));
780 let ask = Asker::from_settings(
782 &settings,
783 task_name.clone(),
784 input.peek()["bundle"]["task"]["title"]
785 .as_str()
786 .unwrap_or_default()
787 .to_owned(),
788 PresentContext {
789 root: settings["root"].as_str().map(std::path::PathBuf::from),
790 ..self.present.clone()
791 },
792 );
793 let remembered: Vec<Object> = decisions_of(&settings, "run_approved")?;
794 let once: Vec<Object> = decisions_of(&settings, "approved_once")?;
795 let mut parts = Vec::new();
797 let given: Vec<crate::instructions::Instruction> =
798 serde_json::from_value(input.peek()["instructions"].clone()).unwrap_or_default();
799 if let Some(text) = crate::instructions::section(&given) {
800 parts.push(crate::approvals::derived::Part {
801 from: provenance::WORKTREE.to_owned(),
802 turn: 0,
803 text,
804 });
805 }
806 if !input.peek()["digest"].is_null() {
807 parts.push(crate::approvals::derived::Part {
808 from: provenance::VERIFIER.to_owned(),
809 turn: 0,
810 text: crate::approvals::derived::text_of(&input.peek()["digest"]),
811 });
812 }
813 let number = |k: &str, d: u32| {
814 settings[k]
815 .as_u64()
816 .map_or(d, |n| u32::try_from(n).unwrap_or(u32::MAX))
817 };
818 tool_loop(
819 cx,
820 &prompt,
821 LoopSpec {
822 tools,
823 policy,
824 max_turns,
825 max_tool_calls,
826 reasoning: settings["reasoning"] == true,
827 schema: Some(report_schema()),
828 max_tokens: None,
829 reads: None,
830 halt: None,
831 routes,
832 root: settings["root"].as_str().map(str::to_owned),
833 frozen_additions: settings["frozen_additions"] == true,
834 ask,
835 run_approved: remembered,
836 once,
837 run: settings["run_id"].as_str().map(str::to_owned),
838 asking: Asking {
839 asked: number("asked", 0),
840 budget: number("budget", DEFAULT_BUDGET),
841 prompts: settings["prompts"] == true,
842 },
843 parts,
844 mcp_digests: mcp
845 .iter()
846 .map(|t| (t.name.clone(), t.digest.clone()))
847 .collect(),
848 stop,
849 observer: self.progress.clone().map(|p| {
850 (
851 task_name.rsplit('/').next().unwrap_or_default().to_owned(),
852 p,
853 )
854 }),
855 finish: |report| {
856 if report["status"] == "incomplete" {
859 "incomplete"
860 } else {
861 "completed"
862 }
863 },
864 },
865 )
866 .await
867 }
868}
869
870fn relativise(arguments: &mut Value, root: Option<&str>) {
875 let (Some(root), Some(path)) = (root, arguments.get("path").and_then(Value::as_str)) else {
876 return;
877 };
878 let root = root.trim_end_matches('/');
879 if let Some(rel) = path.strip_prefix(root).and_then(|r| r.strip_prefix('/')) {
880 let rel = if rel.is_empty() {
881 ".".to_owned()
882 } else {
883 rel.to_owned()
884 };
885 arguments["path"] = Value::String(rel);
886 }
887}
888
889fn emit(
891 observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
892 turn: u32,
893 entry: &Value,
894) {
895 emit_with_tail(observer, turn, entry, Vec::new());
896}
897
898fn emit_with_tail(
901 observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
902 turn: u32,
903 entry: &Value,
904 tail: Vec<String>,
905) {
906 if let Some((task, p)) = observer {
907 let tool = entry["tool"].as_str().unwrap_or_default();
908 p.event(&crate::progress::Event::ToolCall {
909 task: task.clone(),
910 turn,
911 tool: tool.to_owned(),
912 target: crate::progress::target(tool, &entry["arguments"]),
913 decision: entry["decision"].as_str().unwrap_or_default().to_owned(),
914 ok: entry["ok"].as_bool(),
915 tail,
916 });
917 }
918}
919
920fn needs_person(
922 observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
923 asked: &agentplane::model::ToolCall,
924 object: Option<&Object>,
925) {
926 if let Some((task, p)) = observer {
927 let (tool, arguments) = (asked.name.as_str(), &asked.arguments);
928 let target = crate::progress::target(tool, arguments);
929 let what: String = format!("{tool} {target}")
930 .chars()
931 .filter(|c| !c.is_control())
932 .take(60)
933 .collect();
934 p.event(&crate::progress::Event::ApprovalRequested {
935 task: task.clone(),
936 call: asked.id.clone(),
937 tool: tool.to_owned(),
938 what,
939 class: object
940 .map(|o| o.class().as_str().to_owned())
941 .unwrap_or_default(),
942 object: object.map(Object::short).unwrap_or_default(),
943 });
944 }
945}
946
947fn person_decided(
949 observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
950 asked: &agentplane::model::ToolCall,
951 decision: &str,
952 scope: Option<&str>,
953 who: Option<&Who>,
954 object: Option<&Object>,
955) {
956 if let Some((task, p)) = observer {
957 p.event(&crate::progress::Event::ApprovalDecided {
958 task: task.clone(),
959 call: asked.id.clone(),
960 decision: decision.to_owned(),
961 scope: scope.map(str::to_owned),
962 by: who.map_or_else(|| "nobody (expired)".to_owned(), |w| w.name.clone()),
963 how: who.map_or_else(|| "expired".to_owned(), |w| w.basis.clone()),
964 class: object
965 .map(|o| o.class().as_str().to_owned())
966 .unwrap_or_default(),
967 object: object.map(Object::short).unwrap_or_default(),
968 });
969 }
970}
971
972struct Question<'a> {
974 asked: &'a agentplane::model::ToolCall,
975 reason: &'a str,
976 turn: u32,
977 said: &'a str,
978 asker: Option<&'a Asker>,
979 policy: &'a Policy,
980 object: Option<&'a Object>,
981 remembered: &'a [(Object, &'static str)],
982 parts: &'a [crate::approvals::derived::Part],
983 run: Option<&'a str>,
984}
985
986fn question(q: &Question<'_>) -> Request {
990 let (tool, arguments) = (q.asked.name.as_str(), &q.asked.arguments);
991 let kind = crate::approvals::kind_of(tool, arguments);
992 let risk = crate::approvals::risk(tool, q.reason);
993 let always_risk = if kind == "network" {
995 let mut bare = arguments.clone();
996 if let Some(o) = bare.as_object_mut() {
997 o.remove("network");
998 }
999 match q.policy.base(tool, &bare) {
1000 Decision::Allow => "low",
1001 Decision::Approval(r) => crate::approvals::risk(tool, &r),
1002 Decision::Deny(_) => "high",
1003 }
1004 } else {
1005 risk
1006 };
1007 let (sources, more) = crate::approvals::derived::sources(tool, arguments, q.parts);
1008 let changed = q.object.and_then(|o| {
1009 q.remembered
1010 .iter()
1011 .map(|(r, _)| r)
1012 .chain(q.policy.approved())
1013 .find_map(|earlier| o.changed_from(earlier))
1014 });
1015 Request {
1016 task: q.asker.map(|a| a.task.clone()).unwrap_or_default(),
1017 task_title: q.asker.map(|a| a.task_title.clone()).unwrap_or_default(),
1018 turn: q.turn,
1019 tool: tool.to_owned(),
1020 arguments: arguments.clone(),
1021 kind: kind.to_owned(),
1022 reason: q.reason.to_owned(),
1023 object: q.object.cloned(),
1024 changed,
1025 risk: risk.to_owned(),
1026 claim: crate::approvals::claim(q.said),
1027 options: crate::approvals::options(
1028 risk,
1029 q.object.is_some(),
1030 !sources.is_empty(),
1031 kind,
1032 always_risk,
1033 ),
1034 sources,
1035 more,
1036 run: q.run.map(str::to_owned),
1037 asked: 0,
1038 budget: 0,
1039 batched: false,
1040 }
1041}
1042
1043pub(crate) struct LoopSpec {
1045 pub tools: Vec<ToolDeclaration>,
1047 pub policy: Policy,
1049 pub max_turns: u32,
1051 pub max_tool_calls: u32,
1053 pub reasoning: bool,
1055 pub schema: Option<Value>,
1057 pub routes: Vec<(String, String, String)>,
1059 pub root: Option<String>,
1062 pub frozen_additions: bool,
1064 pub finish: fn(&Value) -> &'static str,
1066 pub ask: Option<Asker>,
1068 pub run_approved: Vec<Object>,
1070 pub once: Vec<Object>,
1073 pub observer: Option<(String, std::sync::Arc<dyn crate::progress::Progress>)>,
1075 pub run: Option<String>,
1077 pub asking: Asking,
1080 pub parts: Vec<crate::approvals::derived::Part>,
1083 pub mcp_digests: std::collections::BTreeMap<String, String>,
1085 pub stop: StopGate,
1087 pub max_tokens: Option<u64>,
1089 pub reads: Option<u32>,
1091 pub halt: Option<agentplane::model::Stop>,
1095}
1096
1097#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1099pub struct Asking {
1100 pub asked: u32,
1102 pub budget: u32,
1104 pub prompts: bool,
1107}
1108
1109impl Default for Asking {
1110 fn default() -> Self {
1111 Self {
1112 asked: 0,
1113 budget: DEFAULT_BUDGET,
1114 prompts: false,
1115 }
1116 }
1117}
1118
1119pub use super::run_config::DEFAULT_BUDGET;
1121
1122pub(crate) async fn tool_loop(
1126 cx: &mut StepCtx<'_>,
1127 prompt: &Tainted<Value>,
1128 spec: LoopSpec,
1129) -> Result<Outcome, SkillError> {
1130 let mut transcript = Transcript::new(prompt);
1133 tool_turn(cx, prompt, spec, &mut transcript).await
1134}
1135
1136pub(crate) async fn tool_turn(
1139 cx: &mut StepCtx<'_>,
1140 prompt: &Tainted<Value>,
1141 spec: LoopSpec,
1142 transcript: &mut Transcript,
1143) -> Result<Outcome, SkillError> {
1144 let LoopSpec {
1145 tools,
1146 policy,
1147 max_turns,
1148 max_tool_calls,
1149 reasoning,
1150 schema,
1151 routes,
1152 root,
1153 frozen_additions,
1154 finish,
1155 ask,
1156 run_approved,
1157 mut once,
1158 observer,
1159 run,
1160 asking,
1161 mut parts,
1162 mcp_digests,
1163 stop,
1164 max_tokens,
1165 reads,
1166 halt,
1167 } = spec;
1168 let mut next_read = reads;
1170 let mut proposed = false;
1171 let mut remembered: Vec<(Object, &'static str)> =
1174 run_approved.into_iter().map(|g| (g, "run-grant")).collect();
1175 let mut prompted = asking.asked;
1176 let mut told_budget = false;
1177 let catalog = crate::tools::catalog();
1178
1179 let mut conversation: Label = transcript.label().clone();
1180 let mut calls = 0u32;
1181 let mut denied_in_a_row = 0u32;
1182 let mut asked_to_stop = false;
1184 let mut obligation = false;
1186 let mut usage = agentplane::model::Usage::default();
1187 let mut log: Vec<Value> = Vec::new();
1188
1189 let end = |status: &str,
1190 extra: Value,
1191 log: &[Value],
1192 usage: agentplane::model::Usage,
1193 conversation: &Label,
1194 prompted: u32| {
1195 let mut out = json!({
1196 "status": status,
1197 "tool_calls": log,
1198 "asked": prompted,
1199 "usage": {
1200 "input_tokens": usage.input_tokens,
1201 "output_tokens": usage.output_tokens,
1202 "cache_read_tokens": usage.cache_read_tokens,
1203 "cache_write_tokens": usage.cache_write_tokens,
1204 },
1205 });
1206 if let (Some(o), Some(e)) = (out.as_object_mut(), extra.as_object()) {
1207 o.extend(e.clone());
1208 }
1209 Ok(Outcome::done(Tainted::with_label(
1210 out,
1211 conversation.clone(),
1212 )))
1213 };
1214
1215 for turn in 0..max_turns {
1216 if let Some(max) = max_tokens
1218 && usage.input_tokens + usage.output_tokens >= max
1219 {
1220 return end(
1221 "budget",
1222 json!({ "reason": format!("this turn used {} of its {max} tokens", usage.input_tokens + usage.output_tokens) }),
1223 &log,
1224 usage,
1225 &conversation,
1226 prompted,
1227 );
1228 }
1229 let tools_now = tools.clone();
1233 let schema_now = schema.clone();
1234 let read_so_far = &*transcript;
1235 let stop_now = halt.clone();
1236 let completion = cx
1237 .complete_with(prompt, move |call| {
1238 let call = call.with_tools(tools_now).continuing(read_so_far);
1239 let call = match stop_now {
1240 Some(stop) => call.stopped_by(stop),
1241 None => call,
1242 };
1243 let call = match schema_now {
1244 Some(schema) => call.expecting(schema),
1245 None => call,
1246 };
1247 if reasoning {
1248 call.with_reasoning_summary()
1249 } else {
1250 call
1251 }
1252 })
1253 .await?;
1254 conversation = transcript.label().join(completion.label());
1255 usage = usage.plus(completion.peek().usage);
1256 if let Some((task, p)) = &observer {
1257 p.event(&crate::progress::Event::Usage {
1258 task: task.clone(),
1259 input_tokens: usage.input_tokens,
1260 output_tokens: usage.output_tokens,
1261 cache_read_tokens: usage.cache_read_tokens,
1262 cache_write_tokens: usage.cache_write_tokens,
1263 usd: None,
1264 });
1265 }
1266
1267 if let (Some((task, p)), Some(reasoning)) =
1270 (&observer, completion.peek().reasoning.as_deref())
1271 && !reasoning.trim().is_empty()
1272 {
1273 let text: String = crate::approvals::present::escape(reasoning.trim())
1274 .chars()
1275 .take(4000)
1276 .collect();
1277 p.event(&crate::progress::Event::Reasoning {
1278 task: task.clone(),
1279 turn: turn + 1,
1280 text,
1281 });
1282 }
1283 if completion.peek().stopped
1284 || halt
1285 .as_ref()
1286 .is_some_and(agentplane::model::Stop::is_stopped)
1287 {
1288 return end(
1289 "stopped",
1290 json!({ "text": completion.peek().text }),
1291 &log,
1292 usage,
1293 &conversation,
1294 prompted,
1295 );
1296 }
1297 if completion.peek().truncated {
1298 return Ok(Outcome::fail(
1299 "the model ran out of output budget mid-turn; nothing it was still writing was executed — raise [model] max_output_tokens",
1300 ));
1301 }
1302 let asked_calls = transcript.asked(&completion);
1303 let said = super::said(completion.peek());
1307 if let (Some((task, p)), Some(said)) = (observer.as_ref(), said.as_deref()) {
1308 let text: String = crate::approvals::present::escape(said)
1309 .chars()
1310 .take(2000)
1311 .collect();
1312 p.event(&crate::progress::Event::Said {
1313 task: task.clone(),
1314 turn: turn + 1,
1315 text,
1316 });
1317 }
1318 if asked_calls.is_empty() && schema.is_none() {
1319 return end(
1320 "answered",
1321 json!({ "text": completion.peek().text }),
1322 &log,
1323 usage,
1324 &conversation,
1325 prompted,
1326 );
1327 }
1328 if asked_calls.is_empty() {
1329 let report = completion
1330 .peek()
1331 .structured
1332 .clone()
1333 .unwrap_or_else(|| json!({ "summary": completion.peek().text, "status": "incomplete", "files_changed": [], "checks_run": [], "notes": ["the answer was not the requested structure"] }));
1334 let status = finish(&report);
1335 return end(
1336 status,
1337 json!({ "report": report }),
1338 &log,
1339 usage,
1340 &conversation,
1341 prompted,
1342 );
1343 }
1344
1345 for mut asked in asked_calls {
1346 relativise(&mut asked.arguments, root.as_deref());
1347 if let Some(o) = asked.arguments.as_object_mut() {
1349 o.remove(crate::tools::EXPECT);
1350 }
1351 calls += 1;
1352 if calls > max_tool_calls {
1353 return end(
1354 "budget_exhausted",
1355 json!({ "reason": format!("more than {max_tool_calls} tool calls (`[budget] max_tool_calls`)") }),
1356 &log,
1357 usage,
1358 &conversation,
1359 prompted,
1360 );
1361 }
1362 if asked.name == ASK_PERSON && tools.iter().any(|t| t.name == ASK_PERSON) {
1363 let question = asked.arguments["question"]
1364 .as_str()
1365 .unwrap_or_default()
1366 .to_owned();
1367 let decided = match &ask {
1370 Some(asker) => Some(
1371 wait_for_person(
1372 cx,
1373 asker,
1374 &mut obligation,
1375 &crate::approvals::tasks::question_task(&asker.task, &question),
1376 )
1377 .await?,
1378 ),
1379 None => None,
1380 };
1381 let read = decided.as_ref().map(crate::approvals::tasks::read);
1382 let by = read.as_ref().and_then(|r| r.by.clone());
1383 let entry = json!({
1384 "tool": ASK_PERSON,
1385 "arguments": asked.arguments,
1386 "decision": "allow",
1387 "answered": read.as_ref().is_some_and(|r| r.text.is_some()),
1388 "answer": read.as_ref().and_then(|r| r.text.clone()),
1390 "operator": by.as_ref().map(Who::view),
1391 "via": by.as_ref().map(|w| w.via.clone()),
1392 });
1393 emit(observer.as_ref(), turn + 1, &entry);
1394 log.push(entry);
1395 let text = match (&read, &by) {
1396 (Some(r), _) if r.text.is_some() => r.text.clone().unwrap_or_default(),
1397 (Some(_), Some(w)) => format!("{} declined to answer.", w.name),
1398 _ => "Nobody can answer now. Record the question in the artifact as [NEEDS CLARIFICATION: <question>] and continue.".to_owned(),
1399 };
1400 let label = match &by {
1401 Some(_) => provenance::from(provenance::PERSON),
1402 None => Label::trusted(),
1403 };
1404 transcript.answered(
1405 asked,
1406 &Tainted::with_label(json!({ "answer": text }), label),
1407 );
1408 continue;
1409 }
1410 if asked.name == REPORT_BLOCKED && tools.iter().any(|t| t.name == REPORT_BLOCKED) {
1411 let refused = stop.rejected || log.iter().any(|e| e["decision"] == "deny");
1412 let left = max_turns - turn - 1;
1413 match judge_stop(&stop, &asked.arguments, refused, asked_to_stop, left) {
1414 Stop::Stopped(reason) => {
1415 let entry = json!({ "tool": REPORT_BLOCKED, "arguments": asked.arguments, "decision": "stop" });
1416 emit(observer.as_ref(), turn + 1, &entry);
1417 log.push(entry);
1418 return end(
1419 "blocked",
1420 json!({ "reason": reason }),
1421 &log,
1422 usage,
1423 &conversation,
1424 prompted,
1425 );
1426 }
1427 Stop::Asked(text) => {
1428 asked_to_stop = true;
1429 let entry = json!({ "tool": REPORT_BLOCKED, "arguments": asked.arguments, "decision": "challenged" });
1430 emit(observer.as_ref(), turn + 1, &entry);
1431 log.push(entry);
1432 transcript.answered(
1433 asked,
1434 &Tainted::trusted(json!({ "stopped": false, "note": text })),
1435 );
1436 }
1437 Stop::Refused(text) => {
1438 let entry = json!({ "tool": REPORT_BLOCKED, "arguments": asked.arguments, "decision": "unproven", "reason": text });
1439 emit(observer.as_ref(), turn + 1, &entry);
1440 log.push(entry);
1441 transcript.failed(asked, Tainted::trusted(text));
1442 }
1443 }
1444 continue;
1445 }
1446 if asked.name == crate::ask::PROPOSE && tools.iter().any(|t| t.name == asked.name) {
1447 let entry = json!({ "tool": asked.name, "arguments": asked.arguments, "decision": if proposed { "deny" } else { "proposed" } });
1449 emit(observer.as_ref(), turn + 1, &entry);
1450 log.push(entry);
1451 if proposed {
1452 transcript.failed(
1453 asked,
1454 Tainted::trusted(
1455 "one proposal per turn; this one was not shown".to_owned(),
1456 ),
1457 );
1458 } else {
1459 proposed = true;
1460 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" })));
1461 }
1462 continue;
1463 }
1464 let declared = tools.iter().any(|t| t.name == asked.name);
1465 let target = catalog
1466 .iter()
1467 .find(|t| declared && t.name == asked.name)
1468 .map(|t| ToolId::new(t.server, t.name))
1469 .or_else(|| {
1470 routes
1471 .iter()
1472 .find(|(n, _, _)| declared && *n == asked.name)
1473 .map(|(_, server, tool)| ToolId::new(server, tool))
1474 });
1475 let Some(target) = target else {
1476 let entry = json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "unknown" });
1477 emit(observer.as_ref(), turn + 1, &entry);
1478 log.push(entry);
1479 transcript.failed(asked, Tainted::trusted("no tool of that name".to_owned()));
1480 continue;
1481 };
1482 let mut decision = policy.base(&asked.name, &asked.arguments);
1483 let mut additions_only = false;
1487 if frozen_additions
1488 && matches!(&decision, Decision::Approval(r) if r.contains("frozen test"))
1489 {
1490 let path = asked.arguments["path"]
1491 .as_str()
1492 .unwrap_or_default()
1493 .to_owned();
1494 let read = cx
1495 .call_tool(
1496 ToolId::new("fs", "read_file"),
1497 Tainted::with_label(json!({ "path": path }), completion.label().clone()),
1498 )
1499 .await?;
1500 let current = read.peek().clone();
1501 let lines = current["content"].as_str().unwrap_or_default();
1504 let old_text = if lines.is_empty() {
1505 String::new()
1506 } else {
1507 format!("{lines}\n")
1508 };
1509 let old = old_text.as_str();
1510 let readable = current["ok"] == true && current["truncated"] != true;
1511 let new = match asked.name.as_str() {
1512 "write_file" => asked.arguments["content"].as_str().map(str::to_owned),
1513 "edit_file" => {
1514 let (from, to) = (
1515 asked.arguments["old_string"].as_str().unwrap_or_default(),
1516 asked.arguments["new_string"].as_str().unwrap_or_default(),
1517 );
1518 let all = asked.arguments["replace_all"] == true;
1519 (!from.is_empty() && (all || old.matches(from).count() == 1))
1520 .then(|| old.replace(from, to))
1521 }
1522 _ => None,
1523 };
1524 if readable && new.is_some_and(|n| crate::integrity::only_additions(old, &n)) {
1525 decision = Decision::Allow;
1526 additions_only = true;
1527 }
1528 }
1529 let mut object: Option<Object> = None;
1533 if matches!(decision, Decision::Approval(_)) {
1534 if asked.name.starts_with(crate::mcp::PREFIX) {
1535 object = mcp_digests.get(&asked.name).map(|digest| Object::Tool {
1536 name: asked.name.clone(),
1537 digest: digest.clone(),
1538 });
1539 } else {
1540 let resolved = cx
1541 .call_tool(
1542 ToolId::new("fs", "resolve"),
1543 Tainted::with_label(
1544 json!({ "tool": asked.name, "arguments": asked.arguments }),
1545 completion.label().clone(),
1546 ),
1547 )
1548 .await?;
1549 let out = resolved.peek();
1550 if out["ok"] == true {
1551 object = serde_json::from_value(out["object"].clone()).ok();
1552 } else {
1553 decision = Decision::Deny(
1554 out["error"]
1555 .as_str()
1556 .unwrap_or("the call's object cannot be resolved")
1557 .to_owned(),
1558 );
1559 }
1560 }
1561 if matches!(decision, Decision::Approval(_)) {
1562 decision = policy.classify(&asked.name, &asked.arguments, object.as_ref());
1563 }
1564 }
1565 let mut person: Option<Value> = None;
1566 if decision == Decision::Allow && object.is_some() {
1569 person = Some(json!({
1570 "approved_by": "person",
1571 "scope": "task-grant",
1572 "grant": { "scope": "task", "run": run },
1573 }));
1574 }
1575 let mut note: Option<String> = None;
1576 if matches!(decision, Decision::Approval(_))
1578 && let Some(at) = object
1579 .as_ref()
1580 .and_then(|o| once.iter().position(|r| r == o))
1581 {
1582 once.remove(at);
1583 person = Some(json!({
1584 "approved_by": "person",
1585 "scope": "once-grant",
1586 "grant": { "scope": "once", "run": run },
1587 }));
1588 decision = Decision::Allow;
1589 }
1590 if matches!(decision, Decision::Approval(_))
1592 && let Some((_, scope)) = object
1593 .as_ref()
1594 .and_then(|o| remembered.iter().find(|(r, _)| r == o))
1595 {
1596 person = Some(json!({
1597 "approved_by": "person",
1598 "scope": scope,
1599 "grant": { "scope": scope.trim_end_matches("-grant"), "run": run },
1600 }));
1601 decision = Decision::Allow;
1602 }
1603 decision = policy.settle(decision);
1606 let request = match &decision {
1608 Decision::Approval(reason) => Some(question(&Question {
1609 asked: &asked,
1610 reason,
1611 turn: turn + 1,
1612 said: said.as_deref().unwrap_or_default(),
1613 asker: ask.as_ref(),
1614 policy: &policy,
1615 object: object.as_ref(),
1616 remembered: &remembered,
1617 parts: &parts,
1618 run: run.as_deref(),
1619 })),
1620 _ => None,
1621 };
1622 if let (Some(mut request), Some(asker)) = (request.clone(), &ask) {
1624 if asking.prompts {
1628 if prompted < asking.budget {
1629 prompted += 1;
1630 request.asked = prompted;
1631 } else {
1632 request.batched = true;
1633 }
1634 request.budget = asking.budget;
1635 }
1636 if request.batched {
1637 if !told_budget {
1640 needs_person(observer.as_ref(), &asked, object.as_ref());
1641 }
1642 if !told_budget && let Some((_, p)) = &observer {
1643 p.event(&crate::progress::Event::Notice {
1644 text: format!(
1645 "approval budget ({}) reached: calls wait in Needs You (speccoder approvals)",
1646 asking.budget
1647 ),
1648 });
1649 }
1650 told_budget = true;
1651 } else {
1652 needs_person(observer.as_ref(), &asked, object.as_ref());
1653 }
1654 let decided = wait_for_person(
1655 cx,
1656 asker,
1657 &mut obligation,
1658 &crate::approvals::tasks::approval_task(&request, &log),
1659 )
1660 .await?;
1661 let read = crate::approvals::tasks::read(&decided);
1662 if read.resume_asking {
1664 prompted = 0;
1665 }
1666 let answer: Answer = read.answer.clone();
1667 let approver = serde_json::to_value(&answer.by).unwrap_or(Value::Null);
1669 let operator = read.by.as_ref().map(Who::view);
1670 person_decided(
1671 observer.as_ref(),
1672 &asked,
1673 if answer.decision == Said::Approve {
1674 "approve"
1675 } else {
1676 "reject"
1677 },
1678 (answer.decision == Said::Approve).then(|| answer.scope.name()),
1679 read.by.as_ref().filter(|_| !read.expired),
1680 object.as_ref(),
1681 );
1682 match answer.decision {
1683 Said::Approve => {
1684 let mut p = json!({
1685 "approved_by": "person",
1686 "scope": answer.scope.name(),
1687 "note": answer.note,
1688 "approver": approver,
1689 "operator": operator,
1690 });
1691 if answer.always {
1692 p["always"] = json!(true);
1693 }
1694 if let (Some(o), Scope::Task | Scope::Run) = (&object, answer.scope) {
1695 let remembered_as = if answer.scope == Scope::Run {
1696 "run-grant"
1697 } else {
1698 "task-grant"
1699 };
1700 p["grant"] = json!({ "scope": answer.scope.name(), "run": run });
1701 remembered.push((o.clone(), remembered_as));
1702 }
1703 person = Some(p);
1704 note = answer.note;
1705 decision = Decision::Allow;
1706 }
1707 Said::Reject | Said::Stop => {
1710 let why = answer
1711 .reason
1712 .unwrap_or_else(|| "no reason given".to_owned());
1713 person = Some(if read.expired {
1714 json!({ "expired": true, "rejection": why })
1715 } else {
1716 json!({ "rejected_by": "person", "approver": approver, "operator": operator, "rejection": why })
1717 });
1718 decision = Decision::Deny(format!("a person rejected this: {why}"));
1719 }
1720 }
1721 }
1722 let rule = person
1725 .is_none()
1726 .then(|| policy.rule(&asked.name, &asked.arguments, object.as_ref()));
1727 let with_person = |mut entry: Value| {
1728 if let Some(o) = entry.as_object_mut() {
1729 if let Some(obj) = &object {
1731 o.insert("object".to_owned(), json!(obj));
1732 }
1733 match (person.as_ref().and_then(Value::as_object), &rule) {
1734 (Some(p), _) => {
1735 let by = if p.contains_key("expired") {
1737 "expiry"
1738 } else {
1739 "person"
1740 };
1741 o.insert("by".to_owned(), json!(by));
1742 o.extend(p.clone());
1743 }
1744 (None, Some(r)) => {
1745 o.insert("by".to_owned(), json!("rule"));
1746 o.insert("rule".to_owned(), json!(r));
1747 }
1748 (None, None) => {}
1749 }
1750 }
1751 entry
1752 };
1753 match decision {
1754 Decision::Deny(reason) => {
1755 let entry = with_person(
1756 json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "deny", "reason": reason }),
1757 );
1758 emit(observer.as_ref(), turn + 1, &entry);
1759 log.push(entry);
1760 transcript.failed(
1763 asked,
1764 refusal(format!("denied: {reason}"), completion.label()),
1765 );
1766 denied_in_a_row += 1;
1769 if denied_in_a_row >= MAX_CONSECUTIVE_DENIALS {
1770 return end(
1771 "denied",
1772 json!({ "reason": format!("{denied_in_a_row} calls in a row were denied; last: {reason}") }),
1773 &log,
1774 usage,
1775 &conversation,
1776 prompted,
1777 );
1778 }
1779 continue;
1780 }
1781 Decision::Approval(reason) => {
1782 let mut entry = json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "approval", "reason": reason });
1783 if let Some(obj) = &object {
1784 entry["object"] = json!(obj);
1785 }
1786 emit(observer.as_ref(), turn + 1, &entry);
1787 needs_person(observer.as_ref(), &asked, object.as_ref());
1788 log.push(entry);
1789 return end(
1790 "needs_approval",
1791 json!({ "pending": {
1792 "tool": asked.name,
1793 "arguments": asked.arguments,
1794 "reason": reason,
1795 "object": object,
1796 "run": run,
1797 "request": request,
1798 } }),
1799 &log,
1800 usage,
1801 &conversation,
1802 prompted,
1803 );
1804 }
1805 Decision::Allow => {
1806 denied_in_a_row = 0;
1807 let mut arguments = asked.arguments.clone();
1814 if asked.name == "run_command"
1815 && let Some(o) = arguments.as_object_mut()
1816 {
1817 o.insert("call_id".to_owned(), Value::String(asked.id.clone()));
1818 }
1819 if let (Some(obj), Some(o)) = (&object, arguments.as_object_mut())
1822 && matches!(
1823 asked.name.as_str(),
1824 "write_file" | "edit_file" | "run_command"
1825 )
1826 {
1827 o.insert(crate::tools::EXPECT.to_owned(), json!(obj));
1828 }
1829 let args = Tainted::with_label(arguments, completion.label().clone());
1830 let result = match cx.call_tool(target.clone(), args).await {
1831 Ok(r) => r,
1832 Err(error) => match cx.relayed(&error) {
1835 Some(detail) => {
1836 let entry = with_person(
1837 json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "allow", "ok": false }),
1838 );
1839 emit(observer.as_ref(), turn + 1, &entry);
1840 log.push(entry);
1841 transcript.failed(asked, detail);
1842 continue;
1843 }
1844 None => return Err(error.into()),
1845 },
1846 };
1847 let output = result.peek().clone();
1848 let ok = output["ok"].as_bool() != Some(false);
1849 let mut entry = with_person(
1850 json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "allow", "ok": ok }),
1851 );
1852 let result = match next_read.as_mut() {
1854 Some(n) if ok => {
1855 let id = format!("r{n}");
1856 *n += 1;
1857 entry["read"] = json!(crate::ask::read_of(
1858 &id,
1859 &asked.name,
1860 &asked.arguments,
1861 &output
1862 ));
1863 result.map(|mut v| {
1864 if let Some(o) = v.as_object_mut() {
1865 o.insert("read".to_owned(), json!(id));
1866 }
1867 v
1868 })
1869 }
1870 _ => result,
1871 };
1872 if let Some(changed) = output["object_changed"].as_object() {
1873 entry["decision"] = json!("refused_changed");
1874 entry["expected"] = changed.get("expected").cloned().unwrap_or(Value::Null);
1875 entry["found"] = changed.get("found").cloned().unwrap_or(Value::Null);
1876 }
1877 if let Some(part) = crate::approvals::derived::part_of(
1880 &asked.name,
1881 &asked.arguments,
1882 &output,
1883 turn + 1,
1884 &format!("tool://{}/{}", target.server, target.tool),
1885 |p: &str| {
1886 policy.protected().write_class(p)
1887 == Some(crate::tools::paths::Class::Intent)
1888 },
1889 ) {
1890 parts.push(part);
1891 }
1892 if additions_only {
1893 entry["frozen_additions"] = json!(true);
1894 }
1895 let tail = if asked.name == "run_command" && !ok {
1896 crate::progress::tail(&format!(
1897 "{}\n{}",
1898 output["stdout"].as_str().unwrap_or_default(),
1899 output["stderr"].as_str().unwrap_or_default()
1900 ))
1901 } else {
1902 Vec::new()
1903 };
1904 emit_with_tail(observer.as_ref(), turn + 1, &entry, tail);
1905 log.push(entry);
1906 if let Some(drift) = output["integrity"].as_array().filter(|d| !d.is_empty()) {
1909 let paths: Vec<&str> =
1910 drift.iter().filter_map(|d| d["path"].as_str()).collect();
1911 return end(
1912 "integrity",
1913 json!({
1914 "reason": format!("a command changed what judges the task: {}", paths.join(", ")),
1915 "integrity": drift,
1916 }),
1917 &log,
1918 usage,
1919 &conversation,
1920 prompted,
1921 );
1922 }
1923 match output["error"].as_str() {
1929 Some(error) if !ok => transcript.failed(
1930 asked,
1931 Tainted::with_label(error.to_owned(), result.label().clone()),
1932 ),
1933 _ => match ¬e {
1935 Some(n) => {
1936 let n = format!("Note from the person who approved this call: {n}");
1937 transcript.answered(
1938 asked,
1939 &result.map(|mut v| {
1940 v["note_from_person"] = Value::String(n);
1941 v
1942 }),
1943 );
1944 }
1945 None => transcript.answered(asked, &result),
1946 },
1947 }
1948 }
1949 }
1950 }
1951 }
1952 end(
1953 "budget_exhausted",
1954 json!({ "reason": format!("more than {max_turns} model turns (`[budget] max_turns`)") }),
1955 &log,
1956 usage,
1957 &conversation,
1958 prompted,
1959 )
1960}
1961
1962#[cfg(test)]
1963mod tests {
1964 use super::*;
1965
1966 #[test]
1967 fn labels_are_read_from_the_parts_and_refusals_carry_the_call() {
1968 use agentplane::core::{Label, SourceId};
1969 let model = Label::untrusted(SourceId::new("model:x/y"));
1970 assert!(
1972 refusal("denied: `src/x` is protected".to_owned(), &model)
1973 .label()
1974 .is_untrusted()
1975 );
1976 let input = Tainted::object([
1978 ("bundle", Tainted::trusted(json!({}))),
1979 (
1980 "digest",
1981 Tainted::with_label(json!({}), provenance::from(provenance::VERIFIER)),
1982 ),
1983 (
1984 "settings",
1985 Tainted::object([("nudges", Tainted::trusted(json!([])))]),
1986 ),
1987 ]);
1988 let label = conversation_label(&input);
1989 assert!(
1990 label
1991 .provenance
1992 .contains(&SourceId::new(provenance::VERIFIER))
1993 );
1994 let flat = Tainted::with_label(json!({ "bundle": {} }), model.clone());
1996 assert_eq!(conversation_label(&flat).provenance, model.provenance);
1997 }
1998
1999 #[test]
2000 fn absolute_paths_inside_the_root_become_relative_and_others_stay() {
2001 let mut a = json!({ "path": "/work/repo/specs/001-x/plan.md" });
2002 relativise(&mut a, Some("/work/repo"));
2003 assert_eq!(a["path"], "specs/001-x/plan.md");
2004 let mut outside = json!({ "path": "/work/repository-two/x" });
2005 relativise(&mut outside, Some("/work/repo"));
2006 assert_eq!(outside["path"], "/work/repository-two/x");
2007 let mut none = json!({ "path": "/etc/passwd" });
2008 relativise(&mut none, None);
2009 assert_eq!(none["path"], "/etc/passwd");
2010 }
2011
2012 #[test]
2015 fn a_stop_stands_on_evidence_speccoder_checks() {
2016 let gate = StopGate {
2017 spec: "Rows are sorted\n ascending by name.".to_owned(),
2018 frozen: vec!["tests/test_us3.py".to_owned()],
2019 rejected: false,
2020 };
2021 let call = |kind: &str, evidence: &str| json!({ "kind": kind, "evidence": evidence, "reason": "why" });
2022 let judge =
2023 |args: &Value, refused: bool, asked: bool| judge_stop(&gate, args, refused, asked, 5);
2024 let unproven = |s: Stop| matches!(s, Stop::Refused(_));
2025
2026 assert!(unproven(judge(
2028 &json!({ "reason": "beyond the task's module scope" }),
2029 false,
2030 false
2031 )));
2032 assert!(unproven(judge(&call("scope", "src/cli"), false, false)));
2033 assert!(unproven(judge(
2034 &call("wrong_test", "tests/new_test.py"),
2035 false,
2036 false
2037 )));
2038 assert!(unproven(judge(
2039 &call("spec_conflict", "sorted descending by name"),
2040 false,
2041 false
2042 )));
2043 assert!(unproven(judge(
2044 &call("spec_conflict", "sorted"),
2045 false,
2046 false
2047 )));
2048 assert!(unproven(judge(
2049 &call("refused", "cargo add x"),
2050 false,
2051 false
2052 )));
2053
2054 let quote = call("spec_conflict", "sorted ascending by name");
2057 assert!(
2058 matches!(judge("e, false, false), Stop::Asked(t) if t.contains("5 turns are left"))
2059 );
2060 assert_eq!(
2061 judge("e, false, true),
2062 Stop::Stopped("why — the spec says “sorted ascending by name”".to_owned())
2063 );
2064 assert_eq!(
2065 judge(&call("wrong_test", "./tests/test_us3.py"), false, true),
2066 Stop::Stopped("why — frozen test `./tests/test_us3.py`".to_owned())
2067 );
2068 assert!(matches!(
2070 judge(&call("refused", "cargo add x"), true, false),
2071 Stop::Stopped(_)
2072 ));
2073 let q = call("needs_information", "which locale?");
2074 assert!(matches!(
2075 judge_stop(&gate, &q, false, false, 0),
2076 Stop::Stopped(_)
2077 ));
2078 }
2079}