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, 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 settings = Tainted::object([
206 ("checks", trusted(json!(settings.checks))),
207 ("allow", trusted(json!(settings.allow))),
208 ("max_turns", trusted(json!(settings.max_turns))),
209 ("max_tool_calls", trusted(json!(settings.max_tool_calls))),
210 ("frozen", trusted(json!(settings.frozen))),
211 ("approved", trusted(json!(settings.approved))),
212 ("rejected", trusted(json!(settings.rejected))),
213 ("run_approved", trusted(json!(settings.run_approved))),
214 ("nudges", trusted(json!(settings.nudges))),
215 ("mcp", mcp),
216 ("root", trusted(json!(settings.root))),
217 (
218 "frozen_additions",
219 trusted(json!(settings.frozen_additions)),
220 ),
221 ("network_allow", trusted(json!(settings.network_allow))),
222 ("asks", trusted(json!(settings.asks))),
223 ("wait_secs", trusted(json!(settings.wait_secs))),
224 ("in_place", trusted(json!(settings.in_place))),
225 ("hidden", trusted(json!(settings.hidden))),
226 ]);
227 Tainted::object([
228 ("bundle", Tainted::trusted(bundle)),
229 ("digest", digest),
230 ("settings", settings),
231 (
234 "instructions",
235 if instructions.is_empty() {
236 Tainted::trusted(json!([]))
237 } else {
238 Tainted::with_label(json!(instructions), provenance::from(provenance::WORKTREE))
239 },
240 ),
241 ])
242}
243
244fn refusal(text: String, call: &agentplane::core::Label) -> Tainted<String> {
248 Tainted::with_label(text, call.clone())
249}
250
251const MESSAGE_PARTS: [&str; 6] = [
253 "/bundle",
254 "/instructions",
255 "/digest",
256 "/settings/nudges",
257 "/settings/rejected",
258 "/settings/mcp",
259];
260
261fn conversation_label(input: &Tainted<Value>) -> agentplane::core::Label {
265 MESSAGE_PARTS
266 .iter()
267 .map(|p| input.label_at(p).unwrap_or_else(|| input.label()).clone())
268 .fold(agentplane::core::Label::trusted(), |acc, l| acc.join(&l))
269}
270
271#[derive(Debug, Clone, Copy)]
273pub struct Settings<'a> {
274 pub checks: &'a [Vec<String>],
276 pub allow: &'a [Vec<String>],
278 pub max_turns: u32,
280 pub max_tool_calls: u32,
282 pub frozen: &'a [String],
284 pub approved: &'a [crate::tools::policy::Grant],
286 pub rejected: &'a [crate::tools::policy::Rejection],
288 pub run_approved: &'a [crate::tools::policy::Grant],
290 pub nudges: &'a [String],
292 pub mcp: &'a [crate::mcp::McpTool],
294 pub root: &'a str,
296 pub frozen_additions: bool,
298 pub asks: &'a str,
301 pub wait_secs: u64,
303 pub network_allow: &'a [Vec<String>],
305 pub in_place: bool,
307 pub hidden: &'a [String],
309}
310
311pub const MAX_CONSECUTIVE_DENIALS: u32 = 3;
313
314fn digest_text(digest: &Value) -> String {
316 let mut out = format!(
317 "# Previous attempt\n\nAttempt {} at this task was verified by speccoder and FAILED. Its \
318changes were discarded; the worktree is back where that attempt started. You start fresh — use \
319these facts from the verifier, check them yourself, and do not repeat the mistake.\n",
320 digest["attempt"].as_u64().unwrap_or(1)
321 );
322 let strings = |v: &Value| -> Vec<String> {
323 v.as_array()
324 .map(|a| {
325 a.iter()
326 .filter_map(|x| x.as_str().map(str::to_owned))
327 .collect()
328 })
329 .unwrap_or_default()
330 };
331 let reasons = strings(&digest["reasons"]);
332 if !reasons.is_empty() {
333 out.push_str("\n## Verdict reasons\n\n");
334 for r in reasons {
335 out.push_str(&format!("- {r}\n"));
336 }
337 }
338 for c in digest["checks"].as_array().into_iter().flatten() {
339 out.push_str(&format!(
340 "\n## Check `{}` ({}): `{}`\n",
341 c["name"].as_str().unwrap_or_default(),
342 c["status"].as_str().unwrap_or_default(),
343 strings(&c["argv"]).join(" ")
344 ));
345 let tests = strings(&c["failing_tests"]);
346 if !tests.is_empty() {
347 out.push_str(&format!("\nFailing tests: {}\n", tests.join(", ")));
348 }
349 out.push_str(&format!(
350 "\n```text\n{}\n```\n",
351 c["excerpt"].as_str().unwrap_or_default()
352 ));
353 }
354 let discarded = strings(&digest["discarded"]);
355 if !discarded.is_empty() {
356 out.push_str(&format!(
357 "\n## Files the discarded attempt changed\n\n{}\n",
358 discarded.join("\n")
359 ));
360 }
361 out
362}
363
364fn argvs(v: &Value) -> Vec<Vec<String>> {
365 v.as_array()
366 .map(|a| {
367 a.iter()
368 .map(|argv| {
369 argv.as_array()
370 .map(|p| {
371 p.iter()
372 .filter_map(|x| x.as_str().map(str::to_owned))
373 .collect()
374 })
375 .unwrap_or_default()
376 })
377 .collect()
378 })
379 .unwrap_or_default()
380}
381
382fn usage_sum(total: &mut [u64; 4], usage: &Value) {
383 for (i, key) in [
384 "input_tokens",
385 "output_tokens",
386 "cache_read_tokens",
387 "cache_write_tokens",
388 ]
389 .iter()
390 .enumerate()
391 {
392 total[i] += usage[*key].as_u64().unwrap_or(0);
393 }
394}
395
396#[async_trait]
397impl Skill for Attempt {
398 fn descriptor(&self) -> SkillDescriptor {
399 SkillDescriptor::new("speccoder.task.attempt").provides(CAPABILITY)
400 }
401
402 async fn invoke(
403 &self,
404 cx: &mut StepCtx<'_>,
405 input: Tainted<Value>,
406 ) -> Result<Outcome, SkillError> {
407 let (ttl, style) = (self.cache_ttl, self.style);
408 let settings = input.peek()["settings"].clone();
409 let task_name = format!(
410 "{}/{}",
411 input.peek()["bundle"]["feature"]
412 .as_str()
413 .unwrap_or_default(),
414 input.peek()["bundle"]["task"]["id"]
415 .as_str()
416 .unwrap_or_default()
417 );
418 let frozen: Vec<String> = settings["frozen"]
419 .as_array()
420 .map(|a| {
421 a.iter()
422 .filter_map(|v| v.as_str().map(str::to_owned))
423 .collect()
424 })
425 .unwrap_or_default();
426 let decisions = |key: &str| settings[key].clone();
427 let approved: Vec<crate::tools::policy::Grant> =
428 serde_json::from_value(decisions("approved")).unwrap_or_default();
429 let rejected: Vec<crate::tools::policy::Rejection> =
430 serde_json::from_value(decisions("rejected")).unwrap_or_default();
431 let told = rejected.clone();
432 let mcp: Vec<crate::mcp::McpTool> =
433 serde_json::from_value(settings["mcp"].clone()).unwrap_or_default();
434 let policy = Policy::new(&argvs(&settings["checks"]), &argvs(&settings["allow"]))
435 .rules(self.format.rules())
436 .with_frozen(frozen)
437 .with_decisions(approved, rejected)
438 .with_mcp(mcp.iter().map(|t| (t.name.clone(), t.decision.clone())))
439 .with_network(argvs(&settings["network_allow"]))
440 .with_hidden(
441 serde_json::from_value::<Vec<String>>(settings["hidden"].clone())
442 .unwrap_or_default(),
443 );
444 let policy = if settings["in_place"] == true {
445 policy.in_place()
446 } else {
447 policy
448 };
449 let max_turns = settings["max_turns"]
450 .as_u64()
451 .map_or(30, |n| u32::try_from(n).unwrap_or(u32::MAX))
452 .max(1);
453 let max_tool_calls = settings["max_tool_calls"]
454 .as_u64()
455 .map_or(100, |n| u32::try_from(n).unwrap_or(u32::MAX));
456 let label = conversation_label(&input);
459 let raw = input.peek().clone();
460 let prompt = provenance::labelled_prompt(
461 {
462 let input = raw;
463 let mut p = layout(
464 &input["bundle"],
465 ttl,
466 style,
467 &self::policy(self.format.wording()),
468 self.format,
469 );
470 let given: Vec<crate::instructions::Instruction> =
471 serde_json::from_value(input["instructions"].clone()).unwrap_or_default();
472 if let Some(text) = crate::instructions::section(&given) {
473 let (key, kind) = match style {
474 PromptStyle::OpenAi => ("input", "input_text"),
475 PromptStyle::Anthropic | PromptStyle::Chat => ("messages", "text"),
476 };
477 if let Some(content) = p[key][0]["content"].as_array_mut() {
478 content.push(json!({ "type": kind, "text": text }));
479 }
480 }
481 if !input["digest"].is_null() {
482 let (key, kind) = match style {
483 PromptStyle::OpenAi => ("input", "input_text"),
484 PromptStyle::Anthropic | PromptStyle::Chat => ("messages", "text"),
485 };
486 if let Some(content) = p[key][0]["content"].as_array_mut() {
487 content
488 .push(json!({ "type": kind, "text": digest_text(&input["digest"]) }));
489 }
490 }
491 let notes: Vec<String> = input["settings"]["nudges"]
493 .as_array()
494 .map(|a| {
495 a.iter()
496 .filter_map(|n| n.as_str().map(|n| format!("- {n}")))
497 .collect()
498 })
499 .unwrap_or_default();
500 if !notes.is_empty() {
501 let (key, kind) = match style {
502 PromptStyle::OpenAi => ("input", "input_text"),
503 PromptStyle::Anthropic | PromptStyle::Chat => ("messages", "text"),
504 };
505 let text = format!("# Notes from a person\n\n{}\n", notes.join("\n"));
506 if let Some(content) = p[key][0]["content"].as_array_mut() {
507 content.push(json!({ "type": kind, "text": text }));
508 }
509 }
510 if !told.is_empty() {
512 let (key, kind) = match style {
513 PromptStyle::OpenAi => ("input", "input_text"),
514 PromptStyle::Anthropic | PromptStyle::Chat => ("messages", "text"),
515 };
516 let lines: Vec<String> = told
517 .iter()
518 .map(|r| format!("- {}: {}", r.grant.describe(), r.reason))
519 .collect();
520 let text = format!(
521 "# Rejected by a person\n\nThese calls were rejected for this task; find another way or call report_blocked:\n\n{}\n",
522 lines.join("\n")
523 );
524 if let Some(content) = p[key][0]["content"].as_array_mut() {
525 content.push(json!({ "type": kind, "text": text }));
526 }
527 }
528 p
529 },
530 &label,
531 );
532 let mut tools = crate::tools::declarations();
533 tools.push(report_blocked());
534 let routes: Vec<(String, String, String)> = mcp
536 .iter()
537 .filter(|t| t.decision != "deny")
538 .map(|t| (t.name.clone(), t.server.clone(), t.tool.clone()))
539 .collect();
540 tools.extend(mcp.iter().filter(|t| t.decision != "deny").map(|t| {
541 ToolDeclaration::new(t.name.clone(), t.description.clone(), t.schema.clone())
542 }));
543 let ask = Asker::from_settings(
545 &settings,
546 task_name.clone(),
547 input.peek()["bundle"]["task"]["title"]
548 .as_str()
549 .unwrap_or_default()
550 .to_owned(),
551 PresentContext {
552 root: settings["root"].as_str().map(std::path::PathBuf::from),
553 ..self.present.clone()
554 },
555 );
556 let remembered: Vec<crate::tools::policy::Grant> =
557 serde_json::from_value(settings["run_approved"].clone()).unwrap_or_default();
558 tool_loop(
559 cx,
560 &prompt,
561 LoopSpec {
562 tools,
563 policy,
564 max_turns,
565 max_tool_calls,
566 schema: report_schema(),
567 routes,
568 root: settings["root"].as_str().map(str::to_owned),
569 frozen_additions: settings["frozen_additions"] == true,
570 ask,
571 run_approved: remembered,
572 observer: self.progress.clone().map(|p| {
573 (
574 task_name.rsplit('/').next().unwrap_or_default().to_owned(),
575 p,
576 )
577 }),
578 finish: |report| {
579 if report["status"] == "incomplete" {
582 "incomplete"
583 } else {
584 "completed"
585 }
586 },
587 },
588 )
589 .await
590 }
591}
592
593fn relativise(arguments: &mut Value, root: Option<&str>) {
598 let (Some(root), Some(path)) = (root, arguments.get("path").and_then(Value::as_str)) else {
599 return;
600 };
601 let root = root.trim_end_matches('/');
602 if let Some(rel) = path.strip_prefix(root).and_then(|r| r.strip_prefix('/')) {
603 let rel = if rel.is_empty() {
604 ".".to_owned()
605 } else {
606 rel.to_owned()
607 };
608 arguments["path"] = Value::String(rel);
609 }
610}
611
612fn emit(
614 observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
615 turn: u32,
616 entry: &Value,
617) {
618 emit_with_tail(observer, turn, entry, Vec::new());
619}
620
621fn emit_with_tail(
624 observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
625 turn: u32,
626 entry: &Value,
627 tail: Vec<String>,
628) {
629 if let Some((task, p)) = observer {
630 let tool = entry["tool"].as_str().unwrap_or_default();
631 p.event(&crate::progress::Event::ToolCall {
632 task: task.clone(),
633 turn,
634 tool: tool.to_owned(),
635 target: crate::progress::target(tool, &entry["arguments"]),
636 decision: entry["decision"].as_str().unwrap_or_default().to_owned(),
637 ok: entry["ok"].as_bool(),
638 tail,
639 });
640 }
641}
642
643fn needs_person(
645 observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
646 asked: &agentplane::model::ToolCall,
647) {
648 if let Some((task, p)) = observer {
649 let (tool, arguments) = (asked.name.as_str(), &asked.arguments);
650 let target = crate::progress::target(tool, arguments);
651 let what: String = format!("{tool} {target}")
652 .chars()
653 .filter(|c| !c.is_control())
654 .take(60)
655 .collect();
656 p.event(&crate::progress::Event::ApprovalRequested {
657 task: task.clone(),
658 call: asked.id.clone(),
659 tool: tool.to_owned(),
660 what,
661 class: crate::tools::policy::Grant::of(tool, arguments)
662 .map(|g| g.describe())
663 .unwrap_or_default(),
664 });
665 }
666}
667
668fn person_decided(
670 observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
671 asked: &agentplane::model::ToolCall,
672 decision: &str,
673 scope: Option<&str>,
674 who: Option<&Who>,
675) {
676 if let Some((task, p)) = observer {
677 p.event(&crate::progress::Event::ApprovalDecided {
678 task: task.clone(),
679 call: asked.id.clone(),
680 decision: decision.to_owned(),
681 scope: scope.map(str::to_owned),
682 by: who.map_or_else(|| "nobody (expired)".to_owned(), |w| w.name.clone()),
683 how: who.map_or_else(|| "expired".to_owned(), |w| w.basis.clone()),
684 });
685 }
686}
687
688pub(crate) struct LoopSpec {
690 pub tools: Vec<ToolDeclaration>,
692 pub policy: Policy,
694 pub max_turns: u32,
696 pub max_tool_calls: u32,
698 pub schema: Value,
700 pub routes: Vec<(String, String, String)>,
702 pub root: Option<String>,
705 pub frozen_additions: bool,
707 pub finish: fn(&Value) -> &'static str,
709 pub ask: Option<Asker>,
711 pub run_approved: Vec<crate::tools::policy::Grant>,
713 pub observer: Option<(String, std::sync::Arc<dyn crate::progress::Progress>)>,
715}
716
717pub(crate) async fn tool_loop(
721 cx: &mut StepCtx<'_>,
722 prompt: &Tainted<Value>,
723 spec: LoopSpec,
724) -> Result<Outcome, SkillError> {
725 let LoopSpec {
726 tools,
727 policy,
728 max_turns,
729 max_tool_calls,
730 schema,
731 routes,
732 root,
733 frozen_additions,
734 finish,
735 ask,
736 run_approved,
737 observer,
738 } = spec;
739 let mut remembered: Vec<(crate::tools::policy::Grant, &'static str)> =
742 run_approved.into_iter().map(|g| (g, "run-grant")).collect();
743 let catalog = crate::tools::catalog();
744
745 let mut transcript = Transcript::new(prompt);
748 let mut conversation: Label = prompt.label().clone();
749 let mut calls = 0u32;
750 let mut denied_in_a_row = 0u32;
751 let mut obligation = false;
753 let mut usage = [0u64; 4];
754 let mut log: Vec<Value> = Vec::new();
755
756 let end = |status: &str, extra: Value, log: &[Value], usage: [u64; 4], conversation: &Label| {
757 let mut out = json!({
758 "status": status,
759 "tool_calls": log,
760 "usage": {
761 "input_tokens": usage[0],
762 "output_tokens": usage[1],
763 "cache_read_tokens": usage[2],
764 "cache_write_tokens": usage[3],
765 },
766 });
767 if let (Some(o), Some(e)) = (out.as_object_mut(), extra.as_object()) {
768 o.extend(e.clone());
769 }
770 Ok(Outcome::done(Tainted::with_label(
771 out,
772 conversation.clone(),
773 )))
774 };
775
776 for turn in 0..max_turns {
777 let tools_now = tools.clone();
781 let schema_now = schema.clone();
782 let read_so_far = &transcript;
783 let completion = cx
784 .complete_with(prompt, move |call| {
785 call.with_tools(tools_now)
786 .continuing(read_so_far)
787 .expecting(schema_now)
788 })
789 .await?;
790 conversation = transcript.label().join(completion.label());
791 usage_sum(
792 &mut usage,
793 &serde_json::to_value(completion.peek().usage).unwrap_or(Value::Null),
794 );
795 if let Some((task, p)) = &observer {
796 p.event(&crate::progress::Event::Usage {
797 task: task.clone(),
798 input_tokens: usage[0],
799 output_tokens: usage[1],
800 cache_read_tokens: usage[2],
801 cache_write_tokens: usage[3],
802 usd: None,
803 });
804 }
805
806 if completion.peek().truncated {
807 return Ok(Outcome::fail(
808 "the model ran out of output budget mid-turn; nothing it was still writing was executed — raise [model] max_output_tokens",
809 ));
810 }
811 let asked_calls = transcript.asked(&completion);
812 if let Some((task, p)) = observer.as_ref().filter(|_| !asked_calls.is_empty()) {
815 let said = completion.peek().text.trim();
816 if !said.is_empty() {
817 let text: String = crate::approvals::present::escape(said)
818 .chars()
819 .take(2000)
820 .collect();
821 p.event(&crate::progress::Event::Said {
822 task: task.clone(),
823 turn: turn + 1,
824 text,
825 });
826 }
827 }
828 if asked_calls.is_empty() {
829 let report = completion
830 .peek()
831 .structured
832 .clone()
833 .unwrap_or_else(|| json!({ "summary": completion.peek().text, "status": "incomplete", "files_changed": [], "checks_run": [], "notes": ["the answer was not the requested structure"] }));
834 let status = finish(&report);
835 return end(
836 status,
837 json!({ "report": report }),
838 &log,
839 usage,
840 &conversation,
841 );
842 }
843
844 for mut asked in asked_calls {
845 relativise(&mut asked.arguments, root.as_deref());
846 calls += 1;
847 if calls > max_tool_calls {
848 return end(
849 "budget_exhausted",
850 json!({ "reason": format!("more than {max_tool_calls} tool calls") }),
851 &log,
852 usage,
853 &conversation,
854 );
855 }
856 if asked.name == ASK_PERSON && tools.iter().any(|t| t.name == ASK_PERSON) {
857 let question = asked.arguments["question"]
858 .as_str()
859 .unwrap_or_default()
860 .to_owned();
861 let decided = match &ask {
864 Some(asker) => Some(
865 wait_for_person(
866 cx,
867 asker,
868 &mut obligation,
869 &crate::approvals::tasks::question_task(&asker.task, &question),
870 )
871 .await?,
872 ),
873 None => None,
874 };
875 let read = decided.as_ref().map(crate::approvals::tasks::read);
876 let by = read.as_ref().and_then(|r| r.by.clone());
877 let entry = json!({
878 "tool": ASK_PERSON,
879 "arguments": asked.arguments,
880 "decision": "allow",
881 "answered": read.as_ref().is_some_and(|r| r.text.is_some()),
882 "operator": by.as_ref().map(Who::view),
883 "via": by.as_ref().map(|w| w.via.clone()),
884 });
885 emit(observer.as_ref(), turn + 1, &entry);
886 log.push(entry);
887 let text = match (&read, &by) {
888 (Some(r), _) if r.text.is_some() => r.text.clone().unwrap_or_default(),
889 (Some(_), Some(w)) => format!("{} declined to answer.", w.name),
890 _ => "Nobody can answer now. Record the question in the artifact as [NEEDS CLARIFICATION: <question>] and continue.".to_owned(),
891 };
892 let label = match &by {
893 Some(_) => provenance::from(provenance::PERSON),
894 None => Label::trusted(),
895 };
896 transcript.answered(
897 asked,
898 &Tainted::with_label(json!({ "answer": text }), label),
899 );
900 continue;
901 }
902 if asked.name == REPORT_BLOCKED && tools.iter().any(|t| t.name == REPORT_BLOCKED) {
903 let reason = asked.arguments["reason"]
904 .as_str()
905 .unwrap_or("no reason given")
906 .to_owned();
907 let entry = json!({ "tool": REPORT_BLOCKED, "arguments": asked.arguments, "decision": "stop" });
908 emit(observer.as_ref(), turn + 1, &entry);
909 log.push(entry);
910 return end(
911 "blocked",
912 json!({ "reason": reason }),
913 &log,
914 usage,
915 &conversation,
916 );
917 }
918 let declared = tools.iter().any(|t| t.name == asked.name);
919 let target = catalog
920 .iter()
921 .find(|t| declared && t.name == asked.name)
922 .map(|t| ToolId::new(t.server, t.name))
923 .or_else(|| {
924 routes
925 .iter()
926 .find(|(n, _, _)| declared && *n == asked.name)
927 .map(|(_, server, tool)| ToolId::new(server, tool))
928 });
929 let Some(target) = target else {
930 let entry = json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "unknown" });
931 emit(observer.as_ref(), turn + 1, &entry);
932 log.push(entry);
933 transcript.failed(asked, Tainted::trusted("no tool of that name".to_owned()));
934 continue;
935 };
936 let mut decision = policy.classify(&asked.name, &asked.arguments);
937 let mut additions_only = false;
941 if frozen_additions
942 && matches!(&decision, Decision::Approval(r) if r.contains("frozen test"))
943 {
944 let path = asked.arguments["path"]
945 .as_str()
946 .unwrap_or_default()
947 .to_owned();
948 let read = cx
949 .call_tool(
950 ToolId::new("fs", "read_file"),
951 Tainted::with_label(json!({ "path": path }), completion.label().clone()),
952 )
953 .await?;
954 let current = read.peek().clone();
955 let lines = current["content"].as_str().unwrap_or_default();
958 let old_text = if lines.is_empty() {
959 String::new()
960 } else {
961 format!("{lines}\n")
962 };
963 let old = old_text.as_str();
964 let readable = current["ok"] == true && current["truncated"] != true;
965 let new = match asked.name.as_str() {
966 "write_file" => asked.arguments["content"].as_str().map(str::to_owned),
967 "edit_file" => {
968 let (from, to) = (
969 asked.arguments["old_string"].as_str().unwrap_or_default(),
970 asked.arguments["new_string"].as_str().unwrap_or_default(),
971 );
972 let all = asked.arguments["replace_all"] == true;
973 (!from.is_empty() && (all || old.matches(from).count() == 1))
974 .then(|| old.replace(from, to))
975 }
976 _ => None,
977 };
978 if readable && new.is_some_and(|n| crate::integrity::only_additions(old, &n)) {
979 decision = Decision::Allow;
980 additions_only = true;
981 }
982 }
983 let grant = crate::tools::policy::Grant::of(&asked.name, &asked.arguments);
985 let mut person: Option<Value> = None;
986 let mut note: Option<String> = None;
987 if matches!(decision, Decision::Approval(_))
988 && let Some((_, scope)) = grant
989 .as_ref()
990 .and_then(|g| remembered.iter().find(|(r, _)| r == g))
991 {
992 person = Some(json!({ "approved_by": "person", "scope": scope }));
993 decision = Decision::Allow;
994 }
995 if let (Decision::Approval(reason), Some(asker)) = (&decision, &ask) {
997 let risk = crate::approvals::risk(&asked.name, reason);
998 let claim = crate::approvals::claim(&completion.peek().text);
999 let request = Request {
1000 task: asker.task.clone(),
1001 task_title: asker.task_title.clone(),
1002 turn: turn + 1,
1003 tool: asked.name.clone(),
1004 arguments: asked.arguments.clone(),
1005 kind: crate::approvals::kind_of(&asked.name, &asked.arguments).to_owned(),
1006 reason: reason.clone(),
1007 covers: grant.as_ref().map(|g| g.describe()).unwrap_or_default(),
1008 risk: risk.to_owned(),
1009 claim: claim.clone(),
1010 options: crate::approvals::options(risk, grant.is_some()),
1011 };
1012 needs_person(observer.as_ref(), &asked);
1013 let decided = wait_for_person(
1014 cx,
1015 asker,
1016 &mut obligation,
1017 &crate::approvals::tasks::approval_task(&request, grant.as_ref(), &log),
1018 )
1019 .await?;
1020 let read = crate::approvals::tasks::read(&decided);
1021 let answer: Answer = read.answer.clone();
1022 let approver = serde_json::to_value(&answer.by).unwrap_or(Value::Null);
1024 let operator = read.by.as_ref().map(Who::view);
1025 person_decided(
1026 observer.as_ref(),
1027 &asked,
1028 if answer.decision == Said::Approve {
1029 "approve"
1030 } else {
1031 "reject"
1032 },
1033 (answer.decision == Said::Approve).then(|| answer.scope.name()),
1034 read.by.as_ref().filter(|_| !read.expired),
1035 );
1036 match answer.decision {
1037 Said::Approve => {
1038 person = Some(json!({
1039 "approved_by": "person",
1040 "scope": answer.scope.name(),
1041 "note": answer.note,
1042 "approver": approver,
1043 "operator": operator,
1044 }));
1045 if let (Some(g), Scope::Task | Scope::Run) = (&grant, answer.scope) {
1046 let remembered_as = if answer.scope == Scope::Run {
1047 "run-grant"
1048 } else {
1049 "task-grant"
1050 };
1051 remembered.push((g.clone(), remembered_as));
1052 }
1053 note = answer.note;
1054 decision = Decision::Allow;
1055 }
1056 Said::Reject | Said::Stop => {
1059 let why = answer
1060 .reason
1061 .unwrap_or_else(|| "no reason given".to_owned());
1062 person = Some(if read.expired {
1063 json!({ "expired": true, "rejection": why })
1064 } else {
1065 json!({ "rejected_by": "person", "approver": approver, "operator": operator, "rejection": why })
1066 });
1067 decision = Decision::Deny(format!("a person rejected this: {why}"));
1068 }
1069 }
1070 }
1071 let rule = person
1074 .is_none()
1075 .then(|| policy.rule(&asked.name, &asked.arguments));
1076 let with_person = |mut entry: Value| {
1077 if let Some(o) = entry.as_object_mut() {
1078 match (person.as_ref().and_then(Value::as_object), &rule) {
1079 (Some(p), _) => {
1080 let by = if p.contains_key("expired") {
1082 "expiry"
1083 } else {
1084 "person"
1085 };
1086 o.insert("by".to_owned(), json!(by));
1087 o.extend(p.clone());
1088 }
1089 (None, Some(r)) => {
1090 o.insert("by".to_owned(), json!("rule"));
1091 o.insert("rule".to_owned(), json!(r));
1092 }
1093 (None, None) => {}
1094 }
1095 }
1096 entry
1097 };
1098 match decision {
1099 Decision::Deny(reason) => {
1100 let entry = with_person(
1101 json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "deny", "reason": reason }),
1102 );
1103 emit(observer.as_ref(), turn + 1, &entry);
1104 log.push(entry);
1105 transcript.failed(
1108 asked,
1109 refusal(format!("denied: {reason}"), completion.label()),
1110 );
1111 denied_in_a_row += 1;
1114 if denied_in_a_row >= MAX_CONSECUTIVE_DENIALS {
1115 return end(
1116 "denied",
1117 json!({ "reason": format!("{denied_in_a_row} calls in a row were denied; last: {reason}") }),
1118 &log,
1119 usage,
1120 &conversation,
1121 );
1122 }
1123 continue;
1124 }
1125 Decision::Approval(reason) => {
1126 let entry = json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "approval", "reason": reason });
1127 emit(observer.as_ref(), turn + 1, &entry);
1128 needs_person(observer.as_ref(), &asked);
1129 log.push(entry);
1130 return end(
1131 "needs_approval",
1132 json!({ "pending": { "tool": asked.name, "arguments": asked.arguments, "reason": reason } }),
1133 &log,
1134 usage,
1135 &conversation,
1136 );
1137 }
1138 Decision::Allow => {
1139 denied_in_a_row = 0;
1140 let mut arguments = asked.arguments.clone();
1147 if asked.name == "run_command"
1148 && let Some(o) = arguments.as_object_mut()
1149 {
1150 o.insert("call_id".to_owned(), Value::String(asked.id.clone()));
1151 }
1152 let args = Tainted::with_label(arguments, completion.label().clone());
1153 let result = match cx.call_tool(target.clone(), args).await {
1154 Ok(r) => r,
1155 Err(error) => match cx.relayed(&error) {
1158 Some(detail) => {
1159 let entry = with_person(
1160 json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "allow", "ok": false }),
1161 );
1162 emit(observer.as_ref(), turn + 1, &entry);
1163 log.push(entry);
1164 transcript.failed(asked, detail);
1165 continue;
1166 }
1167 None => return Err(error.into()),
1168 },
1169 };
1170 let output = result.peek().clone();
1171 let ok = output["ok"].as_bool() != Some(false);
1172 let mut entry = with_person(
1173 json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "allow", "ok": ok }),
1174 );
1175 if additions_only {
1176 entry["frozen_additions"] = json!(true);
1177 }
1178 let tail = if asked.name == "run_command" && !ok {
1179 crate::progress::tail(&format!(
1180 "{}\n{}",
1181 output["stdout"].as_str().unwrap_or_default(),
1182 output["stderr"].as_str().unwrap_or_default()
1183 ))
1184 } else {
1185 Vec::new()
1186 };
1187 emit_with_tail(observer.as_ref(), turn + 1, &entry, tail);
1188 log.push(entry);
1189 if let Some(drift) = output["integrity"].as_array().filter(|d| !d.is_empty()) {
1192 let paths: Vec<&str> =
1193 drift.iter().filter_map(|d| d["path"].as_str()).collect();
1194 return end(
1195 "integrity",
1196 json!({
1197 "reason": format!("a command changed what judges the task: {}", paths.join(", ")),
1198 "integrity": drift,
1199 }),
1200 &log,
1201 usage,
1202 &conversation,
1203 );
1204 }
1205 match output["error"].as_str() {
1211 Some(error) if !ok => transcript.failed(
1212 asked,
1213 Tainted::with_label(error.to_owned(), result.label().clone()),
1214 ),
1215 _ => match ¬e {
1217 Some(n) => {
1218 let n = format!("Note from the person who approved this call: {n}");
1219 transcript.answered(
1220 asked,
1221 &result.map(|mut v| {
1222 v["note_from_person"] = Value::String(n);
1223 v
1224 }),
1225 );
1226 }
1227 None => transcript.answered(asked, &result),
1228 },
1229 }
1230 }
1231 }
1232 }
1233 }
1234 end(
1235 "budget_exhausted",
1236 json!({ "reason": format!("more than {max_turns} model turns") }),
1237 &log,
1238 usage,
1239 &conversation,
1240 )
1241}
1242
1243#[cfg(test)]
1244mod tests {
1245 use super::*;
1246
1247 #[test]
1248 fn labels_are_read_from_the_parts_and_refusals_carry_the_call() {
1249 use agentplane::core::{Label, SourceId};
1250 let model = Label::untrusted(SourceId::new("model:x/y"));
1251 assert!(
1253 refusal("denied: `src/x` is protected".to_owned(), &model)
1254 .label()
1255 .is_untrusted()
1256 );
1257 let input = Tainted::object([
1259 ("bundle", Tainted::trusted(json!({}))),
1260 (
1261 "digest",
1262 Tainted::with_label(json!({}), provenance::from(provenance::VERIFIER)),
1263 ),
1264 (
1265 "settings",
1266 Tainted::object([("nudges", Tainted::trusted(json!([])))]),
1267 ),
1268 ]);
1269 let label = conversation_label(&input);
1270 assert!(
1271 label
1272 .provenance
1273 .contains(&SourceId::new(provenance::VERIFIER))
1274 );
1275 let flat = Tainted::with_label(json!({ "bundle": {} }), model.clone());
1277 assert_eq!(conversation_label(&flat).provenance, model.provenance);
1278 }
1279
1280 #[test]
1281 fn absolute_paths_inside_the_root_become_relative_and_others_stay() {
1282 let mut a = json!({ "path": "/work/repo/specs/001-x/plan.md" });
1283 relativise(&mut a, Some("/work/repo"));
1284 assert_eq!(a["path"], "specs/001-x/plan.md");
1285 let mut outside = json!({ "path": "/work/repository-two/x" });
1286 relativise(&mut outside, Some("/work/repo"));
1287 assert_eq!(outside["path"], "/work/repository-two/x");
1288 let mut none = json!({ "path": "/etc/passwd" });
1289 relativise(&mut none, None);
1290 assert_eq!(none["path"], "/etc/passwd");
1291 }
1292}