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 — 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 \
the task's scope: the task's paths are the plan's guess — where they differ from the code, \
use the module that holds it; a change elsewhere that the task or its acceptance tests need \
(an entry point they run, a module they import) is in scope and shown to the reviewer, never \
a reason to stop: an entry point that bypasses the task's module is rewired to it. 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; its status is `incomplete` only when part of \
this task's own work is left undone — a check you could not run, a test a later task makes \
pass, or a finding in a frozen test file is no reason.",
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"],
"description": "done: this task's work is finished — even while tests that later tasks make pass still fail. incomplete: part of this task is left undone."
},
"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. `kind`, with the `evidence` speccoder checks: `refused` — a call the task needs was \
refused (the call); `spec_conflict` — the spec contradicts the task (its exact words); \
`wrong_test` — a frozen test asserts the wrong thing (its path); `needs_information` — only a \
person knows (the question). Scope is never a kind: when the tests run code that bypasses the \
task's module (an entry point, a caller), wiring that code to it is part of the task.",
json!({
"type": "object",
"properties": {
"kind": { "type": "string", "enum": ["refused", "spec_conflict", "wrong_test", "needs_information"] },
"evidence": { "type": "string" },
"reason": { "type": "string" },
},
"required": ["kind", "evidence", "reason"],
"additionalProperties": false,
}),
)
}
#[derive(Default)]
pub(crate) struct StopGate {
pub spec: String,
pub frozen: Vec<String>,
pub rejected: bool,
}
#[derive(Debug, PartialEq, Eq)]
enum Stop {
Refused(String),
Asked(String),
Stopped(String),
}
fn judge_stop(
gate: &StopGate,
arguments: &Value,
refused: bool,
asked: bool,
turns_left: u32,
) -> Stop {
let text = |k: &str| arguments[k].as_str().unwrap_or_default().trim();
let (kind, evidence, reason) = (text("kind"), text("evidence"), text("reason"));
let words = |t: &str| t.split_whitespace().collect::<Vec<_>>().join(" ");
let checked = match kind {
"refused" if refused => format!("refused call `{evidence}`"),
"refused" => return Stop::Refused("not stopped: no call in this attempt was refused or rejected".to_owned()),
"spec_conflict" if evidence.chars().count() >= 12 && words(&gate.spec).contains(&words(evidence)) => {
format!("the spec says “{evidence}”")
}
"spec_conflict" => {
return Stop::Refused("not stopped: the evidence must be the spec's exact words, at least a phrase; these are not in it".to_owned());
}
"wrong_test" if gate.frozen.iter().any(|f| f == evidence.trim_start_matches("./")) => {
format!("frozen test `{evidence}`")
}
"wrong_test" => return Stop::Refused("not stopped: the evidence must be the path of a test that existed when the attempt started".to_owned()),
"needs_information" if !evidence.is_empty() => format!("question: {evidence}"),
_ => return Stop::Refused("not stopped: `kind` is one of refused, spec_conflict, wrong_test, needs_information, with its `evidence`".to_owned()),
};
let said = match reason {
"" => checked,
r if r == evidence => r.to_owned(),
r => format!("{r} — {checked}"),
};
if kind == "refused" || asked || turns_left == 0 {
return Stop::Stopped(said);
}
Stop::Asked(format!(
"Not stopped yet: speccoder asks once before a stop. {turns_left} turns are left. The task's paths are the plan's guess: wiring an entry point, caller or import the tests run to the task's module is part of the task, and a failing check is a reason to keep going. If the task still cannot be done as written, call report_blocked again."
))
}
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,
}
#[derive(Debug)]
struct Quiet {
replaying: std::sync::Arc<std::sync::atomic::AtomicBool>,
next: std::sync::Arc<dyn crate::progress::Progress>,
}
impl crate::progress::Progress for Quiet {
fn event(&self, event: &crate::progress::Event) {
if !self.replaying.load(std::sync::atomic::Ordering::SeqCst) {
self.next.event(event);
}
}
}
#[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 contained = settings.contained;
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))),
("left", trusted(json!(settings.left))),
("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))));
}
if let Some(m) = contained {
settings.push(("contained", trusted(json!(m))));
}
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 contained: Option<&'a str>,
pub left: Option<Left>,
}
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
};
let policy = match settings["contained"].as_str() {
Some(m) => policy.contained(m),
None => 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 bundle = &input.peek()["bundle"];
let stop = StopGate {
spec: ["story", "plan", "constitution"]
.iter()
.filter_map(|k| bundle[*k].as_str())
.chain(bundle["task"]["title"].as_str())
.collect::<Vec<_>>()
.join("\n"),
frozen: settings["frozen"]
.as_array()
.into_iter()
.flatten()
.filter_map(|v| v.as_str().map(str::to_owned))
.collect(),
rejected: !told.is_empty(),
};
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 checks: Vec<String> = argvs(&input["settings"]["checks"])
.iter()
.map(|argv| format!("- `{}`", json!(argv)))
.collect();
if !checks.is_empty() {
let (key, kind) = match style {
PromptStyle::OpenAi => ("input", "input_text"),
PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
("messages", "text")
}
};
let text = format!(
"# The checks\n\nRun them with run_command as these argvs, word for word; they need no approval:\n\n{}\n",
checks.join("\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: Some(report_schema()),
max_tokens: None,
left: serde_json::from_value(settings["left"].clone()).ok(),
reads: None,
halt: None,
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(),
stop,
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,
contained: entry["contained"] == true,
});
}
}
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: Option<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>,
pub stop: StopGate,
pub max_tokens: Option<u64>,
pub left: Option<Left>,
pub reads: Option<u32>,
pub halt: Option<agentplane::model::Stop>,
}
#[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;
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Left {
pub cap: super::run_config::Cap,
pub prices: Option<super::model_config::Prices>,
}
impl Left {
#[must_use]
pub fn spent(&self, usage: &agentplane::model::Usage) -> u64 {
match self.cap {
super::run_config::Cap::Tokens(_) => usage.input_tokens + usage.output_tokens,
super::run_config::Cap::Usd(_) => self.prices.map_or(0, |p| {
micros(p.usd(
usage.input_tokens,
usage.output_tokens,
usage.cache_read_tokens,
usage.cache_write_tokens,
))
}),
}
}
#[must_use]
pub fn reached(&self, usage: &agentplane::model::Usage) -> bool {
self.spent(usage) >= self.cap.amount()
}
#[must_use]
pub fn reason(&self, usage: &agentplane::model::Usage) -> String {
format!(
"the run's cap: this attempt used {} of the {} left (`run --cap`, `[budget] cap`)",
self.cap.with(self.spent(usage)).rounded(),
self.cap.rounded()
)
}
}
#[must_use]
pub fn usage_of(v: Option<&Value>) -> agentplane::model::Usage {
let n = |k: &str| v.and_then(|v| v[k].as_u64()).unwrap_or(0);
agentplane::model::Usage {
input_tokens: n("input_tokens"),
output_tokens: n("output_tokens"),
cache_read_tokens: n("cache_read_tokens"),
cache_write_tokens: n("cache_write_tokens"),
..agentplane::model::Usage::default()
}
}
#[must_use]
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
pub fn micros(usd: f64) -> u64 {
(usd * 1e6).round().max(0.0) as u64
}
pub(crate) async fn tool_loop(
cx: &mut StepCtx<'_>,
prompt: &Tainted<Value>,
spec: LoopSpec,
) -> Result<Outcome, SkillError> {
let mut transcript = Transcript::new(prompt);
tool_turn(cx, prompt, spec, &mut transcript).await
}
pub(crate) async fn tool_turn(
cx: &mut StepCtx<'_>,
prompt: &Tainted<Value>,
spec: LoopSpec,
transcript: &mut Transcript,
) -> 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,
stop,
max_tokens,
left,
reads,
halt,
} = spec;
let replaying = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(cx.replaying()));
let observer = observer.map(|(task, next)| {
let quiet: std::sync::Arc<dyn crate::progress::Progress> = std::sync::Arc::new(Quiet {
replaying: std::sync::Arc::clone(&replaying),
next,
});
(task, quiet)
});
let seen = |cx: &StepCtx<'_>| {
replaying.store(cx.replaying(), std::sync::atomic::Ordering::SeqCst);
};
let mut next_read = reads;
let mut proposed = false;
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 conversation: Label = transcript.label().clone();
let mut calls = 0u32;
let mut denied_in_a_row = 0u32;
let mut asked_to_stop = false;
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 {
if let Some(max) = max_tokens
&& usage.input_tokens + usage.output_tokens >= max
{
return end(
"budget",
json!({ "reason": format!("this turn used {} of its {max} tokens", usage.input_tokens + usage.output_tokens) }),
&log,
usage,
&conversation,
prompted,
);
}
if let Some(l) = left
&& l.reached(&usage)
{
return end(
"budget_exhausted",
json!({ "reason": l.reason(&usage) }),
&log,
usage,
&conversation,
prompted,
);
}
let tools_now = tools.clone();
let schema_now = schema.clone();
let read_so_far = &*transcript;
let stop_now = halt.clone();
let completion = cx
.complete_with(prompt, move |call| {
let call = call.with_tools(tools_now).continuing(read_so_far);
let call = match stop_now {
Some(stop) => call.stopped_by(stop),
None => call,
};
let call = match schema_now {
Some(schema) => call.expecting(schema),
None => call,
};
if reasoning {
call.with_reasoning_summary()
} else {
call
}
})
.await?;
seen(cx);
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().stopped
|| halt
.as_ref()
.is_some_and(agentplane::model::Stop::is_stopped)
{
return end(
"stopped",
json!({ "text": completion.peek().text }),
&log,
usage,
&conversation,
prompted,
);
}
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() && schema.is_none() {
return end(
"answered",
json!({ "text": completion.peek().text }),
&log,
usage,
&conversation,
prompted,
);
}
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 (`[budget] max_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()),
"answer": read.as_ref().and_then(|r| r.text.clone()),
"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 refused = stop.rejected || log.iter().any(|e| e["decision"] == "deny");
let left = max_turns - turn - 1;
match judge_stop(&stop, &asked.arguments, refused, asked_to_stop, left) {
Stop::Stopped(reason) => {
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,
);
}
Stop::Asked(text) => {
asked_to_stop = true;
let entry = json!({ "tool": REPORT_BLOCKED, "arguments": asked.arguments, "decision": "challenged" });
emit(observer.as_ref(), turn + 1, &entry);
log.push(entry);
transcript.answered(
asked,
&Tainted::trusted(json!({ "stopped": false, "note": text })),
);
}
Stop::Refused(text) => {
let entry = json!({ "tool": REPORT_BLOCKED, "arguments": asked.arguments, "decision": "unproven", "reason": text });
emit(observer.as_ref(), turn + 1, &entry);
log.push(entry);
transcript.failed(asked, Tainted::trusted(text));
}
}
continue;
}
if asked.name == crate::ask::PROPOSE && tools.iter().any(|t| t.name == asked.name) {
let entry = json!({ "tool": asked.name, "arguments": asked.arguments, "decision": if proposed { "deny" } else { "proposed" } });
emit(observer.as_ref(), turn + 1, &entry);
log.push(entry);
if proposed {
transcript.failed(
asked,
Tainted::trusted(
"one proposal per turn; this one was not shown".to_owned(),
),
);
} else {
proposed = true;
transcript.answered(asked, &Tainted::trusted(json!({ "shown": true, "note": "shown to the person as a sheet; nothing ran, and only the person can confirm it" })));
}
continue;
}
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?;
seen(cx);
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?;
seen(cx);
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
.as_ref()
.is_some_and(|o| policy.approved().contains(o))
{
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?;
seen(cx);
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));
if r.starts_with("contained (") {
o.insert("contained".to_owned(), json!(true));
}
}
(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 = cx.call_tool(target.clone(), args).await;
seen(cx);
let result = match result {
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 }),
);
let result = match next_read.as_mut() {
Some(n) if ok => {
let id = format!("r{n}");
*n += 1;
entry["read"] = json!(crate::ask::read_of(
&id,
&asked.name,
&asked.arguments,
&output
));
result.map(|mut v| {
if let Some(o) = v.as_object_mut() {
o.insert("read".to_owned(), json!(id));
}
v
})
}
_ => result,
};
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 (`[budget] max_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");
}
#[test]
fn a_stop_stands_on_evidence_speccoder_checks() {
let gate = StopGate {
spec: "Rows are sorted\n ascending by name.".to_owned(),
frozen: vec!["tests/test_us3.py".to_owned()],
rejected: false,
};
let call = |kind: &str, evidence: &str| json!({ "kind": kind, "evidence": evidence, "reason": "why" });
let judge =
|args: &Value, refused: bool, asked: bool| judge_stop(&gate, args, refused, asked, 5);
let unproven = |s: Stop| matches!(s, Stop::Refused(_));
assert!(unproven(judge(
&json!({ "reason": "beyond the task's module scope" }),
false,
false
)));
assert!(unproven(judge(&call("scope", "src/cli"), false, false)));
assert!(unproven(judge(
&call("wrong_test", "tests/new_test.py"),
false,
false
)));
assert!(unproven(judge(
&call("spec_conflict", "sorted descending by name"),
false,
false
)));
assert!(unproven(judge(
&call("spec_conflict", "sorted"),
false,
false
)));
assert!(unproven(judge(
&call("refused", "cargo add x"),
false,
false
)));
let quote = call("spec_conflict", "sorted ascending by name");
assert!(
matches!(judge("e, false, false), Stop::Asked(t) if t.contains("5 turns are left"))
);
assert_eq!(
judge("e, false, true),
Stop::Stopped("why — the spec says “sorted ascending by name”".to_owned())
);
assert_eq!(
judge(&call("wrong_test", "./tests/test_us3.py"), false, true),
Stop::Stopped("why — frozen test `./tests/test_us3.py`".to_owned())
);
assert!(matches!(
judge(&call("refused", "cargo add x"), true, false),
Stop::Stopped(_)
));
let q = call("needs_information", "which locale?");
assert!(matches!(
judge_stop(&gate, &q, false, false, 0),
Stop::Stopped(_)
));
}
#[test]
fn a_replayed_step_says_nothing() {
#[derive(Debug, Default)]
struct Seen(std::sync::Mutex<Vec<u32>>);
impl crate::progress::Progress for Seen {
fn event(&self, e: &crate::progress::Event) {
if let crate::progress::Event::ToolCall { turn, .. } = e {
self.0.lock().unwrap().push(*turn);
}
}
}
let seen = std::sync::Arc::new(Seen::default());
let replaying = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
let quiet = Quiet {
replaying: std::sync::Arc::clone(&replaying),
next: seen.clone(),
};
let call = |turn| crate::progress::Event::ToolCall {
task: "T012".to_owned(),
turn,
tool: "run_command".to_owned(),
target: String::new(),
decision: "allow".to_owned(),
ok: Some(true),
tail: Vec::new(),
contained: false,
};
crate::progress::Progress::event(&quiet, &call(1));
replaying.store(false, std::sync::atomic::Ordering::SeqCst);
crate::progress::Progress::event(&quiet, &call(2));
assert_eq!(*seen.0.lock().unwrap(), [2]);
}
}