use crate::flow_dispatcher::{DispatchCtx, DispatchError, NodeOutcome};
use crate::flow_execution_event::{now_ms, FlowExecutionEvent};
use crate::ir_nodes::{IRLambdaDataApply, IRUseToolStep};
pub fn invoke_tool(tool_name: &str, argument: &str, ctx: &DispatchCtx) -> String {
let resolved_argument = ctx
.let_bindings
.get(argument)
.cloned()
.unwrap_or_else(|| argument.to_string());
format!("tool:{tool_name}({resolved_argument})")
}
pub async fn run_lambda_data_apply(
node: &IRLambdaDataApply,
ctx: &mut DispatchCtx,
) -> Result<NodeOutcome, DispatchError> {
if ctx.cancel.is_cancelled() {
return Err(DispatchError::UpstreamCancelled);
}
let step_index = ctx.step_counter;
ctx.step_counter += 1;
let step_name = if node.lambda_data_name.is_empty() {
"LambdaApply".to_string()
} else {
node.lambda_data_name.clone()
};
emit_step_start(ctx, &step_name, step_index, "lambda_data_apply")?;
let psi_json = elevate_lambda(&node.lambda_data_name, &node.target, ctx)?;
let output_key = if !node.output_type.is_empty() {
node.output_type.clone()
} else if !node.target.is_empty() {
format!("{}_lambda_applied", node.target)
} else {
String::new()
};
if !output_key.is_empty() {
ctx.let_bindings.insert(output_key, psi_json.clone());
}
emit_step_complete(ctx, &step_name, step_index, &psi_json, 0, true)?;
Ok(NodeOutcome::Completed {
output: psi_json,
tokens_emitted: 0,
step_index,
})
}
pub(crate) fn elevate_lambda(
lambda_name: &str,
target: &str,
ctx: &DispatchCtx,
) -> Result<String, DispatchError> {
let blame = format!("lambda:{lambda_name}");
let spec = ctx
.lambda_data_specs
.iter()
.find(|l| l.name == lambda_name)
.ok_or_else(|| DispatchError::BackendError {
name: blame.clone(),
message: format!(
"lambda '{lambda_name}' is not in the compiled program's \u{039b}D \
catalog; nothing can be elevated, so nothing is bound. (The type \
checker rejects unknown names statically \u{2014} an empty catalog \
here usually means the deploy path did not attach \
`lambda_data_specs`.)"
),
})?;
let snapshot = crate::lambda_runtime::SpecSnapshot {
name: spec.name.clone(),
ontology: spec.ontology.clone(),
certainty: spec.certainty,
temporal_frame_start: spec.temporal_frame_start.clone(),
temporal_frame_end: spec.temporal_frame_end.clone(),
provenance: spec.provenance.clone(),
derivation: spec.derivation.clone(),
};
let resolved_target = ctx
.let_bindings
.get(target)
.cloned()
.unwrap_or_else(|| target.to_string());
let psi = crate::lambda_runtime::build_psi(
&snapshot,
serde_json::Value::String(resolved_target),
)
.map_err(|e| DispatchError::BackendError {
name: blame,
message: e.to_string(),
})?;
serde_json::to_string(&psi).map_err(|e| DispatchError::BackendError {
name: format!("lambda:{lambda_name}"),
message: format!("\u{03c8} serialization failed: {e}"),
})
}
pub async fn run_use_tool(
node: &IRUseToolStep,
ctx: &mut DispatchCtx,
) -> Result<NodeOutcome, DispatchError> {
if ctx.cancel.is_cancelled() {
return Err(DispatchError::UpstreamCancelled);
}
let step_index = ctx.step_counter;
ctx.step_counter += 1;
let step_name = if node.tool_name.is_empty() {
"UseTool".to_string()
} else {
node.tool_name.clone()
};
emit_step_start(ctx, &step_name, step_index, "use_tool")?;
match crate::flow_dispatcher::budget_gate::charge(ctx, &node.tool_name)? {
crate::flow_dispatcher::budget_gate::BudgetGrant::Granted => {}
crate::flow_dispatcher::budget_gate::BudgetGrant::Shed { .. } => {
if !node.tool_name.is_empty() {
ctx.let_bindings
.insert(format!("{}_result", node.tool_name), String::new());
}
emit_step_complete(ctx, &step_name, step_index, "", 0, true)?;
return Ok(NodeOutcome::Completed {
output: String::new(),
tokens_emitted: 0,
step_index,
});
}
}
if let Some(breach) = charge_tool_lease(node, ctx) {
let step_index_now = step_index;
emit_step_complete(ctx, &step_name, step_index_now, &breach, 0, false)?;
if !node.tool_name.is_empty() {
ctx.let_bindings
.insert(format!("{}_result", node.tool_name), breach.clone());
}
return Ok(NodeOutcome::Completed {
output: breach,
tokens_emitted: 0,
step_index,
});
}
let _channel_permit = acquire_channel_permit(node, ctx).await;
let (result, success) = match dispatch_use_tool_real(node, ctx).await {
Some(tool_result) => (tool_result.output, tool_result.success),
None => (invoke_tool(&node.tool_name, &node.argument, ctx), true),
};
if ctx.cancel.is_cancelled() {
return Err(DispatchError::UpstreamCancelled);
}
if !node.tool_name.is_empty() {
ctx.let_bindings
.insert(format!("{}_result", node.tool_name), result.clone());
}
emit_step_complete(ctx, &step_name, step_index, &result, 0, success)?;
Ok(NodeOutcome::Completed {
output: result,
tokens_emitted: 0,
step_index,
})
}
pub fn charge_tool_lease_by_name(tool_name: &str, ctx: &DispatchCtx) -> Option<String> {
let leases = ctx.tool_leases.as_ref()?;
let registry = ctx.tool_registry.as_ref()?;
let entry = registry.get(tool_name)?;
if entry.resource_ref.is_empty() {
return None;
}
match leases.charge(&entry.resource_ref) {
Ok(()) => None,
Err(breach) => Some(breach.to_string()),
}
}
fn charge_tool_lease(node: &IRUseToolStep, ctx: &DispatchCtx) -> Option<String> {
charge_tool_lease_by_name(&node.tool_name, ctx)
}
pub async fn acquire_channel_permit_by_name(
tool_name: &str,
ctx: &DispatchCtx,
) -> Option<tokio::sync::OwnedSemaphorePermit> {
let sems = ctx.channel_semaphores.as_ref()?;
let registry = ctx.tool_registry.as_ref()?;
let entry = registry.get(tool_name)?;
if entry.resource_ref.is_empty() {
return None;
}
let sem = sems.for_resource(&entry.resource_ref)?;
sem.acquire_owned().await.ok()
}
async fn acquire_channel_permit(
node: &IRUseToolStep,
ctx: &DispatchCtx,
) -> Option<tokio::sync::OwnedSemaphorePermit> {
acquire_channel_permit_by_name(&node.tool_name, ctx).await
}
async fn dispatch_use_tool_real(
node: &IRUseToolStep,
ctx: &DispatchCtx,
) -> Option<crate::tool_executor::ToolResult> {
let registry = ctx.tool_registry.clone()?;
let entry = registry.get(&node.tool_name)?;
let parameters = entry.parameters.clone();
let secret_key = entry.secret.clone();
let secret_partition = entry.secret_partition.clone();
let mut argument = if node.named_args.is_empty() {
crate::exec_context::interpolate_vars(&node.argument, &ctx.let_bindings)
} else {
let interpolated: Vec<(String, String)> = node
.named_args
.iter()
.map(|a| {
(
a.name.clone(),
crate::exec_context::resolve_named_arg_value(
&a.value,
&a.value_kind,
&ctx.let_bindings,
),
)
})
.collect();
crate::runner::build_structured_tool_body(&interpolated, ¶meters)
};
if !secret_key.is_empty() {
let refuse = |message: String| {
Some(crate::tool_executor::ToolResult {
success: false,
output: message,
tool_name: node.tool_name.clone(),
})
};
let Some(custody) = ctx.secret_custody.clone() else {
return refuse(format!(
"tool '{}' declares `secret: {}` but no secret_custody port is \
configured — injection fails closed (never an unauthenticated \
vendor call)",
node.tool_name, secret_key
));
};
let mut body: serde_json::Value = match serde_json::from_str(&argument) {
Ok(serde_json::Value::Object(map)) => serde_json::Value::Object(map),
_ => {
return refuse(format!(
"tool '{}' declares `secret:` but was invoked with a \
non-structured argument — secret injection requires the \
`use {}(k = v, …)` keyword form (the body must be a JSON \
object to carry the reserved `axon_secret` field)",
node.tool_name, node.tool_name
))
}
};
let resolved_key = if secret_partition.is_empty() {
secret_key.clone()
} else {
let segment = match body.get(&secret_partition) {
Some(serde_json::Value::String(s)) => s.clone(),
Some(_) | None => {
return refuse(format!(
"tool '{}' declares `secret_partition: {}` but the call did \
not bind that parameter to a string value — the partition \
segment is unresolved, so the per-sub-tenant secret key \
cannot be addressed (injection fails closed)",
node.tool_name, secret_partition
))
}
};
if segment.is_empty() || !is_key_segment(&segment) {
return refuse(format!(
"tool '{}' partition value for `{}` is not a valid key segment \
('{}') — a partition segment is a single non-empty run of \
`[a-z0-9_-]` (no `.`, no `/`, no `:`, no uppercase) so the \
resolved custody key can never leave the `{}` class. \
Injection fails closed.",
node.tool_name, secret_partition, segment, secret_key
));
}
format!("{secret_key}.{segment}")
};
let revealed = match custody.reveal_for_dispatch(&ctx.tenant_id, &resolved_key).await
{
Ok(r) => r,
Err(e) => {
return refuse(format!(
"tool '{}' secret injection refused by custody: {e}",
node.tool_name
))
}
};
body["axon_secret"] = serde_json::Value::String(revealed.value.clone());
drop(revealed);
argument = body.to_string();
}
let tool_name = node.tool_name.clone();
let registry_for_task = registry.clone();
match tokio::task::spawn_blocking(move || {
registry_for_task.dispatch(&tool_name, &argument)
})
.await
{
Ok(opt) => opt,
Err(join_err) => Some(crate::tool_executor::ToolResult {
success: false,
output: format!(
"tool '{}' dispatch task failed: {join_err}",
node.tool_name
),
tool_name: node.tool_name.clone(),
}),
}
}
fn is_key_segment(s: &str) -> bool {
s.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
}
fn emit_step_start(
ctx: &mut DispatchCtx,
step_name: &str,
step_index: usize,
step_type: &str,
) -> Result<(), DispatchError> {
ctx.tx
.send(FlowExecutionEvent::StepStart {
step_name: step_name.to_string(),
step_index,
step_type: step_type.to_string(),
branch_path: ctx.branch_path_string(),
timestamp_ms: now_ms(),
})
.map_err(|_| DispatchError::ChannelClosed)
}
fn emit_step_complete(
ctx: &mut DispatchCtx,
step_name: &str,
step_index: usize,
full_output: &str,
tokens_output: u64,
success: bool,
) -> Result<(), DispatchError> {
ctx.tx
.send(FlowExecutionEvent::StepComplete {
step_name: step_name.to_string(),
step_index,
success,
full_output: full_output.to_string(),
tokens_input: 0,
tokens_output,
branch_path: ctx.branch_path_string(),
timestamp_ms: now_ms(),
})
.map_err(|_| DispatchError::ChannelClosed)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cancel_token::CancellationFlag;
use tokio::sync::mpsc;
fn fresh_ctx() -> (
DispatchCtx,
mpsc::UnboundedReceiver<FlowExecutionEvent>,
) {
let (tx, rx) = mpsc::unbounded_channel();
let ctx = DispatchCtx::new(
"TestFlow",
"stub",
"",
CancellationFlag::new(),
tx,
);
(ctx, rx)
}
fn lambda_spec(name: &str, certainty: f64, derivation: &str) -> crate::ir_nodes::IRLambdaData {
crate::ir_nodes::IRLambdaData {
node_type: "lambda_data",
source_line: 0,
source_column: 0,
name: name.into(),
ontology: "measurement.temperature.celsius".into(),
certainty,
temporal_frame_start: "2026-01-01T00:00:00Z".into(),
temporal_frame_end: "2026-12-31T23:59:59Z".into(),
provenance: "Sensor_X".into(),
derivation: derivation.into(),
}
}
#[tokio::test]
async fn a_declared_lambda_elevates_to_a_real_psi_not_a_placeholder() {
let (mut ctx, _rx) = fresh_ctx();
ctx.lambda_data_specs =
std::sync::Arc::new(vec![lambda_spec("SensorReading", 0.95, "raw")]);
ctx.let_bindings.insert("reading".into(), "21.5".into());
let node = IRLambdaDataApply {
node_type: "lambda_data_apply",
source_line: 0,
source_column: 0,
lambda_data_name: "SensorReading".into(),
target: "reading".into(),
output_type: "typed".into(),
};
run_lambda_data_apply(&node, &mut ctx).await.expect("elevates");
let bound = ctx.let_bindings.get("typed").expect("psi bound").clone();
let psi: serde_json::Value = serde_json::from_str(&bound).expect("psi is JSON");
assert_eq!(psi["T"], "measurement.temperature.celsius");
assert_eq!(psi["V"], "21.5");
assert_eq!(psi["E"]["c"], 0.95);
assert_eq!(psi["E"]["rho"], "Sensor_X");
assert!(
!bound.contains("lambda:"),
"the placeholder string shape must be dead: {bound}"
);
}
#[tokio::test]
async fn theorem_5_1_is_enforced_at_apply_time_on_the_dispatch_path() {
let (mut ctx, _rx) = fresh_ctx();
ctx.lambda_data_specs =
std::sync::Arc::new(vec![lambda_spec("Overclaim", 1.0, "inferred")]);
let node = IRLambdaDataApply {
node_type: "lambda_data_apply",
source_line: 0,
source_column: 0,
lambda_data_name: "Overclaim".into(),
target: "x".into(),
output_type: "out".into(),
};
let err = run_lambda_data_apply(&node, &mut ctx)
.await
.err()
.expect("theorem violation refuses");
match err {
DispatchError::BackendError { name, message } => {
assert_eq!(name, "lambda:Overclaim");
assert!(message.contains("Theorem 5.1"), "{message}");
}
other => panic!("{other:?}"),
}
assert!(
ctx.let_bindings.get("out").is_none(),
"nothing binds on a refused elevation"
);
}
#[tokio::test]
async fn an_undeclared_lambda_fails_closed_never_placeholder() {
let (mut ctx, _rx) = fresh_ctx();
let node = IRLambdaDataApply {
node_type: "lambda_data_apply",
source_line: 0,
source_column: 0,
lambda_data_name: "ghost".into(),
target: "x".into(),
output_type: "out".into(),
};
let err = run_lambda_data_apply(&node, &mut ctx)
.await
.err()
.expect("undeclared refuses");
match err {
DispatchError::BackendError { name, message } => {
assert_eq!(name, "lambda:ghost");
assert!(message.contains("nothing is bound"), "{message}");
}
other => panic!("{other:?}"),
}
}
#[tokio::test]
async fn a_lambda_step_guard_elevates_before_the_prompt_interpolates() {
let (mut ctx, _rx) = fresh_ctx();
ctx.lambda_data_specs =
std::sync::Arc::new(vec![lambda_spec("RawQuote", 0.9, "inferred")]);
ctx.let_bindings.insert("ticker".into(), "ACME".into());
let step = crate::ir_nodes::IRStep {
node_type: "step",
source_line: 0,
source_column: 0,
name: "Price".into(),
persona_ref: String::new(),
given: String::new(),
ask: "assess ${verified_quote}".into(),
use_tool: None,
probe: None,
reason: None,
weave: None,
output_type: String::new(),
confidence_floor: None,
navigate_ref: String::new(),
apply_ref: String::new(),
requires_context: None,
now_tz: None,
pix_ops: Vec::new(),
stream: None,
performs: Vec::new(),
guards: vec![crate::ir_nodes::IRStepGuard {
kind: "lambda".into(),
name: "RawQuote".into(),
target: "ticker".into(),
binding: "verified_quote".into(),
}],
body: Vec::new(),
};
super::super::pure_shape::run_step(&step, &mut ctx)
.await
.expect("guarded step runs via the stub");
let bound = ctx.let_bindings.get("verified_quote").expect("elevated");
let psi: serde_json::Value = serde_json::from_str(bound).expect("json");
assert_eq!(psi["V"], "ACME");
assert_eq!(psi["E"]["delta"], "inferred");
}
#[test]
fn invoke_tool_literal_argument() {
let (ctx, _rx) = fresh_ctx();
assert_eq!(
invoke_tool("calculator", "2+2", &ctx),
"tool:calculator(2+2)"
);
}
#[test]
fn invoke_tool_resolves_argument_through_bindings() {
let (mut ctx, _rx) = fresh_ctx();
ctx.let_bindings.insert("query".into(), "weather today".into());
assert_eq!(
invoke_tool("web_search", "query", &ctx),
"tool:web_search(weather today)"
);
}
#[tokio::test]
async fn run_lambda_data_apply_binds_under_output_type() {
let (mut ctx, mut rx) = fresh_ctx();
ctx.lambda_data_specs =
std::sync::Arc::new(vec![lambda_spec("transform", 0.8, "derived")]);
ctx.let_bindings.insert("input_data".into(), "raw".into());
let node = IRLambdaDataApply {
node_type: "lambda_data_apply",
source_line: 0,
source_column: 0,
lambda_data_name: "transform".into(),
target: "input_data".into(),
output_type: "transformed".into(),
};
let outcome = run_lambda_data_apply(&node, &mut ctx).await.unwrap();
match outcome {
NodeOutcome::Completed { output, tokens_emitted, .. } => {
let psi: serde_json::Value = serde_json::from_str(&output).expect("ψ JSON");
assert_eq!(psi["V"], "raw");
assert_eq!(psi["E"]["c"], 0.8);
assert_eq!(tokens_emitted, 0);
}
other => panic!("expected Completed, got {other:?}"),
}
assert!(ctx.let_bindings.get("transformed").is_some());
let first = rx.try_recv().unwrap();
match first {
FlowExecutionEvent::StepStart { step_type, .. } => {
assert_eq!(step_type, "lambda_data_apply");
}
e => panic!("expected StepStart, got {e:?}"),
}
}
#[tokio::test]
async fn run_lambda_data_apply_canonical_fallback() {
let (mut ctx, _rx) = fresh_ctx();
ctx.lambda_data_specs =
std::sync::Arc::new(vec![lambda_spec("norm", 0.8, "derived")]);
let node = IRLambdaDataApply {
node_type: "lambda_data_apply",
source_line: 0,
source_column: 0,
lambda_data_name: "norm".into(),
target: "doc".into(),
output_type: String::new(),
};
run_lambda_data_apply(&node, &mut ctx).await.unwrap();
let bound = ctx
.let_bindings
.get("doc_lambda_applied")
.expect("canonical fallback key");
let psi: serde_json::Value = serde_json::from_str(bound).expect("ψ JSON");
assert_eq!(psi["V"], "doc", "unresolved binding elevates the literal");
}
#[tokio::test]
async fn run_use_tool_binds_under_canonical_result_key() {
let (mut ctx, mut rx) = fresh_ctx();
ctx.let_bindings.insert("input".into(), "5+3".into());
let node = IRUseToolStep {
node_type: "use_tool",
source_line: 0,
source_column: 0,
tool_name: "calculator".into(),
argument: "input".into(),
named_args: Vec::new(),
};
let outcome = run_use_tool(&node, &mut ctx).await.unwrap();
match outcome {
NodeOutcome::Completed { output, tokens_emitted, .. } => {
assert_eq!(output, "tool:calculator(5+3)");
assert_eq!(tokens_emitted, 0);
}
other => panic!("expected Completed, got {other:?}"),
}
assert_eq!(
ctx.let_bindings.get("calculator_result").unwrap(),
"tool:calculator(5+3)"
);
let first = rx.try_recv().unwrap();
match first {
FlowExecutionEvent::StepStart { step_type, .. } => {
assert_eq!(step_type, "use_tool");
}
e => panic!("expected StepStart, got {e:?}"),
}
}
#[tokio::test]
async fn lambda_and_use_tool_short_circuit_on_cancel() {
let cancel = CancellationFlag::new();
cancel.cancel();
let (tx, _rx) = mpsc::unbounded_channel();
let mut ctx = DispatchCtx::new("F", "stub", "", cancel, tx);
let lambda = IRLambdaDataApply {
node_type: "lambda_data_apply",
source_line: 0,
source_column: 0,
lambda_data_name: "x".into(),
target: "y".into(),
output_type: "z".into(),
};
assert!(matches!(
run_lambda_data_apply(&lambda, &mut ctx).await,
Err(DispatchError::UpstreamCancelled)
));
let ut = IRUseToolStep {
node_type: "use_tool",
source_line: 0,
source_column: 0,
tool_name: "x".into(),
argument: "y".into(),
named_args: Vec::new(),
};
assert!(matches!(
run_use_tool(&ut, &mut ctx).await,
Err(DispatchError::UpstreamCancelled)
));
}
}