use serde_json::Value;
use tokio::sync::mpsc;
use super::context_manager::{self, PostUsageDecisionKind};
use super::gate_state::{GateInputs, GateStates};
use super::gate_tally::{BoundaryNudge, GateSource, GateTally};
use super::inflight::InflightSet;
use super::message::{
AssistantMessage, ContentBlock, LoopEvent, LoopMessage, StopReason, ToolResultMessage,
loop_message_to_value, tool_result_to_value,
};
use super::storm::StormBreaker;
use super::stream::{StreamFn, stream_assistant_response};
use super::tool::AbortSignal;
use super::types::{CodeReviewMode, Context, GateMode, LoopConfig};
use super::verifier::VERIFY_TAG;
use crate::sync_util::LockExt;
async fn poll_steering(config: &LoopConfig) -> (Vec<LoopMessage>, bool) {
let out = match &config.get_steering_messages {
Some(get) => get().await,
None => Vec::new(),
};
let had_user_steering = !out.is_empty();
(out, had_user_steering)
}
fn safe_state_repo(config: &LoopConfig) -> Option<std::path::PathBuf> {
config
.code_review_repo
.clone()
.or_else(|| std::env::current_dir().ok())
}
fn coverage_verified_restore(
repo: Option<&std::path::Path>,
green_fp: Option<&super::worktree_probe::TreeFingerprint>,
green_turn: &str,
) -> Option<usize> {
let repo = repo?;
let green_fp = green_fp?;
let now = super::worktree_probe::fingerprint(repo)?;
let mutated = super::worktree_probe::changed_between(green_fp, &now);
if mutated.is_empty() {
return None;
}
let restorable = crate::agent::tools::snapshots::restorable_paths_after(green_turn);
if !super::worktree_probe::coverage_is_complete(&mutated, &restorable) {
tracing::info!(
target: "dirge::loop",
mutated = mutated.len(),
restorable = restorable.len(),
"safe-state auto declined: snapshot coverage incomplete \
(a file changed that the store never captured — likely a bash \
write); falling back to advisory"
);
return None;
}
let restored = crate::agent::tools::snapshots::restore_after_green_turn(green_turn);
if restored.is_empty() {
return None;
}
tracing::info!(
target: "dirge::loop",
files = restored.len(),
"safe-state auto restored the tree to its last verified-green state"
);
Some(restored.len())
}
const HARNESS_TAGS: &[&str] = &[
TODO_NUDGE_TAG,
OPEN_ISSUES_NUDGE_TAG,
RESUME_NUDGE_TAG,
TRACK_WORK_TAG,
VERIFY_TAG,
super::critic::CRITIC_TAG,
super::goal::GOAL_TAG,
super::progress::STALL_TAG,
super::progress::BUDGET_TAG,
super::safe_state::SAFE_STATE_TAG,
];
fn harness_tag_of(text: &str) -> Option<&'static str> {
let t = text.trim_start();
HARNESS_TAGS.iter().copied().find(|tag| t.starts_with(tag))
}
async fn emit_harness_notices(emit: &mpsc::Sender<LoopEvent>, msgs: &[LoopMessage]) {
for m in msgs {
let LoopMessage::User(u) = m else { continue };
let text = u.text_joined();
if harness_tag_of(&text).is_none() {
continue;
}
let _ = emit
.send(LoopEvent::SystemNotice {
content: text.clone(),
})
.await;
}
}
fn tool_result_excerpt(content: &[super::message::ContentBlock]) -> String {
content
.iter()
.filter_map(|b| match b {
super::message::ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join(" ")
}
#[allow(clippy::type_complexity)]
fn storm_for_config(config: &LoopConfig) -> StormBreaker {
let has_custom = config.storm_mutating_tools.is_some() || config.storm_exempt_tools.is_some();
if !has_custom {
return StormBreaker::default();
}
let mutating: Option<Box<dyn Fn(&super::tools::ToolCall) -> bool + Send + Sync>> =
config.storm_mutating_tools.as_ref().map(|extras| {
let extra_set: std::collections::HashSet<String> = extras.iter().cloned().collect();
Box::new(move |c: &super::tools::ToolCall| {
super::storm::default_mutating(c) || extra_set.contains(&c.name)
}) as Box<dyn Fn(&super::tools::ToolCall) -> bool + Send + Sync>
});
let exempt: Option<Box<dyn Fn(&super::tools::ToolCall) -> bool + Send + Sync>> =
config.storm_exempt_tools.as_ref().map(|extras| {
let extra_set: std::collections::HashSet<String> = extras.iter().cloned().collect();
Box::new(move |c: &super::tools::ToolCall| {
super::storm::default_exempt(c) || extra_set.contains(&c.name)
}) as Box<dyn Fn(&super::tools::ToolCall) -> bool + Send + Sync>
});
StormBreaker::new(6, 3, mutating, exempt)
}
const MAX_TODO_NUDGES: u8 = 3;
const MAX_OPEN_ISSUES_NUDGES: u8 = 2;
const MAX_TRACK_NUDGES: u8 = 1;
const MAX_VERIFY_NUDGES: u8 = 1;
const FAST_VERIFY_EDIT_THRESHOLD: u32 = 3;
const FAILURE_REFLECTION_THRESHOLD: usize = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FollowUpSource {
AwaitingUser,
Hook,
ResumeAfterFailure,
Verifier,
Critic,
Goal,
Todo,
OpenIssues,
None,
}
impl From<FollowUpSource> for GateSource {
fn from(source: FollowUpSource) -> Self {
match source {
FollowUpSource::AwaitingUser => GateSource::AwaitingUser,
FollowUpSource::Hook => GateSource::Hook,
FollowUpSource::ResumeAfterFailure => GateSource::ResumeAfterFailure,
FollowUpSource::Verifier => GateSource::Verifier,
FollowUpSource::Critic => GateSource::Critic,
FollowUpSource::Goal => GateSource::Goal,
FollowUpSource::Todo => GateSource::Todo,
FollowUpSource::OpenIssues => GateSource::OpenIssues,
FollowUpSource::None => GateSource::None,
}
}
}
pub(crate) const TODO_NUDGE_TAG: &str = "[todo]";
pub(crate) const OPEN_ISSUES_NUDGE_TAG: &str = "[open-issues]";
pub(crate) const RESUME_NUDGE_TAG: &str = "[resume]";
const TRACK_WORK_TAG: &str = "[track]";
const MAX_RESUME_NUDGE: u8 = 3;
const TRANSIENT_RECOVERY_NUDGE: &str = "Your previous response was cut off by a transient connection error before it finished. Continue from where you left off — do not repeat what you already said.";
const MAX_TRANSIENT_RECOVERIES: u8 = 3;
pub(crate) const MAX_TURNS_NOTICE_PREFIX: &str = "[dirge] Max agent turns";
fn max_turns_notice(cap: usize, board: &[crate::agent::tools::todo::TodoItem]) -> String {
let mut notice = format!(
"{MAX_TURNS_NOTICE_PREFIX} ({cap}) reached. Stopping the run. Increase --max-agent-turns or `max_agent_turns` in config.json to allow more."
);
if let Some(block) = super::residual::residual_block(board) {
notice.push_str("\n\n");
notice.push_str(&block);
}
notice
}
fn todo_nudge_message(unfinished: usize, low: usize) -> LoopMessage {
let mut body = format!(
"{TODO_NUDGE_TAG} You still have {unfinished} unfinished todo{} (pending or in progress). \
Finish the remaining work, or if it's genuinely done or no longer needed, \
update the todo list (mark items completed/cancelled) before stopping.",
if unfinished == 1 { "" } else { "s" }
);
if low > 0 {
body.push_str(&format!(
" You have {low} low-priority item{} left — if one won't fit, cancel it with a one-line reason rather than leaving it open.",
if low == 1 { "" } else { "s" }
));
}
LoopMessage::User(super::message::UserMessage::text(body))
}
fn plan_only_nudge_message(unfinished: usize) -> LoopMessage {
LoopMessage::User(super::message::UserMessage::text(format!(
"{TODO_NUDGE_TAG} You planned {unfinished} item{} this turn but changed no files. \
The plan is not the work — start the first item now with `write` / `edit` \
(or `bash` if it's a command). Do not call write_todo_list again until \
something is actually done.",
if unfinished == 1 { "" } else { "s" }
)))
}
fn last_action_failed_and_stopped(new_messages: &[LoopMessage]) -> bool {
if new_messages.is_empty() {
return false;
}
let Some(LoopMessage::Assistant(last)) = new_messages.last() else {
return false;
};
if !extract_tool_calls_from(last).is_empty() {
return false;
}
let mut error_tail = false;
for msg in new_messages[..new_messages.len() - 1].iter().rev() {
match msg {
LoopMessage::ToolResult(tr) => {
if is_retryable_failure(tr) {
error_tail = true;
}
}
_ => break,
}
}
error_tail
}
fn is_retryable_failure(tr: &super::message::ToolResultMessage) -> bool {
if !tr.is_error {
return false;
}
let excerpt = tool_result_excerpt(&tr.content);
if super::activity::Outcome::classify(true, &excerpt) == super::activity::Outcome::Denied {
return false;
}
if excerpt.contains(super::tools::SUPPRESSED_CALL_NOTE) {
return false;
}
true
}
async fn poll_goal_gate(
config: &LoopConfig,
system_prompt: &str,
new_messages: &[LoopMessage],
goal_reacts: &mut u8,
) -> Option<(Vec<LoopMessage>, FollowUpSource)> {
if *goal_reacts < super::goal::MAX_GOAL_REACT
&& let Some(goal) = &config.goal
&& let Some(judge) = &config.goal_fn
{
let transcript = build_critic_transcript(new_messages);
let verification = config
.verifier
.as_ref()
.map(|v| v.status(config.verification_tiers_mode));
let msgs =
super::goal::run_goal_gate(judge, goal, system_prompt, &transcript, verification).await;
if !msgs.is_empty() {
*goal_reacts += 1;
return Some((msgs, FollowUpSource::Goal));
}
}
None
}
pub(crate) async fn is_awaiting_user(config: &LoopConfig, new_messages: &[LoopMessage]) -> bool {
let Some(LoopMessage::Assistant(last)) = new_messages.last() else {
return false;
};
let joined: String = last
.content
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
if !joined.contains('?') && !joined.contains('\u{ff1f}') {
return false;
}
let Some(classify) = config.classify_fn.as_ref() else {
return awaiting_user_response(new_messages);
};
const OPTIONS: &[&str] = &["BLOCKED", "OFFERING"];
let question = format!(
"Here is the final message a coding agent sent at the end of its turn:\n\n ---\n{joined}\n---\n\n Did it STOP because it needs a decision from the user before it can continue (BLOCKED), or had it finished the work and was offering to do more / asking a rhetorical or courtesy question (OFFERING)?"
);
match classify(question, OPTIONS).await {
Ok(0) => true,
Ok(_) => false,
Err(e) => {
tracing::debug!(
target: "dirge::loop",
error = %e,
"awaiting-user classifier failed; falling back to the heuristic"
);
awaiting_user_response(new_messages)
}
}
}
async fn poll_finalization_follow_up(
config: &LoopConfig,
system_prompt: &str,
new_messages: &[LoopMessage],
gates: &mut GateStates,
inputs: GateInputs<'_>,
emit: &mpsc::Sender<LoopEvent>,
) -> (Vec<LoopMessage>, FollowUpSource) {
let GateStates {
critic_done,
code_review_reacts,
last_reviewed_fingerprint,
last_review_findings,
goal_reacts,
todo_nudges,
resume_nudges,
open_issues_nudges,
track_nudges,
} = gates;
let GateInputs {
code_review_baseline,
open_issues_gate_mode,
issue_db_path,
session_id,
} = inputs;
if is_awaiting_user(config, new_messages).await {
if config
.should_defer_finalization
.as_ref()
.is_some_and(|should_defer| should_defer())
{
return (Vec::new(), FollowUpSource::None);
}
if let Some(hit) = poll_goal_gate(config, system_prompt, new_messages, goal_reacts).await {
return hit;
}
tracing::debug!(
target: "dirge::loop",
"final assistant turn ended with a question to the user; \
finalizing without running the critic/todo/open-issues gates"
);
return (Vec::new(), FollowUpSource::AwaitingUser);
}
if let Some(get) = &config.get_followup_messages {
let msgs = get().await;
if !msgs.is_empty() {
return (msgs, FollowUpSource::Hook);
}
}
if config
.should_defer_finalization
.as_ref()
.is_some_and(|should_defer| should_defer())
{
return (Vec::new(), FollowUpSource::None);
}
if *resume_nudges < MAX_RESUME_NUDGE && last_action_failed_and_stopped(new_messages) {
*resume_nudges += 1;
return (
vec![LoopMessage::User(super::message::UserMessage {
content: vec![super::message::UserPart::text(format!(
"{RESUME_NUDGE_TAG} Your last tool call failed or was rejected and you stopped without \
completing that step. Do not end here. Re-issue the call with corrected arguments and \
finish the work; if it genuinely cannot be done, say so plainly and report what you \
found. Don't just describe what you would do — do it."
))],
})],
FollowUpSource::ResumeAfterFailure,
);
}
if let Some(verifier) = &config.verifier {
let msgs = verifier.check_before_finalize(config.verification_tiers_mode);
if !msgs.is_empty() {
return (msgs, FollowUpSource::Verifier);
}
}
if config.critic_fn.is_some() && run_made_tool_calls(new_messages) {
let mode = config.code_review_mode;
let one_shot = mode != CodeReviewMode::Blocking;
let may_fire = if one_shot {
!*critic_done
} else {
*code_review_reacts < super::code_review::MAX_REVIEW_REACT
};
if may_fire && let Some(judge) = &config.critic_fn {
let (diff_owned, current_fingerprint): (Option<String>, Option<u64>) =
if mode != CodeReviewMode::Off {
let repo = config.code_review_repo.clone().unwrap_or_else(|| {
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
});
let captured = tokio::task::spawn_blocking(move || {
super::code_review::capture_run_diff(&repo)
})
.await
.ok()
.flatten();
let fp = captured.as_ref().map(|d| d.fingerprint);
let diff = run_delta_to_review(captured.as_ref(), code_review_baseline)
.map(str::to_string);
(diff, fp)
} else {
(None, None)
};
let skip = mode == CodeReviewMode::Blocking
&& diff_owned.is_some()
&& current_fingerprint == *last_reviewed_fingerprint
&& last_review_findings.is_some();
if !skip {
let transcript = build_critic_transcript(new_messages);
let verification = config
.verifier
.as_ref()
.map(|v| v.status(config.verification_tiers_mode));
let outcome = super::critic::run_unified_review(
judge,
system_prompt,
&transcript,
diff_owned.as_deref(),
verification,
last_review_findings.as_deref(),
)
.await;
let msgs = outcome.messages;
if mode == CodeReviewMode::Blocking && outcome.judged {
if diff_owned.is_some() {
*last_reviewed_fingerprint = current_fingerprint;
}
*last_review_findings = outcome.raised_findings;
}
if one_shot {
*critic_done = true;
} else if !msgs.is_empty() {
*code_review_reacts += 1;
}
if !msgs.is_empty() {
return (msgs, FollowUpSource::Critic);
}
}
}
}
if let Some(hit) = poll_goal_gate(config, system_prompt, new_messages, goal_reacts).await {
return hit;
}
if *todo_nudges < MAX_TODO_NUDGES {
let edited = turn_made_file_edits(new_messages);
if edited || (*todo_nudges == 0 && turn_wrote_todos(new_messages)) {
let (high, normal, low) = crate::agent::tools::todo::unfinished_by_priority();
let unfinished = high + normal + low;
if unfinished > 0 {
*todo_nudges += 1;
let msg = if edited {
todo_nudge_message(unfinished, low)
} else {
plan_only_nudge_message(unfinished)
};
return (vec![msg], FollowUpSource::Todo);
}
}
}
if open_issues_gate_mode != GateMode::Off
&& turn_made_file_edits(new_messages)
&& let Some(db_path) = issue_db_path
{
let db_path_buf = db_path.to_path_buf();
let session_owned = session_id.map(|s| s.to_string());
let count = tokio::task::spawn_blocking(move || {
crate::extras::issue_db::IssueStore::open_at(&db_path_buf)
.ok()
.and_then(|store| {
store
.board_for_session(session_owned.as_deref(), None)
.ok()
.map(|issues| issues.len())
})
.unwrap_or(0)
})
.await
.unwrap_or(0);
if count > 0 {
match open_issues_gate_mode {
GateMode::Advisory => {
if *open_issues_nudges == 0 {
*open_issues_nudges += 1;
let _ = emit
.send(LoopEvent::SystemNotice {
content: format!(
"{count} issue(s) from this session are still open — \
close or defer them when done."
),
})
.await;
}
}
GateMode::Blocking => {
if *open_issues_nudges < MAX_OPEN_ISSUES_NUDGES {
*open_issues_nudges += 1;
let db_path_buf2 = db_path.to_path_buf();
let sid = session_id.map(|s| s.to_string());
let titles = tokio::task::spawn_blocking(move || {
crate::extras::issue_db::IssueStore::open_at(&db_path_buf2)
.ok()
.and_then(|store| {
store.board_for_session(sid.as_deref(), Some(5)).ok()
})
.unwrap_or_default()
.into_iter()
.map(|i| i.title)
.collect::<Vec<_>>()
})
.await
.unwrap_or_default();
let title_list = if titles.is_empty() {
String::new()
} else {
let mut s = String::from("\n\nStill open:\n");
for t in &titles {
s.push_str(&format!("- {t}\n"));
}
s
};
return (
vec![LoopMessage::User(super::message::UserMessage::text(
format!(
"{OPEN_ISSUES_NUDGE_TAG} {count} issue(s) you worked on \
this session are still open. Close the ones you finished \
(or explicitly defer them), then continue:{title_list}"
),
))],
FollowUpSource::OpenIssues,
);
}
}
GateMode::Off => unreachable!("gated above"),
}
}
}
if should_advise_untracked_work(
session_id,
*track_nudges,
crate::agent::tools::todo::unfinished_count(),
turn_made_file_edits(new_messages),
) {
*track_nudges += 1;
let _ = emit
.send(LoopEvent::SystemNotice {
content: "You modified files this turn but have no active todo. If this task isn't finished, add it with write_todo_list and mark it in_progress so it stays your tracked priority (and gets closed when done).".to_string(),
})
.await;
}
(Vec::new(), FollowUpSource::None)
}
fn build_augmented_focus(
focus_topic: Option<&str>,
provider: Option<&std::sync::Arc<dyn crate::extras::memory_provider::MemoryProvider>>,
middle: &[serde_json::Value],
) -> Option<String> {
let insights = provider.map(|p| {
let transcript = transcript_from_value_slice(middle);
crate::agent::review::fire_pre_compress(p.as_ref(), &transcript)
});
match (
focus_topic.map(str::trim),
insights.as_deref().map(str::trim),
) {
(Some(focus), Some(ins)) if !focus.is_empty() && !ins.is_empty() => {
Some(format!("{focus}\n\nProvider insights:\n{ins}"))
}
(Some(focus), _) if !focus.is_empty() => Some(focus.to_string()),
(_, Some(ins)) if !ins.is_empty() => Some(format!("Provider insights:\n{ins}")),
_ => None,
}
}
fn transcript_from_value_slice(messages: &[serde_json::Value]) -> String {
use std::fmt::Write as _;
let mut out = String::new();
for m in messages {
let role = m.get("role").and_then(|v| v.as_str()).unwrap_or("?");
let content = crate::agent::compression::content_text(m.get("content"));
if !content.is_empty() {
let _ = writeln!(out, "{}: {}", role, content);
out.push('\n');
}
}
out
}
const MAX_CONSECUTIVE_COMPACTION_FAILURES: u32 = 3;
const EXEMPLAR_TOP_K: usize = 3;
const ACTIVE_TOP_N: usize = 7;
const BACKLOG_TOP_N: usize = 5;
fn issue_board_reminder_block(
db_path: &std::path::Path,
session_id: Option<&str>,
) -> Option<String> {
crate::extras::issue_db::IssueStore::open_at(db_path)
.ok()?
.board_reminder_split(session_id, ACTIVE_TOP_N, BACKLOG_TOP_N)
.ok()
.flatten()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SummaryOutcome {
Succeeded(usize),
Failed,
Skipped,
}
fn record_compaction_outcome(failures: &mut u32, outcome: SummaryOutcome) {
match outcome {
SummaryOutcome::Succeeded(_) => *failures = 0,
SummaryOutcome::Failed => *failures = failures.saturating_add(1),
SummaryOutcome::Skipped => {}
}
}
#[derive(Clone)]
struct CachedCheckpoint {
summary: String,
boundary: usize,
generation: u64,
}
type CheckpointSlot = std::sync::Arc<std::sync::Mutex<Option<CachedCheckpoint>>>;
const COMPACTION_SUMMARY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
const CHECKPOINT_SUMMARY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
fn spawn_incremental_checkpoint(
sfn: crate::agent::compression::SummarizeFn,
messages: Vec<serde_json::Value>,
emit: mpsc::WeakSender<LoopEvent>,
slot: CheckpointSlot,
generation: u64,
) {
tokio::spawn(async move {
use crate::agent::compression;
if messages.is_empty() {
return;
}
let boundary = messages.len();
let budget = compression::summary_budget(compression::estimate_messages_tokens(&messages));
let prompt = compression::build_summary_prompt(&messages, budget, None, None);
let result = tokio::time::timeout(CHECKPOINT_SUMMARY_TIMEOUT, sfn(prompt)).await;
if let Ok(Ok(summary)) = result
&& compression::validate_summary(&summary)
{
if let Ok(mut guard) = slot.lock() {
*guard = Some(CachedCheckpoint {
summary: summary.clone(),
boundary,
generation,
});
}
if let Some(emit) = emit.upgrade() {
let _ = emit.send(LoopEvent::CheckpointRefresh { summary }).await;
}
}
});
}
#[allow(clippy::too_many_arguments)]
async fn run_compaction_pass(
current_context: &mut Context,
summarize_fn: &Option<crate::agent::compression::SummarizeFn>,
protect_tail: usize,
compaction_failures: u32,
memory_provider: &Option<std::sync::Arc<dyn crate::extras::memory_provider::MemoryProvider>>,
compaction_hooks: Option<&crate::agent::agent_loop::types::CompactionHooks>,
emit: &mpsc::Sender<LoopEvent>,
checkpoint_slot: &CheckpointSlot,
generation: &mut u64,
fold_target: u64,
) -> SummaryOutcome {
run_compaction_pass_with_focus(
current_context,
summarize_fn,
protect_tail,
compaction_failures,
None,
memory_provider,
compaction_hooks,
emit,
checkpoint_slot,
generation,
fold_target,
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn run_compaction_pass_with_focus(
current_context: &mut Context,
summarize_fn: &Option<crate::agent::compression::SummarizeFn>,
protect_tail: usize,
compaction_failures: u32,
focus_topic: Option<String>,
memory_provider: &Option<std::sync::Arc<dyn crate::extras::memory_provider::MemoryProvider>>,
compaction_hooks: Option<&crate::agent::agent_loop::types::CompactionHooks>,
emit: &mpsc::Sender<LoopEvent>,
checkpoint_slot: &CheckpointSlot,
generation: &mut u64,
fold_target: u64,
) -> SummaryOutcome {
use crate::agent::compression;
let before = compression::estimate_messages_tokens(¤t_context.messages);
if let Some(hooks) = compaction_hooks {
(hooks.on_before)(current_context.messages.len(), before).await;
}
let pruned = compression::prune_tool_outputs(¤t_context.messages, protect_tail);
current_context.messages = pruned;
let after_prune = compression::estimate_messages_tokens(¤t_context.messages);
let mut after_summary = after_prune;
let mut applied_summary = String::new();
let mut applied_first_kept = current_context.messages.len();
let mut outcome = SummaryOutcome::Skipped;
let mut breaker_open = false;
if compaction_failures >= MAX_CONSECUTIVE_COMPACTION_FAILURES {
breaker_open = true;
tracing::warn!(
target: "dirge::agent_loop",
failures = compaction_failures,
"compaction summarizer failed {compaction_failures} consecutive times — circuit breaker open, skipping LLM summarization",
);
} else if let Some(sfn) = summarize_fn {
let reusable = checkpoint_slot
.lock()
.ok()
.and_then(|g| g.clone())
.filter(|cp| cp.generation == *generation);
let mut reused = false;
if let Some(cp) = reusable
&& let Some((new_msgs, cut)) = compression::apply_checkpoint_summary(
¤t_context.messages,
&cp.summary,
cp.boundary,
)
{
let projected = compression::estimate_messages_tokens(&new_msgs);
if projected <= fold_target {
let mut effective_summary = cp.summary;
let mut effective_msgs = new_msgs;
let mut effective_tokens = projected;
if memory_provider.is_some() || compaction_hooks.is_some() {
let discarded: Vec<serde_json::Value> =
current_context.messages[..cut].to_vec();
let _ = build_augmented_focus(
focus_topic.as_deref(),
memory_provider.as_ref(),
&discarded,
);
if let Some(hooks) = compaction_hooks
&& let Some(s) = (hooks.on_compact)(discarded).await
&& compression::validate_summary(&s)
&& let Some((m, _)) = compression::apply_checkpoint_summary(
¤t_context.messages,
&s,
cp.boundary,
)
{
effective_tokens = compression::estimate_messages_tokens(&m);
effective_msgs = m;
effective_summary = s;
}
}
current_context.messages = effective_msgs;
after_summary = effective_tokens;
applied_summary = effective_summary;
applied_first_kept = 0;
outcome = SummaryOutcome::Succeeded(0);
reused = true;
tracing::info!(
target: "dirge::agent_loop",
boundary = cp.boundary,
tokens_after = after_summary,
"fast compaction: reused background checkpoint summary (no inline LLM call)",
);
}
}
let (start, end) = compression::compute_compress_window(
¤t_context.messages,
compression::PROTECT_HEAD_DEFAULT,
protect_tail.max(compression::PROTECT_TAIL_DEFAULT),
);
if !reused && start < end {
let _ = emit
.send(LoopEvent::CompactionStarted {
tokens_before: before,
})
.await;
let middle: Vec<serde_json::Value> = current_context.messages[start..end].to_vec();
let prev =
compression::find_previous_summary(¤t_context.messages).map(|(_, body)| body);
let budget =
compression::summary_budget(compression::estimate_messages_tokens(&middle));
let augmented_focus =
build_augmented_focus(focus_topic.as_deref(), memory_provider.as_ref(), &middle);
let plugin_summary: Option<String> = match compaction_hooks {
Some(hooks) => match (hooks.on_compact)(middle.clone()).await {
Some(s) if compression::validate_summary(&s) => Some(s),
_ => None,
},
None => None,
};
let summary_result: Result<String, _> = match plugin_summary {
Some(s) => Ok(s),
None => {
let prompt = compression::build_summary_prompt(
&middle,
budget,
prev.as_deref(),
augmented_focus.as_deref(),
);
match tokio::time::timeout(COMPACTION_SUMMARY_TIMEOUT, sfn(prompt)).await {
Ok(r) => r,
Err(_) => Err(anyhow::anyhow!(
"compaction summarizer timed out after {}s",
COMPACTION_SUMMARY_TIMEOUT.as_secs()
)),
}
}
};
match summary_result {
Ok(summary) if compression::validate_summary(&summary) => {
let new_msgs =
compression::apply_summary(¤t_context.messages, &summary, start, end);
current_context.messages = new_msgs;
after_summary =
compression::estimate_messages_tokens(¤t_context.messages);
applied_summary = summary;
applied_first_kept = start;
outcome = SummaryOutcome::Succeeded(start);
}
Ok(_) => {
tracing::warn!(
target: "dirge::agent_loop",
"compaction summarizer returned an unvalidated summary — keeping pruned context",
);
outcome = SummaryOutcome::Failed;
}
Err(e) => {
tracing::warn!(
target: "dirge::agent_loop",
error = %e,
"compaction summarizer failed — keeping pruned context",
);
outcome = SummaryOutcome::Failed;
}
}
}
}
if matches!(outcome, SummaryOutcome::Succeeded(_)) {
*generation = generation.wrapping_add(1);
if let Ok(mut guard) = checkpoint_slot.lock() {
*guard = None;
}
}
let compaction_kind = if breaker_open {
crate::event::CompactionKind::PruneSummarizerDisabled
} else {
match outcome {
SummaryOutcome::Succeeded(_) => crate::event::CompactionKind::PruneAndSummary,
SummaryOutcome::Failed => crate::event::CompactionKind::PruneAndFailedSummary,
SummaryOutcome::Skipped => crate::event::CompactionKind::PruneOnly,
}
};
if matches!(outcome, SummaryOutcome::Skipped) && after_summary >= before {
tracing::debug!(
target: "dirge::agent_loop",
tokens = before,
messages = current_context.messages.len(),
"compaction pass freed nothing — not rotating the session",
);
return outcome;
}
let new_id = compression::rotate_session_id();
let _ = emit
.send(LoopEvent::ContextCompacted {
new_session_id: new_id,
tokens_before: before,
tokens_after: after_summary,
summary: applied_summary,
first_kept_index: applied_first_kept,
compaction_kind,
summary_model: None,
})
.await;
outcome
}
const POST_COMPACT_MAX_READ_BYTES: u64 = 2 * 1024 * 1024;
const POST_COMPACT_RESTORE_CEILING: f64 = 0.50;
async fn restore_working_files(
config: &LoopConfig,
ctx: &mut Context,
summary_idx: usize,
ctx_max: u64,
) {
let Some(tracker) = &config.file_touch_tracker else {
return;
};
let files = tracker.working_files();
if files.is_empty() {
return;
}
let post_fold = crate::agent::compression::estimate_messages_tokens(&ctx.messages);
if post_fold as f64 > POST_COMPACT_RESTORE_CEILING * ctx_max.max(1) as f64 {
tracing::debug!(
target: "dirge::agent_loop",
post_fold,
ctx_max,
"skipping post-compaction file restore — insufficient headroom",
);
return;
}
let mut contents: Vec<(std::path::PathBuf, String)> = Vec::new();
for path in files
.into_iter()
.take(crate::agent::compression::POST_COMPACT_MAX_FILES)
{
match tokio::fs::metadata(&path).await {
Ok(m) if m.len() > POST_COMPACT_MAX_READ_BYTES => continue,
Ok(_) => {}
Err(_) => continue,
}
if let Ok(body) = tokio::fs::read_to_string(&path).await {
contents.push((path, body));
}
}
if contents.is_empty() {
return;
}
let snapshots = crate::agent::compression::build_post_compact_snapshots(&contents);
let at = (summary_idx + 1).min(ctx.messages.len());
for (offset, snap) in snapshots.into_iter().enumerate() {
ctx.messages.insert(at + offset, snap);
}
}
#[allow(clippy::too_many_arguments)]
pub async fn run_agent_loop(
prompts: Vec<LoopMessage>,
mut context: Context,
config: LoopConfig,
signal: AbortSignal,
emit: &mpsc::Sender<LoopEvent>,
stream_fn: &StreamFn,
summarize_fn: Option<crate::agent::compression::SummarizeFn>,
memory_provider: Option<std::sync::Arc<dyn crate::extras::memory_provider::MemoryProvider>>,
) -> Vec<LoopMessage> {
let new_messages = prompts.clone();
let task_query: String = prompts
.iter()
.filter_map(|m| match m {
LoopMessage::User(u) => Some(u.text_joined()),
_ => None,
})
.collect::<Vec<_>>()
.join(" ");
if let Some(block) = crate::agent::exemplars::block_for_task(&task_query, EXEMPLAR_TOP_K) {
let ex_msg = LoopMessage::User(super::message::UserMessage::text(block));
context.messages.push(loop_message_to_value(&ex_msg));
}
for prompt in &prompts {
context.messages.push(loop_message_to_value(prompt));
if let (Some(tracker), LoopMessage::User(u)) = (&config.file_touch_tracker, prompt) {
tracker.record_user_message(&u.text_joined());
}
}
if super::context_manager::verbatim_pre_recall_enabled()
&& let Some(provider) = &memory_provider
&& super::context_manager::query_worth_pre_recalling(&task_query)
{
let snapshot = provider.format_for_system_prompt();
let q = task_query.clone();
let p = provider.clone();
match tokio::task::spawn_blocking(move || p.search(&q)).await {
Ok(Ok(resp)) => {
if let Some(block) = super::context_manager::pre_recall_block(&resp, &snapshot) {
let msg = LoopMessage::User(super::message::UserMessage::text(block));
context.messages.push(loop_message_to_value(&msg));
}
}
Ok(Err(e)) => {
tracing::debug!(target: "dirge::memory", error = %e, "pre-recall search failed")
}
Err(e) => {
tracing::debug!(target: "dirge::memory", error = %e, "pre-recall task join failed")
}
}
}
if memory_provider.is_some() {
let db_path = std::env::current_dir()
.map(|c| crate::extras::dirge_paths::ProjectPaths::new(&c).session_db_path())
.unwrap_or_else(|_| std::path::PathBuf::from(".dirge/sessions/state.db"));
let sid = config.session_id.clone();
if let Ok(Some(block)) = tokio::task::spawn_blocking(move || {
issue_board_reminder_block(&db_path, sid.as_deref())
})
.await
{
let msg = LoopMessage::User(super::message::UserMessage::text(block));
context.messages.push(loop_message_to_value(&msg));
}
}
let _ = emit.send(LoopEvent::AgentStart).await;
let _ = emit.send(LoopEvent::TurnStart).await;
for prompt in &prompts {
let _ = emit
.send(LoopEvent::MessageStart {
message: prompt.clone(),
})
.await;
let _ = emit
.send(LoopEvent::MessageEnd {
message: prompt.clone(),
})
.await;
}
run_loop(
context,
new_messages,
config,
signal,
emit,
stream_fn,
summarize_fn,
memory_provider,
)
.await
}
fn record_tool_result_signals(
tally: &mut GateTally,
name: &str,
is_error: bool,
known_tools: &[&str],
) {
tally.record_tool_call(is_error);
if is_error && !known_tools.contains(&name) {
tally.record_hallucinated_tool_name();
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn poll_boundary_nudge(
config: &LoopConfig,
guards: &super::activity::LoopGuards,
safe_state_msg: Option<String>,
new_messages: &[LoopMessage],
turns_taken: usize,
track_nudges: &mut u8,
verify_nudges: &mut u8,
tally: &mut GateTally,
tier: super::capability::CapabilityTier,
) -> Option<(LoopMessage, BoundaryNudge)> {
let progress_signal = config.progress.as_ref().and_then(|progress| {
let snapshot = super::progress::ProgressSnapshot {
todos_unfinished: crate::agent::tools::todo::unfinished_count(),
files_touched: crate::agent::tools::modified::count(),
verified_green: matches!(
config.verifier.as_ref().map(|v| v.status(GateMode::Off)),
Some(super::verifier::VerificationStatus::VerifiedGreen)
),
tool_calls: tally.tool_calls() as usize,
};
progress.record_turn(snapshot)
});
if let Some(msg) = safe_state_msg {
tally.record_nudge(BoundaryNudge::SafeState);
return Some((
LoopMessage::User(super::message::UserMessage::text(msg)),
BoundaryNudge::SafeState,
));
}
if let Some(msg) = guards.poll_reflection(tier).into_iter().next() {
tally.record_nudge(BoundaryNudge::ReflectionCheckpoint);
return Some((msg, BoundaryNudge::ReflectionCheckpoint));
}
if let Some(reminder) = build_early_track_work_reminder(
config.session_id.as_deref(),
*track_nudges,
crate::agent::tools::todo::unfinished_count(),
turn_made_file_edits(new_messages),
) {
*track_nudges += 1;
tally.record_nudge(BoundaryNudge::TrackWork);
return Some((reminder, BoundaryNudge::TrackWork));
}
if let Some(reminder) = build_fast_verify_reminder(
config.verification_tiers_mode,
*verify_nudges,
tier,
config
.verifier
.as_ref()
.map_or(0, |v| v.edits_since_verify()),
) {
*verify_nudges += 1;
tally.record_nudge(BoundaryNudge::FastVerify);
return Some((reminder, BoundaryNudge::FastVerify));
}
if let Some(tracker) = &config.file_touch_tracker
&& let Some(reminder) = tracker.poll_reminder().into_iter().next()
{
tally.record_nudge(BoundaryNudge::FileTouch);
return Some((reminder, BoundaryNudge::FileTouch));
}
if let Some(msg) = progress_signal {
let which = if super::progress::is_prologue_checkpoint(&msg) {
BoundaryNudge::ProgressPrologue
} else {
BoundaryNudge::ProgressStall
};
tally.record_nudge(which);
return Some((msg, which));
}
if let Some(progress) = config.progress.as_ref()
&& let Some(msg) = progress.poll_budget(turns_taken, config.max_turns.unwrap_or(0))
{
tally.record_nudge(BoundaryNudge::ProgressBudget);
return Some((msg, BoundaryNudge::ProgressBudget));
}
None
}
fn finish_tally(
tally: &mut GateTally,
config: &LoopConfig,
capability: &super::capability::CapabilityEstimator,
) {
tally.set_capability_tier(Some(capability.tier()));
tally.set_verification(
config
.verifier
.as_ref()
.map(|v| v.status(config.verification_tiers_mode)),
);
tally.set_repairs(Some(config.repair_stats.snapshot()));
tally.emit();
}
#[allow(clippy::too_many_arguments)]
pub async fn run_loop(
mut current_context: Context,
mut new_messages: Vec<LoopMessage>,
mut config: LoopConfig,
signal: AbortSignal,
emit: &mpsc::Sender<LoopEvent>,
stream_fn: &StreamFn,
summarize_fn: Option<crate::agent::compression::SummarizeFn>,
memory_provider: Option<std::sync::Arc<dyn crate::extras::memory_provider::MemoryProvider>>,
) -> Vec<LoopMessage> {
let mut first_turn = true;
let mut guards = super::activity::LoopGuards::new(
storm_for_config(&config),
super::failure_tracker::FailureTracker::new(FAILURE_REFLECTION_THRESHOLD),
);
let inflight = InflightSet::new();
let mut folded_this_turn: bool;
let mut compaction_failures: u32 = 0;
let mut snip_tokens_freed: u64 = 0;
let mut tally = GateTally::new();
let mut capability = super::capability::CapabilityEstimator::new();
let (mut pending_messages, _initial_user_steering): (Vec<LoopMessage>, bool) =
poll_steering(&config).await;
let mut turns_taken: usize = 0;
let mut reflections = super::reflexion::ReflectionLog::new();
let mut safe_state = super::safe_state::SafeStateEngine::new();
let mut gates = GateStates::default();
let code_review_baseline: Option<super::code_review::RunDiff> =
if config.code_review_fn.is_some() {
let repo = config.code_review_repo.clone().unwrap_or_else(|| {
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
});
tokio::task::spawn_blocking(move || super::code_review::capture_run_diff(&repo))
.await
.ok()
.flatten()
} else {
None
};
let mut checkpoint_schedule: Option<context_manager::CheckpointSchedule> = None;
let checkpoint_slot: CheckpointSlot = std::sync::Arc::new(std::sync::Mutex::new(None));
let mut checkpoint_generation: u64 = 0;
let mut transient_recoveries: u8 = 0;
let mut verify_nudges: u8 = 0;
let issue_db_path: Option<std::path::PathBuf> = {
let p = std::env::current_dir()
.map(|c| crate::extras::dirge_paths::ProjectPaths::new(&c).session_db_path())
.unwrap_or_else(|_| std::path::PathBuf::from(".dirge/sessions/state.db"));
std::fs::metadata(&p).ok().map(|_| p)
};
'outer: loop {
guards.reset_turn();
let mut turn_self_corrected = false;
folded_this_turn = false;
let mut has_more_tool_calls = true;
let mut recovery_pending = false;
while has_more_tool_calls || !pending_messages.is_empty() || recovery_pending {
recovery_pending = false;
let mut compaction_recorded_this_iter = false;
let model_window = context_manager::context_window_override().unwrap_or_else(|| {
config
.model_name
.as_deref()
.and_then(crate::config::context_window_for_model)
.unwrap_or(128_000)
});
let ctx_max = context_manager::effective_ctx_max(model_window);
if !first_turn {
let _ = emit.send(LoopEvent::TurnStart).await;
} else {
first_turn = false;
}
if !folded_this_turn {
let rough_estimate =
crate::agent::compression::estimate_messages_tokens(¤t_context.messages);
let estimate = context_manager::estimate_turn_start(rough_estimate, ctx_max);
if estimate.ratio > context_manager::TURN_START_FOLD_THRESHOLD {
tracing::info!(
target: "dirge::agent_loop",
estimate_tokens = %estimate.estimate_tokens,
ctx_max = %estimate.ctx_max,
ratio = %estimate.ratio,
"context-manager: turn-start fold firing ({}% of context)",
(estimate.ratio * 100.0) as u32,
);
let outcome = run_compaction_pass(
&mut current_context,
&summarize_fn,
5, compaction_failures,
&memory_provider,
config.compaction_hooks.as_ref(),
emit,
&checkpoint_slot,
&mut checkpoint_generation,
(ctx_max as f64 * context_manager::HISTORY_FOLD_THRESHOLD) as u64,
)
.await;
if let SummaryOutcome::Succeeded(idx) = outcome {
restore_working_files(&config, &mut current_context, idx, ctx_max).await;
}
if !compaction_recorded_this_iter {
record_compaction_outcome(&mut compaction_failures, outcome);
compaction_recorded_this_iter = true;
}
folded_this_turn = true;
}
}
if let Some(provider) = &memory_provider
&& context_manager::take_memories_dirty()
{
let block = provider.format_for_system_prompt();
if !block.trim().is_empty() {
current_context.messages.push(serde_json::json!({
"role": "system",
"content": format!(
"## Updated memory (consolidated mid-session)\n{block}"
),
}));
}
}
if !pending_messages.is_empty() {
for msg in &pending_messages {
let _ = emit
.send(LoopEvent::MessageStart {
message: msg.clone(),
})
.await;
let _ = emit
.send(LoopEvent::MessageEnd {
message: msg.clone(),
})
.await;
current_context.messages.push(loop_message_to_value(msg));
new_messages.push(msg.clone());
if let (Some(tracker), LoopMessage::User(u)) = (&config.file_touch_tracker, msg)
{
let joined = u.text_joined();
if !joined.contains("[Context-depth reminder]") {
tracker.record_user_message(&joined);
}
}
}
pending_messages.clear();
}
let cap_estimate =
crate::agent::compression::estimate_messages_tokens(¤t_context.messages);
let result_cap = crate::agent::compression::tiered_result_cap(cap_estimate, ctx_max);
let (capped, freed) = crate::agent::compression::cap_oversized_tool_results_counted(
¤t_context.messages,
result_cap,
);
current_context.messages = capped;
snip_tokens_freed = snip_tokens_freed.saturating_add(freed);
let (assistant_msg, token_usage) = stream_assistant_response(
&mut current_context,
&config,
signal.clone(),
emit,
stream_fn,
)
.await;
new_messages.push(LoopMessage::Assistant(assistant_msg.clone()));
if matches!(
assistant_msg.stop_reason,
StopReason::Error | StopReason::Aborted
) {
let transient = assistant_msg.stop_reason == StopReason::Error
&& transient_recoveries < MAX_TRANSIENT_RECOVERIES
&& assistant_msg
.error_message
.as_deref()
.map(|e| {
use crate::agent::recovery::{ErrorKind, classify_error};
matches!(classify_error(e), ErrorKind::Network | ErrorKind::RateLimit)
})
.unwrap_or(false);
if transient {
transient_recoveries += 1;
current_context.messages.push(serde_json::json!({
"role": "user",
"content": TRANSIENT_RECOVERY_NUDGE,
}));
let _ = emit
.send(LoopEvent::RetryNotice {
attempt: transient_recoveries as u32,
delay_ms: 0,
error: assistant_msg
.error_message
.clone()
.unwrap_or_else(|| "transient stream error".to_string()),
})
.await;
has_more_tool_calls = false;
recovery_pending = true;
continue;
}
let _ = emit
.send(LoopEvent::TurnEnd {
message: assistant_msg.clone(),
tool_results: Vec::new(),
})
.await;
finish_tally(&mut tally, &config, &capability);
let _ = emit
.send(LoopEvent::AgentEnd {
messages: new_messages.clone(),
})
.await;
return new_messages;
}
transient_recoveries = 0;
let mut tool_calls = extract_tool_calls_from(&assistant_msg);
let allowed_names: std::collections::HashSet<String> = current_context
.tools
.iter()
.map(|t| t.name().to_string())
.collect();
let scavenge_source = build_scavenge_source(&assistant_msg.content);
if !scavenge_source.is_empty() {
let scavenge_result =
super::scavenge::scavenge_tool_calls(Some(&scavenge_source), &allowed_names, 4);
if !scavenge_result.calls.is_empty() {
use super::message::canonical_json;
let seen_signatures: std::collections::HashSet<String> = tool_calls
.iter()
.map(|tc| format!("{}::{}", tc.name, canonical_json(&tc.arguments)))
.collect();
for sc in &scavenge_result.calls {
let sig = format!("{}::{}", sc.name, canonical_json(&sc.arguments));
if !seen_signatures.contains(&sig) {
let tool = current_context.tools.iter().find(|t| t.name() == sc.name);
if let Some(tool) = tool {
match crate::agent::agent_loop::tool_input_repair::validate_and_repair(
tool.parameters(),
&sc.arguments,
) {
Ok(None) => {
tally.record_scavenged_call();
tool_calls.push(sc.clone());
}
Ok(Some(rr)) => {
tally.record_scavenged_call();
let mut repaired_call = sc.clone();
repaired_call.arguments = rr.repaired;
tool_calls.push(repaired_call);
}
Err(_) => {
}
}
} else {
tool_calls.push(sc.clone());
tally.record_scavenged_call();
}
}
}
}
}
apply_truncation_repair(
&mut tool_calls,
&config.repair_stats,
&config.truncation_notes,
);
let mut tool_results: Vec<ToolResultMessage> = Vec::new();
has_more_tool_calls = false;
let mut storm_give_up_tools: Option<Vec<String>> = None;
if !tool_calls.is_empty() {
let original_count = tool_calls.len();
let (surviving_calls, storm_report) = guards.inspect_calls(&tool_calls);
for _ in 0..storm_report.storms_broken {
tally.record_storm_suppression();
}
let all_suppressed = storm_report.all_suppressed(original_count);
if all_suppressed && !turn_self_corrected {
turn_self_corrected = true;
const REPEAT_LOOP_GUARD: &str = "[repeat-loop guard] You've made this exact call more than once and gotten the same result — you're stuck in a loop. Do NOT repeat it. Before doing anything else, work through these steps:\n\
1. State what you were trying to achieve with this call and why it isn't getting you there.\n\
2. Look at the earlier results for it above. What assumption of yours might be wrong, and what do those results actually tell you?\n\
3. Propose 2-3 FUNDAMENTALLY different approaches — a different tool, a different entry point, or a different interpretation of the problem — and pick the most promising one.\n\
4. Proceed with that approach.\n\
If none of them can work with the tools available, say so plainly and report what you found instead of trying again.";
for call in &tool_calls {
let args = super::message::canonical_json(&call.arguments);
let sig = super::reflexion::approach_signature(&call.name, &args);
reflections.record(sig);
}
let guard_text = format!(
"{REPEAT_LOOP_GUARD}{}",
reflections.block().unwrap_or_default()
);
let guard_blocks = vec![ContentBlock::Text {
text: guard_text.clone(),
}];
for call in &tool_calls {
let tr = ToolResultMessage {
tool_call_id: call.id.clone(),
tool_name: call.name.clone(),
content: guard_blocks.clone(),
details: Value::Null,
is_error: false,
};
current_context.messages.push(tool_result_to_value(&tr));
new_messages.push(LoopMessage::ToolResult(tr.clone()));
tool_results.push(tr);
}
has_more_tool_calls = true;
} else if storm_report.storms_broken > 0 && surviving_calls.is_empty() {
has_more_tool_calls = false;
storm_give_up_tools = Some(tool_calls.iter().map(|c| c.name.clone()).collect());
}
if !surviving_calls.is_empty() {
let batch = super::tools::execute_tool_calls(
¤t_context,
&assistant_msg,
&surviving_calls,
&config,
&signal,
emit,
&inflight,
)
.await;
tool_results.extend(batch.messages.clone());
has_more_tool_calls = !batch.terminate;
let known_tool_names: Vec<&str> =
current_context.tools.iter().map(|t| t.name()).collect();
for result in &batch.messages {
let excerpt = tool_result_excerpt(&result.content);
let originating = surviving_calls
.iter()
.find(|c| c.id == result.tool_call_id)
.cloned()
.unwrap_or_else(|| super::tools::ToolCall {
id: result.tool_call_id.clone(),
name: result.tool_name.clone(),
arguments: serde_json::Value::Null,
});
guards.record_result(&originating, result.is_error, &excerpt);
record_tool_result_signals(
&mut tally,
&originating.name,
result.is_error,
&known_tool_names,
);
tally.record_failure_streak(guards.failure_streak() as u32);
current_context.messages.push(tool_result_to_value(result));
new_messages.push(LoopMessage::ToolResult(result.clone()));
}
}
for tr in super::tools::backfill_missing_tool_results(&tool_calls, &tool_results) {
current_context.messages.push(tool_result_to_value(&tr));
new_messages.push(LoopMessage::ToolResult(tr.clone()));
tool_results.push(tr);
}
if let Some(tools) = storm_give_up_tools.take() {
let text = super::storm::failure_narrative(&tools);
let msg =
AssistantMessage::new(vec![ContentBlock::Text { text }], StopReason::Stop);
let _ = emit
.send(LoopEvent::MessageStart {
message: LoopMessage::Assistant(msg.clone()),
})
.await;
let _ = emit
.send(LoopEvent::MessageUpdate {
message: msg.clone(),
phase: super::message::DeltaPhase::TextStart,
})
.await;
let _ = emit
.send(LoopEvent::MessageEnd {
message: LoopMessage::Assistant(msg.clone()),
})
.await;
current_context
.messages
.push(loop_message_to_value(&LoopMessage::Assistant(msg.clone())));
new_messages.push(LoopMessage::Assistant(msg));
}
}
{
let repairs = config.repair_stats.snapshot();
capability.observe(&super::capability::CapabilityCounters {
tool_calls: tally.tool_calls(),
errored_tool_calls: tally.errored_tool_calls(),
repair_invalid: repairs.invalid as u32,
repair_successful: repairs.total_successful() as u32,
hallucinated_tool_names: tally.hallucinated_tool_names(),
storm_suppressions: tally.storm_suppressions(),
scavenged_calls: tally.scavenged_calls(),
max_failure_streak: tally.max_failure_streak(),
});
}
let safe_state_msg = if config.safe_state_abort_mode != super::types::SafeStateMode::Off
{
let excerpts = guards.recent_excerpts();
let fresh_green = config.verifier.as_ref().is_some_and(|v| v.is_fresh_green());
if fresh_green && config.safe_state_abort_mode == super::types::SafeStateMode::Auto
{
let fp = safe_state_repo(&config)
.as_deref()
.and_then(super::worktree_probe::fingerprint);
safe_state.set_green_fingerprint(fp);
}
let green_fp = safe_state.green_fingerprint().cloned();
let repo = safe_state_repo(&config);
safe_state.decide(
config.safe_state_abort_mode,
fresh_green,
guards.safe_state_due(),
config
.verifier
.as_ref()
.map_or(0, |v| v.edits_since_verify()),
crate::agent::tools::snapshots::current_turn_id().as_deref(),
&reflections,
&excerpts,
|green| coverage_verified_restore(repo.as_deref(), green_fp.as_ref(), green),
)
} else {
None
};
if let Some((msg, _which)) = poll_boundary_nudge(
&config,
&guards,
safe_state_msg,
&new_messages,
turns_taken,
&mut gates.track_nudges,
&mut verify_nudges,
&mut tally,
capability.tier(),
) {
emit_harness_notices(emit, std::slice::from_ref(&msg)).await;
current_context.messages.push(loop_message_to_value(&msg));
new_messages.push(msg);
}
let _ = emit
.send(LoopEvent::TurnEnd {
message: assistant_msg.clone(),
tool_results: tool_results.clone(),
})
.await;
{
let decision = context_manager::decide_after_usage(
token_usage.map(|u| u.input_tokens),
ctx_max,
folded_this_turn,
);
match decision.kind {
PostUsageDecisionKind::Fold if !folded_this_turn => {
folded_this_turn = true;
if crate::agent::compression::snip_bought_enough(
snip_tokens_freed,
ctx_max,
decision.aggressive,
) {
tracing::info!(
target: "dirge::agent_loop",
freed = snip_tokens_freed,
ratio = %decision.ratio,
"snip freed {snip_tokens_freed} tokens — sufficient, skipping fold",
);
} else {
tracing::info!(
target: "dirge::agent_loop",
ratio = %decision.ratio,
aggressive = decision.aggressive,
tail_budget = ?decision.tail_budget,
"context-manager: fold recommended ({})",
if decision.aggressive { "aggressive" } else { "normal" },
);
if let Some(prompt_tokens) = token_usage.map(|u| u.input_tokens)
&& crate::agent::compression::should_compress(
prompt_tokens,
ctx_max,
)
{
let outcome = run_compaction_pass(
&mut current_context,
&summarize_fn,
5, compaction_failures,
&memory_provider,
config.compaction_hooks.as_ref(),
emit,
&checkpoint_slot,
&mut checkpoint_generation,
(ctx_max as f64 * context_manager::HISTORY_FOLD_THRESHOLD)
as u64,
)
.await;
if let SummaryOutcome::Succeeded(idx) = outcome {
restore_working_files(
&config,
&mut current_context,
idx,
ctx_max,
)
.await;
}
if !compaction_recorded_this_iter {
record_compaction_outcome(&mut compaction_failures, outcome);
}
}
}
}
PostUsageDecisionKind::ExitWithSummary => {
tracing::warn!(
target: "dirge::agent_loop",
ratio = %decision.ratio,
"context-manager: forcing summary and ending turn",
);
let outcome = run_compaction_pass(
&mut current_context,
&summarize_fn,
3, compaction_failures,
&memory_provider,
config.compaction_hooks.as_ref(),
emit,
&checkpoint_slot,
&mut checkpoint_generation,
(ctx_max as f64 * context_manager::HISTORY_FOLD_THRESHOLD) as u64,
)
.await;
if let SummaryOutcome::Succeeded(idx) = outcome {
restore_working_files(&config, &mut current_context, idx, ctx_max)
.await;
}
if !compaction_recorded_this_iter {
record_compaction_outcome(&mut compaction_failures, outcome);
}
}
_ => {}
}
if context_manager::incremental_checkpoint_enabled()
&& let Some(sfn) = &summarize_fn
{
let sched = checkpoint_schedule
.get_or_insert_with(|| context_manager::CheckpointSchedule::new(ctx_max));
match decision.kind {
PostUsageDecisionKind::Fold | PostUsageDecisionKind::ExitWithSummary => {
sched.reset()
}
PostUsageDecisionKind::None => {
if sched.is_enabled() && sched.note_usage(decision.ratio) {
spawn_incremental_checkpoint(
sfn.clone(),
current_context.messages.clone(),
emit.downgrade(),
checkpoint_slot.clone(),
checkpoint_generation,
);
}
}
}
}
snip_tokens_freed = 0;
}
if let Some(hook) = &config.prepare_next_turn {
let hook_ctx = super::hooks::TurnHookContext {
message: assistant_msg.clone(),
tool_results: tool_results.clone(),
context: current_context.clone(),
new_messages: new_messages.clone(),
};
if let Some(update) = hook(hook_ctx).await {
if let Some(new_ctx) = update.context {
current_context = new_ctx;
}
if let Some(level) = update.thinking_level {
config.reasoning = Some(level);
tracing::debug!(
target: "dirge::agent_loop",
thinking = ?level,
"prepareNextTurn applied a new thinking_level for the next turn",
);
}
if let Some(model) = &update.model {
tracing::warn!(
target: "dirge::agent_loop",
requested_model = %model,
"prepareNextTurn returned a new model but mid-run model swap is not yet wired — ignoring",
);
}
}
}
if let Some(hook) = &config.should_stop_after_turn {
let hook_ctx = super::hooks::TurnHookContext {
message: assistant_msg.clone(),
tool_results: tool_results.clone(),
context: current_context.clone(),
new_messages: new_messages.clone(),
};
if hook(hook_ctx).await {
finish_tally(&mut tally, &config, &capability);
let _ = emit
.send(LoopEvent::AgentEnd {
messages: new_messages.clone(),
})
.await;
return new_messages;
}
}
let (next_pending, had_user_steering) = poll_steering(&config).await;
pending_messages = next_pending;
emit_harness_notices(emit, &pending_messages).await;
if had_user_steering {
turns_taken = 0;
}
turns_taken += 1;
tally.record_turn();
if let Some(cap) = config.max_turns
&& turns_taken >= cap
{
tracing::warn!(
target: "dirge::agent_loop",
turns = turns_taken,
cap = cap,
"max_turns reached — terminating run"
);
let notice = max_turns_notice(cap, &crate::agent::tools::todo::snapshot());
let _ = emit
.send(LoopEvent::SystemNotice {
content: notice.clone(),
})
.await;
new_messages.push(LoopMessage::User(super::message::UserMessage::text(notice)));
break 'outer;
}
if signal.is_interjected() {
break;
}
}
if signal.is_interjected() {
break;
}
let (follow_up, source) = poll_finalization_follow_up(
&config,
¤t_context.system_prompt,
&new_messages,
&mut gates,
GateInputs {
code_review_baseline: code_review_baseline.as_ref(),
open_issues_gate_mode: config.open_issues_gate_mode,
issue_db_path: issue_db_path.as_deref(),
session_id: config.session_id.as_deref(),
},
emit,
)
.await;
tally.record_gate(source.into());
if !follow_up.is_empty() {
tracing::trace!(target: "dirge::loop", ?source, "finalization follow-up interjected");
emit_harness_notices(emit, &follow_up).await;
pending_messages = follow_up;
continue 'outer;
}
break;
}
{
let snapshot = config.repair_stats.snapshot();
if !snapshot.is_empty() {
let _ = emit.send(LoopEvent::RepairStats { snapshot }).await;
}
}
finish_tally(&mut tally, &config, &capability);
let _ = emit
.send(LoopEvent::AgentEnd {
messages: new_messages.clone(),
})
.await;
new_messages
}
fn extract_tool_calls_from(msg: &AssistantMessage) -> Vec<super::tools::ToolCall> {
super::tools::extract_tool_calls(msg)
}
fn should_advise_untracked_work(
session_id: Option<&str>,
track_nudges: u8,
unfinished: usize,
made_file_edits: bool,
) -> bool {
session_id.is_some() && track_nudges < MAX_TRACK_NUDGES && unfinished == 0 && made_file_edits
}
fn track_work_reminder_message() -> LoopMessage {
LoopMessage::User(super::message::UserMessage::text(format!(
"{TRACK_WORK_TAG} You're editing files without an active todo. Before continuing, \
add this task to your active work list with write_todo_list and mark the item \
you're working on in_progress, so your progress is tracked."
)))
}
fn should_nudge_fast_verify(
mode: GateMode,
verify_nudges: u8,
edits_since_verify: u32,
tier: super::capability::CapabilityTier,
) -> bool {
mode != GateMode::Off
&& verify_nudges < MAX_VERIFY_NUDGES
&& edits_since_verify >= tier.scale_threshold(FAST_VERIFY_EDIT_THRESHOLD)
}
fn build_fast_verify_reminder(
mode: GateMode,
verify_nudges: u8,
tier: super::capability::CapabilityTier,
edits_since_verify: u32,
) -> Option<LoopMessage> {
if !should_nudge_fast_verify(mode, verify_nudges, edits_since_verify, tier) {
return None;
}
Some(LoopMessage::User(super::message::UserMessage::text(
format!(
"{VERIFY_TAG} You've made {edits_since_verify} code edits without running any check. \
Run a FAST one now — a typecheck, a linter, or just the test covering what you're \
touching — so a mistake surfaces here instead of several edits from now. Save the \
full suite for when the work is done."
),
)))
}
fn build_early_track_work_reminder(
session_id: Option<&str>,
track_nudges: u8,
unfinished: usize,
made_file_edits: bool,
) -> Option<LoopMessage> {
if should_advise_untracked_work(session_id, track_nudges, unfinished, made_file_edits) {
Some(track_work_reminder_message())
} else {
None
}
}
fn turn_made_file_edits(new_messages: &[LoopMessage]) -> bool {
for msg in new_messages {
if let LoopMessage::Assistant(a) = msg {
for tc in extract_tool_calls_from(a) {
if crate::permission::engine::tool_operation(&tc.name)
== crate::permission::engine::types::Operation::Edit
{
return true;
}
}
}
}
false
}
fn turn_wrote_todos(new_messages: &[LoopMessage]) -> bool {
new_messages.iter().any(|msg| {
matches!(msg, LoopMessage::Assistant(a)
if extract_tool_calls_from(a)
.iter()
.any(|tc| tc.name == "write_todo_list"))
})
}
fn run_made_tool_calls(new_messages: &[LoopMessage]) -> bool {
new_messages
.iter()
.any(|m| matches!(m, LoopMessage::ToolResult(_)))
}
fn awaiting_user_response(new_messages: &[LoopMessage]) -> bool {
let Some(LoopMessage::Assistant(last)) = new_messages.last() else {
return false;
};
if !extract_tool_calls_from(last).is_empty() {
return false;
}
let mut joined = String::new();
for block in &last.content {
if let ContentBlock::Text { text } = block {
if !joined.is_empty() {
joined.push('\n');
}
joined.push_str(text);
}
}
if joined.matches("```").count() % 2 == 1 {
return false;
}
let mut candidate: Option<&str> = None;
for line in joined.lines().rev() {
if line.trim().is_empty() || is_option_list_line(line) {
continue;
}
candidate = Some(line);
break;
}
let Some(candidate) = candidate else {
return false;
};
let mut s: String = candidate.trim().to_string();
while let Some(ch) = s.chars().last() {
if ch.is_whitespace()
|| matches!(
ch,
'*' | '_' | '`' | ')' | ']' | '"' | '\'' | '\u{201d}' | '\u{2019}' | '>'
)
{
s.pop();
} else {
break;
}
}
s.ends_with('?') || s.ends_with('\u{ff1f}')
}
fn is_option_list_line(line: &str) -> bool {
let s = line.trim_start();
let Some(marker_len) = option_marker_len(s) else {
return false;
};
let after = &s[marker_len..];
after.is_empty() || after.starts_with(char::is_whitespace)
}
fn option_marker_len(s: &str) -> Option<usize> {
let bytes = s.as_bytes();
if let Some(ch) = s.chars().next() {
match ch {
'-' | '*' | '+' | '\u{2022}' => return Some(ch.len_utf8()),
_ => {}
}
}
let mut i = 0;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i > 0 && i < bytes.len() && (bytes[i] == b'.' || bytes[i] == b')') {
return Some(i + 1);
}
if bytes.first() == Some(&b'(') {
let mut j = 1;
while j < bytes.len() && bytes[j].is_ascii_digit() {
j += 1;
}
if j > 1 && j < bytes.len() && bytes[j] == b')' {
return Some(j + 1);
}
}
let mut chars = s.chars();
if let Some(first) = chars.next()
&& first.is_ascii_alphabetic()
&& let Some(second) = chars.next()
&& (second == '.' || second == ')')
{
return Some(first.len_utf8() + second.len_utf8());
}
None
}
fn run_delta_to_review<'a>(
current: Option<&'a super::code_review::RunDiff>,
baseline: Option<&super::code_review::RunDiff>,
) -> Option<&'a str> {
let current = current?;
let changed = match baseline {
Some(b) => current.fingerprint != b.fingerprint,
None => true,
};
if changed { Some(¤t.capped) } else { None }
}
fn build_critic_transcript(new_messages: &[LoopMessage]) -> String {
const MAX_CHARS: usize = 12_000;
const HEAD_CHARS: usize = 2_000;
const PER_RESULT_CHARS: usize = 400;
const ELISION: &str =
"\n…(earlier run steps elided; showing the start and the most recent activity)…\n";
let mut blocks: Vec<String> = Vec::new();
for m in new_messages {
match m {
LoopMessage::User(u) => {
blocks.push(format!("USER: {}\n", u.text_joined().trim()));
}
LoopMessage::Assistant(a) => {
for block in &a.content {
match block {
ContentBlock::Text { text } if !text.trim().is_empty() => {
blocks.push(format!("ASSISTANT: {}\n", text.trim()));
}
ContentBlock::ToolCall {
name, arguments, ..
} => {
let args = serde_json::to_string(arguments).unwrap_or_default();
let args: String = args.chars().take(200).collect();
blocks.push(format!("ASSISTANT called {name}({args})\n"));
}
_ => {}
}
}
}
LoopMessage::ToolResult(t) => {
let text: String = t
.content
.iter()
.filter_map(|c| match c {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join(" ");
let denied = t.is_error && crate::agent::tools::is_permission_denial(&text);
let text: String = text.chars().take(PER_RESULT_CHARS).collect();
let tag = if denied {
"DENIED"
} else if t.is_error {
"ERROR"
} else {
"result"
};
blocks.push(format!("TOOL {} [{}]: {}\n", t.tool_name, tag, text.trim()));
}
_ => {}
}
}
let total: usize = blocks.iter().map(|b| b.chars().count()).sum();
if total <= MAX_CHARS {
return blocks.concat();
}
let mut head_end = 0;
let mut head_len = 0;
while head_end < blocks.len() {
let n = blocks[head_end].chars().count();
if head_len + n > HEAD_CHARS && head_end > 0 {
break;
}
head_len += n;
head_end += 1;
if head_len >= HEAD_CHARS {
break;
}
}
let tail_budget = MAX_CHARS.saturating_sub(head_len + ELISION.chars().count());
let mut tail_start = blocks.len();
let mut tail_len = 0;
while tail_start > head_end {
let n = blocks[tail_start - 1].chars().count();
if tail_len + n > tail_budget && tail_start < blocks.len() {
break;
}
tail_len += n;
tail_start -= 1;
if tail_len >= tail_budget {
break;
}
}
let mut out = String::new();
out.push_str(&blocks[..head_end].concat());
out.push_str(ELISION);
out.push_str(&blocks[tail_start..].concat());
let len = out.chars().count();
if len > MAX_CHARS {
return out.chars().skip(len - MAX_CHARS).collect();
}
out
}
pub(crate) fn build_scavenge_source(blocks: &[ContentBlock]) -> String {
blocks
.iter()
.filter_map(|b| match b {
ContentBlock::Thinking { text } => Some(text.as_str()),
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) fn apply_truncation_repair(
tool_calls: &mut [crate::agent::agent_loop::ToolCall],
repair_stats: &crate::agent::agent_loop::tool_input_repair::RepairStats,
truncation_notes: &std::sync::Arc<
std::sync::Mutex<std::collections::HashMap<String, Vec<String>>>,
>,
) {
use crate::agent::agent_loop::tool_input_repair::{RepairKind, repair_truncated_json};
for tc in tool_calls.iter_mut() {
if let serde_json::Value::String(raw) = &tc.arguments {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(raw) {
tc.arguments = parsed;
continue;
}
let r = repair_truncated_json(raw);
if !r.changed {
continue;
}
repair_stats.record(RepairKind::TruncationFixed);
let prefix = if r.fallback {
format!("[{}] ⚠️ TRUNCATION UNRECOVERABLE", tc.name)
} else {
format!("[{}]", tc.name)
};
let mut sink = truncation_notes.lock_ignore_poison();
let entry = sink.entry(tc.id.clone()).or_default();
for n in &r.notes {
entry.push(format!("{prefix} {n}"));
}
drop(sink);
if !r.fallback
&& let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&r.repaired)
{
tc.arguments = parsed;
}
}
}
}
#[cfg(test)]
#[path = "run_tests.rs"]
mod tests;