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 settings = vec![
208 ("checks", trusted(json!(settings.checks))),
209 ("allow", trusted(json!(settings.allow))),
210 ("max_turns", trusted(json!(settings.max_turns))),
211 ("max_tool_calls", trusted(json!(settings.max_tool_calls))),
212 ("reasoning", trusted(json!(settings.reasoning))),
213 ("frozen", trusted(json!(settings.frozen))),
214 ("approved", trusted(json!(settings.approved))),
215 ("rejected", trusted(json!(settings.rejected))),
216 ("run_approved", trusted(json!(settings.run_approved))),
217 ("approved_once", trusted(json!(settings.approved_once))),
218 ("nudges", trusted(json!(settings.nudges))),
219 ("mcp", mcp),
220 ("root", trusted(json!(settings.root))),
221 (
222 "frozen_additions",
223 trusted(json!(settings.frozen_additions)),
224 ),
225 ("network_allow", trusted(json!(settings.network_allow))),
226 ("allow_prefix", trusted(json!(settings.allow_prefix))),
227 (
228 "network_allow_prefix",
229 trusted(json!(settings.network_allow_prefix)),
230 ),
231 ("run_id", trusted(json!(settings.run_id))),
232 ("asked", trusted(json!(settings.asked))),
233 ("budget", trusted(json!(settings.budget))),
234 ("prompts", trusted(json!(settings.prompts))),
235 ("asks", trusted(json!(settings.asks))),
236 ("wait_secs", trusted(json!(settings.wait_secs))),
237 ("in_place", trusted(json!(settings.in_place))),
238 ("hidden", trusted(json!(settings.hidden))),
239 ];
240 let mut settings = settings;
243 if autopilot {
244 settings.push(("autopilot", trusted(json!(true))));
245 }
246 let settings = Tainted::object(settings);
247 Tainted::object([
248 ("bundle", Tainted::trusted(bundle)),
249 ("digest", digest),
250 ("settings", settings),
251 (
254 "instructions",
255 if instructions.is_empty() {
256 Tainted::trusted(json!([]))
257 } else {
258 Tainted::with_label(json!(instructions), provenance::from(provenance::WORKTREE))
259 },
260 ),
261 (
263 "test_layout",
264 match test_layout {
265 Some(t) => Tainted::with_label(json!(t), provenance::from(provenance::WORKTREE)),
266 None => Tainted::trusted(Value::Null),
267 },
268 ),
269 ])
270}
271
272fn refusal(text: String, call: &agentplane::core::Label) -> Tainted<String> {
276 Tainted::with_label(text, call.clone())
277}
278
279const MESSAGE_PARTS: [&str; 7] = [
281 "/bundle",
282 "/instructions",
283 "/test_layout",
284 "/digest",
285 "/settings/nudges",
286 "/settings/rejected",
287 "/settings/mcp",
288];
289
290fn conversation_label(input: &Tainted<Value>) -> agentplane::core::Label {
294 MESSAGE_PARTS
295 .iter()
296 .map(|p| input.label_at(p).unwrap_or_else(|| input.label()).clone())
297 .fold(agentplane::core::Label::trusted(), |acc, l| acc.join(&l))
298}
299
300#[derive(Debug, Clone, Copy)]
302pub struct Settings<'a> {
303 pub checks: &'a [Vec<String>],
305 pub allow: &'a [Vec<String>],
307 pub allow_prefix: &'a [Vec<String>],
309 pub max_turns: u32,
311 pub max_tool_calls: u32,
313 pub reasoning: bool,
315 pub frozen: &'a [String],
317 pub approved: &'a [Object],
319 pub rejected: &'a [crate::tools::policy::Rejection],
321 pub run_approved: &'a [Object],
323 pub approved_once: &'a [Object],
326 pub nudges: &'a [String],
328 pub mcp: &'a [crate::mcp::McpTool],
330 pub root: &'a str,
332 pub frozen_additions: bool,
334 pub asks: &'a str,
337 pub wait_secs: u64,
339 pub network_allow: &'a [Vec<String>],
341 pub network_allow_prefix: &'a [Vec<String>],
343 pub run_id: &'a str,
345 pub asked: u32,
347 pub budget: u32,
349 pub prompts: bool,
351 pub in_place: bool,
353 pub hidden: &'a [String],
355 pub autopilot: bool,
357}
358
359pub const MAX_CONSECUTIVE_DENIALS: u32 = 3;
361
362fn digest_text(digest: &Value) -> String {
364 let mut out = format!(
365 "# Previous attempt\n\nAttempt {} at this task was verified by speccoder and FAILED. Its \
366changes were discarded; the worktree is back where that attempt started. You start fresh — use \
367these facts from the verifier, check them yourself, and do not repeat the mistake.\n",
368 digest["attempt"].as_u64().unwrap_or(1)
369 );
370 let strings = |v: &Value| -> Vec<String> {
371 v.as_array()
372 .map(|a| {
373 a.iter()
374 .filter_map(|x| x.as_str().map(str::to_owned))
375 .collect()
376 })
377 .unwrap_or_default()
378 };
379 let reasons = strings(&digest["reasons"]);
380 if !reasons.is_empty() {
381 out.push_str("\n## Verdict reasons\n\n");
382 for r in reasons {
383 out.push_str(&format!("- {r}\n"));
384 }
385 }
386 for c in digest["checks"].as_array().into_iter().flatten() {
387 out.push_str(&format!(
388 "\n## Check `{}` ({}): `{}`\n",
389 c["name"].as_str().unwrap_or_default(),
390 c["status"].as_str().unwrap_or_default(),
391 strings(&c["argv"]).join(" ")
392 ));
393 let tests = strings(&c["failing_tests"]);
394 if !tests.is_empty() {
395 out.push_str(&format!("\nFailing tests: {}\n", tests.join(", ")));
396 }
397 out.push_str(&format!(
398 "\n```text\n{}\n```\n",
399 c["excerpt"].as_str().unwrap_or_default()
400 ));
401 }
402 let uncollected = strings(&digest["uncollected"]);
403 if !uncollected.is_empty() {
404 out.push_str("\n## Tests that never ran\n\n");
405 for l in uncollected {
406 out.push_str(&format!("- {l}\n"));
407 }
408 }
409 let discarded = strings(&digest["discarded"]);
410 if !discarded.is_empty() {
411 out.push_str(&format!(
412 "\n## Files the discarded attempt changed\n\n{}\n",
413 discarded.join("\n")
414 ));
415 }
416 out
417}
418
419fn argvs(v: &Value) -> Vec<Vec<String>> {
420 v.as_array()
421 .map(|a| {
422 a.iter()
423 .map(|argv| {
424 argv.as_array()
425 .map(|p| {
426 p.iter()
427 .filter_map(|x| x.as_str().map(str::to_owned))
428 .collect()
429 })
430 .unwrap_or_default()
431 })
432 .collect()
433 })
434 .unwrap_or_default()
435}
436
437fn decisions_of<T: serde::de::DeserializeOwned>(
441 settings: &Value,
442 key: &str,
443) -> Result<Vec<T>, SkillError> {
444 match &settings[key] {
445 Value::Null => Ok(Vec::new()),
446 v => serde_json::from_value(v.clone())
447 .map_err(|e| SkillError::Input(format!("settings.{key}: {e}"))),
448 }
449}
450
451#[async_trait]
452impl Skill for Attempt {
453 fn descriptor(&self) -> SkillDescriptor {
454 SkillDescriptor::new("speccoder.task.attempt").provides(CAPABILITY)
455 }
456
457 async fn invoke(
458 &self,
459 cx: &mut StepCtx<'_>,
460 input: Tainted<Value>,
461 ) -> Result<Outcome, SkillError> {
462 let (ttl, style) = (self.cache_ttl, self.style);
463 let settings = input.peek()["settings"].clone();
464 let task_name = format!(
465 "{}/{}",
466 input.peek()["bundle"]["feature"]
467 .as_str()
468 .unwrap_or_default(),
469 input.peek()["bundle"]["task"]["id"]
470 .as_str()
471 .unwrap_or_default()
472 );
473 let frozen: Vec<String> = settings["frozen"]
474 .as_array()
475 .map(|a| {
476 a.iter()
477 .filter_map(|v| v.as_str().map(str::to_owned))
478 .collect()
479 })
480 .unwrap_or_default();
481 let approved: Vec<Object> = decisions_of(&settings, "approved")?;
482 let rejected: Vec<crate::tools::policy::Rejection> = decisions_of(&settings, "rejected")?;
483 let told = rejected.clone();
484 let mcp: Vec<crate::mcp::McpTool> =
485 serde_json::from_value(settings["mcp"].clone()).unwrap_or_default();
486 let policy = Policy::new(&argvs(&settings["checks"]), &argvs(&settings["allow"]))
487 .with_prefixes(argvs(&settings["allow_prefix"]))
488 .with_network_prefixes(argvs(&settings["network_allow_prefix"]))
489 .rules(self.format.rules())
490 .with_frozen(frozen)
491 .with_decisions(approved, rejected)
492 .with_mcp(mcp.iter().map(|t| (t.name.clone(), t.decision.clone())))
493 .with_network(argvs(&settings["network_allow"]))
494 .with_hidden(
495 serde_json::from_value::<Vec<String>>(settings["hidden"].clone())
496 .unwrap_or_default(),
497 );
498 let policy = if settings["in_place"] == true {
499 policy.in_place()
500 } else {
501 policy
502 };
503 let policy = if settings["autopilot"] == true {
504 policy.autopilot()
505 } else {
506 policy
507 };
508 let max_turns = settings["max_turns"]
509 .as_u64()
510 .map_or(30, |n| u32::try_from(n).unwrap_or(u32::MAX))
511 .max(1);
512 let max_tool_calls = settings["max_tool_calls"]
513 .as_u64()
514 .map_or(100, |n| u32::try_from(n).unwrap_or(u32::MAX));
515 let label = conversation_label(&input);
518 let raw = input.peek().clone();
519 let prompt = provenance::labelled_prompt(
520 {
521 let input = raw;
522 let mut p = layout(
523 &input["bundle"],
524 ttl,
525 style,
526 &self::policy(self.format.wording()),
527 self.format,
528 );
529 let given: Vec<crate::instructions::Instruction> =
530 serde_json::from_value(input["instructions"].clone()).unwrap_or_default();
531 if let Some(text) = crate::instructions::section(&given) {
532 let (key, kind) = match style {
533 PromptStyle::OpenAi => ("input", "input_text"),
534 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
535 ("messages", "text")
536 }
537 };
538 if let Some(content) = p[key][0]["content"].as_array_mut() {
539 content.push(json!({ "type": kind, "text": text }));
540 }
541 }
542 if let Some(text) = input["test_layout"].as_str().filter(|t| !t.is_empty()) {
543 let (key, kind) = match style {
544 PromptStyle::OpenAi => ("input", "input_text"),
545 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
546 ("messages", "text")
547 }
548 };
549 if let Some(content) = p[key][0]["content"].as_array_mut() {
550 content.push(json!({ "type": kind, "text": text }));
551 }
552 }
553 if !input["digest"].is_null() {
554 let (key, kind) = match style {
555 PromptStyle::OpenAi => ("input", "input_text"),
556 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
557 ("messages", "text")
558 }
559 };
560 if let Some(content) = p[key][0]["content"].as_array_mut() {
561 content
562 .push(json!({ "type": kind, "text": digest_text(&input["digest"]) }));
563 }
564 }
565 let notes: Vec<String> = input["settings"]["nudges"]
567 .as_array()
568 .map(|a| {
569 a.iter()
570 .filter_map(|n| n.as_str().map(|n| format!("- {n}")))
571 .collect()
572 })
573 .unwrap_or_default();
574 if !notes.is_empty() {
575 let (key, kind) = match style {
576 PromptStyle::OpenAi => ("input", "input_text"),
577 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
578 ("messages", "text")
579 }
580 };
581 let text = format!("# Notes from a person\n\n{}\n", notes.join("\n"));
582 if let Some(content) = p[key][0]["content"].as_array_mut() {
583 content.push(json!({ "type": kind, "text": text }));
584 }
585 }
586 if !told.is_empty() {
588 let (key, kind) = match style {
589 PromptStyle::OpenAi => ("input", "input_text"),
590 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
591 ("messages", "text")
592 }
593 };
594 let lines: Vec<String> = told
595 .iter()
596 .map(|r| format!("- {}: {}", r.object.describe(), r.reason))
597 .collect();
598 let text = format!(
599 "# Rejected by a person\n\nThese calls were rejected for this task; find another way or call report_blocked:\n\n{}\n",
600 lines.join("\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 if let PromptStyle::Parts { caches } = style {
607 super::model_config::to_parts(&mut p, caches);
608 }
609 p
610 },
611 &label,
612 );
613 let mut tools = crate::tools::declarations();
614 tools.push(report_blocked());
615 let routes: Vec<(String, String, String)> = mcp
617 .iter()
618 .filter(|t| t.decision != "deny")
619 .map(|t| (t.name.clone(), t.server.clone(), t.tool.clone()))
620 .collect();
621 tools.extend(mcp.iter().filter(|t| t.decision != "deny").map(|t| {
622 ToolDeclaration::new(t.name.clone(), t.description.clone(), t.schema.clone())
623 }));
624 let ask = Asker::from_settings(
626 &settings,
627 task_name.clone(),
628 input.peek()["bundle"]["task"]["title"]
629 .as_str()
630 .unwrap_or_default()
631 .to_owned(),
632 PresentContext {
633 root: settings["root"].as_str().map(std::path::PathBuf::from),
634 ..self.present.clone()
635 },
636 );
637 let remembered: Vec<Object> = decisions_of(&settings, "run_approved")?;
638 let once: Vec<Object> = decisions_of(&settings, "approved_once")?;
639 let mut parts = Vec::new();
641 let given: Vec<crate::instructions::Instruction> =
642 serde_json::from_value(input.peek()["instructions"].clone()).unwrap_or_default();
643 if let Some(text) = crate::instructions::section(&given) {
644 parts.push(crate::approvals::derived::Part {
645 from: provenance::WORKTREE.to_owned(),
646 turn: 0,
647 text,
648 });
649 }
650 if !input.peek()["digest"].is_null() {
651 parts.push(crate::approvals::derived::Part {
652 from: provenance::VERIFIER.to_owned(),
653 turn: 0,
654 text: crate::approvals::derived::text_of(&input.peek()["digest"]),
655 });
656 }
657 let number = |k: &str, d: u32| {
658 settings[k]
659 .as_u64()
660 .map_or(d, |n| u32::try_from(n).unwrap_or(u32::MAX))
661 };
662 tool_loop(
663 cx,
664 &prompt,
665 LoopSpec {
666 tools,
667 policy,
668 max_turns,
669 max_tool_calls,
670 reasoning: settings["reasoning"] == true,
671 schema: report_schema(),
672 routes,
673 root: settings["root"].as_str().map(str::to_owned),
674 frozen_additions: settings["frozen_additions"] == true,
675 ask,
676 run_approved: remembered,
677 once,
678 run: settings["run_id"].as_str().map(str::to_owned),
679 asking: Asking {
680 asked: number("asked", 0),
681 budget: number("budget", DEFAULT_BUDGET),
682 prompts: settings["prompts"] == true,
683 },
684 parts,
685 mcp_digests: mcp
686 .iter()
687 .map(|t| (t.name.clone(), t.digest.clone()))
688 .collect(),
689 observer: self.progress.clone().map(|p| {
690 (
691 task_name.rsplit('/').next().unwrap_or_default().to_owned(),
692 p,
693 )
694 }),
695 finish: |report| {
696 if report["status"] == "incomplete" {
699 "incomplete"
700 } else {
701 "completed"
702 }
703 },
704 },
705 )
706 .await
707 }
708}
709
710fn relativise(arguments: &mut Value, root: Option<&str>) {
715 let (Some(root), Some(path)) = (root, arguments.get("path").and_then(Value::as_str)) else {
716 return;
717 };
718 let root = root.trim_end_matches('/');
719 if let Some(rel) = path.strip_prefix(root).and_then(|r| r.strip_prefix('/')) {
720 let rel = if rel.is_empty() {
721 ".".to_owned()
722 } else {
723 rel.to_owned()
724 };
725 arguments["path"] = Value::String(rel);
726 }
727}
728
729fn emit(
731 observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
732 turn: u32,
733 entry: &Value,
734) {
735 emit_with_tail(observer, turn, entry, Vec::new());
736}
737
738fn emit_with_tail(
741 observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
742 turn: u32,
743 entry: &Value,
744 tail: Vec<String>,
745) {
746 if let Some((task, p)) = observer {
747 let tool = entry["tool"].as_str().unwrap_or_default();
748 p.event(&crate::progress::Event::ToolCall {
749 task: task.clone(),
750 turn,
751 tool: tool.to_owned(),
752 target: crate::progress::target(tool, &entry["arguments"]),
753 decision: entry["decision"].as_str().unwrap_or_default().to_owned(),
754 ok: entry["ok"].as_bool(),
755 tail,
756 });
757 }
758}
759
760fn needs_person(
762 observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
763 asked: &agentplane::model::ToolCall,
764 object: Option<&Object>,
765) {
766 if let Some((task, p)) = observer {
767 let (tool, arguments) = (asked.name.as_str(), &asked.arguments);
768 let target = crate::progress::target(tool, arguments);
769 let what: String = format!("{tool} {target}")
770 .chars()
771 .filter(|c| !c.is_control())
772 .take(60)
773 .collect();
774 p.event(&crate::progress::Event::ApprovalRequested {
775 task: task.clone(),
776 call: asked.id.clone(),
777 tool: tool.to_owned(),
778 what,
779 class: object
780 .map(|o| o.class().as_str().to_owned())
781 .unwrap_or_default(),
782 object: object.map(Object::short).unwrap_or_default(),
783 });
784 }
785}
786
787fn person_decided(
789 observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
790 asked: &agentplane::model::ToolCall,
791 decision: &str,
792 scope: Option<&str>,
793 who: Option<&Who>,
794 object: Option<&Object>,
795) {
796 if let Some((task, p)) = observer {
797 p.event(&crate::progress::Event::ApprovalDecided {
798 task: task.clone(),
799 call: asked.id.clone(),
800 decision: decision.to_owned(),
801 scope: scope.map(str::to_owned),
802 by: who.map_or_else(|| "nobody (expired)".to_owned(), |w| w.name.clone()),
803 how: who.map_or_else(|| "expired".to_owned(), |w| w.basis.clone()),
804 class: object
805 .map(|o| o.class().as_str().to_owned())
806 .unwrap_or_default(),
807 object: object.map(Object::short).unwrap_or_default(),
808 });
809 }
810}
811
812struct Question<'a> {
814 asked: &'a agentplane::model::ToolCall,
815 reason: &'a str,
816 turn: u32,
817 said: &'a str,
818 asker: Option<&'a Asker>,
819 policy: &'a Policy,
820 object: Option<&'a Object>,
821 remembered: &'a [(Object, &'static str)],
822 parts: &'a [crate::approvals::derived::Part],
823 run: Option<&'a str>,
824}
825
826fn question(q: &Question<'_>) -> Request {
830 let (tool, arguments) = (q.asked.name.as_str(), &q.asked.arguments);
831 let kind = crate::approvals::kind_of(tool, arguments);
832 let risk = crate::approvals::risk(tool, q.reason);
833 let always_risk = if kind == "network" {
835 let mut bare = arguments.clone();
836 if let Some(o) = bare.as_object_mut() {
837 o.remove("network");
838 }
839 match q.policy.base(tool, &bare) {
840 Decision::Allow => "low",
841 Decision::Approval(r) => crate::approvals::risk(tool, &r),
842 Decision::Deny(_) => "high",
843 }
844 } else {
845 risk
846 };
847 let (sources, more) = crate::approvals::derived::sources(tool, arguments, q.parts);
848 let changed = q.object.and_then(|o| {
849 q.remembered
850 .iter()
851 .map(|(r, _)| r)
852 .chain(q.policy.approved())
853 .find_map(|earlier| o.changed_from(earlier))
854 });
855 Request {
856 task: q.asker.map(|a| a.task.clone()).unwrap_or_default(),
857 task_title: q.asker.map(|a| a.task_title.clone()).unwrap_or_default(),
858 turn: q.turn,
859 tool: tool.to_owned(),
860 arguments: arguments.clone(),
861 kind: kind.to_owned(),
862 reason: q.reason.to_owned(),
863 object: q.object.cloned(),
864 changed,
865 risk: risk.to_owned(),
866 claim: crate::approvals::claim(q.said),
867 options: crate::approvals::options(
868 risk,
869 q.object.is_some(),
870 !sources.is_empty(),
871 kind,
872 always_risk,
873 ),
874 sources,
875 more,
876 run: q.run.map(str::to_owned),
877 asked: 0,
878 budget: 0,
879 batched: false,
880 }
881}
882
883pub(crate) struct LoopSpec {
885 pub tools: Vec<ToolDeclaration>,
887 pub policy: Policy,
889 pub max_turns: u32,
891 pub max_tool_calls: u32,
893 pub reasoning: bool,
895 pub schema: Value,
897 pub routes: Vec<(String, String, String)>,
899 pub root: Option<String>,
902 pub frozen_additions: bool,
904 pub finish: fn(&Value) -> &'static str,
906 pub ask: Option<Asker>,
908 pub run_approved: Vec<Object>,
910 pub once: Vec<Object>,
913 pub observer: Option<(String, std::sync::Arc<dyn crate::progress::Progress>)>,
915 pub run: Option<String>,
917 pub asking: Asking,
920 pub parts: Vec<crate::approvals::derived::Part>,
923 pub mcp_digests: std::collections::BTreeMap<String, String>,
925}
926
927#[derive(Debug, Clone, Copy, PartialEq, Eq)]
929pub struct Asking {
930 pub asked: u32,
932 pub budget: u32,
934 pub prompts: bool,
937}
938
939impl Default for Asking {
940 fn default() -> Self {
941 Self {
942 asked: 0,
943 budget: DEFAULT_BUDGET,
944 prompts: false,
945 }
946 }
947}
948
949pub use super::run_config::DEFAULT_BUDGET;
951
952pub(crate) async fn tool_loop(
956 cx: &mut StepCtx<'_>,
957 prompt: &Tainted<Value>,
958 spec: LoopSpec,
959) -> Result<Outcome, SkillError> {
960 let LoopSpec {
961 tools,
962 policy,
963 max_turns,
964 max_tool_calls,
965 reasoning,
966 schema,
967 routes,
968 root,
969 frozen_additions,
970 finish,
971 ask,
972 run_approved,
973 mut once,
974 observer,
975 run,
976 asking,
977 mut parts,
978 mcp_digests,
979 } = spec;
980 let mut remembered: Vec<(Object, &'static str)> =
983 run_approved.into_iter().map(|g| (g, "run-grant")).collect();
984 let mut prompted = asking.asked;
985 let mut told_budget = false;
986 let catalog = crate::tools::catalog();
987
988 let mut transcript = Transcript::new(prompt);
991 let mut conversation: Label = prompt.label().clone();
992 let mut calls = 0u32;
993 let mut denied_in_a_row = 0u32;
994 let mut obligation = false;
996 let mut usage = agentplane::model::Usage::default();
997 let mut log: Vec<Value> = Vec::new();
998
999 let end = |status: &str,
1000 extra: Value,
1001 log: &[Value],
1002 usage: agentplane::model::Usage,
1003 conversation: &Label,
1004 prompted: u32| {
1005 let mut out = json!({
1006 "status": status,
1007 "tool_calls": log,
1008 "asked": prompted,
1009 "usage": {
1010 "input_tokens": usage.input_tokens,
1011 "output_tokens": usage.output_tokens,
1012 "cache_read_tokens": usage.cache_read_tokens,
1013 "cache_write_tokens": usage.cache_write_tokens,
1014 },
1015 });
1016 if let (Some(o), Some(e)) = (out.as_object_mut(), extra.as_object()) {
1017 o.extend(e.clone());
1018 }
1019 Ok(Outcome::done(Tainted::with_label(
1020 out,
1021 conversation.clone(),
1022 )))
1023 };
1024
1025 for turn in 0..max_turns {
1026 let tools_now = tools.clone();
1030 let schema_now = schema.clone();
1031 let read_so_far = &transcript;
1032 let completion = cx
1033 .complete_with(prompt, move |call| {
1034 let call = call
1035 .with_tools(tools_now)
1036 .continuing(read_so_far)
1037 .expecting(schema_now);
1038 if reasoning {
1039 call.with_reasoning_summary()
1040 } else {
1041 call
1042 }
1043 })
1044 .await?;
1045 conversation = transcript.label().join(completion.label());
1046 usage = usage.plus(completion.peek().usage);
1047 if let Some((task, p)) = &observer {
1048 p.event(&crate::progress::Event::Usage {
1049 task: task.clone(),
1050 input_tokens: usage.input_tokens,
1051 output_tokens: usage.output_tokens,
1052 cache_read_tokens: usage.cache_read_tokens,
1053 cache_write_tokens: usage.cache_write_tokens,
1054 usd: None,
1055 });
1056 }
1057
1058 if let (Some((task, p)), Some(reasoning)) =
1061 (&observer, completion.peek().reasoning.as_deref())
1062 && !reasoning.trim().is_empty()
1063 {
1064 let text: String = crate::approvals::present::escape(reasoning.trim())
1065 .chars()
1066 .take(4000)
1067 .collect();
1068 p.event(&crate::progress::Event::Reasoning {
1069 task: task.clone(),
1070 turn: turn + 1,
1071 text,
1072 });
1073 }
1074 if completion.peek().truncated {
1075 return Ok(Outcome::fail(
1076 "the model ran out of output budget mid-turn; nothing it was still writing was executed — raise [model] max_output_tokens",
1077 ));
1078 }
1079 let asked_calls = transcript.asked(&completion);
1080 let said = super::said(completion.peek());
1084 if let (Some((task, p)), Some(said)) = (observer.as_ref(), said.as_deref()) {
1085 let text: String = crate::approvals::present::escape(said)
1086 .chars()
1087 .take(2000)
1088 .collect();
1089 p.event(&crate::progress::Event::Said {
1090 task: task.clone(),
1091 turn: turn + 1,
1092 text,
1093 });
1094 }
1095 if asked_calls.is_empty() {
1096 let report = completion
1097 .peek()
1098 .structured
1099 .clone()
1100 .unwrap_or_else(|| json!({ "summary": completion.peek().text, "status": "incomplete", "files_changed": [], "checks_run": [], "notes": ["the answer was not the requested structure"] }));
1101 let status = finish(&report);
1102 return end(
1103 status,
1104 json!({ "report": report }),
1105 &log,
1106 usage,
1107 &conversation,
1108 prompted,
1109 );
1110 }
1111
1112 for mut asked in asked_calls {
1113 relativise(&mut asked.arguments, root.as_deref());
1114 if let Some(o) = asked.arguments.as_object_mut() {
1116 o.remove(crate::tools::EXPECT);
1117 }
1118 calls += 1;
1119 if calls > max_tool_calls {
1120 return end(
1121 "budget_exhausted",
1122 json!({ "reason": format!("more than {max_tool_calls} tool calls") }),
1123 &log,
1124 usage,
1125 &conversation,
1126 prompted,
1127 );
1128 }
1129 if asked.name == ASK_PERSON && tools.iter().any(|t| t.name == ASK_PERSON) {
1130 let question = asked.arguments["question"]
1131 .as_str()
1132 .unwrap_or_default()
1133 .to_owned();
1134 let decided = match &ask {
1137 Some(asker) => Some(
1138 wait_for_person(
1139 cx,
1140 asker,
1141 &mut obligation,
1142 &crate::approvals::tasks::question_task(&asker.task, &question),
1143 )
1144 .await?,
1145 ),
1146 None => None,
1147 };
1148 let read = decided.as_ref().map(crate::approvals::tasks::read);
1149 let by = read.as_ref().and_then(|r| r.by.clone());
1150 let entry = json!({
1151 "tool": ASK_PERSON,
1152 "arguments": asked.arguments,
1153 "decision": "allow",
1154 "answered": read.as_ref().is_some_and(|r| r.text.is_some()),
1155 "operator": by.as_ref().map(Who::view),
1156 "via": by.as_ref().map(|w| w.via.clone()),
1157 });
1158 emit(observer.as_ref(), turn + 1, &entry);
1159 log.push(entry);
1160 let text = match (&read, &by) {
1161 (Some(r), _) if r.text.is_some() => r.text.clone().unwrap_or_default(),
1162 (Some(_), Some(w)) => format!("{} declined to answer.", w.name),
1163 _ => "Nobody can answer now. Record the question in the artifact as [NEEDS CLARIFICATION: <question>] and continue.".to_owned(),
1164 };
1165 let label = match &by {
1166 Some(_) => provenance::from(provenance::PERSON),
1167 None => Label::trusted(),
1168 };
1169 transcript.answered(
1170 asked,
1171 &Tainted::with_label(json!({ "answer": text }), label),
1172 );
1173 continue;
1174 }
1175 if asked.name == REPORT_BLOCKED && tools.iter().any(|t| t.name == REPORT_BLOCKED) {
1176 let reason = asked.arguments["reason"]
1177 .as_str()
1178 .unwrap_or("no reason given")
1179 .to_owned();
1180 let entry = json!({ "tool": REPORT_BLOCKED, "arguments": asked.arguments, "decision": "stop" });
1181 emit(observer.as_ref(), turn + 1, &entry);
1182 log.push(entry);
1183 return end(
1184 "blocked",
1185 json!({ "reason": reason }),
1186 &log,
1187 usage,
1188 &conversation,
1189 prompted,
1190 );
1191 }
1192 let declared = tools.iter().any(|t| t.name == asked.name);
1193 let target = catalog
1194 .iter()
1195 .find(|t| declared && t.name == asked.name)
1196 .map(|t| ToolId::new(t.server, t.name))
1197 .or_else(|| {
1198 routes
1199 .iter()
1200 .find(|(n, _, _)| declared && *n == asked.name)
1201 .map(|(_, server, tool)| ToolId::new(server, tool))
1202 });
1203 let Some(target) = target else {
1204 let entry = json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "unknown" });
1205 emit(observer.as_ref(), turn + 1, &entry);
1206 log.push(entry);
1207 transcript.failed(asked, Tainted::trusted("no tool of that name".to_owned()));
1208 continue;
1209 };
1210 let mut decision = policy.base(&asked.name, &asked.arguments);
1211 let mut additions_only = false;
1215 if frozen_additions
1216 && matches!(&decision, Decision::Approval(r) if r.contains("frozen test"))
1217 {
1218 let path = asked.arguments["path"]
1219 .as_str()
1220 .unwrap_or_default()
1221 .to_owned();
1222 let read = cx
1223 .call_tool(
1224 ToolId::new("fs", "read_file"),
1225 Tainted::with_label(json!({ "path": path }), completion.label().clone()),
1226 )
1227 .await?;
1228 let current = read.peek().clone();
1229 let lines = current["content"].as_str().unwrap_or_default();
1232 let old_text = if lines.is_empty() {
1233 String::new()
1234 } else {
1235 format!("{lines}\n")
1236 };
1237 let old = old_text.as_str();
1238 let readable = current["ok"] == true && current["truncated"] != true;
1239 let new = match asked.name.as_str() {
1240 "write_file" => asked.arguments["content"].as_str().map(str::to_owned),
1241 "edit_file" => {
1242 let (from, to) = (
1243 asked.arguments["old_string"].as_str().unwrap_or_default(),
1244 asked.arguments["new_string"].as_str().unwrap_or_default(),
1245 );
1246 let all = asked.arguments["replace_all"] == true;
1247 (!from.is_empty() && (all || old.matches(from).count() == 1))
1248 .then(|| old.replace(from, to))
1249 }
1250 _ => None,
1251 };
1252 if readable && new.is_some_and(|n| crate::integrity::only_additions(old, &n)) {
1253 decision = Decision::Allow;
1254 additions_only = true;
1255 }
1256 }
1257 let mut object: Option<Object> = None;
1261 if matches!(decision, Decision::Approval(_)) {
1262 if asked.name.starts_with(crate::mcp::PREFIX) {
1263 object = mcp_digests.get(&asked.name).map(|digest| Object::Tool {
1264 name: asked.name.clone(),
1265 digest: digest.clone(),
1266 });
1267 } else {
1268 let resolved = cx
1269 .call_tool(
1270 ToolId::new("fs", "resolve"),
1271 Tainted::with_label(
1272 json!({ "tool": asked.name, "arguments": asked.arguments }),
1273 completion.label().clone(),
1274 ),
1275 )
1276 .await?;
1277 let out = resolved.peek();
1278 if out["ok"] == true {
1279 object = serde_json::from_value(out["object"].clone()).ok();
1280 } else {
1281 decision = Decision::Deny(
1282 out["error"]
1283 .as_str()
1284 .unwrap_or("the call's object cannot be resolved")
1285 .to_owned(),
1286 );
1287 }
1288 }
1289 if matches!(decision, Decision::Approval(_)) {
1290 decision = policy.classify(&asked.name, &asked.arguments, object.as_ref());
1291 }
1292 }
1293 let mut person: Option<Value> = None;
1294 if decision == Decision::Allow && object.is_some() {
1297 person = Some(json!({
1298 "approved_by": "person",
1299 "scope": "task-grant",
1300 "grant": { "scope": "task", "run": run },
1301 }));
1302 }
1303 let mut note: Option<String> = None;
1304 if matches!(decision, Decision::Approval(_))
1306 && let Some(at) = object
1307 .as_ref()
1308 .and_then(|o| once.iter().position(|r| r == o))
1309 {
1310 once.remove(at);
1311 person = Some(json!({
1312 "approved_by": "person",
1313 "scope": "once-grant",
1314 "grant": { "scope": "once", "run": run },
1315 }));
1316 decision = Decision::Allow;
1317 }
1318 if matches!(decision, Decision::Approval(_))
1320 && let Some((_, scope)) = object
1321 .as_ref()
1322 .and_then(|o| remembered.iter().find(|(r, _)| r == o))
1323 {
1324 person = Some(json!({
1325 "approved_by": "person",
1326 "scope": scope,
1327 "grant": { "scope": scope.trim_end_matches("-grant"), "run": run },
1328 }));
1329 decision = Decision::Allow;
1330 }
1331 decision = policy.settle(decision);
1334 let request = match &decision {
1336 Decision::Approval(reason) => Some(question(&Question {
1337 asked: &asked,
1338 reason,
1339 turn: turn + 1,
1340 said: said.as_deref().unwrap_or_default(),
1341 asker: ask.as_ref(),
1342 policy: &policy,
1343 object: object.as_ref(),
1344 remembered: &remembered,
1345 parts: &parts,
1346 run: run.as_deref(),
1347 })),
1348 _ => None,
1349 };
1350 if let (Some(mut request), Some(asker)) = (request.clone(), &ask) {
1352 if asking.prompts {
1356 if prompted < asking.budget {
1357 prompted += 1;
1358 request.asked = prompted;
1359 } else {
1360 request.batched = true;
1361 }
1362 request.budget = asking.budget;
1363 }
1364 if request.batched {
1365 if !told_budget {
1368 needs_person(observer.as_ref(), &asked, object.as_ref());
1369 }
1370 if !told_budget && let Some((_, p)) = &observer {
1371 p.event(&crate::progress::Event::Notice {
1372 text: format!(
1373 "approval budget ({}) reached: calls wait in Needs You (speccoder approvals)",
1374 asking.budget
1375 ),
1376 });
1377 }
1378 told_budget = true;
1379 } else {
1380 needs_person(observer.as_ref(), &asked, object.as_ref());
1381 }
1382 let decided = wait_for_person(
1383 cx,
1384 asker,
1385 &mut obligation,
1386 &crate::approvals::tasks::approval_task(&request, &log),
1387 )
1388 .await?;
1389 let read = crate::approvals::tasks::read(&decided);
1390 if read.resume_asking {
1392 prompted = 0;
1393 }
1394 let answer: Answer = read.answer.clone();
1395 let approver = serde_json::to_value(&answer.by).unwrap_or(Value::Null);
1397 let operator = read.by.as_ref().map(Who::view);
1398 person_decided(
1399 observer.as_ref(),
1400 &asked,
1401 if answer.decision == Said::Approve {
1402 "approve"
1403 } else {
1404 "reject"
1405 },
1406 (answer.decision == Said::Approve).then(|| answer.scope.name()),
1407 read.by.as_ref().filter(|_| !read.expired),
1408 object.as_ref(),
1409 );
1410 match answer.decision {
1411 Said::Approve => {
1412 let mut p = json!({
1413 "approved_by": "person",
1414 "scope": answer.scope.name(),
1415 "note": answer.note,
1416 "approver": approver,
1417 "operator": operator,
1418 });
1419 if answer.always {
1420 p["always"] = json!(true);
1421 }
1422 if let (Some(o), Scope::Task | Scope::Run) = (&object, answer.scope) {
1423 let remembered_as = if answer.scope == Scope::Run {
1424 "run-grant"
1425 } else {
1426 "task-grant"
1427 };
1428 p["grant"] = json!({ "scope": answer.scope.name(), "run": run });
1429 remembered.push((o.clone(), remembered_as));
1430 }
1431 person = Some(p);
1432 note = answer.note;
1433 decision = Decision::Allow;
1434 }
1435 Said::Reject | Said::Stop => {
1438 let why = answer
1439 .reason
1440 .unwrap_or_else(|| "no reason given".to_owned());
1441 person = Some(if read.expired {
1442 json!({ "expired": true, "rejection": why })
1443 } else {
1444 json!({ "rejected_by": "person", "approver": approver, "operator": operator, "rejection": why })
1445 });
1446 decision = Decision::Deny(format!("a person rejected this: {why}"));
1447 }
1448 }
1449 }
1450 let rule = person
1453 .is_none()
1454 .then(|| policy.rule(&asked.name, &asked.arguments, object.as_ref()));
1455 let with_person = |mut entry: Value| {
1456 if let Some(o) = entry.as_object_mut() {
1457 if let Some(obj) = &object {
1459 o.insert("object".to_owned(), json!(obj));
1460 }
1461 match (person.as_ref().and_then(Value::as_object), &rule) {
1462 (Some(p), _) => {
1463 let by = if p.contains_key("expired") {
1465 "expiry"
1466 } else {
1467 "person"
1468 };
1469 o.insert("by".to_owned(), json!(by));
1470 o.extend(p.clone());
1471 }
1472 (None, Some(r)) => {
1473 o.insert("by".to_owned(), json!("rule"));
1474 o.insert("rule".to_owned(), json!(r));
1475 }
1476 (None, None) => {}
1477 }
1478 }
1479 entry
1480 };
1481 match decision {
1482 Decision::Deny(reason) => {
1483 let entry = with_person(
1484 json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "deny", "reason": reason }),
1485 );
1486 emit(observer.as_ref(), turn + 1, &entry);
1487 log.push(entry);
1488 transcript.failed(
1491 asked,
1492 refusal(format!("denied: {reason}"), completion.label()),
1493 );
1494 denied_in_a_row += 1;
1497 if denied_in_a_row >= MAX_CONSECUTIVE_DENIALS {
1498 return end(
1499 "denied",
1500 json!({ "reason": format!("{denied_in_a_row} calls in a row were denied; last: {reason}") }),
1501 &log,
1502 usage,
1503 &conversation,
1504 prompted,
1505 );
1506 }
1507 continue;
1508 }
1509 Decision::Approval(reason) => {
1510 let mut entry = json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "approval", "reason": reason });
1511 if let Some(obj) = &object {
1512 entry["object"] = json!(obj);
1513 }
1514 emit(observer.as_ref(), turn + 1, &entry);
1515 needs_person(observer.as_ref(), &asked, object.as_ref());
1516 log.push(entry);
1517 return end(
1518 "needs_approval",
1519 json!({ "pending": {
1520 "tool": asked.name,
1521 "arguments": asked.arguments,
1522 "reason": reason,
1523 "object": object,
1524 "run": run,
1525 "request": request,
1526 } }),
1527 &log,
1528 usage,
1529 &conversation,
1530 prompted,
1531 );
1532 }
1533 Decision::Allow => {
1534 denied_in_a_row = 0;
1535 let mut arguments = asked.arguments.clone();
1542 if asked.name == "run_command"
1543 && let Some(o) = arguments.as_object_mut()
1544 {
1545 o.insert("call_id".to_owned(), Value::String(asked.id.clone()));
1546 }
1547 if let (Some(obj), Some(o)) = (&object, arguments.as_object_mut())
1550 && matches!(
1551 asked.name.as_str(),
1552 "write_file" | "edit_file" | "run_command"
1553 )
1554 {
1555 o.insert(crate::tools::EXPECT.to_owned(), json!(obj));
1556 }
1557 let args = Tainted::with_label(arguments, completion.label().clone());
1558 let result = match cx.call_tool(target.clone(), args).await {
1559 Ok(r) => r,
1560 Err(error) => match cx.relayed(&error) {
1563 Some(detail) => {
1564 let entry = with_person(
1565 json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "allow", "ok": false }),
1566 );
1567 emit(observer.as_ref(), turn + 1, &entry);
1568 log.push(entry);
1569 transcript.failed(asked, detail);
1570 continue;
1571 }
1572 None => return Err(error.into()),
1573 },
1574 };
1575 let output = result.peek().clone();
1576 let ok = output["ok"].as_bool() != Some(false);
1577 let mut entry = with_person(
1578 json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "allow", "ok": ok }),
1579 );
1580 if let Some(changed) = output["object_changed"].as_object() {
1581 entry["decision"] = json!("refused_changed");
1582 entry["expected"] = changed.get("expected").cloned().unwrap_or(Value::Null);
1583 entry["found"] = changed.get("found").cloned().unwrap_or(Value::Null);
1584 }
1585 if let Some(part) = crate::approvals::derived::part_of(
1588 &asked.name,
1589 &asked.arguments,
1590 &output,
1591 turn + 1,
1592 &format!("tool://{}/{}", target.server, target.tool),
1593 |p: &str| {
1594 policy.protected().write_class(p)
1595 == Some(crate::tools::paths::Class::Intent)
1596 },
1597 ) {
1598 parts.push(part);
1599 }
1600 if additions_only {
1601 entry["frozen_additions"] = json!(true);
1602 }
1603 let tail = if asked.name == "run_command" && !ok {
1604 crate::progress::tail(&format!(
1605 "{}\n{}",
1606 output["stdout"].as_str().unwrap_or_default(),
1607 output["stderr"].as_str().unwrap_or_default()
1608 ))
1609 } else {
1610 Vec::new()
1611 };
1612 emit_with_tail(observer.as_ref(), turn + 1, &entry, tail);
1613 log.push(entry);
1614 if let Some(drift) = output["integrity"].as_array().filter(|d| !d.is_empty()) {
1617 let paths: Vec<&str> =
1618 drift.iter().filter_map(|d| d["path"].as_str()).collect();
1619 return end(
1620 "integrity",
1621 json!({
1622 "reason": format!("a command changed what judges the task: {}", paths.join(", ")),
1623 "integrity": drift,
1624 }),
1625 &log,
1626 usage,
1627 &conversation,
1628 prompted,
1629 );
1630 }
1631 match output["error"].as_str() {
1637 Some(error) if !ok => transcript.failed(
1638 asked,
1639 Tainted::with_label(error.to_owned(), result.label().clone()),
1640 ),
1641 _ => match ¬e {
1643 Some(n) => {
1644 let n = format!("Note from the person who approved this call: {n}");
1645 transcript.answered(
1646 asked,
1647 &result.map(|mut v| {
1648 v["note_from_person"] = Value::String(n);
1649 v
1650 }),
1651 );
1652 }
1653 None => transcript.answered(asked, &result),
1654 },
1655 }
1656 }
1657 }
1658 }
1659 }
1660 end(
1661 "budget_exhausted",
1662 json!({ "reason": format!("more than {max_turns} model turns") }),
1663 &log,
1664 usage,
1665 &conversation,
1666 prompted,
1667 )
1668}
1669
1670#[cfg(test)]
1671mod tests {
1672 use super::*;
1673
1674 #[test]
1675 fn labels_are_read_from_the_parts_and_refusals_carry_the_call() {
1676 use agentplane::core::{Label, SourceId};
1677 let model = Label::untrusted(SourceId::new("model:x/y"));
1678 assert!(
1680 refusal("denied: `src/x` is protected".to_owned(), &model)
1681 .label()
1682 .is_untrusted()
1683 );
1684 let input = Tainted::object([
1686 ("bundle", Tainted::trusted(json!({}))),
1687 (
1688 "digest",
1689 Tainted::with_label(json!({}), provenance::from(provenance::VERIFIER)),
1690 ),
1691 (
1692 "settings",
1693 Tainted::object([("nudges", Tainted::trusted(json!([])))]),
1694 ),
1695 ]);
1696 let label = conversation_label(&input);
1697 assert!(
1698 label
1699 .provenance
1700 .contains(&SourceId::new(provenance::VERIFIER))
1701 );
1702 let flat = Tainted::with_label(json!({ "bundle": {} }), model.clone());
1704 assert_eq!(conversation_label(&flat).provenance, model.provenance);
1705 }
1706
1707 #[test]
1708 fn absolute_paths_inside_the_root_become_relative_and_others_stay() {
1709 let mut a = json!({ "path": "/work/repo/specs/001-x/plan.md" });
1710 relativise(&mut a, Some("/work/repo"));
1711 assert_eq!(a["path"], "specs/001-x/plan.md");
1712 let mut outside = json!({ "path": "/work/repository-two/x" });
1713 relativise(&mut outside, Some("/work/repo"));
1714 assert_eq!(outside["path"], "/work/repository-two/x");
1715 let mut none = json!({ "path": "/etc/passwd" });
1716 relativise(&mut none, None);
1717 assert_eq!(none["path"], "/etc/passwd");
1718 }
1719}