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<&StageInference>,
Option<&mut crate::telemetry::StageActivity>,
),
With<AwaitingInference>,
>,
mut commands: Commands,
) {
crate::tick_scope::clear();
while let Ok(outcome) = results.0.try_recv() {
let Ok((mut state, totals, cursor, mut ledger, buffer, 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);
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: inference
.map(|i| i.provider_name.clone())
.unwrap_or_default(),
model: inference.map(|i| i.model.clone()).unwrap_or_default(),
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(),
});
}
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) => {
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()
}
pub(crate) const NUDGE_TEXT: &str = "You have tools available. Please use them to complete the task. Start by reading the relevant files in the working directory.";
pub(crate) const MAX_TEXT_ONLY_NUDGES: usize = 3;
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,
),
With<ReadyForTransition>,
>,
mut commands: Commands,
) {
crate::tick_scope::clear();
for (entity, mut window, infer, mut progress, bp, cursor) in agents.iter_mut() {
crate::tick_scope::enter(entity);
if progress.total_tool_calls > 0
|| progress.text_only_nudges >= MAX_TEXT_ONLY_NUDGES
|| stage_output_is_reviewed(bp, cursor)
{
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 nudge_tokens = leviath_core::estimate_tokens(NUDGE_TEXT);
let _ = window.add_typed_entry(
"conversation",
leviath_core::EntryKind::UserMessage,
NUDGE_TEXT.to_string(),
nudge_tokens,
);
commands
.entity(entity)
.remove::<ReadyForTransition>()
.insert(ReadyToInfer);
}
}
}