use super::*;
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProcessResponse;
#[derive(Resource)]
pub struct InferenceResults(pub UnboundedReceiver<InferenceOutcome>);
pub(crate) fn to_inference_result(
response: &leviath_providers::InferenceResponse,
) -> crate::components::InferenceResult {
crate::components::InferenceResult {
response: response.content.clone(),
tool_calls: response
.tool_calls
.iter()
.map(|tc| crate::components::ToolCall {
tool_id: tc.id.clone(),
name: tc.name.clone(),
arguments: tc.arguments.clone(),
thought_signature: tc.thought_signature.clone(),
})
.collect(),
tokens_used: response.tokens_used.total_tokens,
timestamp: chrono::Utc::now().timestamp(),
}
}
#[allow(clippy::type_complexity)]
pub fn collect_inference(
mut results: ResMut<InferenceResults>,
mut agents: Query<
(
&mut AgentState,
Option<&mut crate::persistence::TokenTotals>,
Option<&StageCursor>,
Option<&mut StageLedger>,
Option<&mut StageIoBuffer>,
Option<&mut StageInference>,
Option<&mut crate::telemetry::StageActivity>,
),
With<AwaitingInference>,
>,
mut circuits: Option<ResMut<ProviderCircuits>>,
policy: Option<Res<CircuitPolicy>>,
mut commands: Commands,
) {
crate::tick_scope::clear();
let policy = policy.map(|p| *p).unwrap_or_default();
let now = chrono::Utc::now().timestamp();
while let Ok(outcome) = results.0.try_recv() {
let Ok((mut state, totals, cursor, mut ledger, buffer, mut inference, activity)) =
agents.get_mut(outcome.entity)
else {
continue; };
crate::tick_scope::enter(outcome.entity);
if is_terminal_status(&state.status) {
commands
.entity(outcome.entity)
.remove::<AwaitingInference>()
.remove::<InFlightWork>();
continue;
}
let idx = cursor.map_or(0, |c| c.index);
let (called_provider, called_model) = inference
.as_deref()
.map(|i| (i.provider_name.clone(), i.model.clone()))
.unwrap_or_default();
if let Some(mut activity) = activity {
let usage = outcome.result.as_ref().ok().map(|r| &r.tokens_used);
activity
.0
.push(crate::telemetry::ActivityRecord::Inference {
provider: called_provider.clone(),
model: called_model.clone(),
latency_ms: u64::try_from(outcome.latency.as_millis()).unwrap_or(u64::MAX),
prompt_tokens: usage.map_or(0, |u| u.prompt_tokens),
completion_tokens: usage.map_or(0, |u| u.completion_tokens),
cached_tokens: usage.map_or(0, |u| u.cached_tokens),
success: outcome.result.is_ok(),
});
}
if let Some(circuits) = circuits.as_deref_mut() {
match outcome
.result
.as_ref()
.err()
.and_then(|e| e.unavailable_reason())
{
Some(reason) => {
if circuits.record_failure(&called_provider, reason, now, &policy) {
tracing::error!(
provider = %called_provider,
reason = reason.label(),
failures = policy.failures_before_open,
cooldown_secs = policy.cooldown_secs,
"provider circuit opened; no run will be dispatched to it \
until it recovers"
);
}
}
None if outcome.result.is_ok() => circuits.record_success(&called_provider),
None => {}
}
}
match outcome.result {
Ok(response) => {
state.iteration += 1;
if let Some(mut totals) = totals {
totals.add_usage(&response.tokens_used);
}
if let Some(rec) = ledger.as_deref_mut().and_then(|l| l.0.get_mut(idx)) {
rec.prompt_tokens += response.tokens_used.prompt_tokens;
rec.completion_tokens += response.tokens_used.completion_tokens;
rec.cached_tokens += response.tokens_used.cached_tokens;
}
if let Some(mut buffer) = buffer {
if !response.content.trim().is_empty() {
buffer.output.push((idx, response.content.clone()));
}
buffer.logs.push((
idx,
format!(
"[Tokens: {} in, {} out]",
response.tokens_used.prompt_tokens,
response.tokens_used.completion_tokens
),
));
}
let result = to_inference_result(&response);
commands
.entity(outcome.entity)
.insert(result)
.remove::<AwaitingInference>()
.remove::<InFlightWork>()
.insert(ProcessResponse);
}
Err(err) => {
let next = err.unavailable_reason().and_then(|_| {
let si = inference.as_deref_mut()?;
(!si.fallbacks.is_empty()).then(|| si.fallbacks.remove(0))
});
if let Some(next) = next {
tracing::warn!(
from_provider = %called_provider,
from_model = %called_model,
to_provider = %next.provider,
to_model = %next.model,
error = %err,
"provider unusable; failing over to the next configured model"
);
if let Some(mut buffer) = buffer {
buffer.logs.push((
idx,
format!(
"[failover] {called_provider}/{called_model} is unusable \
({err}); retrying on {}/{}",
next.provider, next.model
),
));
}
let si = inference
.as_deref_mut()
.expect("the failover branch only runs with a StageInference");
si.provider_name = next.provider;
si.model = next.model;
commands
.entity(outcome.entity)
.remove::<AwaitingInference>()
.remove::<InFlightWork>()
.insert(ReadyToInfer);
continue;
}
if let Some(mut buffer) = buffer {
buffer.logs.push((idx, format!("[error] {err}")));
}
state.status = AgentStatus::Error {
message: err.to_string(),
};
commands
.entity(outcome.entity)
.remove::<AwaitingInference>()
.remove::<InFlightWork>()
.insert(StageOutcome::Errored(err.to_string()))
.insert(ResolveTransition);
}
}
}
}
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReadyForTools;
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReadyForTransition;
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResolveTransition;
#[derive(Component, Debug, Clone, Default)]
pub struct StageProgress {
pub total_tool_calls: usize,
pub text_only_nudges: usize,
pub iterations: usize,
pub modifying_tool_calls: usize,
pub blocked_modification_calls: usize,
pub gate_reentries: usize,
pub stage_started_at: Option<i64>,
pub edits_by_path: std::collections::HashMap<String, usize>,
pub stuck_fired: bool,
}
#[derive(Component, Debug, Clone, PartialEq, Eq)]
pub enum StageOutcome {
Errored(String),
MaxIterations,
Stuck(String),
}
#[derive(Component, Debug, Clone)]
pub struct StageLedger(pub Vec<leviath_core::run_meta::StageRecord>);
#[derive(Component, Debug, Clone, Default)]
pub struct StageIoBuffer {
pub output: Vec<(usize, String)>,
pub logs: Vec<(usize, String)>,
}
#[allow(clippy::type_complexity)]
pub fn process_response(
mut agents: Query<
(
Entity,
&crate::components::InferenceResult,
&mut StageProgress,
Option<&mut crate::persistence::TokenTotals>,
),
With<ProcessResponse>,
>,
mut commands: Commands,
) {
crate::tick_scope::clear();
for (entity, result, mut progress, totals) in agents.iter_mut() {
crate::tick_scope::enter(entity);
progress.iterations += 1; let mut e = commands.entity(entity);
e.remove::<ProcessResponse>();
if result.tool_calls.is_empty() {
e.insert(ReadyForTransition);
} else {
progress.total_tool_calls += result.tool_calls.len();
for path in result.tool_calls.iter().filter_map(edited_path) {
*progress.edits_by_path.entry(path.to_string()).or_insert(0) += 1;
}
if let Some(mut totals) = totals {
totals.tool_calls += result.tool_calls.len();
}
e.insert(ReadyForTools);
}
}
}
pub(crate) fn edited_path(call: &crate::components::ToolCall) -> Option<&str> {
matches!(call.name.as_str(), "write_file" | "edit_file")
.then(|| call.arguments.get("path").and_then(|v| v.as_str()))
.flatten()
}
#[derive(Component, Debug, Clone, Default)]
pub struct GlobalNudge(pub leviath_core::NudgeConfig);
pub(crate) fn stage_output_is_reviewed(bp: &AgentBlueprint, cursor: &StageCursor) -> bool {
matches!(
bp.0.stages.get(cursor.index).map(|s| &s.mode),
Some(leviath_core::blueprint::StageMode::InteractivePoints { points }) if !points.is_empty()
)
}
#[allow(clippy::type_complexity)]
pub fn handle_empty_response(
mut agents: Query<
(
Entity,
&mut ContextWindow,
&crate::components::InferenceResult,
&mut StageProgress,
&AgentBlueprint,
&StageCursor,
Option<&GlobalNudge>,
),
With<ReadyForTransition>,
>,
mut commands: Commands,
) {
crate::tick_scope::clear();
for (entity, mut window, infer, mut progress, bp, cursor, global) in agents.iter_mut() {
crate::tick_scope::enter(entity);
let stage = bp.0.stages.get(cursor.index);
let nudge = leviath_core::resolve_nudge(
global.map(|g| &g.0),
bp.0.nudge.as_ref(),
stage.and_then(|s| s.nudge.as_ref()),
stage_output_is_reviewed(bp, cursor),
);
if progress.total_tool_calls > 0 || !nudge.enabled || progress.text_only_nudges >= nudge.max
{
commands
.entity(entity)
.remove::<ReadyForTransition>()
.insert(ResolveTransition);
} else {
progress.text_only_nudges += 1;
let response_tokens = leviath_core::estimate_tokens(&infer.response);
let _ = window.add_typed_entry(
"conversation",
leviath_core::EntryKind::AssistantTurn { tool_calls: vec![] },
infer.response.clone(),
response_tokens,
);
let stage_name = stage.map(|s| s.name.as_str()).unwrap_or("");
let regions = stage
.and_then(|s| s.context_layout.as_ref())
.unwrap_or(&bp.0.context_layout)
.regions
.iter()
.filter(|r| r.required)
.map(|r| r.name.as_str())
.collect::<Vec<_>>()
.join(", ");
let text = leviath_core::text::interpolate(
&nudge.text,
&[("stage", stage_name), ("regions", ®ions)],
);
inject_system_nudge(&mut window, &text);
commands
.entity(entity)
.remove::<ReadyForTransition>()
.insert(ReadyToInfer);
}
}
}
pub(crate) fn inject_system_nudge(window: &mut ContextWindow, text: &str) {
let content = format!("[System] {text}");
let tokens = leviath_core::estimate_tokens(&content);
let _ = window.add_to_region("conversation", content, tokens);
}