use super::*;
pub const WORKSPACE_CHECK_INTERVAL: usize = 5;
type WorkspaceHealthQuery = (
Entity,
&'static RunMetadata,
&'static StageProgress,
&'static mut AgentState,
Option<&'static mut crate::persistence::RunOutcomeFlags>,
);
pub fn check_workspace_health(
mut agents: Query<WorkspaceHealthQuery, With<ReadyToInfer>>,
mut commands: Commands,
) {
crate::tick_scope::clear();
for (entity, md, progress, mut state, flags) in agents.iter_mut() {
crate::tick_scope::enter(entity);
if state.status != AgentStatus::Active {
continue;
}
if progress.iterations % WORKSPACE_CHECK_INTERVAL != 0 {
continue;
}
if std::fs::metadata(&md.workdir).is_ok_and(|m| m.is_dir()) {
continue;
}
tracing::error!(
run_id = %md.run_id,
workdir = %md.workdir,
"working directory is gone; failing the run"
);
state.status = AgentStatus::Error {
message: format!("workspace '{}' is no longer accessible", md.workdir),
};
if let Some(mut flags) = flags {
flags.0.workspace_lost = true;
}
commands.entity(entity).remove::<ReadyToInfer>();
}
}
type MaxIterationQuery = (
Entity,
&'static AgentState,
&'static AgentBlueprint,
&'static StageCursor,
&'static StageProgress,
Option<&'static mut crate::persistence::RunOutcomeFlags>,
);
pub fn enforce_max_iterations(
mut agents: Query<MaxIterationQuery, With<ReadyToInfer>>,
mut commands: Commands,
) {
crate::tick_scope::clear();
for (entity, state, bp, cursor, progress, flags) in agents.iter_mut() {
crate::tick_scope::enter(entity);
if state.status != AgentStatus::Active {
continue;
}
let max = bp.0.stages[cursor.index].max_iterations.unwrap_or(0);
if max > 0 && progress.iterations >= max {
if let Some(mut flags) = flags {
flags.0.max_iterations_hit += 1;
}
commands
.entity(entity)
.remove::<ReadyToInfer>()
.insert(ResolveTransition)
.insert(StageOutcome::MaxIterations);
}
}
}
pub(crate) const STUCK_REPORT_REGION: &str = "stuck_report";
pub(crate) const ERROR_REPORT_REGION: &str = "error_report";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct StuckMetrics {
pub iterations: usize,
pub elapsed_secs: u64,
pub tool_calls: usize,
pub hottest_edit: Option<(String, usize)>,
}
pub(crate) fn detect_stuck(
cfg: &leviath_core::blueprint::StuckConfig,
m: &StuckMetrics,
) -> Option<String> {
if let (Some(limit), Some((path, hits))) = (cfg.after_same_file_edits, m.hottest_edit.as_ref())
&& *hits >= limit
{
return Some(format!(
"you have written or edited '{path}' {hits} times in this stage without \
resolving the task - the problem is very likely not in that file"
));
}
if let Some(limit) = cfg.after_iterations
&& m.iterations >= limit
{
return Some(format!(
"you have run {} inference turns in this stage without finishing it",
m.iterations
));
}
if let Some(limit) = cfg.after_tool_calls
&& m.tool_calls >= limit
{
return Some(format!(
"you have made {} tool calls in this stage without finishing it",
m.tool_calls
));
}
if let Some(limit) = cfg.after_minutes
&& m.elapsed_secs >= limit as u64 * 60
{
return Some(format!(
"you have spent {} minutes in this stage without finishing it",
m.elapsed_secs / 60
));
}
None
}
pub(crate) fn hottest_edit(
edits: &std::collections::HashMap<String, usize>,
) -> Option<(String, usize)> {
edits
.iter()
.max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
.map(|(path, n)| (path.clone(), *n))
}
pub(crate) fn note_stuck(window: &mut ContextWindow, stage: &str, reason: &str) {
let region = if window.get_region(STUCK_REPORT_REGION).is_some() {
STUCK_REPORT_REGION
} else {
"conversation"
};
let content = format!(
"[Stuck detected in stage '{stage}'] {reason}. Stop repeating what you have been \
doing. Re-read the original task, separate what you have actually verified from \
what you assumed, and take a different approach - including reverting changes \
that made things worse."
);
let tokens = leviath_core::estimate_tokens(&content);
let _ = window.add_to_region(region, content, tokens);
}
fn note_abnormal_ending(window: &mut ContextWindow, content: String) {
let region = if window.get_region(ERROR_REPORT_REGION).is_some() {
ERROR_REPORT_REGION
} else {
"conversation"
};
let tokens = leviath_core::estimate_tokens(&content);
let _ = window.add_to_region(region, content, tokens);
}
pub(crate) fn note_error(window: &mut ContextWindow, stage: &str, message: &str) {
note_abnormal_ending(
window,
format!(
"[Inference error in stage '{stage}'] {message}. Diagnose this failure from \
the error text above before retrying or working around it."
),
);
}
pub(crate) fn note_max_iterations(window: &mut ContextWindow, stage: &str, cap: usize) {
note_abnormal_ending(
window,
format!(
"[Stage '{stage}' hit its iteration cap ({cap})] The stage was cut off before \
it declared completion - treat its output as possibly incomplete and verify \
it before building on it."
),
);
}
type StuckStageQuery = (
Entity,
&'static AgentState,
&'static AgentBlueprint,
&'static StageCursor,
&'static mut StageProgress,
&'static VisitCounts,
&'static mut ContextWindow,
Option<&'static mut StageIoBuffer>,
);
pub fn detect_stuck_stage(
mut agents: Query<StuckStageQuery, With<ReadyToInfer>>,
mut commands: Commands,
) {
use leviath_core::blueprint::TransitionCondition;
let now = chrono::Utc::now().timestamp();
crate::tick_scope::clear();
for (entity, state, bp, cursor, mut progress, visits, mut window, buffer) in agents.iter_mut() {
crate::tick_scope::enter(entity);
if state.status != AgentStatus::Active || progress.stuck_fired {
continue; }
let stage = &bp.0.stages[cursor.index];
let Some(cfg) =
find_conditioned_edge_ref(&bp.0, stage, &visits.0, TransitionCondition::Stuck)
.and_then(|(_, edge)| edge.stuck)
else {
continue; };
let started = *progress.stage_started_at.get_or_insert(now);
let metrics = StuckMetrics {
iterations: progress.iterations,
elapsed_secs: (now - started).max(0) as u64,
tool_calls: progress.total_tool_calls,
hottest_edit: hottest_edit(&progress.edits_by_path),
};
let Some(reason) = detect_stuck(&cfg, &metrics) else {
continue;
};
progress.stuck_fired = true;
note_stuck(&mut window, &stage.name, &reason);
if let Some(mut buffer) = buffer {
buffer
.logs
.push((cursor.index, format!("[stuck] {reason}")));
}
commands
.entity(entity)
.remove::<ReadyToInfer>()
.insert(ResolveTransition)
.insert(StageOutcome::Stuck(reason));
}
}