use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
use serde_json::json;
use tracing::info;
use crate::containment::{Containment, Draw, Ledger};
use crate::contract::TaskContract;
use crate::error::Result;
use crate::provider::{CompletionRequest, CompletionResponse, Provider, ToolCall, ToolSpec};
use crate::state::{AgentEvent, RunStatus, StepRecord, Store};
use crate::approve::{ApproveAll, Approver, Decision, Request};
use crate::policy::{Act, Effect, Policy, Rule};
use crate::state::PolicyEvent;
use crate::verify::{ExecGuard, Verification};
use crate::tools::{
FsTool, Workspace, FIND_TOOL, GREP_TOOL, READ_FILE_TOOL, WRITE_FILE_TOOL,
};
pub const SPAWN_TOOL: &str = "spawn_agent";
const OBS_READ_CAP: usize = 4_000;
const OBS_GREP_CAP: usize = 50;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RunOutcome {
Success { steps: u32 },
StepCapReached { steps: u32 },
TimeBudgetExceeded { steps: u32 },
CostBudgetExceeded { steps: u32 },
Denied { steps: u32 },
AwaitingApproval { request_id: i64, steps: u32 },
BudgetCeilingReached { steps: u32 },
}
#[derive(Debug, Clone)]
pub struct RunResult {
pub outcome: RunOutcome,
pub run_id: i64,
pub remembered: Vec<Rule>,
}
impl RunResult {
fn new(outcome: RunOutcome, run_id: i64) -> Self {
Self { outcome, run_id, remembered: Vec::new() }
}
fn with_remembered(mut self, remembered: Vec<Rule>) -> Self {
self.remembered = remembered;
self
}
}
pub async fn run<P: Provider>(
contract: &TaskContract,
provider: &P,
store: &Store,
) -> Result<RunResult> {
run_with(contract, provider, store, &Policy::permissive(), &ApproveAll).await
}
pub async fn run_with<P: Provider>(
contract: &TaskContract,
provider: &P,
store: &Store,
policy: &Policy,
approver: &dyn Approver,
) -> Result<RunResult> {
let file_str = contract.file.display().to_string();
let run_id = store.start_run(&contract.goal, &file_str)?;
store.set_provider(run_id, provider.name())?;
match contract.root.clone() {
Some(root) => {
run_workspace_from(
contract, provider, store, run_id, &root, 1, policy, approver,
)
.await
}
None if !policy.is_permissive() => Err(crate::error::Error::Config(
"a permission policy requires workspace mode — build the contract \
with TaskContract::workspace(goal, root, verify). Single-file \
contracts are not policy-enforced in 0.4.0."
.into(),
)),
None => run_from(contract, provider, store, run_id, 1).await,
}
}
pub async fn resume<P: Provider>(
contract: &TaskContract,
provider: &P,
store: &Store,
run_id: i64,
) -> Result<RunResult> {
store.check_resumable(run_id)?;
if store.run_status(run_id)? == Some(RunStatus::Completed) {
if let Some(o) = terminal_outcome(store, run_id)? {
return Ok(RunResult::new(o, run_id));
}
}
let start_step = record_resume_markers(store, run_id)?;
store.set_provider(run_id, provider.name())?;
match contract.root.clone() {
Some(root) => {
run_workspace_from(
contract,
provider,
store,
run_id,
&root,
start_step,
&Policy::permissive(),
&ApproveAll,
)
.await
}
None => run_from(contract, provider, store, run_id, start_step).await,
}
}
#[allow(clippy::too_many_arguments)]
pub async fn resume_with_decision<P: Provider>(
contract: &TaskContract,
provider: &P,
store: &Store,
run_id: i64,
request_id: i64,
decision: Decision,
policy: &Policy,
approver: &dyn Approver,
) -> Result<RunResult> {
let pending = store
.pending(request_id)?
.ok_or_else(|| crate::error::Error::Config(format!("no pending request {request_id}")))?;
if pending.run_id != run_id {
return Err(crate::error::Error::Config(format!(
"request {request_id} belongs to run {}, not {run_id}",
pending.run_id
)));
}
let root = contract
.root
.clone()
.ok_or_else(|| crate::error::Error::Config("resume_with_decision needs a workspace".into()))?;
let step = pending.step;
match decision {
Decision::Defer => {
Ok(RunResult::new(
RunOutcome::AwaitingApproval { request_id, steps: step },
run_id,
))
}
Decision::Deny { reason } => {
store.resolve_pending(request_id, "deny")?;
store.record_event(
run_id,
&PolicyEvent::decision(
step,
&pending.act,
&pending.target,
"deny",
format!("resumed:{request_id}"),
),
)?;
info!(run_id, request_id, %reason, "deferred action denied");
store.finish_run(run_id, "denied")?;
Ok(RunResult::new(RunOutcome::Denied { steps: step }, run_id))
}
Decision::Approve { modified, remember } => {
let target = modified
.as_ref()
.map(|m| m.target.clone())
.unwrap_or_else(|| pending.target.clone());
let content = modified
.as_ref()
.and_then(|m| m.content.clone())
.or_else(|| pending.content.clone());
let mut effective = policy.clone();
if !remember.is_empty() {
let mut layer = Policy::permissive().layer("remembered");
for r in &remember {
layer = layer.rule(r.act, r.effect, r.pattern.clone());
}
effective = effective.merge(layer);
}
let ws = Workspace::with_policy(&root, effective.clone());
let act = if pending.act == "read" { Act::Read } else { Act::Write };
let recheck = ws.check_path(act, &target);
if recheck.effect == Effect::Deny {
let mut ev = PolicyEvent::refusal(step, &pending.act, &target);
ev.rule = recheck.rule.clone();
ev.layer = recheck.layer.clone();
store.record_event(run_id, &ev)?;
store.resolve_pending(request_id, "deny")?;
store.finish_run(run_id, "denied")?;
return Ok(RunResult::new(RunOutcome::Denied { steps: step }, run_id));
}
if act == Act::Write {
ws.write_file(&target, content.as_deref().unwrap_or_default())?;
}
store.resolve_pending(request_id, "approve")?;
let mut ev = PolicyEvent::decision(
step,
&pending.act,
&pending.target,
"approve",
format!("resumed:{request_id}"),
);
if target != pending.target {
ev = ev.with_performed(&target);
}
store.record_event(run_id, &ev)?;
run_workspace_from(
contract,
provider,
store,
run_id,
&root,
step + 1,
&effective,
approver,
)
.await
.map(|r| r.with_remembered(remember))
}
}
}
#[allow(clippy::too_many_arguments)]
pub async fn resume_tree_with_decision<P: Provider>(
contract: &TaskContract,
provider: &P,
store: &Store,
run_id: i64,
request_id: i64,
decision: Decision,
policy: &Policy,
approver: &dyn Approver,
containment: &Containment,
) -> Result<RunResult> {
store.check_resumable(run_id)?;
let pending = store
.pending(request_id)?
.ok_or_else(|| crate::error::Error::Config(format!("no pending request {request_id}")))?;
if !store.tree_run_ids(run_id)?.contains(&pending.run_id) {
return Err(crate::error::Error::Config(format!(
"request {request_id} belongs to run {}, which is not in the tree rooted at {run_id}",
pending.run_id
)));
}
let root = contract.root.clone().ok_or_else(|| {
crate::error::Error::Config("resume_tree_with_decision needs a workspace".into())
})?;
let step = pending.step;
match decision {
Decision::Defer => Ok(RunResult::new(
RunOutcome::AwaitingApproval { request_id, steps: step },
run_id,
)),
Decision::Deny { reason } => {
store.resolve_pending(request_id, "deny")?;
store.record_event(
pending.run_id,
&PolicyEvent::decision(
step,
&pending.act,
&pending.target,
"deny",
format!("resumed:{request_id}"),
),
)?;
info!(run_id, request_id, %reason, "deferred tree action denied; tree stops");
store.finish_run(run_id, "denied")?;
Ok(RunResult::new(RunOutcome::Denied { steps: step }, run_id))
}
Decision::Approve { modified, remember } => {
let target = modified
.as_ref()
.map(|m| m.target.clone())
.unwrap_or_else(|| pending.target.clone());
let content = modified
.as_ref()
.and_then(|m| m.content.clone())
.or_else(|| pending.content.clone());
let ws = Workspace::with_policy(&root, policy.clone());
let act = if pending.act == "read" { Act::Read } else { Act::Write };
if ws.check_path(act, &target).effect == Effect::Deny {
store.resolve_pending(request_id, "deny")?;
store.finish_run(run_id, "denied")?;
return Ok(RunResult::new(RunOutcome::Denied { steps: step }, run_id));
}
if act == Act::Write {
ws.write_file(&target, content.as_deref().unwrap_or_default())?;
}
store.resolve_pending(request_id, "approve")?;
store.record_event(
pending.run_id,
&PolicyEvent::decision(
step,
&pending.act,
&pending.target,
"approve",
format!("resumed:{request_id}"),
),
)?;
let mut effective = policy.clone();
if !remember.is_empty() {
let mut layer = Policy::permissive().layer("remembered");
for r in &remember {
layer = layer.rule(r.act, r.effect, r.pattern.clone());
}
effective = effective.merge(layer);
}
let ledger = Arc::new(Ledger::from_state(
containment,
store.spent_tokens_tree(run_id)?,
store.agent_count_tree(run_id)?,
));
let start_step = record_resume_markers(store, run_id)?;
store.set_provider(run_id, provider.name())?;
let tree = Tree { provider, store, approver, ledger, containment, root };
let outcome = run_agent(&tree, contract, run_id, 0, &effective, start_step).await?;
Ok(RunResult::new(outcome, run_id).with_remembered(remember))
}
}
}
fn record_resume_markers(store: &Store, run_id: i64) -> Result<u32> {
let last = store.last_step(run_id)?;
let start_step = last + 1;
store.record_checkpoint_event(&crate::state::CheckpointEvent::resume(
run_id,
start_step,
format!("resuming at step {start_step}, {last} committed step(s) skipped"),
))?;
for s in 1..=last {
store.record_checkpoint_event(&crate::state::CheckpointEvent::skipped(run_id, s))?;
}
Ok(start_step)
}
fn terminal_outcome(store: &Store, run_id: i64) -> Result<Option<RunOutcome>> {
let last = store.last_step(run_id)?;
Ok(store.outcome(run_id)?.and_then(|o| match o.as_str() {
"success" => Some(RunOutcome::Success { steps: last }),
"denied" => Some(RunOutcome::Denied { steps: last }),
"budget_ceiling_reached" => Some(RunOutcome::BudgetCeilingReached { steps: last }),
_ => None,
}))
}
async fn run_from<P: Provider>(
contract: &TaskContract,
provider: &P,
store: &Store,
run_id: i64,
start_step: u32,
) -> Result<RunResult> {
let fs = FsTool::new(&contract.file);
let system = system_prompt();
let tool = write_file_tool();
let mut tokens_used: u64 = store.spent_tokens(run_id)?;
let permissive = Policy::permissive();
for step in start_step..=contract.max_steps {
if let Some(max) = contract.max_duration {
if store.elapsed_secs(run_id)? > max.as_secs_f64() {
store.finish_run(run_id, "time_budget_exceeded")?;
return Ok(RunResult::new(RunOutcome::TimeBudgetExceeded { steps: step - 1 }, run_id));
}
}
let current = fs.read().await?;
let user = user_prompt(contract, ¤t);
let request = CompletionRequest {
system: system.clone(),
user: user.clone(),
tools: vec![tool.clone()],
};
let response =
complete_with_retry(provider, &request, contract.max_retries, store, run_id, step)
.await?;
let step_tokens = response.usage.map(|u| u.total_tokens).unwrap_or(0);
tokens_used += step_tokens;
let call = response
.tool_calls
.iter()
.find(|c| c.name == WRITE_FILE_TOOL);
let tool_call_json = call.map(|c| c.arguments.to_string()).unwrap_or_default();
let write = call.and_then(|c| c.arguments.get("content").and_then(|v| v.as_str()));
let (decision, result_text) = match write {
Some(content) => {
fs.write(content).await?;
("wrote file", content.to_string())
}
None => ("no tool call", response.text.clone().unwrap_or_default()),
};
store.checkpoint_step(
run_id,
&StepRecord::new(step, decision, result_text).with_trace(
user,
tool_call_json,
step_tokens,
),
)?;
info!(step, decision, tokens = step_tokens, "loop step");
if let Some(max) = contract.max_tokens {
if tokens_used > max {
store.finish_run(run_id, "cost_budget_exceeded")?;
return Ok(RunResult::new(RunOutcome::CostBudgetExceeded { steps: step }, run_id));
}
}
let contents = fs.read().await?;
let guard = ExecGuard::new(&permissive).tracing(store, run_id, step);
if contract
.verify
.passes_guarded(&contract.file, &contents, &guard)
.await?
{
store.finish_run(run_id, "success")?;
return Ok(RunResult::new(RunOutcome::Success { steps: step }, run_id));
}
}
store.finish_run(run_id, "step_cap_reached")?;
Ok(RunResult::new(RunOutcome::StepCapReached {
steps: contract.max_steps,
}, run_id))
}
#[allow(clippy::too_many_arguments)]
async fn run_workspace_from<P: Provider>(
contract: &TaskContract,
provider: &P,
store: &Store,
run_id: i64,
root: &Path,
start_step: u32,
policy: &Policy,
approver: &dyn Approver,
) -> Result<RunResult> {
let mut effective = policy.clone();
let mut remembered: Vec<Rule> = Vec::new();
let mut ws = Workspace::with_policy(root, effective.clone());
let system = workspace_system_prompt();
let tools = workspace_tools();
let mut tokens_used: u64 = store.spent_tokens(run_id)?;
let mut observations = String::new();
for step in start_step..=contract.max_steps {
if let Some(max) = contract.max_duration {
if store.elapsed_secs(run_id)? > max.as_secs_f64() {
store.finish_run(run_id, "time_budget_exceeded")?;
return Ok(RunResult::new(RunOutcome::TimeBudgetExceeded { steps: step - 1 }, run_id).with_remembered(remembered));
}
}
let user = workspace_user_prompt(contract, &observations);
let request = CompletionRequest {
system: system.clone(),
user: user.clone(),
tools: tools.clone(),
};
let response =
complete_with_retry(provider, &request, contract.max_retries, store, run_id, step)
.await?;
let step_tokens = response.usage.map(|u| u.total_tokens).unwrap_or(0);
tokens_used += step_tokens;
let mut decisions: Vec<String> = Vec::new();
let mut calls_json: Vec<String> = Vec::new();
if response.tool_calls.is_empty() {
let said = response.text.clone().unwrap_or_default();
observations.push_str(&format!("\n[step {step}] (no tool call) {said}\n"));
decisions.push("no tool call".into());
}
let mut paused: Option<i64> = None;
let mut new_rules: Vec<Rule> = Vec::new();
for call in &response.tool_calls {
calls_json.push(format!("{}:{}", call.name, call.arguments));
match dispatch(&ws, call, approver, store, run_id, step).await? {
Dispatched::Continue {
decision,
obs,
remember,
} => {
observations.push_str(&obs);
decisions.push(decision);
new_rules.extend(remember);
}
Dispatched::Pause { request_id } => {
decisions.push(format!("awaiting approval (request {request_id})"));
paused = Some(request_id);
break;
}
}
}
store.checkpoint_step(
run_id,
&StepRecord::new(step, decisions.join("; "), tail(&observations, OBS_READ_CAP))
.with_trace(user, calls_json.join(" | "), step_tokens),
)?;
info!(step, decisions = %decisions.join("; "), tokens = step_tokens, "workspace step");
if let Some(request_id) = paused {
store.finish_run(run_id, "awaiting_approval")?;
return Ok(RunResult::new(
RunOutcome::AwaitingApproval {
request_id,
steps: step,
},
run_id,
)
.with_remembered(remembered));
}
if !new_rules.is_empty() {
let mut layer = Policy::permissive().layer("remembered");
for r in &new_rules {
layer = layer.rule(r.act, r.effect, r.pattern.clone());
}
effective = effective.merge(layer);
ws = Workspace::with_policy(root, effective.clone());
remembered.extend(new_rules);
}
if let Some(max) = contract.max_tokens {
if tokens_used > max {
store.finish_run(run_id, "cost_budget_exceeded")?;
return Ok(RunResult::new(RunOutcome::CostBudgetExceeded { steps: step }, run_id).with_remembered(remembered));
}
}
if contract
.verify
.passes_in_guarded(root, &ExecGuard::new(&effective).tracing(store, run_id, step))
.await?
{
store.finish_run(run_id, "success")?;
return Ok(RunResult::new(RunOutcome::Success { steps: step }, run_id).with_remembered(remembered));
}
}
store.finish_run(run_id, "step_cap_reached")?;
Ok(RunResult::new(RunOutcome::StepCapReached {
steps: contract.max_steps,
}, run_id))
}
struct Tree<'a, P: Provider> {
provider: &'a P,
store: &'a Store,
approver: &'a dyn Approver,
ledger: Arc<Ledger>,
containment: &'a Containment,
root: PathBuf,
}
pub async fn run_tree<P: Provider>(
contract: &TaskContract,
provider: &P,
store: &Store,
policy: &Policy,
approver: &dyn Approver,
containment: &Containment,
) -> Result<RunResult> {
let root = contract.root.clone().ok_or_else(|| {
crate::error::Error::Config(
"run_tree needs a workspace contract — build it with TaskContract::workspace".into(),
)
})?;
let ledger = Arc::new(Ledger::new(containment));
let run_id = store.start_run(&contract.goal, &root.display().to_string())?;
store.set_provider(run_id, provider.name())?;
let tree = Tree {
provider,
store,
approver,
ledger,
containment,
root,
};
let outcome = run_agent(&tree, contract, run_id, 0, policy, 1).await?;
Ok(RunResult::new(outcome, run_id))
}
#[allow(clippy::too_many_arguments)]
pub async fn resume_tree<P: Provider>(
contract: &TaskContract,
provider: &P,
store: &Store,
run_id: i64,
policy: &Policy,
approver: &dyn Approver,
containment: &Containment,
) -> Result<RunResult> {
store.check_resumable(run_id)?;
if store.run_status(run_id)? == Some(RunStatus::Completed) {
if let Some(o) = terminal_outcome(store, run_id)? {
return Ok(RunResult::new(o, run_id));
}
}
let root = contract.root.clone().ok_or_else(|| {
crate::error::Error::Config(
"resume_tree needs a workspace contract — build it with TaskContract::workspace".into(),
)
})?;
let ledger = Arc::new(Ledger::from_state(
containment,
store.spent_tokens_tree(run_id)?,
store.agent_count_tree(run_id)?,
));
let start_step = record_resume_markers(store, run_id)?;
store.set_provider(run_id, provider.name())?;
let tree = Tree { provider, store, approver, ledger, containment, root };
let outcome = run_agent(&tree, contract, run_id, 0, policy, start_step).await?;
Ok(RunResult::new(outcome, run_id))
}
fn run_agent<'f, P: Provider>(
tree: &'f Tree<'_, P>,
contract: &'f TaskContract,
run_id: i64,
depth: u32,
policy: &'f Policy,
start_step: u32,
) -> Pin<Box<dyn Future<Output = Result<RunOutcome>> + 'f>> {
Box::pin(async move {
let ws = Workspace::with_policy(&tree.root, policy.clone());
let system = tree_system_prompt();
let tools = tree_tools();
let token_cap = tree.ledger.effective_token_budget(contract.max_tokens);
let mut tokens_used: u64 = tree.store.spent_tokens(run_id)?;
let mut observations = String::new();
for step in start_step..=contract.max_steps {
if let Some(max) = contract.max_duration {
if tree.store.elapsed_secs(run_id)? > max.as_secs_f64() {
tree.store.finish_run(run_id, "time_budget_exceeded")?;
return Ok(RunOutcome::TimeBudgetExceeded { steps: step - 1 });
}
}
let user = workspace_user_prompt(contract, &observations);
let request = CompletionRequest {
system: system.clone(),
user: user.clone(),
tools: tools.clone(),
};
let response = complete_with_retry(
tree.provider,
&request,
contract.max_retries,
tree.store,
run_id,
step,
)
.await?;
let step_tokens = response.usage.map(|u| u.total_tokens).unwrap_or(0);
tokens_used += step_tokens;
let mut decisions: Vec<String> = Vec::new();
let mut calls_json: Vec<String> = Vec::new();
if response.tool_calls.is_empty() {
let said = response.text.clone().unwrap_or_default();
observations.push_str(&format!("\n[step {step}] (no tool call) {said}\n"));
decisions.push("no tool call".into());
}
let mut paused: Option<i64> = None;
let mut paused_by_child = false;
let mut spawn_calls: Vec<&ToolCall> = Vec::new();
for call in &response.tool_calls {
calls_json.push(format!("{}:{}", call.name, call.arguments));
if call.name == SPAWN_TOOL {
spawn_calls.push(call);
continue;
}
match dispatch(&ws, call, tree.approver, tree.store, run_id, step).await? {
Dispatched::Continue { decision, obs, .. } => {
observations.push_str(&obs);
decisions.push(decision);
}
Dispatched::Pause { request_id } => {
decisions.push(format!("awaiting approval (request {request_id})"));
paused = Some(request_id);
break;
}
}
}
if paused.is_none() && !spawn_calls.is_empty() {
use futures_util::stream::{self, StreamExt};
let max_c = tree.containment.max_concurrent.max(1) as usize;
let results: Vec<Result<SpawnResult>> = stream::iter(
spawn_calls
.into_iter()
.map(|c| spawn_child(tree, c, run_id, depth, policy, step)),
)
.buffer_unordered(max_c)
.collect()
.await;
for r in results {
match r? {
SpawnResult::Composed { decision, obs } => {
observations.push_str(&obs);
decisions.push(decision);
}
SpawnResult::Paused { request_id } => {
decisions.push(format!("child awaiting approval (request {request_id})"));
paused = Some(request_id);
paused_by_child = true;
}
}
}
}
if paused.is_some() && paused_by_child {
info!(run_id, depth, step, "tree paused for a child's approval (step left uncommitted for replay)");
} else {
tree.store.checkpoint_step(
run_id,
&StepRecord::new(step, decisions.join("; "), tail(&observations, OBS_READ_CAP))
.with_trace(user, calls_json.join(" | "), step_tokens),
)?;
info!(run_id, depth, step, decisions = %decisions.join("; "), tokens = step_tokens, "agent step");
}
if let Some(request_id) = paused {
tree.store.finish_run(run_id, "awaiting_approval")?;
return Ok(RunOutcome::AwaitingApproval { request_id, steps: step });
}
let draw = tree.ledger.draw_tokens(step_tokens);
tree.store.record_agent_event(&AgentEvent::budget_draw(
run_id,
step,
step_tokens,
tree.ledger.remaining_tokens(),
))?;
if draw == Draw::Halted {
tree.store.finish_run(run_id, "budget_ceiling_reached")?;
return Ok(RunOutcome::BudgetCeilingReached { steps: step });
}
if tokens_used > token_cap {
tree.store.finish_run(run_id, "cost_budget_exceeded")?;
return Ok(RunOutcome::CostBudgetExceeded { steps: step });
}
if contract
.verify
.passes_in_guarded(&tree.root, &ExecGuard::new(policy).tracing(tree.store, run_id, step))
.await?
{
tree.store.finish_run(run_id, "success")?;
return Ok(RunOutcome::Success { steps: step });
}
}
tree.store.finish_run(run_id, "step_cap_reached")?;
Ok(RunOutcome::StepCapReached { steps: contract.max_steps })
})
}
enum SpawnResult {
Composed { decision: String, obs: String },
Paused { request_id: i64 },
}
async fn spawn_child<P: Provider>(
tree: &Tree<'_, P>,
call: &ToolCall,
parent_run_id: i64,
depth: u32,
parent_policy: &Policy,
step: u32,
) -> Result<SpawnResult> {
let a = &call.arguments;
let goal = a.get("goal").and_then(|v| v.as_str()).unwrap_or_default();
let file = a.get("verify_file").and_then(|v| v.as_str()).unwrap_or_default();
let needle = a.get("verify_contains").and_then(|v| v.as_str()).unwrap_or_default();
if goal.is_empty() || file.is_empty() {
return Ok(SpawnResult::Composed {
decision: "spawn missing fields".into(),
obs: "\n[spawn error] spawn_agent needs \"goal\" and \"verify_file\"\n".into(),
});
}
let child_depth = depth + 1;
let mut overlay = Policy::permissive().layer("child");
if let Some(denies) = a.get("deny_write").and_then(|v| v.as_array()) {
for d in denies.iter().filter_map(|v| v.as_str()) {
overlay = overlay.deny_write(d);
}
}
let child_policy = parent_policy.contain(&overlay);
let verify = Verification::WorkspaceFileContains {
file: file.into(),
needle: needle.into(),
};
let mut child_contract = TaskContract::workspace(goal, &tree.root, verify);
if let Some(n) = a.get("max_steps").and_then(|v| v.as_u64()) {
child_contract = child_contract.with_max_steps(n as u32);
}
let (child_run, child_start) = match tree.store.find_spawn(parent_run_id, step, goal)? {
Some(row) => {
if let Some(o) = terminal_outcome(tree.store, row.child_run_id)? {
return Ok(compose_child(row.child_run_id, goal, o));
}
(row.child_run_id, tree.store.last_step(row.child_run_id)? + 1)
}
None => {
if let Err(refusal) = tree.ledger.register_agent(child_depth) {
tree.store.record_agent_event(&AgentEvent::spawn_refused(
parent_run_id,
step,
refusal.cap(),
))?;
return Ok(SpawnResult::Composed {
decision: format!("spawn refused ({})", refusal.cap()),
obs: format!("\n[spawn refused] {refusal} — adapt or finish with what you have\n"),
});
}
let child_run = tree.store.start_child_run(
goal,
&tree.root.display().to_string(),
parent_run_id,
child_depth,
)?;
tree.store
.record_agent_event(&AgentEvent::spawn(parent_run_id, step, child_run, goal))?;
let deny_json = a.get("deny_write").map(|v| v.to_string()).unwrap_or_else(|| "[]".into());
tree.store.record_spawn(
parent_run_id,
step,
child_run,
goal,
file,
needle,
a.get("max_steps").and_then(|v| v.as_u64()).map(|n| n as u32),
&deny_json,
)?;
(child_run, 1)
}
};
let outcome =
run_agent(tree, &child_contract, child_run, child_depth, &child_policy, child_start).await?;
if let RunOutcome::AwaitingApproval { request_id, .. } = outcome {
return Ok(SpawnResult::Paused { request_id });
}
Ok(compose_child(child_run, goal, outcome))
}
fn compose_child(child_run: i64, goal: &str, outcome: RunOutcome) -> SpawnResult {
SpawnResult::Composed {
decision: format!("spawned child {child_run}: {outcome:?}"),
obs: format!("\n[child {child_run} \"{goal}\" -> {outcome:?}]\n"),
}
}
enum Dispatched {
Continue {
decision: String,
obs: String,
remember: Vec<Rule>,
},
Pause { request_id: i64 },
}
impl Dispatched {
fn go(decision: impl Into<String>, obs: impl Into<String>) -> Self {
Dispatched::Continue {
decision: decision.into(),
obs: obs.into(),
remember: Vec::new(),
}
}
}
async fn dispatch(
ws: &Workspace,
call: &ToolCall,
approver: &dyn Approver,
store: &Store,
run_id: i64,
step: u32,
) -> Result<Dispatched> {
let a = &call.arguments;
let s = |k: &str| a.get(k).and_then(|v| v.as_str());
Ok(match call.name.as_str() {
GREP_TOOL => {
let pattern = s("pattern").unwrap_or_default();
match ws.grep(pattern, s("path_glob")) {
Ok(hits) => {
let shown: Vec<String> = hits
.iter()
.take(OBS_GREP_CAP)
.map(|m| format!("{}:{}: {}", m.path, m.line, m.text))
.collect();
Dispatched::go(
format!("grep {pattern:?} ({} hits)", hits.len()),
format!("\n[grep {pattern:?}]\n{}\n", shown.join("\n")),
)
}
Err(e) => Dispatched::go("grep error", format!("\n[grep error] {e}\n")),
}
}
FIND_TOOL => {
let glob = s("name_glob").or_else(|| s("glob")).unwrap_or_default();
match ws.find(glob) {
Ok(paths) => Dispatched::go(
format!("find {glob:?} ({} paths)", paths.len()),
format!("\n[find {glob:?}]\n{}\n", paths.join("\n")),
),
Err(e) => Dispatched::go("find error", format!("\n[find error] {e}\n")),
}
}
READ_FILE_TOOL => {
let path = s("path").unwrap_or_default();
match gate(ws, approver, store, run_id, step, Act::Read, path, None).await? {
Gated::Refused { decision, obs } => Dispatched::go(decision, obs),
Gated::Paused { request_id } => Dispatched::Pause { request_id },
Gated::Go { target, remember, .. } => match ws.read_file(&target) {
Ok(c) => Dispatched::Continue {
decision: format!("read {target}"),
obs: format!("\n[read {target}]\n{}\n", tail(&c, OBS_READ_CAP)),
remember,
},
Err(e) => Dispatched::go("read error", format!("\n[read error] {e}\n")),
},
}
}
WRITE_FILE_TOOL => {
let path = s("path").unwrap_or_default();
let content = s("content").unwrap_or_default();
if path.is_empty() {
return Ok(Dispatched::go(
"write missing path",
"\n[write error] write_file needs a \"path\" in workspace mode\n",
));
}
match gate(ws, approver, store, run_id, step, Act::Write, path, Some(content)).await? {
Gated::Refused { decision, obs } => Dispatched::go(decision, obs),
Gated::Paused { request_id } => Dispatched::Pause { request_id },
Gated::Go { target, content, remember } => {
let body = content.unwrap_or_default();
match ws.write_file(&target, &body) {
Ok(()) => Dispatched::Continue {
decision: format!("wrote {target}"),
obs: format!("\n[wrote {target}]\n"),
remember,
},
Err(e) => Dispatched::go("write error", format!("\n[write error] {e}\n")),
}
}
}
}
other => Dispatched::go(
format!("unknown tool {other}"),
format!("\n[unknown tool {other}]\n"),
),
})
}
enum Gated {
Go {
target: String,
content: Option<String>,
remember: Vec<Rule>,
},
Refused { decision: String, obs: String },
Paused { request_id: i64 },
}
#[allow(clippy::too_many_arguments)]
async fn gate(
ws: &Workspace,
approver: &dyn Approver,
store: &Store,
run_id: i64,
step: u32,
act: Act,
target: &str,
content: Option<&str>,
) -> Result<Gated> {
let kind = format!("{act:?}").to_lowercase();
let verdict = ws.check_path(act, target);
match verdict.effect {
Effect::Deny => {
let mut ev = PolicyEvent::refusal(step, &kind, target);
if let (Some(rule), layer) = (verdict.rule.clone(), verdict.layer.clone()) {
ev.rule = Some(rule);
ev.layer = layer;
}
store.record_event(run_id, &ev)?;
let why = verdict
.rule
.as_deref()
.map(|r| format!(" (rule {r})"))
.unwrap_or_default();
Ok(Gated::Refused {
decision: format!("{kind} refused"),
obs: format!("\n[{kind} refused] {target}{why} — the policy forbids this; try another path\n"),
})
}
Effect::Allow => Ok(Gated::Go {
target: target.to_string(),
content: content.map(str::to_string),
remember: Vec::new(),
}),
Effect::Ask => {
let mut request = Request::new(act, target);
if let Some(c) = content {
request = request.with_content(c);
}
match approver.decide(&request).await {
Decision::Approve { modified, remember } => {
let performed = modified.unwrap_or_else(|| request.clone());
let recheck = ws.check_path(act, &performed.target);
if recheck.effect == Effect::Deny {
let mut ev = PolicyEvent::refusal(step, &kind, &performed.target);
ev.rule = recheck.rule.clone();
ev.layer = recheck.layer.clone();
store.record_event(run_id, &ev)?;
return Ok(Gated::Refused {
decision: format!("{kind} refused after approval"),
obs: format!(
"\n[{kind} refused] {} — an approved change may not cross a deny\n",
performed.target
),
});
}
let mut ev =
PolicyEvent::decision(step, &kind, target, "approve", "approver");
if performed.target != target {
ev = ev.with_performed(&performed.target);
}
store.record_event(run_id, &ev)?;
Ok(Gated::Go {
target: performed.target,
content: performed.content,
remember,
})
}
Decision::Deny { reason } => {
store.record_event(
run_id,
&PolicyEvent::decision(step, &kind, target, "deny", "approver"),
)?;
Ok(Gated::Refused {
decision: format!("{kind} denied"),
obs: format!("\n[{kind} denied] {target} — {reason}\n"),
})
}
Decision::Defer => {
store.record_event(
run_id,
&PolicyEvent::decision(step, &kind, target, "defer", "approver"),
)?;
let request_id = store.put_pending(run_id, step, &kind, target, content)?;
Ok(Gated::Paused { request_id })
}
}
}
}
}
fn tail(s: &str, cap: usize) -> String {
if s.len() <= cap {
s.to_string()
} else {
let start = s.len() - cap;
let start = (start..s.len()).find(|&i| s.is_char_boundary(i)).unwrap_or(s.len());
format!("...(truncated)...{}", &s[start..])
}
}
async fn complete_with_retry<P: Provider>(
provider: &P,
request: &CompletionRequest,
max_retries: u32,
store: &Store,
run_id: i64,
step: u32,
) -> Result<CompletionResponse> {
let mut attempt = 0;
loop {
match provider.complete(request.clone()).await {
Ok(response) => return Ok(response),
Err(e) if attempt < max_retries => {
attempt += 1;
store.record(
run_id,
&StepRecord::new(step, format!("retry {attempt} after error"), e.to_string()),
)?;
}
Err(e) => {
store.record(run_id, &StepRecord::new(step, "escalated", e.to_string()))?;
store.finish_run(run_id, "escalated")?;
return Err(e);
}
}
}
}
fn system_prompt() -> String {
"You are an agent that edits exactly one file to meet a stated specification. \
Call the `write_file` tool with the file's full new contents. Do not explain; \
make the edit. The file will be checked against the success criterion after \
each write."
.to_string()
}
fn user_prompt(contract: &TaskContract, current: &str) -> String {
let constraints = if contract.constraints.is_empty() {
"(none)".to_string()
} else {
contract.constraints.join("; ")
};
format!(
"Goal: {goal}\nConstraints: {constraints}\nSuccess criterion: {criterion}\n\n\
Current file contents:\n---\n{current}\n---\n\n\
Call write_file with the full new contents that satisfy the success criterion.",
goal = contract.goal,
criterion = contract.verify.describe(),
)
}
fn write_file_tool() -> ToolSpec {
ToolSpec {
name: WRITE_FILE_TOOL.to_string(),
description: "Write the full new contents of the target file.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"content": { "type": "string", "description": "Full new file contents." }
},
"required": ["content"]
}),
}
}
fn workspace_system_prompt() -> String {
"You are an agent working across a repository to meet a stated specification. \
Use `grep` to search file contents and `find` to locate files by name, then \
`read_file` to inspect a file before changing it, and `write_file` with the \
file's path and full new contents to edit it. You may edit several files. \
Work in small steps; after each of your steps the whole set is checked \
against the success criterion. Do not explain; call tools."
.to_string()
}
fn workspace_user_prompt(contract: &TaskContract, observations: &str) -> String {
let constraints = if contract.constraints.is_empty() {
"(none)".to_string()
} else {
contract.constraints.join("; ")
};
let obs = if observations.is_empty() {
"(nothing yet — start by grepping or finding)".to_string()
} else {
observations.to_string()
};
format!(
"Goal: {goal}\nConstraints: {constraints}\nSuccess criterion: {criterion}\n\n\
Observations so far (results of your tool calls):\n{obs}\n\n\
Call a tool to make progress toward the success criterion.",
goal = contract.goal,
criterion = contract.verify.describe(),
)
}
fn tree_system_prompt() -> String {
"You are an agent working across a repository to meet a stated specification. \
Use `grep`, `find`, `read_file`, and `write_file` as in a normal run. You may \
also decompose the work: call `spawn_agent` to launch a sub-agent that pursues \
a smaller goal over the same workspace, and its result is reported back to you. \
A sub-agent inherits your permissions and can only be more restricted, never \
less. Prefer spawning when parts of the task are independent. Work in small \
steps; the whole set is checked against the success criterion after each. Do \
not explain; call tools."
.to_string()
}
fn tree_tools() -> Vec<ToolSpec> {
let mut tools = workspace_tools();
tools.push(ToolSpec {
name: SPAWN_TOOL.to_string(),
description: "Spawn a contained sub-agent to pursue a smaller goal over the same \
workspace. The sub-agent inherits your permissions (it can only be \
further restricted) and its outcome is reported back to you."
.to_string(),
parameters: json!({
"type": "object",
"properties": {
"goal": { "type": "string", "description": "The sub-agent's goal." },
"verify_file": { "type": "string", "description": "File (relative to the workspace root) whose contents decide the sub-agent's success." },
"verify_contains": { "type": "string", "description": "Text that file must contain for the sub-agent to succeed." },
"deny_write": { "type": "array", "items": { "type": "string" }, "description": "Optional globs the sub-agent must not write — tightens its inherited policy." },
"max_steps": { "type": "integer", "description": "Optional step budget for the sub-agent." }
},
"required": ["goal", "verify_file", "verify_contains"]
}),
});
tools
}
fn workspace_tools() -> Vec<ToolSpec> {
vec![
ToolSpec {
name: GREP_TOOL.to_string(),
description: "Search file contents by regex (a plain substring is valid). Returns file:line: matches.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"pattern": { "type": "string", "description": "Regex or substring to search for." },
"path_glob": { "type": "string", "description": "Optional glob limiting which files are searched, e.g. src/*.rs." }
},
"required": ["pattern"]
}),
},
ToolSpec {
name: FIND_TOOL.to_string(),
description: "List files whose name or relative path matches a glob (* and ?).".to_string(),
parameters: json!({
"type": "object",
"properties": {
"name_glob": { "type": "string", "description": "Glob to match, e.g. *.rs or src/*.rs." }
},
"required": ["name_glob"]
}),
},
ToolSpec {
name: READ_FILE_TOOL.to_string(),
description: "Read a file (path relative to the workspace root) into context.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "File path relative to the workspace root." }
},
"required": ["path"]
}),
},
ToolSpec {
name: WRITE_FILE_TOOL.to_string(),
description: "Write the full new contents of a file (path relative to the workspace root); creates it if absent.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "File path relative to the workspace root." },
"content": { "type": "string", "description": "Full new file contents." }
},
"required": ["path", "content"]
}),
},
]
}