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. You cannot edit the spec ({intent}), the checks' configuration or \
40.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 reason.",
79 json!({
80 "type": "object",
81 "properties": { "reason": { "type": "string" } },
82 "required": ["reason"],
83 "additionalProperties": false,
84 }),
85 )
86}
87
88pub const ASK_PERSON: &str = "ask_person";
90
91#[must_use]
93pub fn ask_person() -> ToolDeclaration {
94 ToolDeclaration::new(
95 ASK_PERSON,
96 "Ask the person one question and wait for their answer. If nobody can answer, the reply says so.",
97 json!({
98 "type": "object",
99 "properties": { "question": { "type": "string" } },
100 "required": ["question"],
101 "additionalProperties": false,
102 }),
103 )
104}
105
106#[derive(Debug, Clone)]
110pub struct Asker {
111 pub task: String,
113 pub task_title: String,
115 pub context: PresentContext,
117 pub wait_secs: u64,
119}
120
121impl Asker {
122 #[must_use]
125 pub fn from_settings(
126 settings: &Value,
127 task: String,
128 task_title: String,
129 context: PresentContext,
130 ) -> Option<Self> {
131 (settings["asks"] == "tasks").then(|| Self {
132 task,
133 task_title,
134 context,
135 wait_secs: settings["wait_secs"].as_u64().unwrap_or(7 * 86_400),
136 })
137 }
138}
139
140async fn wait_for_person(
144 cx: &mut StepCtx<'_>,
145 asker: &Asker,
146 obligation: &mut bool,
147 spec: &agentplane::core::TaskSpec,
148) -> Result<agentplane::core::Decision, SkillError> {
149 if !*obligation {
150 let minutes = u32::try_from(asker.wait_secs.div_ceil(60).max(1)).unwrap_or(u32::MAX);
151 cx.deadline(
152 crate::approvals::tasks::DEADLINE,
153 &agentplane::core::DeadlineSpec::minutes(minutes),
154 None,
155 )
156 .await?;
157 *obligation = true;
158 }
159 Ok(cx.task(spec).await?)
160}
161
162#[derive(Debug)]
169pub struct Attempt {
170 pub style: PromptStyle,
172 pub cache_ttl: CacheTtl,
174 pub progress: Option<std::sync::Arc<dyn crate::progress::Progress>>,
176 pub format: &'static dyn crate::format::Adapter,
178 pub present: PresentContext,
180}
181
182#[must_use]
191pub fn input(
192 bundle: Value,
193 settings: &Settings<'_>,
194 digest: Option<&Value>,
195 instructions: &[crate::instructions::Instruction],
196 test_layout: Option<&str>,
197) -> Tainted<Value> {
198 let digest = match digest.filter(|d| !d.is_null()) {
199 Some(d) => Tainted::with_label(d.clone(), provenance::from(provenance::VERIFIER)),
200 None => Tainted::trusted(Value::Null),
201 };
202 let mcp = Tainted::array(settings.mcp.iter().map(|t| {
204 Tainted::with_label(
205 serde_json::to_value(t).unwrap_or(Value::Null),
206 agentplane::core::Label::untrusted(provenance::tool(&t.server, &t.tool)),
207 )
208 }));
209 let trusted = |v: Value| Tainted::trusted(v);
210 let autopilot = settings.autopilot;
211 let planning = settings.planning;
212 let settings = vec![
213 ("checks", trusted(json!(settings.checks))),
214 ("allow", trusted(json!(settings.allow))),
215 ("max_turns", trusted(json!(settings.max_turns))),
216 ("max_tool_calls", trusted(json!(settings.max_tool_calls))),
217 ("reasoning", trusted(json!(settings.reasoning))),
218 ("frozen", trusted(json!(settings.frozen))),
219 ("approved", trusted(json!(settings.approved))),
220 ("rejected", trusted(json!(settings.rejected))),
221 ("run_approved", trusted(json!(settings.run_approved))),
222 ("approved_once", trusted(json!(settings.approved_once))),
223 ("nudges", trusted(json!(settings.nudges))),
224 ("mcp", mcp),
225 ("root", trusted(json!(settings.root))),
226 (
227 "frozen_additions",
228 trusted(json!(settings.frozen_additions)),
229 ),
230 ("network_allow", trusted(json!(settings.network_allow))),
231 ("allow_prefix", trusted(json!(settings.allow_prefix))),
232 (
233 "network_allow_prefix",
234 trusted(json!(settings.network_allow_prefix)),
235 ),
236 ("run_id", trusted(json!(settings.run_id))),
237 ("asked", trusted(json!(settings.asked))),
238 ("budget", trusted(json!(settings.budget))),
239 ("prompts", trusted(json!(settings.prompts))),
240 ("asks", trusted(json!(settings.asks))),
241 ("wait_secs", trusted(json!(settings.wait_secs))),
242 ("in_place", trusted(json!(settings.in_place))),
243 ("hidden", trusted(json!(settings.hidden))),
244 ];
245 let mut settings = settings;
248 if autopilot {
249 settings.push(("autopilot", trusted(json!(true))));
250 }
251 if let Some(dir) = planning {
253 settings.push(("planning", trusted(json!(dir))));
254 }
255 let settings = Tainted::object(settings);
256 Tainted::object([
257 ("bundle", Tainted::trusted(bundle)),
258 ("digest", digest),
259 ("settings", settings),
260 (
263 "instructions",
264 if instructions.is_empty() {
265 Tainted::trusted(json!([]))
266 } else {
267 Tainted::with_label(json!(instructions), provenance::from(provenance::WORKTREE))
268 },
269 ),
270 (
272 "test_layout",
273 match test_layout {
274 Some(t) => Tainted::with_label(json!(t), provenance::from(provenance::WORKTREE)),
275 None => Tainted::trusted(Value::Null),
276 },
277 ),
278 ])
279}
280
281fn refusal(text: String, call: &agentplane::core::Label) -> Tainted<String> {
285 Tainted::with_label(text, call.clone())
286}
287
288const MESSAGE_PARTS: [&str; 7] = [
290 "/bundle",
291 "/instructions",
292 "/test_layout",
293 "/digest",
294 "/settings/nudges",
295 "/settings/rejected",
296 "/settings/mcp",
297];
298
299fn conversation_label(input: &Tainted<Value>) -> agentplane::core::Label {
303 MESSAGE_PARTS
304 .iter()
305 .map(|p| input.label_at(p).unwrap_or_else(|| input.label()).clone())
306 .fold(agentplane::core::Label::trusted(), |acc, l| acc.join(&l))
307}
308
309#[derive(Debug, Clone, Copy)]
311pub struct Settings<'a> {
312 pub checks: &'a [Vec<String>],
314 pub allow: &'a [Vec<String>],
316 pub allow_prefix: &'a [Vec<String>],
318 pub max_turns: u32,
320 pub max_tool_calls: u32,
322 pub reasoning: bool,
324 pub frozen: &'a [String],
326 pub approved: &'a [Object],
328 pub rejected: &'a [crate::tools::policy::Rejection],
330 pub run_approved: &'a [Object],
332 pub approved_once: &'a [Object],
335 pub nudges: &'a [String],
337 pub mcp: &'a [crate::mcp::McpTool],
339 pub root: &'a str,
341 pub frozen_additions: bool,
343 pub asks: &'a str,
346 pub wait_secs: u64,
348 pub network_allow: &'a [Vec<String>],
350 pub network_allow_prefix: &'a [Vec<String>],
352 pub run_id: &'a str,
354 pub asked: u32,
356 pub budget: u32,
358 pub prompts: bool,
360 pub in_place: bool,
362 pub hidden: &'a [String],
364 pub autopilot: bool,
366 pub planning: Option<&'a str>,
369}
370
371pub const MAX_CONSECUTIVE_DENIALS: u32 = 3;
373
374fn digest_text(digest: &Value) -> String {
376 let mut out = format!(
377 "# Previous attempt\n\nAttempt {} at this task was verified by speccoder and FAILED. Its \
378changes were discarded; the worktree is back where that attempt started. You start fresh — use \
379these facts from the verifier, check them yourself, and do not repeat the mistake.\n",
380 digest["attempt"].as_u64().unwrap_or(1)
381 );
382 let strings = |v: &Value| -> Vec<String> {
383 v.as_array()
384 .map(|a| {
385 a.iter()
386 .filter_map(|x| x.as_str().map(str::to_owned))
387 .collect()
388 })
389 .unwrap_or_default()
390 };
391 let reasons = strings(&digest["reasons"]);
392 if !reasons.is_empty() {
393 out.push_str("\n## Verdict reasons\n\n");
394 for r in reasons {
395 out.push_str(&format!("- {r}\n"));
396 }
397 }
398 for c in digest["checks"].as_array().into_iter().flatten() {
399 out.push_str(&format!(
400 "\n## Check `{}` ({}): `{}`\n",
401 c["name"].as_str().unwrap_or_default(),
402 c["status"].as_str().unwrap_or_default(),
403 strings(&c["argv"]).join(" ")
404 ));
405 let tests = strings(&c["failing_tests"]);
406 if !tests.is_empty() {
407 out.push_str(&format!("\nFailing tests: {}\n", tests.join(", ")));
408 }
409 out.push_str(&format!(
410 "\n```text\n{}\n```\n",
411 c["excerpt"].as_str().unwrap_or_default()
412 ));
413 }
414 let uncollected = strings(&digest["uncollected"]);
415 if !uncollected.is_empty() {
416 out.push_str("\n## Tests that never ran\n\n");
417 for l in uncollected {
418 out.push_str(&format!("- {l}\n"));
419 }
420 }
421 let discarded = strings(&digest["discarded"]);
422 if !discarded.is_empty() {
423 out.push_str(&format!(
424 "\n## Files the discarded attempt changed\n\n{}\n",
425 discarded.join("\n")
426 ));
427 }
428 out
429}
430
431fn argvs(v: &Value) -> Vec<Vec<String>> {
432 v.as_array()
433 .map(|a| {
434 a.iter()
435 .map(|argv| {
436 argv.as_array()
437 .map(|p| {
438 p.iter()
439 .filter_map(|x| x.as_str().map(str::to_owned))
440 .collect()
441 })
442 .unwrap_or_default()
443 })
444 .collect()
445 })
446 .unwrap_or_default()
447}
448
449fn decisions_of<T: serde::de::DeserializeOwned>(
453 settings: &Value,
454 key: &str,
455) -> Result<Vec<T>, SkillError> {
456 match &settings[key] {
457 Value::Null => Ok(Vec::new()),
458 v => serde_json::from_value(v.clone())
459 .map_err(|e| SkillError::Input(format!("settings.{key}: {e}"))),
460 }
461}
462
463pub(crate) fn policy_of(
468 settings: &Value,
469 rules: &'static crate::format::Rules,
470) -> Result<Policy, SkillError> {
471 let frozen: Vec<String> = settings["frozen"]
472 .as_array()
473 .map(|a| {
474 a.iter()
475 .filter_map(|v| v.as_str().map(str::to_owned))
476 .collect()
477 })
478 .unwrap_or_default();
479 let approved: Vec<Object> = decisions_of(settings, "approved")?;
480 let rejected: Vec<crate::tools::policy::Rejection> = decisions_of(settings, "rejected")?;
481 let mcp: Vec<crate::mcp::McpTool> =
482 serde_json::from_value(settings["mcp"].clone()).unwrap_or_default();
483 let policy = Policy::new(&argvs(&settings["checks"]), &argvs(&settings["allow"]))
484 .with_prefixes(argvs(&settings["allow_prefix"]))
485 .with_network_prefixes(argvs(&settings["network_allow_prefix"]))
486 .rules(rules)
487 .with_frozen(frozen)
488 .with_decisions(approved, rejected)
489 .with_mcp(mcp.iter().map(|t| (t.name.clone(), t.decision.clone())))
490 .with_network(argvs(&settings["network_allow"]))
491 .with_hidden(
492 serde_json::from_value::<Vec<String>>(settings["hidden"].clone()).unwrap_or_default(),
493 );
494 let policy = if settings["in_place"] == true {
495 policy.in_place()
496 } else {
497 policy
498 };
499 let policy = if settings["autopilot"] == true {
500 policy.autopilot()
501 } else {
502 policy
503 };
504 Ok(match settings["planning"].as_str() {
505 Some(dir) => policy.planning(dir),
506 None => policy,
507 })
508}
509
510#[async_trait]
511impl Skill for Attempt {
512 fn descriptor(&self) -> SkillDescriptor {
513 SkillDescriptor::new("speccoder.task.attempt").provides(CAPABILITY)
514 }
515
516 async fn invoke(
517 &self,
518 cx: &mut StepCtx<'_>,
519 input: Tainted<Value>,
520 ) -> Result<Outcome, SkillError> {
521 let (ttl, style) = (self.cache_ttl, self.style);
522 let settings = input.peek()["settings"].clone();
523 let task_name = format!(
524 "{}/{}",
525 input.peek()["bundle"]["feature"]
526 .as_str()
527 .unwrap_or_default(),
528 input.peek()["bundle"]["task"]["id"]
529 .as_str()
530 .unwrap_or_default()
531 );
532 let rejected: Vec<crate::tools::policy::Rejection> = decisions_of(&settings, "rejected")?;
533 let told = rejected;
534 let mcp: Vec<crate::mcp::McpTool> =
535 serde_json::from_value(settings["mcp"].clone()).unwrap_or_default();
536 let planning = settings["planning"].as_str().map(str::to_owned);
537 let policy = policy_of(&settings, self.format.rules())?;
538 let max_turns = settings["max_turns"]
539 .as_u64()
540 .map_or(30, |n| u32::try_from(n).unwrap_or(u32::MAX))
541 .max(1);
542 let max_tool_calls = settings["max_tool_calls"]
543 .as_u64()
544 .map_or(100, |n| u32::try_from(n).unwrap_or(u32::MAX));
545 let label = conversation_label(&input);
548 let raw = input.peek().clone();
549 let prompt = provenance::labelled_prompt(
550 {
551 let input = raw;
552 let mut p = layout(
553 &input["bundle"],
554 ttl,
555 style,
556 &self::policy(self.format.wording()),
557 self.format,
558 );
559 let given: Vec<crate::instructions::Instruction> =
560 serde_json::from_value(input["instructions"].clone()).unwrap_or_default();
561 if let Some(text) = crate::instructions::section(&given) {
562 let (key, kind) = match style {
563 PromptStyle::OpenAi => ("input", "input_text"),
564 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
565 ("messages", "text")
566 }
567 };
568 if let Some(content) = p[key][0]["content"].as_array_mut() {
569 content.push(json!({ "type": kind, "text": text }));
570 }
571 }
572 if let Some(text) = input["test_layout"].as_str().filter(|t| !t.is_empty()) {
573 let (key, kind) = match style {
574 PromptStyle::OpenAi => ("input", "input_text"),
575 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
576 ("messages", "text")
577 }
578 };
579 if let Some(content) = p[key][0]["content"].as_array_mut() {
580 content.push(json!({ "type": kind, "text": text }));
581 }
582 }
583 if !input["digest"].is_null() {
584 let (key, kind) = match style {
585 PromptStyle::OpenAi => ("input", "input_text"),
586 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
587 ("messages", "text")
588 }
589 };
590 if let Some(content) = p[key][0]["content"].as_array_mut() {
591 content
592 .push(json!({ "type": kind, "text": digest_text(&input["digest"]) }));
593 }
594 }
595 if let Some(dir) = &planning {
597 let (key, kind) = match style {
598 PromptStyle::OpenAi => ("input", "input_text"),
599 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
600 ("messages", "text")
601 }
602 };
603 let text = format!(
604 "# 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"
605 );
606 if let Some(content) = p[key][0]["content"].as_array_mut() {
607 content.push(json!({ "type": kind, "text": text }));
608 }
609 }
610 let checks: Vec<String> = argvs(&input["settings"]["checks"])
612 .iter()
613 .map(|argv| format!("- `{}`", json!(argv)))
614 .collect();
615 if !checks.is_empty() {
616 let (key, kind) = match style {
617 PromptStyle::OpenAi => ("input", "input_text"),
618 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
619 ("messages", "text")
620 }
621 };
622 let text = format!(
623 "# The checks\n\nRun them with run_command as these argvs, word for word; they need no approval:\n\n{}\n",
624 checks.join("\n")
625 );
626 if let Some(content) = p[key][0]["content"].as_array_mut() {
627 content.push(json!({ "type": kind, "text": text }));
628 }
629 }
630 let notes: Vec<String> = input["settings"]["nudges"]
632 .as_array()
633 .map(|a| {
634 a.iter()
635 .filter_map(|n| n.as_str().map(|n| format!("- {n}")))
636 .collect()
637 })
638 .unwrap_or_default();
639 if !notes.is_empty() {
640 let (key, kind) = match style {
641 PromptStyle::OpenAi => ("input", "input_text"),
642 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
643 ("messages", "text")
644 }
645 };
646 let text = format!("# Notes from a person\n\n{}\n", notes.join("\n"));
647 if let Some(content) = p[key][0]["content"].as_array_mut() {
648 content.push(json!({ "type": kind, "text": text }));
649 }
650 }
651 if !told.is_empty() {
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 let lines: Vec<String> = told
660 .iter()
661 .map(|r| format!("- {}: {}", r.object.describe(), r.reason))
662 .collect();
663 let text = format!(
664 "# Rejected by a person\n\nThese calls were rejected for this task; find another way or call report_blocked:\n\n{}\n",
665 lines.join("\n")
666 );
667 if let Some(content) = p[key][0]["content"].as_array_mut() {
668 content.push(json!({ "type": kind, "text": text }));
669 }
670 }
671 if let PromptStyle::Parts { caches } = style {
672 super::model_config::to_parts(&mut p, caches);
673 }
674 p
675 },
676 &label,
677 );
678 let mut tools = crate::tools::declarations();
679 tools.push(report_blocked());
680 let routes: Vec<(String, String, String)> = mcp
682 .iter()
683 .filter(|t| t.decision != "deny")
684 .map(|t| (t.name.clone(), t.server.clone(), t.tool.clone()))
685 .collect();
686 tools.extend(mcp.iter().filter(|t| t.decision != "deny").map(|t| {
687 ToolDeclaration::new(t.name.clone(), t.description.clone(), t.schema.clone())
688 }));
689 let ask = Asker::from_settings(
691 &settings,
692 task_name.clone(),
693 input.peek()["bundle"]["task"]["title"]
694 .as_str()
695 .unwrap_or_default()
696 .to_owned(),
697 PresentContext {
698 root: settings["root"].as_str().map(std::path::PathBuf::from),
699 ..self.present.clone()
700 },
701 );
702 let remembered: Vec<Object> = decisions_of(&settings, "run_approved")?;
703 let once: Vec<Object> = decisions_of(&settings, "approved_once")?;
704 let mut parts = Vec::new();
706 let given: Vec<crate::instructions::Instruction> =
707 serde_json::from_value(input.peek()["instructions"].clone()).unwrap_or_default();
708 if let Some(text) = crate::instructions::section(&given) {
709 parts.push(crate::approvals::derived::Part {
710 from: provenance::WORKTREE.to_owned(),
711 turn: 0,
712 text,
713 });
714 }
715 if !input.peek()["digest"].is_null() {
716 parts.push(crate::approvals::derived::Part {
717 from: provenance::VERIFIER.to_owned(),
718 turn: 0,
719 text: crate::approvals::derived::text_of(&input.peek()["digest"]),
720 });
721 }
722 let number = |k: &str, d: u32| {
723 settings[k]
724 .as_u64()
725 .map_or(d, |n| u32::try_from(n).unwrap_or(u32::MAX))
726 };
727 tool_loop(
728 cx,
729 &prompt,
730 LoopSpec {
731 tools,
732 policy,
733 max_turns,
734 max_tool_calls,
735 reasoning: settings["reasoning"] == true,
736 schema: report_schema(),
737 routes,
738 root: settings["root"].as_str().map(str::to_owned),
739 frozen_additions: settings["frozen_additions"] == true,
740 ask,
741 run_approved: remembered,
742 once,
743 run: settings["run_id"].as_str().map(str::to_owned),
744 asking: Asking {
745 asked: number("asked", 0),
746 budget: number("budget", DEFAULT_BUDGET),
747 prompts: settings["prompts"] == true,
748 },
749 parts,
750 mcp_digests: mcp
751 .iter()
752 .map(|t| (t.name.clone(), t.digest.clone()))
753 .collect(),
754 observer: self.progress.clone().map(|p| {
755 (
756 task_name.rsplit('/').next().unwrap_or_default().to_owned(),
757 p,
758 )
759 }),
760 finish: |report| {
761 if report["status"] == "incomplete" {
764 "incomplete"
765 } else {
766 "completed"
767 }
768 },
769 },
770 )
771 .await
772 }
773}
774
775fn relativise(arguments: &mut Value, root: Option<&str>) {
780 let (Some(root), Some(path)) = (root, arguments.get("path").and_then(Value::as_str)) else {
781 return;
782 };
783 let root = root.trim_end_matches('/');
784 if let Some(rel) = path.strip_prefix(root).and_then(|r| r.strip_prefix('/')) {
785 let rel = if rel.is_empty() {
786 ".".to_owned()
787 } else {
788 rel.to_owned()
789 };
790 arguments["path"] = Value::String(rel);
791 }
792}
793
794fn emit(
796 observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
797 turn: u32,
798 entry: &Value,
799) {
800 emit_with_tail(observer, turn, entry, Vec::new());
801}
802
803fn emit_with_tail(
806 observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
807 turn: u32,
808 entry: &Value,
809 tail: Vec<String>,
810) {
811 if let Some((task, p)) = observer {
812 let tool = entry["tool"].as_str().unwrap_or_default();
813 p.event(&crate::progress::Event::ToolCall {
814 task: task.clone(),
815 turn,
816 tool: tool.to_owned(),
817 target: crate::progress::target(tool, &entry["arguments"]),
818 decision: entry["decision"].as_str().unwrap_or_default().to_owned(),
819 ok: entry["ok"].as_bool(),
820 tail,
821 });
822 }
823}
824
825fn needs_person(
827 observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
828 asked: &agentplane::model::ToolCall,
829 object: Option<&Object>,
830) {
831 if let Some((task, p)) = observer {
832 let (tool, arguments) = (asked.name.as_str(), &asked.arguments);
833 let target = crate::progress::target(tool, arguments);
834 let what: String = format!("{tool} {target}")
835 .chars()
836 .filter(|c| !c.is_control())
837 .take(60)
838 .collect();
839 p.event(&crate::progress::Event::ApprovalRequested {
840 task: task.clone(),
841 call: asked.id.clone(),
842 tool: tool.to_owned(),
843 what,
844 class: object
845 .map(|o| o.class().as_str().to_owned())
846 .unwrap_or_default(),
847 object: object.map(Object::short).unwrap_or_default(),
848 });
849 }
850}
851
852fn person_decided(
854 observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
855 asked: &agentplane::model::ToolCall,
856 decision: &str,
857 scope: Option<&str>,
858 who: Option<&Who>,
859 object: Option<&Object>,
860) {
861 if let Some((task, p)) = observer {
862 p.event(&crate::progress::Event::ApprovalDecided {
863 task: task.clone(),
864 call: asked.id.clone(),
865 decision: decision.to_owned(),
866 scope: scope.map(str::to_owned),
867 by: who.map_or_else(|| "nobody (expired)".to_owned(), |w| w.name.clone()),
868 how: who.map_or_else(|| "expired".to_owned(), |w| w.basis.clone()),
869 class: object
870 .map(|o| o.class().as_str().to_owned())
871 .unwrap_or_default(),
872 object: object.map(Object::short).unwrap_or_default(),
873 });
874 }
875}
876
877struct Question<'a> {
879 asked: &'a agentplane::model::ToolCall,
880 reason: &'a str,
881 turn: u32,
882 said: &'a str,
883 asker: Option<&'a Asker>,
884 policy: &'a Policy,
885 object: Option<&'a Object>,
886 remembered: &'a [(Object, &'static str)],
887 parts: &'a [crate::approvals::derived::Part],
888 run: Option<&'a str>,
889}
890
891fn question(q: &Question<'_>) -> Request {
895 let (tool, arguments) = (q.asked.name.as_str(), &q.asked.arguments);
896 let kind = crate::approvals::kind_of(tool, arguments);
897 let risk = crate::approvals::risk(tool, q.reason);
898 let always_risk = if kind == "network" {
900 let mut bare = arguments.clone();
901 if let Some(o) = bare.as_object_mut() {
902 o.remove("network");
903 }
904 match q.policy.base(tool, &bare) {
905 Decision::Allow => "low",
906 Decision::Approval(r) => crate::approvals::risk(tool, &r),
907 Decision::Deny(_) => "high",
908 }
909 } else {
910 risk
911 };
912 let (sources, more) = crate::approvals::derived::sources(tool, arguments, q.parts);
913 let changed = q.object.and_then(|o| {
914 q.remembered
915 .iter()
916 .map(|(r, _)| r)
917 .chain(q.policy.approved())
918 .find_map(|earlier| o.changed_from(earlier))
919 });
920 Request {
921 task: q.asker.map(|a| a.task.clone()).unwrap_or_default(),
922 task_title: q.asker.map(|a| a.task_title.clone()).unwrap_or_default(),
923 turn: q.turn,
924 tool: tool.to_owned(),
925 arguments: arguments.clone(),
926 kind: kind.to_owned(),
927 reason: q.reason.to_owned(),
928 object: q.object.cloned(),
929 changed,
930 risk: risk.to_owned(),
931 claim: crate::approvals::claim(q.said),
932 options: crate::approvals::options(
933 risk,
934 q.object.is_some(),
935 !sources.is_empty(),
936 kind,
937 always_risk,
938 ),
939 sources,
940 more,
941 run: q.run.map(str::to_owned),
942 asked: 0,
943 budget: 0,
944 batched: false,
945 }
946}
947
948pub(crate) struct LoopSpec {
950 pub tools: Vec<ToolDeclaration>,
952 pub policy: Policy,
954 pub max_turns: u32,
956 pub max_tool_calls: u32,
958 pub reasoning: bool,
960 pub schema: Value,
962 pub routes: Vec<(String, String, String)>,
964 pub root: Option<String>,
967 pub frozen_additions: bool,
969 pub finish: fn(&Value) -> &'static str,
971 pub ask: Option<Asker>,
973 pub run_approved: Vec<Object>,
975 pub once: Vec<Object>,
978 pub observer: Option<(String, std::sync::Arc<dyn crate::progress::Progress>)>,
980 pub run: Option<String>,
982 pub asking: Asking,
985 pub parts: Vec<crate::approvals::derived::Part>,
988 pub mcp_digests: std::collections::BTreeMap<String, String>,
990}
991
992#[derive(Debug, Clone, Copy, PartialEq, Eq)]
994pub struct Asking {
995 pub asked: u32,
997 pub budget: u32,
999 pub prompts: bool,
1002}
1003
1004impl Default for Asking {
1005 fn default() -> Self {
1006 Self {
1007 asked: 0,
1008 budget: DEFAULT_BUDGET,
1009 prompts: false,
1010 }
1011 }
1012}
1013
1014pub use super::run_config::DEFAULT_BUDGET;
1016
1017pub(crate) async fn tool_loop(
1021 cx: &mut StepCtx<'_>,
1022 prompt: &Tainted<Value>,
1023 spec: LoopSpec,
1024) -> Result<Outcome, SkillError> {
1025 let LoopSpec {
1026 tools,
1027 policy,
1028 max_turns,
1029 max_tool_calls,
1030 reasoning,
1031 schema,
1032 routes,
1033 root,
1034 frozen_additions,
1035 finish,
1036 ask,
1037 run_approved,
1038 mut once,
1039 observer,
1040 run,
1041 asking,
1042 mut parts,
1043 mcp_digests,
1044 } = spec;
1045 let mut remembered: Vec<(Object, &'static str)> =
1048 run_approved.into_iter().map(|g| (g, "run-grant")).collect();
1049 let mut prompted = asking.asked;
1050 let mut told_budget = false;
1051 let catalog = crate::tools::catalog();
1052
1053 let mut transcript = Transcript::new(prompt);
1056 let mut conversation: Label = prompt.label().clone();
1057 let mut calls = 0u32;
1058 let mut denied_in_a_row = 0u32;
1059 let mut obligation = false;
1061 let mut usage = agentplane::model::Usage::default();
1062 let mut log: Vec<Value> = Vec::new();
1063
1064 let end = |status: &str,
1065 extra: Value,
1066 log: &[Value],
1067 usage: agentplane::model::Usage,
1068 conversation: &Label,
1069 prompted: u32| {
1070 let mut out = json!({
1071 "status": status,
1072 "tool_calls": log,
1073 "asked": prompted,
1074 "usage": {
1075 "input_tokens": usage.input_tokens,
1076 "output_tokens": usage.output_tokens,
1077 "cache_read_tokens": usage.cache_read_tokens,
1078 "cache_write_tokens": usage.cache_write_tokens,
1079 },
1080 });
1081 if let (Some(o), Some(e)) = (out.as_object_mut(), extra.as_object()) {
1082 o.extend(e.clone());
1083 }
1084 Ok(Outcome::done(Tainted::with_label(
1085 out,
1086 conversation.clone(),
1087 )))
1088 };
1089
1090 for turn in 0..max_turns {
1091 let tools_now = tools.clone();
1095 let schema_now = schema.clone();
1096 let read_so_far = &transcript;
1097 let completion = cx
1098 .complete_with(prompt, move |call| {
1099 let call = call
1100 .with_tools(tools_now)
1101 .continuing(read_so_far)
1102 .expecting(schema_now);
1103 if reasoning {
1104 call.with_reasoning_summary()
1105 } else {
1106 call
1107 }
1108 })
1109 .await?;
1110 conversation = transcript.label().join(completion.label());
1111 usage = usage.plus(completion.peek().usage);
1112 if let Some((task, p)) = &observer {
1113 p.event(&crate::progress::Event::Usage {
1114 task: task.clone(),
1115 input_tokens: usage.input_tokens,
1116 output_tokens: usage.output_tokens,
1117 cache_read_tokens: usage.cache_read_tokens,
1118 cache_write_tokens: usage.cache_write_tokens,
1119 usd: None,
1120 });
1121 }
1122
1123 if let (Some((task, p)), Some(reasoning)) =
1126 (&observer, completion.peek().reasoning.as_deref())
1127 && !reasoning.trim().is_empty()
1128 {
1129 let text: String = crate::approvals::present::escape(reasoning.trim())
1130 .chars()
1131 .take(4000)
1132 .collect();
1133 p.event(&crate::progress::Event::Reasoning {
1134 task: task.clone(),
1135 turn: turn + 1,
1136 text,
1137 });
1138 }
1139 if completion.peek().truncated {
1140 return Ok(Outcome::fail(
1141 "the model ran out of output budget mid-turn; nothing it was still writing was executed — raise [model] max_output_tokens",
1142 ));
1143 }
1144 let asked_calls = transcript.asked(&completion);
1145 let said = super::said(completion.peek());
1149 if let (Some((task, p)), Some(said)) = (observer.as_ref(), said.as_deref()) {
1150 let text: String = crate::approvals::present::escape(said)
1151 .chars()
1152 .take(2000)
1153 .collect();
1154 p.event(&crate::progress::Event::Said {
1155 task: task.clone(),
1156 turn: turn + 1,
1157 text,
1158 });
1159 }
1160 if asked_calls.is_empty() {
1161 let report = completion
1162 .peek()
1163 .structured
1164 .clone()
1165 .unwrap_or_else(|| json!({ "summary": completion.peek().text, "status": "incomplete", "files_changed": [], "checks_run": [], "notes": ["the answer was not the requested structure"] }));
1166 let status = finish(&report);
1167 return end(
1168 status,
1169 json!({ "report": report }),
1170 &log,
1171 usage,
1172 &conversation,
1173 prompted,
1174 );
1175 }
1176
1177 for mut asked in asked_calls {
1178 relativise(&mut asked.arguments, root.as_deref());
1179 if let Some(o) = asked.arguments.as_object_mut() {
1181 o.remove(crate::tools::EXPECT);
1182 }
1183 calls += 1;
1184 if calls > max_tool_calls {
1185 return end(
1186 "budget_exhausted",
1187 json!({ "reason": format!("more than {max_tool_calls} tool calls (`[budget] max_tool_calls`)") }),
1188 &log,
1189 usage,
1190 &conversation,
1191 prompted,
1192 );
1193 }
1194 if asked.name == ASK_PERSON && tools.iter().any(|t| t.name == ASK_PERSON) {
1195 let question = asked.arguments["question"]
1196 .as_str()
1197 .unwrap_or_default()
1198 .to_owned();
1199 let decided = match &ask {
1202 Some(asker) => Some(
1203 wait_for_person(
1204 cx,
1205 asker,
1206 &mut obligation,
1207 &crate::approvals::tasks::question_task(&asker.task, &question),
1208 )
1209 .await?,
1210 ),
1211 None => None,
1212 };
1213 let read = decided.as_ref().map(crate::approvals::tasks::read);
1214 let by = read.as_ref().and_then(|r| r.by.clone());
1215 let entry = json!({
1216 "tool": ASK_PERSON,
1217 "arguments": asked.arguments,
1218 "decision": "allow",
1219 "answered": read.as_ref().is_some_and(|r| r.text.is_some()),
1220 "operator": by.as_ref().map(Who::view),
1221 "via": by.as_ref().map(|w| w.via.clone()),
1222 });
1223 emit(observer.as_ref(), turn + 1, &entry);
1224 log.push(entry);
1225 let text = match (&read, &by) {
1226 (Some(r), _) if r.text.is_some() => r.text.clone().unwrap_or_default(),
1227 (Some(_), Some(w)) => format!("{} declined to answer.", w.name),
1228 _ => "Nobody can answer now. Record the question in the artifact as [NEEDS CLARIFICATION: <question>] and continue.".to_owned(),
1229 };
1230 let label = match &by {
1231 Some(_) => provenance::from(provenance::PERSON),
1232 None => Label::trusted(),
1233 };
1234 transcript.answered(
1235 asked,
1236 &Tainted::with_label(json!({ "answer": text }), label),
1237 );
1238 continue;
1239 }
1240 if asked.name == REPORT_BLOCKED && tools.iter().any(|t| t.name == REPORT_BLOCKED) {
1241 let reason = asked.arguments["reason"]
1242 .as_str()
1243 .unwrap_or("no reason given")
1244 .to_owned();
1245 let entry = json!({ "tool": REPORT_BLOCKED, "arguments": asked.arguments, "decision": "stop" });
1246 emit(observer.as_ref(), turn + 1, &entry);
1247 log.push(entry);
1248 return end(
1249 "blocked",
1250 json!({ "reason": reason }),
1251 &log,
1252 usage,
1253 &conversation,
1254 prompted,
1255 );
1256 }
1257 let declared = tools.iter().any(|t| t.name == asked.name);
1258 let target = catalog
1259 .iter()
1260 .find(|t| declared && t.name == asked.name)
1261 .map(|t| ToolId::new(t.server, t.name))
1262 .or_else(|| {
1263 routes
1264 .iter()
1265 .find(|(n, _, _)| declared && *n == asked.name)
1266 .map(|(_, server, tool)| ToolId::new(server, tool))
1267 });
1268 let Some(target) = target else {
1269 let entry = json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "unknown" });
1270 emit(observer.as_ref(), turn + 1, &entry);
1271 log.push(entry);
1272 transcript.failed(asked, Tainted::trusted("no tool of that name".to_owned()));
1273 continue;
1274 };
1275 let mut decision = policy.base(&asked.name, &asked.arguments);
1276 let mut additions_only = false;
1280 if frozen_additions
1281 && matches!(&decision, Decision::Approval(r) if r.contains("frozen test"))
1282 {
1283 let path = asked.arguments["path"]
1284 .as_str()
1285 .unwrap_or_default()
1286 .to_owned();
1287 let read = cx
1288 .call_tool(
1289 ToolId::new("fs", "read_file"),
1290 Tainted::with_label(json!({ "path": path }), completion.label().clone()),
1291 )
1292 .await?;
1293 let current = read.peek().clone();
1294 let lines = current["content"].as_str().unwrap_or_default();
1297 let old_text = if lines.is_empty() {
1298 String::new()
1299 } else {
1300 format!("{lines}\n")
1301 };
1302 let old = old_text.as_str();
1303 let readable = current["ok"] == true && current["truncated"] != true;
1304 let new = match asked.name.as_str() {
1305 "write_file" => asked.arguments["content"].as_str().map(str::to_owned),
1306 "edit_file" => {
1307 let (from, to) = (
1308 asked.arguments["old_string"].as_str().unwrap_or_default(),
1309 asked.arguments["new_string"].as_str().unwrap_or_default(),
1310 );
1311 let all = asked.arguments["replace_all"] == true;
1312 (!from.is_empty() && (all || old.matches(from).count() == 1))
1313 .then(|| old.replace(from, to))
1314 }
1315 _ => None,
1316 };
1317 if readable && new.is_some_and(|n| crate::integrity::only_additions(old, &n)) {
1318 decision = Decision::Allow;
1319 additions_only = true;
1320 }
1321 }
1322 let mut object: Option<Object> = None;
1326 if matches!(decision, Decision::Approval(_)) {
1327 if asked.name.starts_with(crate::mcp::PREFIX) {
1328 object = mcp_digests.get(&asked.name).map(|digest| Object::Tool {
1329 name: asked.name.clone(),
1330 digest: digest.clone(),
1331 });
1332 } else {
1333 let resolved = cx
1334 .call_tool(
1335 ToolId::new("fs", "resolve"),
1336 Tainted::with_label(
1337 json!({ "tool": asked.name, "arguments": asked.arguments }),
1338 completion.label().clone(),
1339 ),
1340 )
1341 .await?;
1342 let out = resolved.peek();
1343 if out["ok"] == true {
1344 object = serde_json::from_value(out["object"].clone()).ok();
1345 } else {
1346 decision = Decision::Deny(
1347 out["error"]
1348 .as_str()
1349 .unwrap_or("the call's object cannot be resolved")
1350 .to_owned(),
1351 );
1352 }
1353 }
1354 if matches!(decision, Decision::Approval(_)) {
1355 decision = policy.classify(&asked.name, &asked.arguments, object.as_ref());
1356 }
1357 }
1358 let mut person: Option<Value> = None;
1359 if decision == Decision::Allow && object.is_some() {
1362 person = Some(json!({
1363 "approved_by": "person",
1364 "scope": "task-grant",
1365 "grant": { "scope": "task", "run": run },
1366 }));
1367 }
1368 let mut note: Option<String> = None;
1369 if matches!(decision, Decision::Approval(_))
1371 && let Some(at) = object
1372 .as_ref()
1373 .and_then(|o| once.iter().position(|r| r == o))
1374 {
1375 once.remove(at);
1376 person = Some(json!({
1377 "approved_by": "person",
1378 "scope": "once-grant",
1379 "grant": { "scope": "once", "run": run },
1380 }));
1381 decision = Decision::Allow;
1382 }
1383 if matches!(decision, Decision::Approval(_))
1385 && let Some((_, scope)) = object
1386 .as_ref()
1387 .and_then(|o| remembered.iter().find(|(r, _)| r == o))
1388 {
1389 person = Some(json!({
1390 "approved_by": "person",
1391 "scope": scope,
1392 "grant": { "scope": scope.trim_end_matches("-grant"), "run": run },
1393 }));
1394 decision = Decision::Allow;
1395 }
1396 decision = policy.settle(decision);
1399 let request = match &decision {
1401 Decision::Approval(reason) => Some(question(&Question {
1402 asked: &asked,
1403 reason,
1404 turn: turn + 1,
1405 said: said.as_deref().unwrap_or_default(),
1406 asker: ask.as_ref(),
1407 policy: &policy,
1408 object: object.as_ref(),
1409 remembered: &remembered,
1410 parts: &parts,
1411 run: run.as_deref(),
1412 })),
1413 _ => None,
1414 };
1415 if let (Some(mut request), Some(asker)) = (request.clone(), &ask) {
1417 if asking.prompts {
1421 if prompted < asking.budget {
1422 prompted += 1;
1423 request.asked = prompted;
1424 } else {
1425 request.batched = true;
1426 }
1427 request.budget = asking.budget;
1428 }
1429 if request.batched {
1430 if !told_budget {
1433 needs_person(observer.as_ref(), &asked, object.as_ref());
1434 }
1435 if !told_budget && let Some((_, p)) = &observer {
1436 p.event(&crate::progress::Event::Notice {
1437 text: format!(
1438 "approval budget ({}) reached: calls wait in Needs You (speccoder approvals)",
1439 asking.budget
1440 ),
1441 });
1442 }
1443 told_budget = true;
1444 } else {
1445 needs_person(observer.as_ref(), &asked, object.as_ref());
1446 }
1447 let decided = wait_for_person(
1448 cx,
1449 asker,
1450 &mut obligation,
1451 &crate::approvals::tasks::approval_task(&request, &log),
1452 )
1453 .await?;
1454 let read = crate::approvals::tasks::read(&decided);
1455 if read.resume_asking {
1457 prompted = 0;
1458 }
1459 let answer: Answer = read.answer.clone();
1460 let approver = serde_json::to_value(&answer.by).unwrap_or(Value::Null);
1462 let operator = read.by.as_ref().map(Who::view);
1463 person_decided(
1464 observer.as_ref(),
1465 &asked,
1466 if answer.decision == Said::Approve {
1467 "approve"
1468 } else {
1469 "reject"
1470 },
1471 (answer.decision == Said::Approve).then(|| answer.scope.name()),
1472 read.by.as_ref().filter(|_| !read.expired),
1473 object.as_ref(),
1474 );
1475 match answer.decision {
1476 Said::Approve => {
1477 let mut p = json!({
1478 "approved_by": "person",
1479 "scope": answer.scope.name(),
1480 "note": answer.note,
1481 "approver": approver,
1482 "operator": operator,
1483 });
1484 if answer.always {
1485 p["always"] = json!(true);
1486 }
1487 if let (Some(o), Scope::Task | Scope::Run) = (&object, answer.scope) {
1488 let remembered_as = if answer.scope == Scope::Run {
1489 "run-grant"
1490 } else {
1491 "task-grant"
1492 };
1493 p["grant"] = json!({ "scope": answer.scope.name(), "run": run });
1494 remembered.push((o.clone(), remembered_as));
1495 }
1496 person = Some(p);
1497 note = answer.note;
1498 decision = Decision::Allow;
1499 }
1500 Said::Reject | Said::Stop => {
1503 let why = answer
1504 .reason
1505 .unwrap_or_else(|| "no reason given".to_owned());
1506 person = Some(if read.expired {
1507 json!({ "expired": true, "rejection": why })
1508 } else {
1509 json!({ "rejected_by": "person", "approver": approver, "operator": operator, "rejection": why })
1510 });
1511 decision = Decision::Deny(format!("a person rejected this: {why}"));
1512 }
1513 }
1514 }
1515 let rule = person
1518 .is_none()
1519 .then(|| policy.rule(&asked.name, &asked.arguments, object.as_ref()));
1520 let with_person = |mut entry: Value| {
1521 if let Some(o) = entry.as_object_mut() {
1522 if let Some(obj) = &object {
1524 o.insert("object".to_owned(), json!(obj));
1525 }
1526 match (person.as_ref().and_then(Value::as_object), &rule) {
1527 (Some(p), _) => {
1528 let by = if p.contains_key("expired") {
1530 "expiry"
1531 } else {
1532 "person"
1533 };
1534 o.insert("by".to_owned(), json!(by));
1535 o.extend(p.clone());
1536 }
1537 (None, Some(r)) => {
1538 o.insert("by".to_owned(), json!("rule"));
1539 o.insert("rule".to_owned(), json!(r));
1540 }
1541 (None, None) => {}
1542 }
1543 }
1544 entry
1545 };
1546 match decision {
1547 Decision::Deny(reason) => {
1548 let entry = with_person(
1549 json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "deny", "reason": reason }),
1550 );
1551 emit(observer.as_ref(), turn + 1, &entry);
1552 log.push(entry);
1553 transcript.failed(
1556 asked,
1557 refusal(format!("denied: {reason}"), completion.label()),
1558 );
1559 denied_in_a_row += 1;
1562 if denied_in_a_row >= MAX_CONSECUTIVE_DENIALS {
1563 return end(
1564 "denied",
1565 json!({ "reason": format!("{denied_in_a_row} calls in a row were denied; last: {reason}") }),
1566 &log,
1567 usage,
1568 &conversation,
1569 prompted,
1570 );
1571 }
1572 continue;
1573 }
1574 Decision::Approval(reason) => {
1575 let mut entry = json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "approval", "reason": reason });
1576 if let Some(obj) = &object {
1577 entry["object"] = json!(obj);
1578 }
1579 emit(observer.as_ref(), turn + 1, &entry);
1580 needs_person(observer.as_ref(), &asked, object.as_ref());
1581 log.push(entry);
1582 return end(
1583 "needs_approval",
1584 json!({ "pending": {
1585 "tool": asked.name,
1586 "arguments": asked.arguments,
1587 "reason": reason,
1588 "object": object,
1589 "run": run,
1590 "request": request,
1591 } }),
1592 &log,
1593 usage,
1594 &conversation,
1595 prompted,
1596 );
1597 }
1598 Decision::Allow => {
1599 denied_in_a_row = 0;
1600 let mut arguments = asked.arguments.clone();
1607 if asked.name == "run_command"
1608 && let Some(o) = arguments.as_object_mut()
1609 {
1610 o.insert("call_id".to_owned(), Value::String(asked.id.clone()));
1611 }
1612 if let (Some(obj), Some(o)) = (&object, arguments.as_object_mut())
1615 && matches!(
1616 asked.name.as_str(),
1617 "write_file" | "edit_file" | "run_command"
1618 )
1619 {
1620 o.insert(crate::tools::EXPECT.to_owned(), json!(obj));
1621 }
1622 let args = Tainted::with_label(arguments, completion.label().clone());
1623 let result = match cx.call_tool(target.clone(), args).await {
1624 Ok(r) => r,
1625 Err(error) => match cx.relayed(&error) {
1628 Some(detail) => {
1629 let entry = with_person(
1630 json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "allow", "ok": false }),
1631 );
1632 emit(observer.as_ref(), turn + 1, &entry);
1633 log.push(entry);
1634 transcript.failed(asked, detail);
1635 continue;
1636 }
1637 None => return Err(error.into()),
1638 },
1639 };
1640 let output = result.peek().clone();
1641 let ok = output["ok"].as_bool() != Some(false);
1642 let mut entry = with_person(
1643 json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "allow", "ok": ok }),
1644 );
1645 if let Some(changed) = output["object_changed"].as_object() {
1646 entry["decision"] = json!("refused_changed");
1647 entry["expected"] = changed.get("expected").cloned().unwrap_or(Value::Null);
1648 entry["found"] = changed.get("found").cloned().unwrap_or(Value::Null);
1649 }
1650 if let Some(part) = crate::approvals::derived::part_of(
1653 &asked.name,
1654 &asked.arguments,
1655 &output,
1656 turn + 1,
1657 &format!("tool://{}/{}", target.server, target.tool),
1658 |p: &str| {
1659 policy.protected().write_class(p)
1660 == Some(crate::tools::paths::Class::Intent)
1661 },
1662 ) {
1663 parts.push(part);
1664 }
1665 if additions_only {
1666 entry["frozen_additions"] = json!(true);
1667 }
1668 let tail = if asked.name == "run_command" && !ok {
1669 crate::progress::tail(&format!(
1670 "{}\n{}",
1671 output["stdout"].as_str().unwrap_or_default(),
1672 output["stderr"].as_str().unwrap_or_default()
1673 ))
1674 } else {
1675 Vec::new()
1676 };
1677 emit_with_tail(observer.as_ref(), turn + 1, &entry, tail);
1678 log.push(entry);
1679 if let Some(drift) = output["integrity"].as_array().filter(|d| !d.is_empty()) {
1682 let paths: Vec<&str> =
1683 drift.iter().filter_map(|d| d["path"].as_str()).collect();
1684 return end(
1685 "integrity",
1686 json!({
1687 "reason": format!("a command changed what judges the task: {}", paths.join(", ")),
1688 "integrity": drift,
1689 }),
1690 &log,
1691 usage,
1692 &conversation,
1693 prompted,
1694 );
1695 }
1696 match output["error"].as_str() {
1702 Some(error) if !ok => transcript.failed(
1703 asked,
1704 Tainted::with_label(error.to_owned(), result.label().clone()),
1705 ),
1706 _ => match ¬e {
1708 Some(n) => {
1709 let n = format!("Note from the person who approved this call: {n}");
1710 transcript.answered(
1711 asked,
1712 &result.map(|mut v| {
1713 v["note_from_person"] = Value::String(n);
1714 v
1715 }),
1716 );
1717 }
1718 None => transcript.answered(asked, &result),
1719 },
1720 }
1721 }
1722 }
1723 }
1724 }
1725 end(
1726 "budget_exhausted",
1727 json!({ "reason": format!("more than {max_turns} model turns (`[budget] max_turns`)") }),
1728 &log,
1729 usage,
1730 &conversation,
1731 prompted,
1732 )
1733}
1734
1735#[cfg(test)]
1736mod tests {
1737 use super::*;
1738
1739 #[test]
1740 fn labels_are_read_from_the_parts_and_refusals_carry_the_call() {
1741 use agentplane::core::{Label, SourceId};
1742 let model = Label::untrusted(SourceId::new("model:x/y"));
1743 assert!(
1745 refusal("denied: `src/x` is protected".to_owned(), &model)
1746 .label()
1747 .is_untrusted()
1748 );
1749 let input = Tainted::object([
1751 ("bundle", Tainted::trusted(json!({}))),
1752 (
1753 "digest",
1754 Tainted::with_label(json!({}), provenance::from(provenance::VERIFIER)),
1755 ),
1756 (
1757 "settings",
1758 Tainted::object([("nudges", Tainted::trusted(json!([])))]),
1759 ),
1760 ]);
1761 let label = conversation_label(&input);
1762 assert!(
1763 label
1764 .provenance
1765 .contains(&SourceId::new(provenance::VERIFIER))
1766 );
1767 let flat = Tainted::with_label(json!({ "bundle": {} }), model.clone());
1769 assert_eq!(conversation_label(&flat).provenance, model.provenance);
1770 }
1771
1772 #[test]
1773 fn absolute_paths_inside_the_root_become_relative_and_others_stay() {
1774 let mut a = json!({ "path": "/work/repo/specs/001-x/plan.md" });
1775 relativise(&mut a, Some("/work/repo"));
1776 assert_eq!(a["path"], "specs/001-x/plan.md");
1777 let mut outside = json!({ "path": "/work/repository-two/x" });
1778 relativise(&mut outside, Some("/work/repo"));
1779 assert_eq!(outside["path"], "/work/repository-two/x");
1780 let mut none = json!({ "path": "/etc/passwd" });
1781 relativise(&mut none, None);
1782 assert_eq!(none["path"], "/etc/passwd");
1783 }
1784}