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 trait ToolService: Send + Sync {
fn exec_for(&self, entity: Entity, calls: Vec<leviath_providers::ToolCall>) -> 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 UnboundedSender<ToolJob>);
#[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]")
}
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(", ")
),
})
}
#[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>,
),
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>>,
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,
) 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 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 exec = service.0.exec_for(entity, lane_calls);
let cancel = crate::cancel::CancelToken::new();
let _ = stage.0.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));
}
}