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 — never work another task of the list is for — and run the project's checks with run_command to see whether it works. Stay inside \
36the task's scope: the task's paths are the plan's guess — where they differ from the code, \
37use the module that holds it; a change elsewhere that the task or its acceptance tests need \
38(an entry point they run, a module they import) is in scope and shown to the reviewer, never \
39a reason to stop: an entry point that bypasses the task's module is rewired to it. You \
40cannot edit the spec ({intent}), the checks' configuration or .git; such calls are refused. Some commands need a person's approval and will end the \
41attempt — prefer configured checks. If the task cannot be done as written — it \
42contradicts the spec, needs information or access you lack, or a test is wrong — call \
43report_blocked with the reason instead of working around it. Test files that existed \
44when the attempt started are frozen: add new tests in new files (or next to the code), \
45never by editing those. Never weaken, skip or delete tests to make checks pass. Never \
46return values copied from tests or examples in place of the specified behaviour (a \
47constant where the spec asks for a computed, fetched or current value is a cheat): if \
48the specified behaviour is impossible under the stated constraints, call report_blocked. \
49You are not the judge: when you finish, speccoder runs the checks itself and decides. \
50Finish with the requested structured report; its status is `incomplete` only when part of \
51this task's own work is left undone — a check you could not run, a test a later task makes \
52pass, or a finding in a frozen test file is no reason.",
53 product = w.product,
54 intent = w.intent_paths,
55 )
56}
57
58#[must_use]
60pub fn report_schema() -> Value {
61 super::answers::schema::<super::answers::AttemptReport>()
62}
63
64fn report_blocked() -> ToolDeclaration {
65 ToolDeclaration::new(
66 REPORT_BLOCKED,
67 "Stop the attempt because the task cannot be done as written; a person will look at the \
68reason. `kind`, with the `evidence` speccoder checks: `refused` — a call the task needs was \
69refused (the call); `spec_conflict` — the spec contradicts the task (its exact words); \
70`wrong_test` — a frozen test asserts the wrong thing (its path); `needs_information` — only a \
71person knows (the question). Scope is never a kind: when the tests run code that bypasses the \
72task's module (an entry point, a caller), wiring that code to it is part of the task.",
73 super::answers::schema::<super::answers::Blocked>(),
74 )
75}
76
77mod loop_spec;
78mod person;
79#[cfg(test)]
80mod tests;
81mod tool_loop;
82
83pub use loop_spec::*;
84pub use person::*;
85pub(crate) use tool_loop::*;
86
87#[derive(Debug)]
94pub struct Attempt {
95 pub style: PromptStyle,
97 pub cache_ttl: CacheTtl,
99 pub progress: Option<std::sync::Arc<dyn crate::progress::Progress>>,
101 pub format: &'static dyn crate::format::Adapter,
103 pub present: PresentContext,
105}
106
107#[derive(Debug)]
111struct Quiet {
112 replaying: std::sync::Arc<std::sync::atomic::AtomicBool>,
113 next: std::sync::Arc<dyn crate::progress::Progress>,
114}
115
116impl crate::progress::Progress for Quiet {
117 fn event(&self, event: &crate::progress::Event) {
118 if !self.replaying.load(std::sync::atomic::Ordering::SeqCst) {
119 self.next.event(event);
120 }
121 }
122}
123
124#[must_use]
133pub fn input(
134 bundle: Value,
135 settings: &Settings<'_>,
136 digest: Option<&Value>,
137 instructions: &[crate::instructions::Instruction],
138 test_layout: Option<&str>,
139) -> Tainted<Value> {
140 let digest = match digest.filter(|d| !d.is_null()) {
141 Some(d) => Tainted::with_label(d.clone(), provenance::from(provenance::VERIFIER)),
142 None => Tainted::trusted(Value::Null),
143 };
144 let mcp = Tainted::array(settings.mcp.iter().map(|t| {
146 Tainted::with_label(
147 serde_json::to_value(t).unwrap_or(Value::Null),
148 agentplane::core::Label::untrusted(provenance::tool(&t.server, &t.tool)),
149 )
150 }));
151 let trusted = |v: Value| Tainted::trusted(v);
152 let autopilot = settings.autopilot;
153 let planning = settings.planning;
154 let contained = settings.contained;
155 let settings = vec![
156 ("checks", trusted(json!(settings.checks))),
157 ("allow", trusted(json!(settings.allow))),
158 ("max_turns", trusted(json!(settings.max_turns))),
159 ("max_tool_calls", trusted(json!(settings.max_tool_calls))),
160 ("reasoning", trusted(json!(settings.reasoning))),
161 ("frozen", trusted(json!(settings.frozen))),
162 ("approved", trusted(json!(settings.approved))),
163 ("rejected", trusted(json!(settings.rejected))),
164 ("run_approved", trusted(json!(settings.run_approved))),
165 ("approved_once", trusted(json!(settings.approved_once))),
166 ("nudges", trusted(json!(settings.nudges))),
167 ("mcp", mcp),
168 ("root", trusted(json!(settings.root))),
169 (
170 "frozen_additions",
171 trusted(json!(settings.frozen_additions)),
172 ),
173 ("network_allow", trusted(json!(settings.network_allow))),
174 ("allow_prefix", trusted(json!(settings.allow_prefix))),
175 (
176 "network_allow_prefix",
177 trusted(json!(settings.network_allow_prefix)),
178 ),
179 ("run_id", trusted(json!(settings.run_id))),
180 ("asked", trusted(json!(settings.asked))),
181 ("budget", trusted(json!(settings.budget))),
182 ("left", trusted(json!(settings.left))),
183 ("prompts", trusted(json!(settings.prompts))),
184 ("asks", trusted(json!(settings.asks))),
185 ("wait_secs", trusted(json!(settings.wait_secs))),
186 ("in_place", trusted(json!(settings.in_place))),
187 ("hidden", trusted(json!(settings.hidden))),
188 ];
189 let mut settings = settings;
192 if autopilot {
193 settings.push(("autopilot", trusted(json!(true))));
194 }
195 if let Some(dir) = planning {
197 settings.push(("planning", trusted(json!(dir))));
198 }
199 if let Some(m) = contained {
201 settings.push(("contained", trusted(json!(m))));
202 }
203 let settings = Tainted::object(settings);
204 Tainted::object([
205 ("bundle", Tainted::trusted(bundle)),
206 ("digest", digest),
207 ("settings", settings),
208 (
211 "instructions",
212 if instructions.is_empty() {
213 Tainted::trusted(json!([]))
214 } else {
215 Tainted::with_label(json!(instructions), provenance::from(provenance::WORKTREE))
216 },
217 ),
218 (
220 "test_layout",
221 match test_layout {
222 Some(t) => Tainted::with_label(json!(t), provenance::from(provenance::WORKTREE)),
223 None => Tainted::trusted(Value::Null),
224 },
225 ),
226 ])
227}
228
229fn refusal(text: String, call: &agentplane::core::Label) -> Tainted<String> {
233 Tainted::with_label(text, call.clone())
234}
235
236const MESSAGE_PARTS: [&str; 7] = [
238 "/bundle",
239 "/instructions",
240 "/test_layout",
241 "/digest",
242 "/settings/nudges",
243 "/settings/rejected",
244 "/settings/mcp",
245];
246
247fn conversation_label(input: &Tainted<Value>) -> agentplane::core::Label {
251 MESSAGE_PARTS
252 .iter()
253 .map(|p| input.label_at(p).unwrap_or_else(|| input.label()).clone())
254 .fold(agentplane::core::Label::trusted(), |acc, l| acc.join(&l))
255}
256
257#[derive(Debug, Clone, Copy)]
259pub struct Settings<'a> {
260 pub checks: &'a [Vec<String>],
262 pub allow: &'a [Vec<String>],
264 pub allow_prefix: &'a [Vec<String>],
266 pub max_turns: u32,
268 pub max_tool_calls: u32,
270 pub reasoning: bool,
272 pub frozen: &'a [String],
274 pub approved: &'a [Object],
276 pub rejected: &'a [crate::tools::policy::Rejection],
278 pub run_approved: &'a [Object],
280 pub approved_once: &'a [Object],
283 pub nudges: &'a [String],
285 pub mcp: &'a [crate::mcp::McpTool],
287 pub root: &'a str,
289 pub frozen_additions: bool,
291 pub asks: &'a str,
294 pub wait_secs: u64,
296 pub network_allow: &'a [Vec<String>],
298 pub network_allow_prefix: &'a [Vec<String>],
300 pub run_id: &'a str,
302 pub asked: u32,
304 pub budget: u32,
306 pub prompts: bool,
308 pub in_place: bool,
310 pub hidden: &'a [String],
312 pub autopilot: bool,
314 pub planning: Option<&'a str>,
317 pub contained: Option<&'a str>,
320 pub left: Option<Left>,
322}
323
324pub const MAX_CONSECUTIVE_DENIALS: u32 = 3;
326
327fn digest_text(digest: &Value) -> String {
329 let mut out = format!(
330 "# Previous attempt\n\nAttempt {} at this task was verified by speccoder and FAILED. Its \
331changes were discarded; the worktree is back where that attempt started. You start fresh — use \
332these facts from the verifier, check them yourself, and do not repeat the mistake.\n",
333 digest["attempt"].as_u64().unwrap_or(1)
334 );
335 let strings = |v: &Value| -> Vec<String> {
336 v.as_array()
337 .map(|a| {
338 a.iter()
339 .filter_map(|x| x.as_str().map(str::to_owned))
340 .collect()
341 })
342 .unwrap_or_default()
343 };
344 let reasons = strings(&digest["reasons"]);
345 if !reasons.is_empty() {
346 out.push_str("\n## Verdict reasons\n\n");
347 for r in reasons {
348 out.push_str(&format!("- {r}\n"));
349 }
350 }
351 for c in digest["checks"].as_array().into_iter().flatten() {
352 out.push_str(&format!(
353 "\n## Check `{}` ({}): `{}`\n",
354 c["name"].as_str().unwrap_or_default(),
355 c["status"].as_str().unwrap_or_default(),
356 strings(&c["argv"]).join(" ")
357 ));
358 let tests = strings(&c["failing_tests"]);
359 if !tests.is_empty() {
360 out.push_str(&format!("\nFailing tests: {}\n", tests.join(", ")));
361 }
362 out.push_str(&format!(
363 "\n```text\n{}\n```\n",
364 c["excerpt"].as_str().unwrap_or_default()
365 ));
366 }
367 let uncollected = strings(&digest["uncollected"]);
368 if !uncollected.is_empty() {
369 out.push_str("\n## Tests that never ran\n\n");
370 for l in uncollected {
371 out.push_str(&format!("- {l}\n"));
372 }
373 }
374 let discarded = strings(&digest["discarded"]);
375 if !discarded.is_empty() {
376 out.push_str(&format!(
377 "\n## Files the discarded attempt changed\n\n{}\n",
378 discarded.join("\n")
379 ));
380 }
381 out
382}
383
384fn argvs(v: &Value) -> Vec<Vec<String>> {
385 v.as_array()
386 .map(|a| {
387 a.iter()
388 .map(|argv| {
389 argv.as_array()
390 .map(|p| {
391 p.iter()
392 .filter_map(|x| x.as_str().map(str::to_owned))
393 .collect()
394 })
395 .unwrap_or_default()
396 })
397 .collect()
398 })
399 .unwrap_or_default()
400}
401
402fn decisions_of<T: serde::de::DeserializeOwned>(
406 settings: &Value,
407 key: &str,
408) -> Result<Vec<T>, SkillError> {
409 match &settings[key] {
410 Value::Null => Ok(Vec::new()),
411 v => serde_json::from_value(v.clone())
412 .map_err(|e| SkillError::Input(format!("settings.{key}: {e}"))),
413 }
414}
415
416pub(crate) fn policy_of(
421 settings: &Value,
422 rules: &'static crate::format::Rules,
423) -> Result<Policy, SkillError> {
424 let frozen: Vec<String> = settings["frozen"]
425 .as_array()
426 .map(|a| {
427 a.iter()
428 .filter_map(|v| v.as_str().map(str::to_owned))
429 .collect()
430 })
431 .unwrap_or_default();
432 let approved: Vec<Object> = decisions_of(settings, "approved")?;
433 let rejected: Vec<crate::tools::policy::Rejection> = decisions_of(settings, "rejected")?;
434 let mcp: Vec<crate::mcp::McpTool> =
435 serde_json::from_value(settings["mcp"].clone()).unwrap_or_default();
436 let policy = Policy::new(&argvs(&settings["checks"]), &argvs(&settings["allow"]))
437 .with_prefixes(argvs(&settings["allow_prefix"]))
438 .with_network_prefixes(argvs(&settings["network_allow_prefix"]))
439 .rules(rules)
440 .with_frozen(frozen)
441 .with_decisions(approved, rejected)
442 .with_mcp(mcp.iter().map(|t| (t.name.clone(), t.decision.clone())))
443 .with_network(argvs(&settings["network_allow"]))
444 .with_hidden(
445 serde_json::from_value::<Vec<String>>(settings["hidden"].clone()).unwrap_or_default(),
446 );
447 let policy = if settings["in_place"] == true {
448 policy.in_place()
449 } else {
450 policy
451 };
452 let policy = if settings["autopilot"] == true {
453 policy.autopilot()
454 } else {
455 policy
456 };
457 let policy = match settings["contained"].as_str() {
458 Some(m) => policy.contained(m),
459 None => policy,
460 };
461 Ok(match settings["planning"].as_str() {
462 Some(dir) => policy.planning(dir),
463 None => policy,
464 })
465}
466
467#[async_trait]
468impl Skill for Attempt {
469 fn descriptor(&self) -> SkillDescriptor {
470 SkillDescriptor::new("speccoder.task.attempt").provides(CAPABILITY)
471 }
472
473 async fn invoke(
474 &self,
475 cx: &mut StepCtx<'_>,
476 input: Tainted<Value>,
477 ) -> Result<Outcome, SkillError> {
478 let (ttl, style) = (self.cache_ttl, self.style);
479 let settings = input.peek()["settings"].clone();
480 let task_name = format!(
481 "{}/{}",
482 input.peek()["bundle"]["feature"]
483 .as_str()
484 .unwrap_or_default(),
485 input.peek()["bundle"]["task"]["id"]
486 .as_str()
487 .unwrap_or_default()
488 );
489 let rejected: Vec<crate::tools::policy::Rejection> = decisions_of(&settings, "rejected")?;
490 let told = rejected;
491 let bundle = &input.peek()["bundle"];
492 let stop = StopGate {
493 spec: ["story", "plan", "constitution"]
494 .iter()
495 .filter_map(|k| bundle[*k].as_str())
496 .chain(bundle["task"]["title"].as_str())
497 .collect::<Vec<_>>()
498 .join("\n"),
499 frozen: settings["frozen"]
500 .as_array()
501 .into_iter()
502 .flatten()
503 .filter_map(|v| v.as_str().map(str::to_owned))
504 .collect(),
505 rejected: !told.is_empty(),
506 };
507 let mcp: Vec<crate::mcp::McpTool> =
508 serde_json::from_value(settings["mcp"].clone()).unwrap_or_default();
509 let planning = settings["planning"].as_str().map(str::to_owned);
510 let policy = policy_of(&settings, self.format.rules())?;
511 let max_turns = settings["max_turns"]
512 .as_u64()
513 .map_or(30, |n| u32::try_from(n).unwrap_or(u32::MAX))
514 .max(1);
515 let max_tool_calls = settings["max_tool_calls"]
516 .as_u64()
517 .map_or(100, |n| u32::try_from(n).unwrap_or(u32::MAX));
518 let label = conversation_label(&input);
521 let raw = input.peek().clone();
522 let prompt = provenance::labelled_prompt(
523 {
524 let input = raw;
525 let mut p = layout(
526 &input["bundle"],
527 ttl,
528 style,
529 &self::policy(self.format.wording()),
530 self.format,
531 );
532 let given: Vec<crate::instructions::Instruction> =
533 serde_json::from_value(input["instructions"].clone()).unwrap_or_default();
534 if let Some(text) = crate::instructions::section(&given) {
535 let (key, kind) = match style {
536 PromptStyle::OpenAi => ("input", "input_text"),
537 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
538 ("messages", "text")
539 }
540 };
541 if let Some(content) = p[key][0]["content"].as_array_mut() {
542 content.push(json!({ "type": kind, "text": text }));
543 }
544 }
545 if let Some(text) = input["test_layout"].as_str().filter(|t| !t.is_empty()) {
546 let (key, kind) = match style {
547 PromptStyle::OpenAi => ("input", "input_text"),
548 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
549 ("messages", "text")
550 }
551 };
552 if let Some(content) = p[key][0]["content"].as_array_mut() {
553 content.push(json!({ "type": kind, "text": text }));
554 }
555 }
556 if !input["digest"].is_null() {
557 let (key, kind) = match style {
558 PromptStyle::OpenAi => ("input", "input_text"),
559 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
560 ("messages", "text")
561 }
562 };
563 if let Some(content) = p[key][0]["content"].as_array_mut() {
564 content
565 .push(json!({ "type": kind, "text": digest_text(&input["digest"]) }));
566 }
567 }
568 if let Some(dir) = &planning {
570 let (key, kind) = match style {
571 PromptStyle::OpenAi => ("input", "input_text"),
572 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
573 ("messages", "text")
574 }
575 };
576 let text = format!(
577 "# A planning task\n\nThis task writes design documents, not code: write them under `{dir}/` (the plan, contracts, the data model, research and the like are yours to write there). The requirements, the task list and the person's review stay protected, as does everything else of the spec; change no source code. speccoder runs the checks afterwards.\n"
578 );
579 if let Some(content) = p[key][0]["content"].as_array_mut() {
580 content.push(json!({ "type": kind, "text": text }));
581 }
582 }
583 let checks: Vec<String> = argvs(&input["settings"]["checks"])
585 .iter()
586 .map(|argv| format!("- `{}`", json!(argv)))
587 .collect();
588 if !checks.is_empty() {
589 let (key, kind) = match style {
590 PromptStyle::OpenAi => ("input", "input_text"),
591 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
592 ("messages", "text")
593 }
594 };
595 let text = format!(
596 "# The checks\n\nRun them with run_command as these argvs, word for word; they need no approval:\n\n{}\n",
597 checks.join("\n")
598 );
599 if let Some(content) = p[key][0]["content"].as_array_mut() {
600 content.push(json!({ "type": kind, "text": text }));
601 }
602 }
603 let notes: Vec<String> = input["settings"]["nudges"]
605 .as_array()
606 .map(|a| {
607 a.iter()
608 .filter_map(|n| n.as_str().map(|n| format!("- {n}")))
609 .collect()
610 })
611 .unwrap_or_default();
612 if !notes.is_empty() {
613 let (key, kind) = match style {
614 PromptStyle::OpenAi => ("input", "input_text"),
615 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
616 ("messages", "text")
617 }
618 };
619 let text = format!("# Notes from a person\n\n{}\n", notes.join("\n"));
620 if let Some(content) = p[key][0]["content"].as_array_mut() {
621 content.push(json!({ "type": kind, "text": text }));
622 }
623 }
624 if !told.is_empty() {
626 let (key, kind) = match style {
627 PromptStyle::OpenAi => ("input", "input_text"),
628 PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
629 ("messages", "text")
630 }
631 };
632 let lines: Vec<String> = told
633 .iter()
634 .map(|r| format!("- {}: {}", r.object.describe(), r.reason))
635 .collect();
636 let text = format!(
637 "# Rejected by a person\n\nThese calls were rejected for this task; find another way or call report_blocked:\n\n{}\n",
638 lines.join("\n")
639 );
640 if let Some(content) = p[key][0]["content"].as_array_mut() {
641 content.push(json!({ "type": kind, "text": text }));
642 }
643 }
644 if let PromptStyle::Parts { caches } = style {
645 super::model_config::to_parts(&mut p, caches);
646 }
647 p
648 },
649 &label,
650 );
651 let mut tools = crate::tools::declarations();
652 tools.push(report_blocked());
653 let routes: Vec<(String, String, String)> = mcp
655 .iter()
656 .filter(|t| t.decision != "deny")
657 .map(|t| (t.name.clone(), t.server.clone(), t.tool.clone()))
658 .collect();
659 tools.extend(mcp.iter().filter(|t| t.decision != "deny").map(|t| {
660 ToolDeclaration::new(t.name.clone(), t.description.clone(), t.schema.clone())
661 }));
662 let ask = Asker::from_settings(
664 &settings,
665 task_name.clone(),
666 input.peek()["bundle"]["task"]["title"]
667 .as_str()
668 .unwrap_or_default()
669 .to_owned(),
670 PresentContext {
671 root: settings["root"].as_str().map(std::path::PathBuf::from),
672 ..self.present.clone()
673 },
674 );
675 let remembered: Vec<Object> = decisions_of(&settings, "run_approved")?;
676 let once: Vec<Object> = decisions_of(&settings, "approved_once")?;
677 let mut parts = Vec::new();
679 let given: Vec<crate::instructions::Instruction> =
680 serde_json::from_value(input.peek()["instructions"].clone()).unwrap_or_default();
681 if let Some(text) = crate::instructions::section(&given) {
682 parts.push(crate::approvals::derived::Part {
683 from: provenance::WORKTREE.to_owned(),
684 turn: 0,
685 text,
686 });
687 }
688 if !input.peek()["digest"].is_null() {
689 parts.push(crate::approvals::derived::Part {
690 from: provenance::VERIFIER.to_owned(),
691 turn: 0,
692 text: crate::approvals::derived::text_of(&input.peek()["digest"]),
693 });
694 }
695 let number = |k: &str, d: u32| {
696 settings[k]
697 .as_u64()
698 .map_or(d, |n| u32::try_from(n).unwrap_or(u32::MAX))
699 };
700 tool_loop(
701 cx,
702 &prompt,
703 LoopSpec {
704 tools,
705 policy,
706 max_turns,
707 max_tool_calls,
708 reasoning: settings["reasoning"] == true,
709 schema: Some(report_schema()),
710 max_tokens: None,
711 left: serde_json::from_value(settings["left"].clone()).ok(),
712 reads: None,
713 halt: None,
714 routes,
715 root: settings["root"].as_str().map(str::to_owned),
716 frozen_additions: settings["frozen_additions"] == true,
717 ask,
718 run_approved: remembered,
719 once,
720 run: settings["run_id"].as_str().map(str::to_owned),
721 asking: Asking {
722 asked: number("asked", 0),
723 budget: number("budget", DEFAULT_BUDGET),
724 prompts: settings["prompts"] == true,
725 },
726 parts,
727 mcp_digests: mcp
728 .iter()
729 .map(|t| (t.name.clone(), t.digest.clone()))
730 .collect(),
731 stop,
732 observer: self.progress.clone().map(|p| {
733 (
734 task_name.rsplit('/').next().unwrap_or_default().to_owned(),
735 p,
736 )
737 }),
738 finish: |report| {
739 if report["status"] == "incomplete" {
742 "incomplete"
743 } else {
744 "completed"
745 }
746 },
747 },
748 )
749 .await
750 }
751}