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