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