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>,
pub max_time: Option<std::time::Duration>,
}
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
),
})
}
};
let max_time = if agent.max_time.trim().is_empty() {
None
} else {
match crate::type_checker::parse_duration_ms(&agent.max_time) {
Some(ms) => Some(std::time::Duration::from_millis(ms)),
None => {
return Err(DispatchError::BackendError {
name: format!("agent:{}", agent.name),
message: format!(
"agent '{}' declares `max_time: {}`, which is not a duration \
(`500ms`, `30s`, `2m`, `1h`). axon-T1220 refuses this at \
check time; refused here too rather than run with a bound \
that cannot be read.",
agent.name, agent.max_time
),
})
}
}
};
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),
max_time,
})
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum AgentEnd {
Answered,
Stuck { bound: &'static str },
}
struct Spend {
iterations: u32,
tokens: u64,
pricing: crate::cost_estimator::PricingModel,
started: std::time::Instant,
}
impl Spend {
fn new() -> Self {
Spend {
iterations: 0,
tokens: 0,
started: std::time::Instant::now(),
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");
}
}
if let Some(cap) = b.max_time {
if self.started.elapsed() >= cap {
return Some("max_time");
}
}
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" => run_custom(agent, input, &bounds, &mut spend, ctx).await?,
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 end = match end {
AgentEnd::Answered if !agent.return_schema.is_empty() => {
match return_schema_defect(&agent.return_schema, &output) {
None => AgentEnd::Answered,
Some(why) => {
ctx.let_bindings
.insert(format!("{}_return_defect", agent.name), why);
AgentEnd::Stuck { bound: "return" }
}
}
}
other => other,
};
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"
),
}),
}
}
pub const REACT_FRAMING: &str = "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.";
pub const REFLEXION_CRITIQUE_FRAMING: &str = "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.";
pub const TOOLS_LINE_PREFIX: &str = "TOOLS YOU MAY USE: ";
pub const NO_OBSERVATIONS_YET: &str = "(none yet)";
fn return_schema_instruction(agent: &IRAgent) -> String {
if agent.return_schema.is_empty() {
return String::new();
}
let fields: Vec<String> = agent
.return_schema
.iter()
.map(|f| {
let t = if f.generic_param.is_empty() {
f.type_name.clone()
} else {
format!("{}<{}>", f.type_name, f.generic_param)
};
format!("{}{}: {t}", f.name, if f.optional { "?" } else { "" })
})
.collect();
format!(
" Your ANSWER must be a single JSON object of type {} with fields {{ {} }} — \
no prose around it.",
agent.return_type,
fields.join(", ")
)
}
pub fn return_schema_defect(schema: &[crate::ir_nodes::IRTypeField], answer: &str) -> Option<String> {
let text = answer.trim();
let text = text
.strip_prefix("```json")
.or_else(|| text.strip_prefix("```"))
.map(|t| t.trim_end_matches("```").trim())
.unwrap_or(text);
let value: serde_json::Value = match serde_json::from_str(text) {
Ok(v) => v,
Err(e) => return Some(format!("the answer is not a JSON object ({e})")),
};
let obj = match value.as_object() {
Some(o) => o,
None => return Some("the answer is JSON but not an object".to_string()),
};
for f in schema {
match obj.get(&f.name) {
None | Some(serde_json::Value::Null) if f.optional => {}
None | Some(serde_json::Value::Null) => {
return Some(format!("required field `{}` is missing", f.name))
}
Some(v) => {
let ok = match f.type_name.as_str() {
"String" => v.is_string(),
"Integer" => v.as_i64().is_some() || v.as_u64().is_some(),
"Float" => v.is_number(),
"Boolean" => v.is_boolean(),
"List" => v.is_array(),
_ => true,
};
if !ok {
return Some(format!(
"field `{}` is declared `{}` but the answer carries {}",
f.name,
f.type_name,
match v {
serde_json::Value::String(_) => "a string",
serde_json::Value::Number(_) => "a number",
serde_json::Value::Bool(_) => "a boolean",
serde_json::Value::Array(_) => "an array",
serde_json::Value::Object(_) => "an object",
serde_json::Value::Null => "null",
}
));
}
}
}
}
None
}
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\n{TOOLS_LINE_PREFIX}{}\n\nOBSERVATIONS SO FAR:\n{}",
agent.goal,
input,
tool_catalog(agent),
if observations.is_empty() {
NO_OBSERVATIONS_YET.to_string()
} else {
observations.join("\n")
}
);
let framing = format!("{REACT_FRAMING}{}", return_schema_instruction(agent));
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
),
REFLEXION_CRITIQUE_FRAMING.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 run_custom(
agent: &IRAgent,
input: &str,
bounds: &AgentBounds,
spend: &mut Spend,
ctx: &mut DispatchCtx,
) -> Result<(AgentEnd, String), DispatchError> {
if agent.body.is_empty() {
return Err(DispatchError::BackendError {
name: format!("agent:{}", agent.name),
message: format!(
"agent '{}' declares `strategy: custom` with no `step` blocks — the custom \
policy IS the step sequence inside the agent block (axon-T1217 refuses \
this at check time); refused here rather than run an empty policy.",
agent.name
),
});
}
ctx.let_bindings.insert("input".to_string(), input.to_string());
ctx.let_bindings
.insert(format!("{}_input", agent.name), input.to_string());
let mut last = String::new();
for node in &agent.body {
if let Some(bound) = spend.affords(bounds) {
return Ok((AgentEnd::Stuck { bound }, last));
}
if ctx.cancel.is_cancelled() {
return Err(DispatchError::UpstreamCancelled);
}
match Box::pin(super::dispatch_node(node, ctx)).await? {
NodeOutcome::Completed {
output,
tokens_emitted,
..
} => {
spend.iterations += 1;
spend.tokens += tokens_emitted;
last = output;
}
other => {
return Err(DispatchError::BackendError {
name: format!("agent:{}", agent.name),
message: format!(
"a `custom` agent step returned {other:?}, a flow-walk sentinel with \
no meaning inside an agent body"
),
})
}
}
}
Ok((AgentEnd::Answered, last))
}
async fn apply_on_stuck(
agent: &IRAgent,
bound: &'static str,
partial: &str,
spend: &Spend,
ctx: &mut DispatchCtx,
) -> Result<String, DispatchError> {
let context = if bound == "return" {
format!(
"agent '{}' answered, but the answer does not inhabit its declared `return: {}` \
({}) after {} iteration(s) (~{} output tokens, ~${:.4})",
agent.name,
agent.return_type,
ctx.let_bindings
.get(&format!("{}_return_defect", agent.name))
.cloned()
.unwrap_or_default(),
spend.iterations,
spend.tokens,
spend.usd()
)
} else {
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 — \
`hibernate` 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."
),
}),
}
}