use crate::format::Wording;
use agentplane::core::Label;
use agentplane::model::{ToolDeclaration, Transcript};
use agentplane::prelude::*;
use agentplane::tools::ToolId;
use serde_json::{Value, json};
use super::model_config::{CacheTtl, PromptStyle};
use super::propose::layout;
use super::provenance;
use crate::approvals::{Answer, Decision as Said, PresentContext, Request, Scope, Who};
use crate::tools::policy::{Decision, Object, Policy};
pub const CAPABILITY: &str = "speccoder.task.attempt";
pub const REPORT_BLOCKED: &str = "report_blocked";
pub fn policy(w: &Wording) -> String {
format!(
"You are speccoder, a coding agent implementing exactly one {product} task in a git \
worktree. Use the tools to inspect the code, make the smallest change that completes the \
task, and run the project's checks with run_command to see whether it works. Stay inside \
the task's scope. You cannot edit the spec ({intent}), the checks' configuration or \
.git; such calls are refused. Some commands need a person's approval and will end the \
attempt — prefer configured checks. If the task cannot be done as written — it \
contradicts the spec, needs information or access you lack, or a test is wrong — call \
report_blocked with the reason instead of working around it. Test files that existed \
when the attempt started are frozen: add new tests in new files (or next to the code), \
never by editing those. Never weaken, skip or delete tests to make checks pass. Never \
return values copied from tests or examples in place of the specified behaviour (a \
constant where the spec asks for a computed, fetched or current value is a cheat): if \
the specified behaviour is impossible under the stated constraints, call report_blocked. \
You are not the judge: when you finish, speccoder runs the checks itself and decides. \
Finish with the requested structured report.",
product = w.product,
intent = w.intent_paths,
)
}
#[must_use]
pub fn report_schema() -> Value {
let strings = json!({ "type": "array", "items": { "type": "string" } });
json!({
"type": "object",
"properties": {
"summary": { "type": "string" },
"status": { "type": "string", "enum": ["done", "incomplete"] },
"files_changed": strings,
"checks_run": strings,
"notes": strings,
},
"required": ["summary", "status", "files_changed", "checks_run", "notes"],
"additionalProperties": false,
})
}
fn report_blocked() -> ToolDeclaration {
ToolDeclaration::new(
REPORT_BLOCKED,
"Stop the attempt because the task cannot be done as written; a person will look at the reason.",
json!({
"type": "object",
"properties": { "reason": { "type": "string" } },
"required": ["reason"],
"additionalProperties": false,
}),
)
}
pub const ASK_PERSON: &str = "ask_person";
#[must_use]
pub fn ask_person() -> ToolDeclaration {
ToolDeclaration::new(
ASK_PERSON,
"Ask the person one question and wait for their answer. If nobody can answer, the reply says so.",
json!({
"type": "object",
"properties": { "question": { "type": "string" } },
"required": ["question"],
"additionalProperties": false,
}),
)
}
#[derive(Debug, Clone)]
pub struct Asker {
pub task: String,
pub task_title: String,
pub context: PresentContext,
pub wait_secs: u64,
}
impl Asker {
#[must_use]
pub fn from_settings(
settings: &Value,
task: String,
task_title: String,
context: PresentContext,
) -> Option<Self> {
(settings["asks"] == "tasks").then(|| Self {
task,
task_title,
context,
wait_secs: settings["wait_secs"].as_u64().unwrap_or(7 * 86_400),
})
}
}
async fn wait_for_person(
cx: &mut StepCtx<'_>,
asker: &Asker,
obligation: &mut bool,
spec: &agentplane::core::TaskSpec,
) -> Result<agentplane::core::Decision, SkillError> {
if !*obligation {
let minutes = u32::try_from(asker.wait_secs.div_ceil(60).max(1)).unwrap_or(u32::MAX);
cx.deadline(
crate::approvals::tasks::DEADLINE,
&agentplane::core::DeadlineSpec::minutes(minutes),
None,
)
.await?;
*obligation = true;
}
Ok(cx.task(spec).await?)
}
#[derive(Debug)]
pub struct Attempt {
pub style: PromptStyle,
pub cache_ttl: CacheTtl,
pub progress: Option<std::sync::Arc<dyn crate::progress::Progress>>,
pub format: &'static dyn crate::format::Adapter,
pub present: PresentContext,
}
#[must_use]
pub fn input(
bundle: Value,
settings: &Settings<'_>,
digest: Option<&Value>,
instructions: &[crate::instructions::Instruction],
test_layout: Option<&str>,
) -> Tainted<Value> {
let digest = match digest.filter(|d| !d.is_null()) {
Some(d) => Tainted::with_label(d.clone(), provenance::from(provenance::VERIFIER)),
None => Tainted::trusted(Value::Null),
};
let mcp = Tainted::array(settings.mcp.iter().map(|t| {
Tainted::with_label(
serde_json::to_value(t).unwrap_or(Value::Null),
agentplane::core::Label::untrusted(provenance::tool(&t.server, &t.tool)),
)
}));
let trusted = |v: Value| Tainted::trusted(v);
let autopilot = settings.autopilot;
let planning = settings.planning;
let settings = vec![
("checks", trusted(json!(settings.checks))),
("allow", trusted(json!(settings.allow))),
("max_turns", trusted(json!(settings.max_turns))),
("max_tool_calls", trusted(json!(settings.max_tool_calls))),
("reasoning", trusted(json!(settings.reasoning))),
("frozen", trusted(json!(settings.frozen))),
("approved", trusted(json!(settings.approved))),
("rejected", trusted(json!(settings.rejected))),
("run_approved", trusted(json!(settings.run_approved))),
("approved_once", trusted(json!(settings.approved_once))),
("nudges", trusted(json!(settings.nudges))),
("mcp", mcp),
("root", trusted(json!(settings.root))),
(
"frozen_additions",
trusted(json!(settings.frozen_additions)),
),
("network_allow", trusted(json!(settings.network_allow))),
("allow_prefix", trusted(json!(settings.allow_prefix))),
(
"network_allow_prefix",
trusted(json!(settings.network_allow_prefix)),
),
("run_id", trusted(json!(settings.run_id))),
("asked", trusted(json!(settings.asked))),
("budget", trusted(json!(settings.budget))),
("prompts", trusted(json!(settings.prompts))),
("asks", trusted(json!(settings.asks))),
("wait_secs", trusted(json!(settings.wait_secs))),
("in_place", trusted(json!(settings.in_place))),
("hidden", trusted(json!(settings.hidden))),
];
let mut settings = settings;
if autopilot {
settings.push(("autopilot", trusted(json!(true))));
}
if let Some(dir) = planning {
settings.push(("planning", trusted(json!(dir))));
}
let settings = Tainted::object(settings);
Tainted::object([
("bundle", Tainted::trusted(bundle)),
("digest", digest),
("settings", settings),
(
"instructions",
if instructions.is_empty() {
Tainted::trusted(json!([]))
} else {
Tainted::with_label(json!(instructions), provenance::from(provenance::WORKTREE))
},
),
(
"test_layout",
match test_layout {
Some(t) => Tainted::with_label(json!(t), provenance::from(provenance::WORKTREE)),
None => Tainted::trusted(Value::Null),
},
),
])
}
fn refusal(text: String, call: &agentplane::core::Label) -> Tainted<String> {
Tainted::with_label(text, call.clone())
}
const MESSAGE_PARTS: [&str; 7] = [
"/bundle",
"/instructions",
"/test_layout",
"/digest",
"/settings/nudges",
"/settings/rejected",
"/settings/mcp",
];
fn conversation_label(input: &Tainted<Value>) -> agentplane::core::Label {
MESSAGE_PARTS
.iter()
.map(|p| input.label_at(p).unwrap_or_else(|| input.label()).clone())
.fold(agentplane::core::Label::trusted(), |acc, l| acc.join(&l))
}
#[derive(Debug, Clone, Copy)]
pub struct Settings<'a> {
pub checks: &'a [Vec<String>],
pub allow: &'a [Vec<String>],
pub allow_prefix: &'a [Vec<String>],
pub max_turns: u32,
pub max_tool_calls: u32,
pub reasoning: bool,
pub frozen: &'a [String],
pub approved: &'a [Object],
pub rejected: &'a [crate::tools::policy::Rejection],
pub run_approved: &'a [Object],
pub approved_once: &'a [Object],
pub nudges: &'a [String],
pub mcp: &'a [crate::mcp::McpTool],
pub root: &'a str,
pub frozen_additions: bool,
pub asks: &'a str,
pub wait_secs: u64,
pub network_allow: &'a [Vec<String>],
pub network_allow_prefix: &'a [Vec<String>],
pub run_id: &'a str,
pub asked: u32,
pub budget: u32,
pub prompts: bool,
pub in_place: bool,
pub hidden: &'a [String],
pub autopilot: bool,
pub planning: Option<&'a str>,
}
pub const MAX_CONSECUTIVE_DENIALS: u32 = 3;
fn digest_text(digest: &Value) -> String {
let mut out = format!(
"# Previous attempt\n\nAttempt {} at this task was verified by speccoder and FAILED. Its \
changes were discarded; the worktree is back where that attempt started. You start fresh — use \
these facts from the verifier, check them yourself, and do not repeat the mistake.\n",
digest["attempt"].as_u64().unwrap_or(1)
);
let strings = |v: &Value| -> Vec<String> {
v.as_array()
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default()
};
let reasons = strings(&digest["reasons"]);
if !reasons.is_empty() {
out.push_str("\n## Verdict reasons\n\n");
for r in reasons {
out.push_str(&format!("- {r}\n"));
}
}
for c in digest["checks"].as_array().into_iter().flatten() {
out.push_str(&format!(
"\n## Check `{}` ({}): `{}`\n",
c["name"].as_str().unwrap_or_default(),
c["status"].as_str().unwrap_or_default(),
strings(&c["argv"]).join(" ")
));
let tests = strings(&c["failing_tests"]);
if !tests.is_empty() {
out.push_str(&format!("\nFailing tests: {}\n", tests.join(", ")));
}
out.push_str(&format!(
"\n```text\n{}\n```\n",
c["excerpt"].as_str().unwrap_or_default()
));
}
let uncollected = strings(&digest["uncollected"]);
if !uncollected.is_empty() {
out.push_str("\n## Tests that never ran\n\n");
for l in uncollected {
out.push_str(&format!("- {l}\n"));
}
}
let discarded = strings(&digest["discarded"]);
if !discarded.is_empty() {
out.push_str(&format!(
"\n## Files the discarded attempt changed\n\n{}\n",
discarded.join("\n")
));
}
out
}
fn argvs(v: &Value) -> Vec<Vec<String>> {
v.as_array()
.map(|a| {
a.iter()
.map(|argv| {
argv.as_array()
.map(|p| {
p.iter()
.filter_map(|x| x.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default()
})
.collect()
})
.unwrap_or_default()
}
fn decisions_of<T: serde::de::DeserializeOwned>(
settings: &Value,
key: &str,
) -> Result<Vec<T>, SkillError> {
match &settings[key] {
Value::Null => Ok(Vec::new()),
v => serde_json::from_value(v.clone())
.map_err(|e| SkillError::Input(format!("settings.{key}: {e}"))),
}
}
pub(crate) fn policy_of(
settings: &Value,
rules: &'static crate::format::Rules,
) -> Result<Policy, SkillError> {
let frozen: Vec<String> = settings["frozen"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default();
let approved: Vec<Object> = decisions_of(settings, "approved")?;
let rejected: Vec<crate::tools::policy::Rejection> = decisions_of(settings, "rejected")?;
let mcp: Vec<crate::mcp::McpTool> =
serde_json::from_value(settings["mcp"].clone()).unwrap_or_default();
let policy = Policy::new(&argvs(&settings["checks"]), &argvs(&settings["allow"]))
.with_prefixes(argvs(&settings["allow_prefix"]))
.with_network_prefixes(argvs(&settings["network_allow_prefix"]))
.rules(rules)
.with_frozen(frozen)
.with_decisions(approved, rejected)
.with_mcp(mcp.iter().map(|t| (t.name.clone(), t.decision.clone())))
.with_network(argvs(&settings["network_allow"]))
.with_hidden(
serde_json::from_value::<Vec<String>>(settings["hidden"].clone()).unwrap_or_default(),
);
let policy = if settings["in_place"] == true {
policy.in_place()
} else {
policy
};
let policy = if settings["autopilot"] == true {
policy.autopilot()
} else {
policy
};
Ok(match settings["planning"].as_str() {
Some(dir) => policy.planning(dir),
None => policy,
})
}
#[async_trait]
impl Skill for Attempt {
fn descriptor(&self) -> SkillDescriptor {
SkillDescriptor::new("speccoder.task.attempt").provides(CAPABILITY)
}
async fn invoke(
&self,
cx: &mut StepCtx<'_>,
input: Tainted<Value>,
) -> Result<Outcome, SkillError> {
let (ttl, style) = (self.cache_ttl, self.style);
let settings = input.peek()["settings"].clone();
let task_name = format!(
"{}/{}",
input.peek()["bundle"]["feature"]
.as_str()
.unwrap_or_default(),
input.peek()["bundle"]["task"]["id"]
.as_str()
.unwrap_or_default()
);
let rejected: Vec<crate::tools::policy::Rejection> = decisions_of(&settings, "rejected")?;
let told = rejected;
let mcp: Vec<crate::mcp::McpTool> =
serde_json::from_value(settings["mcp"].clone()).unwrap_or_default();
let planning = settings["planning"].as_str().map(str::to_owned);
let policy = policy_of(&settings, self.format.rules())?;
let max_turns = settings["max_turns"]
.as_u64()
.map_or(30, |n| u32::try_from(n).unwrap_or(u32::MAX))
.max(1);
let max_tool_calls = settings["max_tool_calls"]
.as_u64()
.map_or(100, |n| u32::try_from(n).unwrap_or(u32::MAX));
let label = conversation_label(&input);
let raw = input.peek().clone();
let prompt = provenance::labelled_prompt(
{
let input = raw;
let mut p = layout(
&input["bundle"],
ttl,
style,
&self::policy(self.format.wording()),
self.format,
);
let given: Vec<crate::instructions::Instruction> =
serde_json::from_value(input["instructions"].clone()).unwrap_or_default();
if let Some(text) = crate::instructions::section(&given) {
let (key, kind) = match style {
PromptStyle::OpenAi => ("input", "input_text"),
PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
("messages", "text")
}
};
if let Some(content) = p[key][0]["content"].as_array_mut() {
content.push(json!({ "type": kind, "text": text }));
}
}
if let Some(text) = input["test_layout"].as_str().filter(|t| !t.is_empty()) {
let (key, kind) = match style {
PromptStyle::OpenAi => ("input", "input_text"),
PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
("messages", "text")
}
};
if let Some(content) = p[key][0]["content"].as_array_mut() {
content.push(json!({ "type": kind, "text": text }));
}
}
if !input["digest"].is_null() {
let (key, kind) = match style {
PromptStyle::OpenAi => ("input", "input_text"),
PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
("messages", "text")
}
};
if let Some(content) = p[key][0]["content"].as_array_mut() {
content
.push(json!({ "type": kind, "text": digest_text(&input["digest"]) }));
}
}
if let Some(dir) = &planning {
let (key, kind) = match style {
PromptStyle::OpenAi => ("input", "input_text"),
PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
("messages", "text")
}
};
let text = format!(
"# 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"
);
if let Some(content) = p[key][0]["content"].as_array_mut() {
content.push(json!({ "type": kind, "text": text }));
}
}
let notes: Vec<String> = input["settings"]["nudges"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|n| n.as_str().map(|n| format!("- {n}")))
.collect()
})
.unwrap_or_default();
if !notes.is_empty() {
let (key, kind) = match style {
PromptStyle::OpenAi => ("input", "input_text"),
PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
("messages", "text")
}
};
let text = format!("# Notes from a person\n\n{}\n", notes.join("\n"));
if let Some(content) = p[key][0]["content"].as_array_mut() {
content.push(json!({ "type": kind, "text": text }));
}
}
if !told.is_empty() {
let (key, kind) = match style {
PromptStyle::OpenAi => ("input", "input_text"),
PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
("messages", "text")
}
};
let lines: Vec<String> = told
.iter()
.map(|r| format!("- {}: {}", r.object.describe(), r.reason))
.collect();
let text = format!(
"# Rejected by a person\n\nThese calls were rejected for this task; find another way or call report_blocked:\n\n{}\n",
lines.join("\n")
);
if let Some(content) = p[key][0]["content"].as_array_mut() {
content.push(json!({ "type": kind, "text": text }));
}
}
if let PromptStyle::Parts { caches } = style {
super::model_config::to_parts(&mut p, caches);
}
p
},
&label,
);
let mut tools = crate::tools::declarations();
tools.push(report_blocked());
let routes: Vec<(String, String, String)> = mcp
.iter()
.filter(|t| t.decision != "deny")
.map(|t| (t.name.clone(), t.server.clone(), t.tool.clone()))
.collect();
tools.extend(mcp.iter().filter(|t| t.decision != "deny").map(|t| {
ToolDeclaration::new(t.name.clone(), t.description.clone(), t.schema.clone())
}));
let ask = Asker::from_settings(
&settings,
task_name.clone(),
input.peek()["bundle"]["task"]["title"]
.as_str()
.unwrap_or_default()
.to_owned(),
PresentContext {
root: settings["root"].as_str().map(std::path::PathBuf::from),
..self.present.clone()
},
);
let remembered: Vec<Object> = decisions_of(&settings, "run_approved")?;
let once: Vec<Object> = decisions_of(&settings, "approved_once")?;
let mut parts = Vec::new();
let given: Vec<crate::instructions::Instruction> =
serde_json::from_value(input.peek()["instructions"].clone()).unwrap_or_default();
if let Some(text) = crate::instructions::section(&given) {
parts.push(crate::approvals::derived::Part {
from: provenance::WORKTREE.to_owned(),
turn: 0,
text,
});
}
if !input.peek()["digest"].is_null() {
parts.push(crate::approvals::derived::Part {
from: provenance::VERIFIER.to_owned(),
turn: 0,
text: crate::approvals::derived::text_of(&input.peek()["digest"]),
});
}
let number = |k: &str, d: u32| {
settings[k]
.as_u64()
.map_or(d, |n| u32::try_from(n).unwrap_or(u32::MAX))
};
tool_loop(
cx,
&prompt,
LoopSpec {
tools,
policy,
max_turns,
max_tool_calls,
reasoning: settings["reasoning"] == true,
schema: report_schema(),
routes,
root: settings["root"].as_str().map(str::to_owned),
frozen_additions: settings["frozen_additions"] == true,
ask,
run_approved: remembered,
once,
run: settings["run_id"].as_str().map(str::to_owned),
asking: Asking {
asked: number("asked", 0),
budget: number("budget", DEFAULT_BUDGET),
prompts: settings["prompts"] == true,
},
parts,
mcp_digests: mcp
.iter()
.map(|t| (t.name.clone(), t.digest.clone()))
.collect(),
observer: self.progress.clone().map(|p| {
(
task_name.rsplit('/').next().unwrap_or_default().to_owned(),
p,
)
}),
finish: |report| {
if report["status"] == "incomplete" {
"incomplete"
} else {
"completed"
}
},
},
)
.await
}
}
fn relativise(arguments: &mut Value, root: Option<&str>) {
let (Some(root), Some(path)) = (root, arguments.get("path").and_then(Value::as_str)) else {
return;
};
let root = root.trim_end_matches('/');
if let Some(rel) = path.strip_prefix(root).and_then(|r| r.strip_prefix('/')) {
let rel = if rel.is_empty() {
".".to_owned()
} else {
rel.to_owned()
};
arguments["path"] = Value::String(rel);
}
}
fn emit(
observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
turn: u32,
entry: &Value,
) {
emit_with_tail(observer, turn, entry, Vec::new());
}
fn emit_with_tail(
observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
turn: u32,
entry: &Value,
tail: Vec<String>,
) {
if let Some((task, p)) = observer {
let tool = entry["tool"].as_str().unwrap_or_default();
p.event(&crate::progress::Event::ToolCall {
task: task.clone(),
turn,
tool: tool.to_owned(),
target: crate::progress::target(tool, &entry["arguments"]),
decision: entry["decision"].as_str().unwrap_or_default().to_owned(),
ok: entry["ok"].as_bool(),
tail,
});
}
}
fn needs_person(
observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
asked: &agentplane::model::ToolCall,
object: Option<&Object>,
) {
if let Some((task, p)) = observer {
let (tool, arguments) = (asked.name.as_str(), &asked.arguments);
let target = crate::progress::target(tool, arguments);
let what: String = format!("{tool} {target}")
.chars()
.filter(|c| !c.is_control())
.take(60)
.collect();
p.event(&crate::progress::Event::ApprovalRequested {
task: task.clone(),
call: asked.id.clone(),
tool: tool.to_owned(),
what,
class: object
.map(|o| o.class().as_str().to_owned())
.unwrap_or_default(),
object: object.map(Object::short).unwrap_or_default(),
});
}
}
fn person_decided(
observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
asked: &agentplane::model::ToolCall,
decision: &str,
scope: Option<&str>,
who: Option<&Who>,
object: Option<&Object>,
) {
if let Some((task, p)) = observer {
p.event(&crate::progress::Event::ApprovalDecided {
task: task.clone(),
call: asked.id.clone(),
decision: decision.to_owned(),
scope: scope.map(str::to_owned),
by: who.map_or_else(|| "nobody (expired)".to_owned(), |w| w.name.clone()),
how: who.map_or_else(|| "expired".to_owned(), |w| w.basis.clone()),
class: object
.map(|o| o.class().as_str().to_owned())
.unwrap_or_default(),
object: object.map(Object::short).unwrap_or_default(),
});
}
}
struct Question<'a> {
asked: &'a agentplane::model::ToolCall,
reason: &'a str,
turn: u32,
said: &'a str,
asker: Option<&'a Asker>,
policy: &'a Policy,
object: Option<&'a Object>,
remembered: &'a [(Object, &'static str)],
parts: &'a [crate::approvals::derived::Part],
run: Option<&'a str>,
}
fn question(q: &Question<'_>) -> Request {
let (tool, arguments) = (q.asked.name.as_str(), &q.asked.arguments);
let kind = crate::approvals::kind_of(tool, arguments);
let risk = crate::approvals::risk(tool, q.reason);
let always_risk = if kind == "network" {
let mut bare = arguments.clone();
if let Some(o) = bare.as_object_mut() {
o.remove("network");
}
match q.policy.base(tool, &bare) {
Decision::Allow => "low",
Decision::Approval(r) => crate::approvals::risk(tool, &r),
Decision::Deny(_) => "high",
}
} else {
risk
};
let (sources, more) = crate::approvals::derived::sources(tool, arguments, q.parts);
let changed = q.object.and_then(|o| {
q.remembered
.iter()
.map(|(r, _)| r)
.chain(q.policy.approved())
.find_map(|earlier| o.changed_from(earlier))
});
Request {
task: q.asker.map(|a| a.task.clone()).unwrap_or_default(),
task_title: q.asker.map(|a| a.task_title.clone()).unwrap_or_default(),
turn: q.turn,
tool: tool.to_owned(),
arguments: arguments.clone(),
kind: kind.to_owned(),
reason: q.reason.to_owned(),
object: q.object.cloned(),
changed,
risk: risk.to_owned(),
claim: crate::approvals::claim(q.said),
options: crate::approvals::options(
risk,
q.object.is_some(),
!sources.is_empty(),
kind,
always_risk,
),
sources,
more,
run: q.run.map(str::to_owned),
asked: 0,
budget: 0,
batched: false,
}
}
pub(crate) struct LoopSpec {
pub tools: Vec<ToolDeclaration>,
pub policy: Policy,
pub max_turns: u32,
pub max_tool_calls: u32,
pub reasoning: bool,
pub schema: Value,
pub routes: Vec<(String, String, String)>,
pub root: Option<String>,
pub frozen_additions: bool,
pub finish: fn(&Value) -> &'static str,
pub ask: Option<Asker>,
pub run_approved: Vec<Object>,
pub once: Vec<Object>,
pub observer: Option<(String, std::sync::Arc<dyn crate::progress::Progress>)>,
pub run: Option<String>,
pub asking: Asking,
pub parts: Vec<crate::approvals::derived::Part>,
pub mcp_digests: std::collections::BTreeMap<String, String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Asking {
pub asked: u32,
pub budget: u32,
pub prompts: bool,
}
impl Default for Asking {
fn default() -> Self {
Self {
asked: 0,
budget: DEFAULT_BUDGET,
prompts: false,
}
}
}
pub use super::run_config::DEFAULT_BUDGET;
pub(crate) async fn tool_loop(
cx: &mut StepCtx<'_>,
prompt: &Tainted<Value>,
spec: LoopSpec,
) -> Result<Outcome, SkillError> {
let LoopSpec {
tools,
policy,
max_turns,
max_tool_calls,
reasoning,
schema,
routes,
root,
frozen_additions,
finish,
ask,
run_approved,
mut once,
observer,
run,
asking,
mut parts,
mcp_digests,
} = spec;
let mut remembered: Vec<(Object, &'static str)> =
run_approved.into_iter().map(|g| (g, "run-grant")).collect();
let mut prompted = asking.asked;
let mut told_budget = false;
let catalog = crate::tools::catalog();
let mut transcript = Transcript::new(prompt);
let mut conversation: Label = prompt.label().clone();
let mut calls = 0u32;
let mut denied_in_a_row = 0u32;
let mut obligation = false;
let mut usage = agentplane::model::Usage::default();
let mut log: Vec<Value> = Vec::new();
let end = |status: &str,
extra: Value,
log: &[Value],
usage: agentplane::model::Usage,
conversation: &Label,
prompted: u32| {
let mut out = json!({
"status": status,
"tool_calls": log,
"asked": prompted,
"usage": {
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
"cache_read_tokens": usage.cache_read_tokens,
"cache_write_tokens": usage.cache_write_tokens,
},
});
if let (Some(o), Some(e)) = (out.as_object_mut(), extra.as_object()) {
o.extend(e.clone());
}
Ok(Outcome::done(Tainted::with_label(
out,
conversation.clone(),
)))
};
for turn in 0..max_turns {
let tools_now = tools.clone();
let schema_now = schema.clone();
let read_so_far = &transcript;
let completion = cx
.complete_with(prompt, move |call| {
let call = call
.with_tools(tools_now)
.continuing(read_so_far)
.expecting(schema_now);
if reasoning {
call.with_reasoning_summary()
} else {
call
}
})
.await?;
conversation = transcript.label().join(completion.label());
usage = usage.plus(completion.peek().usage);
if let Some((task, p)) = &observer {
p.event(&crate::progress::Event::Usage {
task: task.clone(),
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cache_read_tokens: usage.cache_read_tokens,
cache_write_tokens: usage.cache_write_tokens,
usd: None,
});
}
if let (Some((task, p)), Some(reasoning)) =
(&observer, completion.peek().reasoning.as_deref())
&& !reasoning.trim().is_empty()
{
let text: String = crate::approvals::present::escape(reasoning.trim())
.chars()
.take(4000)
.collect();
p.event(&crate::progress::Event::Reasoning {
task: task.clone(),
turn: turn + 1,
text,
});
}
if completion.peek().truncated {
return Ok(Outcome::fail(
"the model ran out of output budget mid-turn; nothing it was still writing was executed — raise [model] max_output_tokens",
));
}
let asked_calls = transcript.asked(&completion);
let said = super::said(completion.peek());
if let (Some((task, p)), Some(said)) = (observer.as_ref(), said.as_deref()) {
let text: String = crate::approvals::present::escape(said)
.chars()
.take(2000)
.collect();
p.event(&crate::progress::Event::Said {
task: task.clone(),
turn: turn + 1,
text,
});
}
if asked_calls.is_empty() {
let report = completion
.peek()
.structured
.clone()
.unwrap_or_else(|| json!({ "summary": completion.peek().text, "status": "incomplete", "files_changed": [], "checks_run": [], "notes": ["the answer was not the requested structure"] }));
let status = finish(&report);
return end(
status,
json!({ "report": report }),
&log,
usage,
&conversation,
prompted,
);
}
for mut asked in asked_calls {
relativise(&mut asked.arguments, root.as_deref());
if let Some(o) = asked.arguments.as_object_mut() {
o.remove(crate::tools::EXPECT);
}
calls += 1;
if calls > max_tool_calls {
return end(
"budget_exhausted",
json!({ "reason": format!("more than {max_tool_calls} tool calls") }),
&log,
usage,
&conversation,
prompted,
);
}
if asked.name == ASK_PERSON && tools.iter().any(|t| t.name == ASK_PERSON) {
let question = asked.arguments["question"]
.as_str()
.unwrap_or_default()
.to_owned();
let decided = match &ask {
Some(asker) => Some(
wait_for_person(
cx,
asker,
&mut obligation,
&crate::approvals::tasks::question_task(&asker.task, &question),
)
.await?,
),
None => None,
};
let read = decided.as_ref().map(crate::approvals::tasks::read);
let by = read.as_ref().and_then(|r| r.by.clone());
let entry = json!({
"tool": ASK_PERSON,
"arguments": asked.arguments,
"decision": "allow",
"answered": read.as_ref().is_some_and(|r| r.text.is_some()),
"operator": by.as_ref().map(Who::view),
"via": by.as_ref().map(|w| w.via.clone()),
});
emit(observer.as_ref(), turn + 1, &entry);
log.push(entry);
let text = match (&read, &by) {
(Some(r), _) if r.text.is_some() => r.text.clone().unwrap_or_default(),
(Some(_), Some(w)) => format!("{} declined to answer.", w.name),
_ => "Nobody can answer now. Record the question in the artifact as [NEEDS CLARIFICATION: <question>] and continue.".to_owned(),
};
let label = match &by {
Some(_) => provenance::from(provenance::PERSON),
None => Label::trusted(),
};
transcript.answered(
asked,
&Tainted::with_label(json!({ "answer": text }), label),
);
continue;
}
if asked.name == REPORT_BLOCKED && tools.iter().any(|t| t.name == REPORT_BLOCKED) {
let reason = asked.arguments["reason"]
.as_str()
.unwrap_or("no reason given")
.to_owned();
let entry = json!({ "tool": REPORT_BLOCKED, "arguments": asked.arguments, "decision": "stop" });
emit(observer.as_ref(), turn + 1, &entry);
log.push(entry);
return end(
"blocked",
json!({ "reason": reason }),
&log,
usage,
&conversation,
prompted,
);
}
let declared = tools.iter().any(|t| t.name == asked.name);
let target = catalog
.iter()
.find(|t| declared && t.name == asked.name)
.map(|t| ToolId::new(t.server, t.name))
.or_else(|| {
routes
.iter()
.find(|(n, _, _)| declared && *n == asked.name)
.map(|(_, server, tool)| ToolId::new(server, tool))
});
let Some(target) = target else {
let entry = json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "unknown" });
emit(observer.as_ref(), turn + 1, &entry);
log.push(entry);
transcript.failed(asked, Tainted::trusted("no tool of that name".to_owned()));
continue;
};
let mut decision = policy.base(&asked.name, &asked.arguments);
let mut additions_only = false;
if frozen_additions
&& matches!(&decision, Decision::Approval(r) if r.contains("frozen test"))
{
let path = asked.arguments["path"]
.as_str()
.unwrap_or_default()
.to_owned();
let read = cx
.call_tool(
ToolId::new("fs", "read_file"),
Tainted::with_label(json!({ "path": path }), completion.label().clone()),
)
.await?;
let current = read.peek().clone();
let lines = current["content"].as_str().unwrap_or_default();
let old_text = if lines.is_empty() {
String::new()
} else {
format!("{lines}\n")
};
let old = old_text.as_str();
let readable = current["ok"] == true && current["truncated"] != true;
let new = match asked.name.as_str() {
"write_file" => asked.arguments["content"].as_str().map(str::to_owned),
"edit_file" => {
let (from, to) = (
asked.arguments["old_string"].as_str().unwrap_or_default(),
asked.arguments["new_string"].as_str().unwrap_or_default(),
);
let all = asked.arguments["replace_all"] == true;
(!from.is_empty() && (all || old.matches(from).count() == 1))
.then(|| old.replace(from, to))
}
_ => None,
};
if readable && new.is_some_and(|n| crate::integrity::only_additions(old, &n)) {
decision = Decision::Allow;
additions_only = true;
}
}
let mut object: Option<Object> = None;
if matches!(decision, Decision::Approval(_)) {
if asked.name.starts_with(crate::mcp::PREFIX) {
object = mcp_digests.get(&asked.name).map(|digest| Object::Tool {
name: asked.name.clone(),
digest: digest.clone(),
});
} else {
let resolved = cx
.call_tool(
ToolId::new("fs", "resolve"),
Tainted::with_label(
json!({ "tool": asked.name, "arguments": asked.arguments }),
completion.label().clone(),
),
)
.await?;
let out = resolved.peek();
if out["ok"] == true {
object = serde_json::from_value(out["object"].clone()).ok();
} else {
decision = Decision::Deny(
out["error"]
.as_str()
.unwrap_or("the call's object cannot be resolved")
.to_owned(),
);
}
}
if matches!(decision, Decision::Approval(_)) {
decision = policy.classify(&asked.name, &asked.arguments, object.as_ref());
}
}
let mut person: Option<Value> = None;
if decision == Decision::Allow && object.is_some() {
person = Some(json!({
"approved_by": "person",
"scope": "task-grant",
"grant": { "scope": "task", "run": run },
}));
}
let mut note: Option<String> = None;
if matches!(decision, Decision::Approval(_))
&& let Some(at) = object
.as_ref()
.and_then(|o| once.iter().position(|r| r == o))
{
once.remove(at);
person = Some(json!({
"approved_by": "person",
"scope": "once-grant",
"grant": { "scope": "once", "run": run },
}));
decision = Decision::Allow;
}
if matches!(decision, Decision::Approval(_))
&& let Some((_, scope)) = object
.as_ref()
.and_then(|o| remembered.iter().find(|(r, _)| r == o))
{
person = Some(json!({
"approved_by": "person",
"scope": scope,
"grant": { "scope": scope.trim_end_matches("-grant"), "run": run },
}));
decision = Decision::Allow;
}
decision = policy.settle(decision);
let request = match &decision {
Decision::Approval(reason) => Some(question(&Question {
asked: &asked,
reason,
turn: turn + 1,
said: said.as_deref().unwrap_or_default(),
asker: ask.as_ref(),
policy: &policy,
object: object.as_ref(),
remembered: &remembered,
parts: &parts,
run: run.as_deref(),
})),
_ => None,
};
if let (Some(mut request), Some(asker)) = (request.clone(), &ask) {
if asking.prompts {
if prompted < asking.budget {
prompted += 1;
request.asked = prompted;
} else {
request.batched = true;
}
request.budget = asking.budget;
}
if request.batched {
if !told_budget {
needs_person(observer.as_ref(), &asked, object.as_ref());
}
if !told_budget && let Some((_, p)) = &observer {
p.event(&crate::progress::Event::Notice {
text: format!(
"approval budget ({}) reached: calls wait in Needs You (speccoder approvals)",
asking.budget
),
});
}
told_budget = true;
} else {
needs_person(observer.as_ref(), &asked, object.as_ref());
}
let decided = wait_for_person(
cx,
asker,
&mut obligation,
&crate::approvals::tasks::approval_task(&request, &log),
)
.await?;
let read = crate::approvals::tasks::read(&decided);
if read.resume_asking {
prompted = 0;
}
let answer: Answer = read.answer.clone();
let approver = serde_json::to_value(&answer.by).unwrap_or(Value::Null);
let operator = read.by.as_ref().map(Who::view);
person_decided(
observer.as_ref(),
&asked,
if answer.decision == Said::Approve {
"approve"
} else {
"reject"
},
(answer.decision == Said::Approve).then(|| answer.scope.name()),
read.by.as_ref().filter(|_| !read.expired),
object.as_ref(),
);
match answer.decision {
Said::Approve => {
let mut p = json!({
"approved_by": "person",
"scope": answer.scope.name(),
"note": answer.note,
"approver": approver,
"operator": operator,
});
if answer.always {
p["always"] = json!(true);
}
if let (Some(o), Scope::Task | Scope::Run) = (&object, answer.scope) {
let remembered_as = if answer.scope == Scope::Run {
"run-grant"
} else {
"task-grant"
};
p["grant"] = json!({ "scope": answer.scope.name(), "run": run });
remembered.push((o.clone(), remembered_as));
}
person = Some(p);
note = answer.note;
decision = Decision::Allow;
}
Said::Reject | Said::Stop => {
let why = answer
.reason
.unwrap_or_else(|| "no reason given".to_owned());
person = Some(if read.expired {
json!({ "expired": true, "rejection": why })
} else {
json!({ "rejected_by": "person", "approver": approver, "operator": operator, "rejection": why })
});
decision = Decision::Deny(format!("a person rejected this: {why}"));
}
}
}
let rule = person
.is_none()
.then(|| policy.rule(&asked.name, &asked.arguments, object.as_ref()));
let with_person = |mut entry: Value| {
if let Some(o) = entry.as_object_mut() {
if let Some(obj) = &object {
o.insert("object".to_owned(), json!(obj));
}
match (person.as_ref().and_then(Value::as_object), &rule) {
(Some(p), _) => {
let by = if p.contains_key("expired") {
"expiry"
} else {
"person"
};
o.insert("by".to_owned(), json!(by));
o.extend(p.clone());
}
(None, Some(r)) => {
o.insert("by".to_owned(), json!("rule"));
o.insert("rule".to_owned(), json!(r));
}
(None, None) => {}
}
}
entry
};
match decision {
Decision::Deny(reason) => {
let entry = with_person(
json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "deny", "reason": reason }),
);
emit(observer.as_ref(), turn + 1, &entry);
log.push(entry);
transcript.failed(
asked,
refusal(format!("denied: {reason}"), completion.label()),
);
denied_in_a_row += 1;
if denied_in_a_row >= MAX_CONSECUTIVE_DENIALS {
return end(
"denied",
json!({ "reason": format!("{denied_in_a_row} calls in a row were denied; last: {reason}") }),
&log,
usage,
&conversation,
prompted,
);
}
continue;
}
Decision::Approval(reason) => {
let mut entry = json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "approval", "reason": reason });
if let Some(obj) = &object {
entry["object"] = json!(obj);
}
emit(observer.as_ref(), turn + 1, &entry);
needs_person(observer.as_ref(), &asked, object.as_ref());
log.push(entry);
return end(
"needs_approval",
json!({ "pending": {
"tool": asked.name,
"arguments": asked.arguments,
"reason": reason,
"object": object,
"run": run,
"request": request,
} }),
&log,
usage,
&conversation,
prompted,
);
}
Decision::Allow => {
denied_in_a_row = 0;
let mut arguments = asked.arguments.clone();
if asked.name == "run_command"
&& let Some(o) = arguments.as_object_mut()
{
o.insert("call_id".to_owned(), Value::String(asked.id.clone()));
}
if let (Some(obj), Some(o)) = (&object, arguments.as_object_mut())
&& matches!(
asked.name.as_str(),
"write_file" | "edit_file" | "run_command"
)
{
o.insert(crate::tools::EXPECT.to_owned(), json!(obj));
}
let args = Tainted::with_label(arguments, completion.label().clone());
let result = match cx.call_tool(target.clone(), args).await {
Ok(r) => r,
Err(error) => match cx.relayed(&error) {
Some(detail) => {
let entry = with_person(
json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "allow", "ok": false }),
);
emit(observer.as_ref(), turn + 1, &entry);
log.push(entry);
transcript.failed(asked, detail);
continue;
}
None => return Err(error.into()),
},
};
let output = result.peek().clone();
let ok = output["ok"].as_bool() != Some(false);
let mut entry = with_person(
json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "allow", "ok": ok }),
);
if let Some(changed) = output["object_changed"].as_object() {
entry["decision"] = json!("refused_changed");
entry["expected"] = changed.get("expected").cloned().unwrap_or(Value::Null);
entry["found"] = changed.get("found").cloned().unwrap_or(Value::Null);
}
if let Some(part) = crate::approvals::derived::part_of(
&asked.name,
&asked.arguments,
&output,
turn + 1,
&format!("tool://{}/{}", target.server, target.tool),
|p: &str| {
policy.protected().write_class(p)
== Some(crate::tools::paths::Class::Intent)
},
) {
parts.push(part);
}
if additions_only {
entry["frozen_additions"] = json!(true);
}
let tail = if asked.name == "run_command" && !ok {
crate::progress::tail(&format!(
"{}\n{}",
output["stdout"].as_str().unwrap_or_default(),
output["stderr"].as_str().unwrap_or_default()
))
} else {
Vec::new()
};
emit_with_tail(observer.as_ref(), turn + 1, &entry, tail);
log.push(entry);
if let Some(drift) = output["integrity"].as_array().filter(|d| !d.is_empty()) {
let paths: Vec<&str> =
drift.iter().filter_map(|d| d["path"].as_str()).collect();
return end(
"integrity",
json!({
"reason": format!("a command changed what judges the task: {}", paths.join(", ")),
"integrity": drift,
}),
&log,
usage,
&conversation,
prompted,
);
}
match output["error"].as_str() {
Some(error) if !ok => transcript.failed(
asked,
Tainted::with_label(error.to_owned(), result.label().clone()),
),
_ => match ¬e {
Some(n) => {
let n = format!("Note from the person who approved this call: {n}");
transcript.answered(
asked,
&result.map(|mut v| {
v["note_from_person"] = Value::String(n);
v
}),
);
}
None => transcript.answered(asked, &result),
},
}
}
}
}
}
end(
"budget_exhausted",
json!({ "reason": format!("more than {max_turns} model turns") }),
&log,
usage,
&conversation,
prompted,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn labels_are_read_from_the_parts_and_refusals_carry_the_call() {
use agentplane::core::{Label, SourceId};
let model = Label::untrusted(SourceId::new("model:x/y"));
assert!(
refusal("denied: `src/x` is protected".to_owned(), &model)
.label()
.is_untrusted()
);
let input = Tainted::object([
("bundle", Tainted::trusted(json!({}))),
(
"digest",
Tainted::with_label(json!({}), provenance::from(provenance::VERIFIER)),
),
(
"settings",
Tainted::object([("nudges", Tainted::trusted(json!([])))]),
),
]);
let label = conversation_label(&input);
assert!(
label
.provenance
.contains(&SourceId::new(provenance::VERIFIER))
);
let flat = Tainted::with_label(json!({ "bundle": {} }), model.clone());
assert_eq!(conversation_label(&flat).provenance, model.provenance);
}
#[test]
fn absolute_paths_inside_the_root_become_relative_and_others_stay() {
let mut a = json!({ "path": "/work/repo/specs/001-x/plan.md" });
relativise(&mut a, Some("/work/repo"));
assert_eq!(a["path"], "specs/001-x/plan.md");
let mut outside = json!({ "path": "/work/repository-two/x" });
relativise(&mut outside, Some("/work/repo"));
assert_eq!(outside["path"], "/work/repository-two/x");
let mut none = json!({ "path": "/etc/passwd" });
relativise(&mut none, None);
assert_eq!(none["path"], "/etc/passwd");
}
}