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