use crate::flow_dispatcher::{DispatchCtx, DispatchError, NodeOutcome};
use crate::ir_nodes::IRAgent;
#[derive(Debug, Clone, Copy)]
pub struct AgentBounds {
pub max_iterations: u32,
pub max_tokens: Option<u64>,
pub max_cost: Option<f64>,
}
impl AgentBounds {
pub fn resolve(agent: &IRAgent) -> Result<Self, DispatchError> {
let max_iterations = match agent.max_iterations {
Some(n) if n > 0 => n as u32,
Some(n) => {
return Err(DispatchError::BackendError {
name: format!("agent:{}", agent.name),
message: format!(
"agent '{}' declares `max_iterations: {n}` — an agent that may \
not think at all is a declaration with no execution. Declare a \
positive bound.",
agent.name
),
})
}
None => {
return Err(DispatchError::BackendError {
name: format!("agent:{}", agent.name),
message: format!(
"agent '{}' declares no `max_iterations`, so its loop has no \
termination bound. Refused before the first token is spent: an \
unbounded agent is unbounded spend, and no default this runtime \
picked would be a number you wrote. Every agent the README \
publishes declares one.",
agent.name
),
})
}
};
Ok(AgentBounds {
max_iterations,
max_tokens: agent.max_tokens.filter(|n| *n > 0).map(|n| n as u64),
max_cost: agent.max_cost.filter(|c| *c > 0.0),
})
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum AgentEnd {
Answered,
Stuck { bound: &'static str },
}
struct Spend {
iterations: u32,
tokens: u64,
pricing: crate::cost_estimator::PricingModel,
}
impl Spend {
fn new() -> Self {
Spend {
iterations: 0,
tokens: 0,
pricing: crate::cost_estimator::PricingModel::default_sonnet(),
}
}
fn usd(&self) -> f64 {
self.pricing.compute_cost(0, self.tokens)
}
fn affords(&self, b: &AgentBounds) -> Option<&'static str> {
if self.iterations >= b.max_iterations {
return Some("max_iterations");
}
if let Some(cap) = b.max_tokens {
if self.tokens >= cap {
return Some("max_tokens");
}
}
if let Some(cap) = b.max_cost {
if self.usd() >= cap {
return Some("max_cost");
}
}
None
}
}
pub async fn run_agent_call(
node: &crate::ir_nodes::IRAgentCall,
ctx: &mut DispatchCtx,
) -> Result<NodeOutcome, DispatchError> {
if ctx.cancel.is_cancelled() {
return Err(DispatchError::UpstreamCancelled);
}
let Some(agent) = ctx
.agent_specs
.iter()
.find(|a| a.name == node.agent_name)
.cloned()
else {
return Err(DispatchError::BackendError {
name: format!("agent:{}", node.agent_name),
message: format!(
"no `agent {}` is declared in this program, so its bounds — including \
`max_iterations` — cannot be resolved. Refused rather than run \
unbounded. ({} agent(s) in this catalog.)",
node.agent_name,
ctx.agent_specs.len()
),
});
};
let input = node
.arguments
.iter()
.map(|a| match a.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
Some(literal) => literal.to_string(),
None => crate::exec_context::resolve_value_reference(a, &ctx.let_bindings),
})
.collect::<Vec<_>>()
.join("\n");
run_agent(&agent, &input, ctx).await
}
pub async fn run_agent(
agent: &IRAgent,
input: &str,
ctx: &mut DispatchCtx,
) -> Result<NodeOutcome, DispatchError> {
if ctx.cancel.is_cancelled() {
return Err(DispatchError::UpstreamCancelled);
}
let bounds = AgentBounds::resolve(agent)?;
let mut spend = Spend::new();
let (end, output) = match agent.strategy.as_str() {
"react" | "" => run_react(agent, input, &bounds, &mut spend, ctx).await?,
"plan_and_execute" => run_plan_and_execute(agent, input, &bounds, &mut spend, ctx).await?,
"reflexion" => run_reflexion(agent, input, &bounds, &mut spend, ctx).await?,
"custom" => {
return Err(DispatchError::BackendError {
name: format!("agent:{}", agent.name),
message: format!(
"agent '{}' declares `strategy: custom`, whose control policy is the \
`step` list written inside the agent block — and that body is \
discarded by the parser (`AgentDefinition` has no body field). \
Running it would mean silently substituting a policy you did not \
write. Refused until the agent body survives parsing; use react, \
reflexion or plan_and_execute meanwhile.",
agent.name
),
})
}
other => {
return Err(DispatchError::BackendError {
name: format!("agent:{}", agent.name),
message: format!(
"agent '{}' declares `strategy: {other}`, which is outside the closed \
catalog the type-checker enforces (react, reflexion, \
plan_and_execute, custom). This is a compiler bug — the check should \
have caught it — reported rather than silently executed.",
agent.name
),
})
}
};
let output = match end {
AgentEnd::Answered => output,
AgentEnd::Stuck { bound } => {
apply_on_stuck(agent, bound, &output, &spend, ctx).await?
}
};
if !agent.name.is_empty() {
ctx.let_bindings.insert(agent.name.clone(), output.clone());
}
Ok(NodeOutcome::Completed {
output,
tokens_emitted: spend.tokens,
step_index: ctx.step_counter.saturating_sub(1),
})
}
async fn deliberate(
label: &str,
prompt: String,
framing: String,
spend: &mut Spend,
ctx: &mut DispatchCtx,
) -> Result<String, DispatchError> {
let shape = super::pure_shape::PureShapeStep {
name: label.to_string(),
user_prompt: prompt,
framing_addendum: Some(framing),
kind_slug: "agent",
tools: Vec::new(),
requires_context: None,
temperature: None,
now_tz: None,
stream_on_chunk: None,
};
let outcome = super::pure_shape::run_pure_shape(shape, ctx).await?;
spend.iterations += 1;
match outcome {
NodeOutcome::Completed {
output,
tokens_emitted,
..
} => {
spend.tokens += tokens_emitted;
Ok(output)
}
other => Err(DispatchError::BackendError {
name: "agent".to_string(),
message: format!(
"an agent deliberation returned {other:?}, which is a flow-walk sentinel \
with no meaning inside an agent loop"
),
}),
}
}
fn tool_catalog(agent: &IRAgent) -> String {
if agent.tools.is_empty() {
"(none — answer from your own reasoning)".to_string()
} else {
agent.tools.join(", ")
}
}
pub fn granted_tool<'a>(agent: &'a IRAgent, named: &str) -> Option<&'a String> {
agent.tools.iter().find(|t| t.eq_ignore_ascii_case(named))
}
#[derive(Debug, PartialEq)]
pub enum AgentMove {
Act(String),
Answer(String),
Unstructured,
}
pub fn parse_agent_move(text: &str) -> AgentMove {
for line in text.lines() {
let t = line.trim();
if let Some(rest) = strip_prefix_ci(t, "ACT:") {
if !rest.is_empty() {
return AgentMove::Act(rest.to_string());
}
}
if let Some(rest) = strip_prefix_ci(t, "ANSWER:") {
return AgentMove::Answer(rest.to_string());
}
}
AgentMove::Unstructured
}
async fn run_react(
agent: &IRAgent,
input: &str,
bounds: &AgentBounds,
spend: &mut Spend,
ctx: &mut DispatchCtx,
) -> Result<(AgentEnd, String), DispatchError> {
let mut observations: Vec<String> = Vec::new();
let mut last = String::new();
while spend.affords(bounds).is_none() {
let prompt = format!(
"GOAL: {}\n\nINPUT: {}\n\nTOOLS YOU MAY USE: {}\n\nOBSERVATIONS SO FAR:\n{}",
agent.goal,
input,
tool_catalog(agent),
if observations.is_empty() {
"(none yet)".to_string()
} else {
observations.join("\n")
}
);
let framing = "You are acting under the ReAct policy. Think, then do exactly ONE \
of two things. To use a tool, reply with a single line \
`ACT: <ToolName>`. To finish, reply `ANSWER: <your answer>`. Name \
only tools from the list you were given."
.to_string();
last = deliberate(&format!("{}:react", agent.name), prompt, framing, spend, ctx).await?;
match parse_agent_move(&last) {
AgentMove::Answer(text) => return Ok((AgentEnd::Answered, text)),
AgentMove::Act(named) => {
let Some(tool) = granted_tool(agent, &named) else {
observations.push(format!(
"REFUSED: `{named}` is not in this agent's declared tools. \
Choose from: {}",
tool_catalog(agent)
));
continue;
};
let node = crate::ir_nodes::IRUseToolStep {
node_type: "use_tool",
source_line: agent.source_line,
source_column: agent.source_column,
tool_name: tool.clone(),
argument: input.to_string(),
named_args: Vec::new(),
};
let observed = match super::lambda_tools::run_use_tool(&node, ctx).await? {
NodeOutcome::Completed { output, .. } => output,
other => format!("(tool returned {other:?})"),
};
observations.push(format!("OBSERVATION from {}: {observed}", tool.clone()));
}
AgentMove::Unstructured => {
observations.push(
"PROTOCOL VIOLATION: the previous reply was neither `ACT: <ToolName>` \
nor `ANSWER: <text>`. Reply with exactly one of those two forms."
.to_string(),
);
}
}
}
Ok((
AgentEnd::Stuck {
bound: spend.affords(bounds).unwrap_or("max_iterations"),
},
last,
))
}
fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
if s.len() >= prefix.len() && s[..prefix.len()].eq_ignore_ascii_case(prefix) {
Some(s[prefix.len()..].trim())
} else {
None
}
}
async fn run_plan_and_execute(
agent: &IRAgent,
input: &str,
bounds: &AgentBounds,
spend: &mut Spend,
ctx: &mut DispatchCtx,
) -> Result<(AgentEnd, String), DispatchError> {
if let Some(bound) = spend.affords(bounds) {
return Ok((AgentEnd::Stuck { bound }, String::new()));
}
let plan_text = deliberate(
&format!("{}:plan", agent.name),
format!(
"GOAL: {}\n\nINPUT: {}\n\nTOOLS AVAILABLE: {}",
agent.goal,
input,
tool_catalog(agent)
),
"You are planning under the Plan-and-Execute policy. Produce the plan ONCE, as a \
numbered list, one step per line, no commentary. The plan will not be \
regenerated."
.to_string(),
spend,
ctx,
)
.await?;
let remaining = bounds.max_iterations.saturating_sub(spend.iterations) as usize;
let plan: Vec<String> = plan_text
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.take(remaining)
.collect();
if plan.is_empty() {
return Ok((AgentEnd::Answered, plan_text));
}
let mut results: Vec<String> = Vec::new();
for (i, step) in plan.iter().enumerate() {
if let Some(bound) = spend.affords(bounds) {
return Ok((AgentEnd::Stuck { bound }, results.join("\n\n")));
}
let out = deliberate(
&format!("{}:execute", agent.name),
format!(
"GOAL: {}\n\nPLAN STEP {} OF {}: {step}\n\nRESULTS SO FAR:\n{}",
agent.goal,
i + 1,
plan.len(),
if results.is_empty() {
"(none yet)".to_string()
} else {
results.join("\n")
}
),
"Execute exactly this one plan step. Do not plan further; the plan is fixed."
.to_string(),
spend,
ctx,
)
.await?;
results.push(out);
}
Ok((AgentEnd::Answered, results.join("\n\n")))
}
async fn run_reflexion(
agent: &IRAgent,
input: &str,
bounds: &AgentBounds,
spend: &mut Spend,
ctx: &mut DispatchCtx,
) -> Result<(AgentEnd, String), DispatchError> {
if let Some(bound) = spend.affords(bounds) {
return Ok((AgentEnd::Stuck { bound }, String::new()));
}
let mut attempt = deliberate(
&format!("{}:attempt", agent.name),
format!("GOAL: {}\n\nINPUT: {}", agent.goal, input),
"Produce your best first attempt at the goal.".to_string(),
spend,
ctx,
)
.await?;
while spend.affords(bounds).is_none() {
let critique = deliberate(
&format!("{}:critique", agent.name),
format!(
"GOAL: {}\n\nATTEMPT:\n{attempt}",
agent.goal
),
"You are self-critiquing under the Reflexion policy. If the attempt meets the \
goal, reply exactly `ACCEPT`. Otherwise reply with the single most important \
defect, and nothing else."
.to_string(),
spend,
ctx,
)
.await?;
if critique.trim().eq_ignore_ascii_case("ACCEPT")
|| critique.trim_start().to_ascii_uppercase().starts_with("ACCEPT")
{
return Ok((AgentEnd::Answered, attempt));
}
if let Some(bound) = spend.affords(bounds) {
return Ok((AgentEnd::Stuck { bound }, attempt));
}
attempt = deliberate(
&format!("{}:revise", agent.name),
format!(
"GOAL: {}\n\nPREVIOUS ATTEMPT:\n{attempt}\n\nDEFECT TO FIX:\n{critique}",
agent.goal
),
"Revise the attempt to fix exactly the named defect. Change nothing else."
.to_string(),
spend,
ctx,
)
.await?;
}
Ok((
AgentEnd::Stuck {
bound: spend.affords(bounds).unwrap_or("max_iterations"),
},
attempt,
))
}
async fn apply_on_stuck(
agent: &IRAgent,
bound: &'static str,
partial: &str,
spend: &Spend,
ctx: &mut DispatchCtx,
) -> Result<String, DispatchError> {
let context = format!(
"agent '{}' hit `{bound}` after {} iteration(s) (~{} output tokens, ~${:.4})",
agent.name,
spend.iterations,
spend.tokens,
spend.usd()
);
match agent.on_stuck.as_str() {
"escalate" => Err(DispatchError::BackendError {
name: format!("agent:{}", agent.name),
message: format!(
"AgentStuckError — {context}. Last partial state:\n{partial}"
),
}),
"forge" => {
let node = crate::ir_nodes::IRForgeBlock {
node_type: "forge",
source_line: agent.source_line,
source_column: agent.source_column,
name: format!("{}_unstuck", agent.name),
seed: format!("{}\n\nGOAL: {}\n\nSTUCK AT:\n{partial}", context, agent.goal),
output_type: String::new(),
mode: String::new(),
novelty: 0.0,
branches: 1,
depth: 1,
constraints_ref: String::new(),
};
match super::cognitive::run_forge(&node, ctx).await? {
NodeOutcome::Completed { output, .. } => Ok(output),
other => Err(DispatchError::BackendError {
name: format!("agent:{}", agent.name),
message: format!("forge recovery returned {other:?}"),
}),
}
}
"retry" => Ok(format!(
"[retry] {context}. The declared bound was reached; this is the partial \
state, not a completed answer:\n{partial}"
)),
"hibernate" => Err(DispatchError::BackendError {
name: format!("agent:{}", agent.name),
message: format!(
"{context}, and `on_stuck: hibernate` has no continuation to park — \
§119.d suspends a flow walk by capturing its remaining nodes, and an \
agent loop has no such list. Refused rather than quietly escalated. Use \
escalate, forge or retry."
),
}),
"" => Err(DispatchError::BackendError {
name: format!("agent:{}", agent.name),
message: format!(
"AgentStuckError — {context}, and no `on_stuck:` policy is declared. \
Refusing rather than returning a partial answer that reads as a \
finished one. Last partial state:\n{partial}"
),
}),
other => Err(DispatchError::BackendError {
name: format!("agent:{}", agent.name),
message: format!(
"unknown `on_stuck: {other}` — outside the closed catalog the \
type-checker enforces (escalate, forge, hibernate, retry). This is a \
compiler bug, reported rather than silently executed."
),
}),
}
}