From 59037ecadd8dfc9d99e916d8470993174cfa44db Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Wed, 28 Oct 2026 11:24:25 +0530 Subject: [PATCH] fix(agent_loop): grant one deliver-first turn when a no-progress guard halts When the repeat/no-progress guard trips, the run ended immediately in the Pause steering branch -- the model is stopped mid-loop with its deliverable possibly unwritten, so the run terminates as Halted having produced nothing. Grant exactly one final "deliver-first" turn when the pause was a guard halt (ctx.halted_by_guard set): clear the guard latch, resume steering, push a nudge telling the model to write its final output now, and loop once more. Gated one-shot per run via ctx.deliver_first_attempted and on remaining model calls, so a model that ignores the nudge and trips the guard again ends the run for real -- it cannot loop on the nudge. The change is additive and fires only on a guard-caused pause; human and orchestrator pauses are unaffected. Co-Authored-By: Claude Opus 4.8 --- .../src/agent_loop/run_loop.rs | 39 +++++++++++++++++++ crates/tinyagents-harness/src/context/mod.rs | 1 + .../tinyagents-harness/src/context/types.rs | 5 +++ 3 files changed, 45 insertions(+) diff --git a/crates/tinyagents-harness/src/agent_loop/run_loop.rs b/crates/tinyagents-harness/src/agent_loop/run_loop.rs index 261d4640e..c15829f65 100644 --- a/crates/tinyagents-harness/src/agent_loop/run_loop.rs +++ b/crates/tinyagents-harness/src/agent_loop/run_loop.rs @@ -10,6 +10,15 @@ use super::model_call::ModelCallBase; use super::types::{MixedStructuredTurn, ResponseTurn, TruncationOutcome, TurnFlow, TurnRecovery}; use super::*; +/// Final-turn nudge when a repeat/no-progress guard halts the run: the model +/// was stopped mid-loop, possibly with its deliverable unwritten, so it is +/// given one last turn to produce its output before the run ends. +const DELIVER_FIRST_NUDGE: &str = "You were stopped because you kept repeating the same \ +steps without making progress. This is your last turn before the run ends. If you have \ +enough to finish, write your final output now to the exact file or location the task \ +requires, then stop. Do not re-issue calls you already have results for -- act on what \ +you already have, or state plainly what is blocking you."; + impl AgentHarness { /// Drives the loop body, returning `Ok(())` on a clean finish or the first /// error encountered. The caller owns lifecycle bookkeeping (final status @@ -290,6 +299,36 @@ impl AgentHarness { return Err(TinyAgentsError::Cancelled); } crate::steering::SteeringOutcome::Pause => { + // A repeat/no-progress guard halt (not a human/orchestrator pause) + // gets one deliver-first turn before it ends the run: the model was + // stopped mid-loop with its deliverable possibly unwritten. Nudge it + // to write its final output, lift the guard's pause latch, and loop + // once more. One-shot per run; a second guard halt ends it for real. + if ctx.halted_by_guard.is_some() + && !ctx.deliver_first_attempted + && ctx.limits.remaining_model_calls() > 0 + { + ctx.deliver_first_attempted = true; + let _ = ctx.take_halted_by_guard(); + let _ = ctx.take_halted_by_guard_cause(); + if let Some(handle) = ctx.steering.as_ref() { + handle.resume(); + } + messages.push(Message::user(DELIVER_FIRST_NUDGE.to_string())); + let record = ctx.emit(AgentEvent::ControlApplied { + control: "deliver_first_nudge".to_string(), + detail: "repeat/no-progress guard halt; granting a final turn \ + to write the deliverable before ending" + .to_string(), + }); + status.set_last_event(record.id); + tracing::info!( + target: "tinyagents::agent_loop", + run_id = %ctx.run_id(), + "[agent_loop] guard halt: granting one deliver-first turn before ending" + ); + continue; + } let pause = ctx .steering .as_ref() diff --git a/crates/tinyagents-harness/src/context/mod.rs b/crates/tinyagents-harness/src/context/mod.rs index 4b4dfbbe7..fc9020744 100644 --- a/crates/tinyagents-harness/src/context/mod.rs +++ b/crates/tinyagents-harness/src/context/mod.rs @@ -336,6 +336,7 @@ impl RunContext { host_authority: None, terminal_observer: None, turns: crate::agent_loop::TurnTracker::default(), + deliver_first_attempted: false, halted_by_guard: None, halted_by_guard_cause: None, last_limit: std::sync::Mutex::new(None), diff --git a/crates/tinyagents-harness/src/context/types.rs b/crates/tinyagents-harness/src/context/types.rs index 0720f4b83..647571012 100644 --- a/crates/tinyagents-harness/src/context/types.rs +++ b/crates/tinyagents-harness/src/context/types.rs @@ -511,6 +511,11 @@ pub struct RunContext { pub(crate) turns: crate::agent_loop::TurnTracker, /// Set by a no-progress / repeat guard that paused the run, holding its /// root-cause summary, so the loop can report `TerminalReason::Halted`. + /// One deliver-first nudge has been spent on a repeat/no-progress guard + /// halt: the model gets a final turn to write its deliverable before the + /// run ends. A second guard halt then ends the run for real. One-shot per + /// run so a model that ignores the nudge cannot loop on it. + pub(crate) deliver_first_attempted: bool, pub(crate) halted_by_guard: Option, /// Typed classifier cause for a guard halt, preserved independently from /// the human-readable summary.