use super::*;
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub struct AwaitingTools;
#[derive(Component, Debug, Clone, Copy)]
pub struct ToolsNeedRefresh;
#[derive(Component, Debug, Clone, Copy)]
pub struct DynamicTools;
pub type ToolProgress = Arc<dyn Fn(&str, &str) + Send + Sync>;
pub fn noop_progress() -> ToolProgress {
Arc::new(|_, _| {})
}
pub trait ToolService: Send + Sync {
fn exec_for(
&self,
entity: Entity,
calls: Vec<leviath_providers::ToolCall>,
progress: ToolProgress,
) -> BoxedToolExec;
fn sync_stage(&self, _entity: Entity, _stage_index: usize, _stage_name: &str) {}
fn refresh_tools(
&self,
_entity: Entity,
_stage_index: usize,
) -> Option<Vec<leviath_providers::Tool>> {
None
}
fn wants_refresh(&self, _entity: Entity) -> bool {
false
}
}
#[derive(Resource, Clone)]
pub struct ToolServiceRes(pub Arc<dyn ToolService>);
#[derive(Resource, Clone)]
pub struct ToolStage {
pub jobs: UnboundedSender<ToolJob>,
pub stats: Arc<crate::tool_bridge::ToolLaneStats>,
}
impl ToolStage {
pub fn new(
jobs: UnboundedSender<ToolJob>,
stats: Arc<crate::tool_bridge::ToolLaneStats>,
) -> Self {
Self { jobs, stats }
}
pub fn detached(jobs: UnboundedSender<ToolJob>) -> Self {
Self::new(jobs, Arc::new(crate::tool_bridge::ToolLaneStats::new(1)))
}
}
#[derive(Component, Debug, Clone, Default)]
pub struct ContextToolResults(pub Vec<(String, String)>);
pub(crate) fn one_line(s: &str, max: usize) -> String {
let flat = s.split_whitespace().collect::<Vec<_>>().join(" ");
if flat.chars().count() > max {
format!("{}…", flat.chars().take(max).collect::<String>())
} else {
flat
}
}
pub(crate) fn merge_in_call_order(
tool_calls: &[crate::components::ToolCall],
parts: &[(String, String)],
) -> Vec<(String, String)> {
tool_calls
.iter()
.map(|tc| {
let result = parts
.iter()
.find(|(id, _)| id == &tc.tool_id)
.map(|(_, r)| r.clone())
.unwrap_or_default();
(tc.tool_id.clone(), result)
})
.collect()
}
pub(crate) fn call_had_no_effect(result: &str) -> bool {
result.starts_with("[error]")
|| result.starts_with("[denied]")
|| result.starts_with("[unavailable]")
|| result.starts_with("[blocked]")
}
pub(crate) fn offered_tool_names(stage: &StageInference) -> Vec<&str> {
stage
.tools
.iter()
.filter(|t| match stage.tool_filter.as_deref() {
Some(filter) if !filter.is_empty() => filter.iter().any(|f| f == &t.name),
_ => true,
})
.map(|t| leviath_tools::canonical_tool_name(&t.name))
.collect()
}
pub(crate) fn unoffered_tool_refusal(stage: &StageInference, name: &str) -> Option<String> {
let canonical = leviath_tools::canonical_tool_name(name);
let offered = offered_tool_names(stage);
if offered.contains(&canonical) {
return None;
}
Some(match offered.is_empty() {
true => format!(
"[unavailable] '{name}' is not available in this stage, which has no \
tools at all. Answer directly instead of calling a tool."
),
false => format!(
"[unavailable] '{name}' is not available in this stage. You may call: {}.",
offered.join(", ")
),
})
}
pub(crate) const BATCH_JOURNAL_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1);
pub(crate) fn barrier_then(
exec: BoxedToolExec,
ack: tokio::sync::oneshot::Receiver<()>,
timeout: std::time::Duration,
) -> BoxedToolExec {
Box::new(move || {
Box::pin(async move {
let _ = tokio::time::timeout(timeout, ack).await;
exec().await
})
})
}
pub(crate) fn invalid_args_refusal(
stage: &StageInference,
name: &str,
args: &serde_json::Value,
) -> Option<String> {
let canonical = leviath_tools::canonical_tool_name(name);
let tool = stage
.tools
.iter()
.find(|t| leviath_tools::canonical_tool_name(&t.name) == canonical)?;
match leviath_tools::validate_tool_args(name, &tool.parameters, args) {
leviath_tools::ArgValidation::Valid => None,
leviath_tools::ArgValidation::SchemaUnusable(e) => {
tracing::warn!(
tool = %name,
error = %e,
"tool schema did not compile; skipping argument validation"
);
None
}
leviath_tools::ArgValidation::Invalid(msg) => Some(msg),
}
}
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub fn dispatch_tools(
mut agents: Query<
(
Entity,
&AgentState,
&StageInference,
&crate::components::InferenceResult,
&mut ContextWindow,
Option<&crate::components::ToolResultRoutingComponent>,
Option<&ToolSensitivities>,
Option<&mut crate::taint::TaintGate>,
Option<&crate::gate_prompt::GateResolved>,
Option<&crate::components::GateAutoApprove>,
Option<&InFlightWork>,
Option<&StageCursor>,
Option<&RunMetadata>,
),
With<ReadyForTools>,
>,
service: Res<ToolServiceRes>,
stage: Res<ToolStage>,
policy: Option<Res<PolicyGate>>,
script_rules: Option<Res<GateScriptRules>>,
hub: Option<Res<InteractionHub>>,
gate_stage: Option<Res<crate::gate_prompt::GatePromptStage>>,
persist: Option<Res<PersistenceStage>>,
sink: Option<Res<crate::host::WorldEventSink>>,
mut commands: Commands,
) {
crate::tick_scope::clear();
let default_policy = leviath_core::PolicyConfig::default();
let policy_ref = policy.as_ref().map(|p| &p.0).unwrap_or(&default_policy);
let script_checker = script_rules.as_ref().map(|r| r.0.as_ref());
let interactive = hub.as_ref().zip(gate_stage.as_ref());
for (
entity,
state,
stage_inf,
result,
mut window,
routing,
sensitivities,
mut gate,
resolved,
auto_gate,
in_flight,
cursor,
metadata,
) in agents.iter_mut()
{
crate::tick_scope::enter(entity);
let auto_approve_gates = auto_gate.is_some();
if state.status != AgentStatus::Active {
continue; }
let mut context_results = Vec::new();
let mut lane_calls = Vec::new();
let mut pending_prompts: Vec<(
String,
String,
leviath_core::TaintLevel,
leviath_core::TaintLevel,
)> = Vec::new();
for c in &result.tool_calls {
if let Some(refusal) = unoffered_tool_refusal(stage_inf, &c.name) {
context_results.push((c.tool_id.clone(), refusal));
continue;
}
if let Some(refusal) = invalid_args_refusal(stage_inf, &c.name, &c.arguments) {
context_results.push((c.tool_id.clone(), refusal));
continue;
}
if crate::context_tools::is_context_tool(&c.name) {
let text =
crate::context_tools::handle_context_tool(&c.name, &c.arguments, &mut window);
context_results.push((c.tool_id.clone(), text));
continue;
}
if let Some(resolved) = resolved {
if let Some(msg) = resolved.denied.get(&c.tool_id) {
context_results.push((c.tool_id.clone(), msg.clone()));
continue;
}
if resolved.approved.contains(&c.tool_id) {
lane_calls.push(leviath_providers::ToolCall {
id: c.tool_id.clone(),
name: c.name.clone(),
arguments: c.arguments.clone(),
thought_signature: c.thought_signature.clone(),
});
continue;
}
}
if let Some(gate) = gate.as_deref_mut() {
let decision = gate.check_with_policy(
&state.agent_id,
&c.name,
&window,
None,
policy_ref,
script_checker,
);
if !decision.is_allowed() {
if auto_approve_gates {
let (taint, clearance) = decision
.blocked_levels()
.expect("a non-Allowed GateDecision is always Blocked");
gate.record_allow(
&state.agent_id,
&c.name,
taint,
clearance,
leviath_core::taint::GateDecisionSource::YoloAutoApprove,
);
} else {
match (interactive, decision.blocked_levels()) {
(Some(_), Some((taint, clearance))) => {
pending_prompts.push((
c.tool_id.clone(),
c.name.clone(),
taint,
clearance,
));
}
_ => {
context_results
.push((c.tool_id.clone(), taint_block_message(&decision)));
}
}
continue;
}
}
}
lane_calls.push(leviath_providers::ToolCall {
id: c.tool_id.clone(),
name: c.name.clone(),
arguments: c.arguments.clone(),
thought_signature: c.thought_signature.clone(),
});
}
if let (false, Some((hub, gate_stage))) = (pending_prompts.is_empty(), interactive) {
let n = pending_prompts.len();
for (tool_id, name, taint, clearance) in pending_prompts {
gate_stage
.runtime
.spawn(crate::gate_prompt::run_gate_prompt(
entity,
(*hub).clone(),
state.agent_id.clone(),
tool_id,
name,
taint,
clearance,
gate_stage.outcomes.clone(),
gate_stage.wake.clone(),
));
}
commands
.entity(entity)
.remove::<ReadyForTools>()
.insert(crate::gate_prompt::AwaitingGatePrompt(n))
.insert(crate::gate_prompt::GateResolved::default());
continue; }
commands
.entity(entity)
.remove::<crate::gate_prompt::GateResolved>();
if lane_calls.is_empty() {
let merged = merge_in_call_order(&result.tool_calls, &context_results);
apply_tool_results(
&mut window,
&result.response,
&result.tool_calls,
&merged,
routing.map(|c| &c.routing),
sensitivities.map(|s| &s.0),
);
commands
.entity(entity)
.remove::<ReadyForTools>()
.insert(ReadyToInfer);
continue;
}
let (progress, ack) = match (persist.as_ref(), metadata) {
(Some(persist), Some(md)) => {
let record = leviath_core::run_archive::RunRecord::ToolBatch {
calls: result
.tool_calls
.iter()
.map(|c| leviath_core::run_archive::ToolCallRecord {
id: c.tool_id.clone(),
name: c.name.clone(),
arguments: c.arguments.to_string(),
result: context_results
.iter()
.find(|(id, _)| id == &c.tool_id)
.map(|(_, r)| r.clone()),
thought_signature: c.thought_signature.clone(),
})
.collect(),
at: chrono::Utc::now().timestamp(),
stage_index: cursor.map_or(0, |c| c.index),
iteration: state.iteration,
response: result.response.clone(),
};
let (ack_tx, ack_rx) = tokio::sync::oneshot::channel();
let _ = persist.0.send(PersistMsg::Append {
run_id: md.run_id.clone(),
record: Box::new(record),
ack: Some(ack_tx),
});
let sender = persist.0.clone();
let run_id = md.run_id.clone();
let iteration = state.iteration;
let progress: ToolProgress = Arc::new(move |call_id: &str, result: &str| {
let _ = sender.send(PersistMsg::Append {
run_id: run_id.clone(),
record: Box::new(leviath_core::run_archive::RunRecord::ToolCallDone {
iteration,
call_id: call_id.to_string(),
result: result.to_string(),
at: chrono::Utc::now().timestamp(),
}),
ack: None,
});
});
(progress, Some(ack_rx))
}
_ => (noop_progress(), None),
};
if let (Some(sink), Some(md)) = (sink.as_ref(), metadata) {
for call in &lane_calls {
let _ = sink.0.send(crate::host::WorldEvent::ToolCallStarted {
run_id: md.run_id.clone(),
agent_id: state.agent_id.clone(),
call_id: call.id.clone(),
tool: call.name.clone(),
});
}
}
let exec = service.0.exec_for(entity, lane_calls, progress);
let exec = match ack {
Some(ack) => barrier_then(exec, ack, BATCH_JOURNAL_ACK_TIMEOUT),
None => exec,
};
let cancel = crate::cancel::CancelToken::new();
stage.stats.enqueued();
let _ = stage.jobs.send(ToolJob {
entity,
exec,
cancel: cancel.clone(),
});
track_in_flight(&mut commands, entity, in_flight, cancel);
commands
.entity(entity)
.remove::<ReadyForTools>()
.insert(AwaitingTools)
.insert(ContextToolResults(context_results));
}
}