From 59037ecadd8dfc9d99e916d8470993174cfa44db Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Wed, 28 Oct 2026 11:24:25 +0530 Subject: [PATCH 1/4] 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. From b0d548af32413ecd1f672b92f8079c861cd1bf11 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 11 Nov 2026 15:16:21 +0530 Subject: [PATCH 2/4] Make the deliver-first turn opt-in, keep host pauses, end after one turn - RunPolicy::deliver_first_on_guard_halt turns the deliver-first turn on; it is off by default, so a guard halt still ends the run at once and existing hosts and tests see no change. - The turn is granted only when the guard's pause is the only one at that checkpoint, so a host or orchestrator pause sent alongside it is no longer cleared by resume(). - The halt is kept rather than discarded and restored at the next checkpoint, so the run ends halted after that one turn even if the turn made new tool calls; a text-only answer still finishes the run. - The new field's doc comment no longer takes over halted_by_guard's. - Tests: one deliver-first turn, nothing after it, and a host pause at the halting checkpoint is not lifted. --- .../src/agent_loop/run_loop.rs | 89 ++++++++----- crates/tinyagents-harness/src/context/mod.rs | 1 + .../tinyagents-harness/src/context/types.rs | 21 ++- .../library/repeat_escalation_loop_tests.rs | 125 +++++++++++++++++- .../tinyagents-harness/src/runtime/types.rs | 7 + crates/tinyagents-harness/src/steering/mod.rs | 16 ++- .../tinyagents-harness/src/steering/types.rs | 3 + 7 files changed, 222 insertions(+), 40 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/run_loop.rs b/crates/tinyagents-harness/src/agent_loop/run_loop.rs index bcce673c6..98ff018f8 100644 --- a/crates/tinyagents-harness/src/agent_loop/run_loop.rs +++ b/crates/tinyagents-harness/src/agent_loop/run_loop.rs @@ -351,41 +351,27 @@ impl AgentHarness { // Safe steering checkpoint: drain any orchestrator/human steering // commands and apply the policy-permitted ones before the next // model call. Cancel terminates the run; Pause short-circuits it. - match crate::steering::apply_pending_steering(ctx, messages)? { + let steering = crate::steering::apply_pending_steering(ctx, messages)?; + // The deliver-first turn has run: end the run with the halt it + // deferred, whatever that turn asked for next. + if !matches!(steering, crate::steering::SteeringOutcome::Cancel) + && let Some(halt) = ctx.deferred_halt.take() + { + ctx.halted_by_guard = Some(halt.summary); + ctx.halted_by_guard_cause = halt.cause; + let pause = match ctx.steering.as_ref() { + Some(handle) => { + handle.latch_pause(halt.pause.paused_at_checkpoint, halt.pause.reason) + } + None => halt.pause, + }; + return Ok(LoopExit::Paused(pause)); + } + match steering { crate::steering::SteeringOutcome::Cancel => { 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() @@ -394,7 +380,46 @@ impl AgentHarness { reason: None, paused_at_checkpoint: 0, }); - return Ok(LoopExit::Paused(pause)); + // A repeat/no-progress guard halt may first grant one + // deliver-first turn (`RunPolicy::deliver_first_on_guard_halt`): + // the model was stopped mid-loop with its deliverable possibly + // unwritten. Only the guard's own pause is lifted; a host pause + // at the same checkpoint still ends the run. The halt is kept + // and restored at the next checkpoint, after that one turn. + let lone_guard_pause = ctx + .steering + .as_ref() + .is_some_and(|handle| handle.pause_requests() == 1); + if !(self.policy.deliver_first_on_guard_halt + && lone_guard_pause + && ctx.halted_by_guard.is_some() + && !ctx.deliver_first_attempted + && ctx.limits.remaining_model_calls() > 0) + { + return Ok(LoopExit::Paused(pause)); + } + ctx.deliver_first_attempted = true; + ctx.deferred_halt = Some(crate::context::DeferredHalt { + summary: ctx.take_halted_by_guard().unwrap_or_default(), + cause: ctx.take_halted_by_guard_cause(), + pause, + }); + 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" + ); } crate::steering::SteeringOutcome::Continue => {} } diff --git a/crates/tinyagents-harness/src/context/mod.rs b/crates/tinyagents-harness/src/context/mod.rs index 0ee4cb7e2..8fd970858 100644 --- a/crates/tinyagents-harness/src/context/mod.rs +++ b/crates/tinyagents-harness/src/context/mod.rs @@ -373,6 +373,7 @@ impl RunContext { terminal_observer: None, turns: crate::agent_loop::TurnTracker::default(), deliver_first_attempted: false, + deferred_halt: None, 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 9ddcfb6ff..111f41875 100644 --- a/crates/tinyagents-harness/src/context/types.rs +++ b/crates/tinyagents-harness/src/context/types.rs @@ -22,7 +22,7 @@ use crate::ids::{CallId, RunId, ThreadId}; use crate::limits::LimitTracker; use crate::reasoning_replay::ReasoningReplay; use crate::result_expiry::ExpiryOverrides; -use crate::steering::SteeringHandle; +use crate::steering::{PauseState, SteeringHandle}; use crate::store::StoreRegistry; use crate::tool::discover::DiscoveryOverrides; @@ -534,13 +534,15 @@ pub struct RunContext { /// Lifecycle cursor over the working transcript: which messages were /// announced and which turn is open (see `agent_loop::lifecycle`). pub(crate) turns: crate::agent_loop::TurnTracker, + /// One deliver-first turn has been granted on a guard halt (see + /// [`crate::runtime::RunPolicy::deliver_first_on_guard_halt`]). One-shot + /// per run so a model that ignores the nudge cannot loop on it. + pub(crate) deliver_first_attempted: bool, + /// The guard halt a deliver-first turn deferred. The loop restores it at + /// the next checkpoint, so the run still ends halted after that one turn. + pub(crate) deferred_halt: Option, /// 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. @@ -690,3 +692,10 @@ impl RunDepth { }) } } + +/// A guard halt held back for one deliver-first turn. +pub(crate) struct DeferredHalt { + pub(crate) summary: String, + pub(crate) cause: Option, + pub(crate) pause: PauseState, +} diff --git a/crates/tinyagents-harness/src/middleware/library/repeat_escalation_loop_tests.rs b/crates/tinyagents-harness/src/middleware/library/repeat_escalation_loop_tests.rs index 7b5ddeff4..a1da54457 100644 --- a/crates/tinyagents-harness/src/middleware/library/repeat_escalation_loop_tests.rs +++ b/crates/tinyagents-harness/src/middleware/library/repeat_escalation_loop_tests.rs @@ -1,5 +1,6 @@ //! The staged repeat escalation inside the real agent loop: a blocked call -//! must never reach its tool, and the run must pause on the second block. +//! must never reach its tool, and the run must pause on the second block, +//! after one deliver-first turn when the host opts in. use std::sync::{Arc, Mutex}; @@ -198,3 +199,125 @@ async fn a_guard_halt_is_reported_as_a_halted_terminal_outcome() { assert_eq!(outcome.class, TerminalClass::Failure); assert_eq!(Some(outcome.message), summary.lock().unwrap().clone()); } + +/// Sends a host pause at the guard's halting call, so both pauses reach the +/// same steering checkpoint. +struct HostPauseAtHalt(SteeringHandle); + +#[async_trait] +impl Middleware<(), ()> for HostPauseAtHalt { + fn name(&self) -> &str { + "host_pause_at_halt" + } + + async fn after_tool( + &self, + _ctx: &mut RunContext<()>, + _state: &(), + _invocation: &ToolInvocationIdentity, + result: &mut ToolResult, + ) -> TaResult<()> { + if repeat_guard_marker(result) == Some("halted") { + self.0.send(crate::steering::SteeringCommand::PauseWith { + reason: "host review".into(), + }); + } + Ok(()) + } +} + +/// A run whose guard halts on attempt 6, with the deliver-first turn turned +/// on; `after_halt` are the model's responses from the deliver-first turn on. +async fn deliver_first_run( + after_halt: Vec, + host_pause: bool, +) -> (crate::middleware::AgentRun, u32) { + let steering = SteeringHandle::allow_all(); + let tool = Arc::new(CountingTool { + runs: Mutex::new(0), + }); + let mut harness: AgentHarness<()> = AgentHarness::new(); + harness.with_model_resolver(Arc::new(crate::host::FixedModelResolver::named( + "mock", + Arc::new(MockModel::with_responses( + (0..6) + .map(repeat_call) + .chain(after_halt) + .collect::>(), + )), + ))); + harness.register_tool(tool.clone()); + harness.with_policy(crate::runtime::RunPolicy { + deliver_first_on_guard_halt: true, + ..Default::default() + }); + harness.push_middleware(Arc::new(RepeatProgressMiddleware::new( + steering.clone(), + Arc::new(Mutex::new(None)), + Arc::new(|_| false), + ))); + if host_pause { + harness.push_middleware(Arc::new(HostPauseAtHalt(steering.clone()))); + } + let ctx = RunContext::new(RunConfig::new("deliver-first"), ()).with_steering(steering); + let run = harness + .invoke_in_context_with_status(&(), ctx, vec![Message::user("go")]) + .await + .unwrap() + .run; + let runs = *tool.runs.lock().unwrap(); + (run, runs) +} + +fn nudges(run: &crate::middleware::AgentRun) -> usize { + run.messages + .iter() + .filter(|m| matches!(m, Message::User(_)) && m.text().contains("This is your last turn")) + .count() +} + +#[tokio::test] +async fn an_opted_in_guard_halt_grants_one_turn_to_deliver() { + let (run, _) = deliver_first_run(vec![ModelResponse::assistant("final answer")], false).await; + + assert_eq!(nudges(&run), 1); + assert_eq!(run.messages.last().unwrap().text(), "final answer"); + assert!( + run.paused.is_none(), + "the model delivered, so the run finished" + ); +} + +#[tokio::test] +async fn the_deliver_first_turn_is_the_last_one_even_when_it_calls_a_tool() { + use crate::terminal::TerminalReason; + let mut new_call = repeat_call(6); + new_call.message.tool_calls = vec![ToolCall::new("call-6", "lookup", json!({"id": 2}))]; + let (run, runs) = deliver_first_run( + vec![new_call, ModelResponse::assistant("never asked for")], + false, + ) + .await; + + assert_eq!(nudges(&run), 1); + assert_eq!( + runs, 5, + "attempts 1-4 and the deliver-first turn's new call" + ); + assert!( + run.messages.iter().all(|m| m.text() != "never asked for"), + "no model call after the deliver-first turn" + ); + assert!(run.paused.is_some()); + assert_eq!(run.terminal.unwrap().reason, TerminalReason::Halted); +} + +#[tokio::test] +async fn a_host_pause_at_the_halting_checkpoint_is_not_lifted() { + let (run, runs) = + deliver_first_run(vec![ModelResponse::assistant("never asked for")], true).await; + + assert_eq!(nudges(&run), 0, "no deliver-first turn over a host pause"); + assert_eq!(runs, 4); + assert!(run.paused.is_some()); +} diff --git a/crates/tinyagents-harness/src/runtime/types.rs b/crates/tinyagents-harness/src/runtime/types.rs index 51e8911f6..71b37dda6 100644 --- a/crates/tinyagents-harness/src/runtime/types.rs +++ b/crates/tinyagents-harness/src/runtime/types.rs @@ -554,6 +554,12 @@ pub struct RunPolicy { /// [`crate::context::RunConfig::replay_reasoning`]. The stored transcript /// always keeps every reasoning block. See [`crate::reasoning_replay`]. pub replay_reasoning: crate::reasoning_replay::ReasoningReplay, + /// Whether a repeat / no-progress guard halt first grants the model one + /// final turn, with a nudge to write its deliverable, before the run ends + /// halted. Off by default, so a guard halt ends the run at once unless the + /// host opts in. Not granted when a host pause arrived at the same + /// checkpoint or no model call is left. + pub deliver_first_on_guard_halt: bool, } /// See [`RunPolicy::structured_strategy_override`]. @@ -724,6 +730,7 @@ impl Default for RunPolicy { structured_strategy_override: None, queue_mode: QueueMode::default(), tool_result_durations: false, + deliver_first_on_guard_halt: false, result_expiry: crate::result_expiry::ToolResultExpiryPolicy::default(), replay_reasoning: crate::reasoning_replay::ReasoningReplay::default(), } diff --git a/crates/tinyagents-harness/src/steering/mod.rs b/crates/tinyagents-harness/src/steering/mod.rs index 48bc125d4..a9ae2b474 100644 --- a/crates/tinyagents-harness/src/steering/mod.rs +++ b/crates/tinyagents-harness/src/steering/mod.rs @@ -308,7 +308,8 @@ impl SteeringHandle { /// at `checkpoint`. Idempotent: an existing pause keeps its original reason /// and checkpoint, so a repeated `Pause` does not rewrite why the run /// stopped. - fn latch_pause(&self, checkpoint: usize, reason: Option) -> PauseState { + pub(crate) fn latch_pause(&self, checkpoint: usize, reason: Option) -> PauseState { + *self.lock_pause_requests() += 1; let mut paused = self.lock_paused(); let state = paused.get_or_insert(PauseState { reason, @@ -330,6 +331,7 @@ impl SteeringHandle { /// pause is in effect. pub fn resume(&self) -> Option { let cleared = self.lock_paused().take(); + *self.lock_pause_requests() = 0; if cleared.is_some() { tracing::debug!(target: "tinyagents::steering", "[steering] pause cleared by resume"); } @@ -408,6 +410,18 @@ impl SteeringHandle { .unwrap_or_else(std::sync::PoisonError::into_inner) } + /// How many pause commands were applied since the last resume. + pub(crate) fn pause_requests(&self) -> usize { + *self.lock_pause_requests() + } + + fn lock_pause_requests(&self) -> std::sync::MutexGuard<'_, usize> { + self.local + .pause_requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + /// Locks the pause latch, recovering from poisoning (see /// [`SteeringHandle::send`]). fn lock_paused(&self) -> std::sync::MutexGuard<'_, Option> { diff --git a/crates/tinyagents-harness/src/steering/types.rs b/crates/tinyagents-harness/src/steering/types.rs index d75684865..36708512d 100644 --- a/crates/tinyagents-harness/src/steering/types.rs +++ b/crates/tinyagents-harness/src/steering/types.rs @@ -339,6 +339,9 @@ pub(crate) struct SteeringLocal { /// How many steering checkpoints this handle has processed. Recorded into /// [`PauseState::paused_at_checkpoint`]. pub(crate) checkpoints: Mutex, + /// Pause commands applied since the last resume, so the loop can tell a + /// guard's lone pause from one a host sent at the same checkpoint. + pub(crate) pause_requests: Mutex, /// The model a [`SteeringCommand::SwitchModel`] selected for *this* run, /// sticky until replaced or rejected by the model call. Per run like the /// pause latch, so it never crosses a [`SteeringHandle::for_child`] From 882e0b63480e77cdd7813ff414dcd5ac9c2b3fbb Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 11 Nov 2026 17:28:06 +0530 Subject: [PATCH 3/4] Tell guard pauses from host pauses, and keep the halt on a final-turn pause - The deliver-first turn compared the checkpoint's pause count with 1, so two guards halting on the same result looked like a host pause and blocked it. The guards now count the pauses they send on the run context, and the turn is granted when every applied pause is a guard's. - A host pause that ends the deliver-first turn, through the natural finish's steering checkpoint, returned without the deferred halt, so the run reported Paused without its guard cause. Any paused exit now restores the deferred halt first; the host's pause state is kept. - Tests for both. --- .../src/agent_loop/run_loop.rs | 21 ++- crates/tinyagents-harness/src/context/mod.rs | 1 + .../tinyagents-harness/src/context/types.rs | 3 + .../src/middleware/library/failure_scope.rs | 1 + .../library/repeat_escalation_loop_tests.rs | 123 +++++++++++++++++- .../src/middleware/library/repeat_progress.rs | 1 + 6 files changed, 137 insertions(+), 13 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/run_loop.rs b/crates/tinyagents-harness/src/agent_loop/run_loop.rs index 98ff018f8..dcb10c341 100644 --- a/crates/tinyagents-harness/src/agent_loop/run_loop.rs +++ b/crates/tinyagents-harness/src/agent_loop/run_loop.rs @@ -138,6 +138,14 @@ impl AgentHarness { // One typed answer to "how did the loop end", derived once here so the // event, `run.terminal` and the legacy fields cannot disagree. let mut terminal = TerminalOutcome::from_loop_exit(&exit, ctx.provider_started()); + // A pause that ends the deliver-first turn (a host pause at its natural + // finish, say) still reports the guard halt that turn deferred. + if matches!(exit, LoopExit::Paused(_)) + && let Some(halt) = ctx.deferred_halt.take() + { + ctx.halted_by_guard = Some(halt.summary); + ctx.halted_by_guard_cause = halt.cause; + } // A repeat / no-progress guard halts by pausing; its marker says so. if matches!(exit, LoopExit::Paused(_)) && let Some(summary) = ctx.halted_by_guard.take() @@ -383,15 +391,15 @@ impl AgentHarness { // A repeat/no-progress guard halt may first grant one // deliver-first turn (`RunPolicy::deliver_first_on_guard_halt`): // the model was stopped mid-loop with its deliverable possibly - // unwritten. Only the guard's own pause is lifted; a host pause - // at the same checkpoint still ends the run. The halt is kept - // and restored at the next checkpoint, after that one turn. - let lone_guard_pause = ctx + // unwritten. Only guard pauses are lifted; a host pause at the + // same checkpoint still ends the run. The halt is kept and + // restored at the next checkpoint, after that one turn. + let only_guard_pauses = ctx .steering .as_ref() - .is_some_and(|handle| handle.pause_requests() == 1); + .is_some_and(|handle| handle.pause_requests() == ctx.guard_pauses); if !(self.policy.deliver_first_on_guard_halt - && lone_guard_pause + && only_guard_pauses && ctx.halted_by_guard.is_some() && !ctx.deliver_first_attempted && ctx.limits.remaining_model_calls() > 0) @@ -399,6 +407,7 @@ impl AgentHarness { return Ok(LoopExit::Paused(pause)); } ctx.deliver_first_attempted = true; + ctx.guard_pauses = 0; ctx.deferred_halt = Some(crate::context::DeferredHalt { summary: ctx.take_halted_by_guard().unwrap_or_default(), cause: ctx.take_halted_by_guard_cause(), diff --git a/crates/tinyagents-harness/src/context/mod.rs b/crates/tinyagents-harness/src/context/mod.rs index 8fd970858..210ed5e5c 100644 --- a/crates/tinyagents-harness/src/context/mod.rs +++ b/crates/tinyagents-harness/src/context/mod.rs @@ -374,6 +374,7 @@ impl RunContext { turns: crate::agent_loop::TurnTracker::default(), deliver_first_attempted: false, deferred_halt: None, + guard_pauses: 0, 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 111f41875..7fcfe6955 100644 --- a/crates/tinyagents-harness/src/context/types.rs +++ b/crates/tinyagents-harness/src/context/types.rs @@ -541,6 +541,9 @@ pub struct RunContext { /// The guard halt a deliver-first turn deferred. The loop restores it at /// the next checkpoint, so the run still ends halted after that one turn. pub(crate) deferred_halt: Option, + /// Pauses the repeat / no-progress guards sent since the last resume, so + /// the loop can tell guard pauses from a host's at the same checkpoint. + pub(crate) guard_pauses: usize, /// Set by a no-progress / repeat guard that paused the run, holding its /// root-cause summary, so the loop can report `TerminalReason::Halted`. pub(crate) halted_by_guard: Option, diff --git a/crates/tinyagents-harness/src/middleware/library/failure_scope.rs b/crates/tinyagents-harness/src/middleware/library/failure_scope.rs index 34b3172f8..293fadb44 100644 --- a/crates/tinyagents-harness/src/middleware/library/failure_scope.rs +++ b/crates/tinyagents-harness/src/middleware/library/failure_scope.rs @@ -328,6 +328,7 @@ impl FailureScopeMiddleware { ctx.halted_by_guard = Some(first); ctx.halted_by_guard_cause = owns_first.then_some(cause).flatten(); } + ctx.guard_pauses += 1; self.handle.send(SteeringCommand::Pause); } diff --git a/crates/tinyagents-harness/src/middleware/library/repeat_escalation_loop_tests.rs b/crates/tinyagents-harness/src/middleware/library/repeat_escalation_loop_tests.rs index a1da54457..85ccacbd8 100644 --- a/crates/tinyagents-harness/src/middleware/library/repeat_escalation_loop_tests.rs +++ b/crates/tinyagents-harness/src/middleware/library/repeat_escalation_loop_tests.rs @@ -226,11 +226,71 @@ impl Middleware<(), ()> for HostPauseAtHalt { } } +/// Sends a host pause from inside the deliver-first turn, after its model call. +struct HostPauseInFinalTurn(SteeringHandle); + +#[async_trait] +impl Middleware<(), ()> for HostPauseInFinalTurn { + fn name(&self) -> &str { + "host_pause_in_final_turn" + } + + async fn after_model( + &self, + ctx: &mut RunContext<()>, + _state: &(), + _response: &mut ModelResponse, + ) -> TaResult<()> { + if ctx.deferred_halt.is_some() { + self.0.send(crate::steering::SteeringCommand::PauseWith { + reason: "host review".into(), + }); + } + Ok(()) + } +} + +/// A second guard that halts on the same call as the repeat guard: it marks +/// the run and pauses the way the library guards do. +struct GuardPauseAtHalt(SteeringHandle); + +#[async_trait] +impl Middleware<(), ()> for GuardPauseAtHalt { + fn name(&self) -> &str { + "guard_pause_at_halt" + } + + async fn after_tool( + &self, + ctx: &mut RunContext<()>, + _state: &(), + _invocation: &ToolInvocationIdentity, + result: &mut ToolResult, + ) -> TaResult<()> { + if repeat_guard_marker(result) == Some("halted") && !ctx.deliver_first_attempted { + ctx.halted_by_guard + .get_or_insert_with(|| "second guard".into()); + ctx.guard_pauses += 1; + self.0.send(crate::steering::SteeringCommand::Pause); + } + Ok(()) + } +} + +/// What else a deliver-first run has registered next to its repeat guard. +#[derive(Clone, Copy, PartialEq)] +enum Alongside { + Nothing, + HostPauseAtHalt, + HostPauseInFinalTurn, + SecondGuard, +} + /// A run whose guard halts on attempt 6, with the deliver-first turn turned /// on; `after_halt` are the model's responses from the deliver-first turn on. async fn deliver_first_run( after_halt: Vec, - host_pause: bool, + alongside: Alongside, ) -> (crate::middleware::AgentRun, u32) { let steering = SteeringHandle::allow_all(); let tool = Arc::new(CountingTool { @@ -256,8 +316,17 @@ async fn deliver_first_run( Arc::new(Mutex::new(None)), Arc::new(|_| false), ))); - if host_pause { - harness.push_middleware(Arc::new(HostPauseAtHalt(steering.clone()))); + match alongside { + Alongside::Nothing => {} + Alongside::HostPauseAtHalt => { + harness.push_middleware(Arc::new(HostPauseAtHalt(steering.clone()))); + } + Alongside::HostPauseInFinalTurn => { + harness.push_middleware(Arc::new(HostPauseInFinalTurn(steering.clone()))); + } + Alongside::SecondGuard => { + harness.push_middleware(Arc::new(GuardPauseAtHalt(steering.clone()))); + } } let ctx = RunContext::new(RunConfig::new("deliver-first"), ()).with_steering(steering); let run = harness @@ -278,7 +347,11 @@ fn nudges(run: &crate::middleware::AgentRun) -> usize { #[tokio::test] async fn an_opted_in_guard_halt_grants_one_turn_to_deliver() { - let (run, _) = deliver_first_run(vec![ModelResponse::assistant("final answer")], false).await; + let (run, _) = deliver_first_run( + vec![ModelResponse::assistant("final answer")], + Alongside::Nothing, + ) + .await; assert_eq!(nudges(&run), 1); assert_eq!(run.messages.last().unwrap().text(), "final answer"); @@ -295,7 +368,7 @@ async fn the_deliver_first_turn_is_the_last_one_even_when_it_calls_a_tool() { new_call.message.tool_calls = vec![ToolCall::new("call-6", "lookup", json!({"id": 2}))]; let (run, runs) = deliver_first_run( vec![new_call, ModelResponse::assistant("never asked for")], - false, + Alongside::Nothing, ) .await; @@ -314,10 +387,46 @@ async fn the_deliver_first_turn_is_the_last_one_even_when_it_calls_a_tool() { #[tokio::test] async fn a_host_pause_at_the_halting_checkpoint_is_not_lifted() { - let (run, runs) = - deliver_first_run(vec![ModelResponse::assistant("never asked for")], true).await; + let (run, runs) = deliver_first_run( + vec![ModelResponse::assistant("never asked for")], + Alongside::HostPauseAtHalt, + ) + .await; assert_eq!(nudges(&run), 0, "no deliver-first turn over a host pause"); assert_eq!(runs, 4); assert!(run.paused.is_some()); } + +#[tokio::test] +async fn two_guards_halting_together_still_grant_the_deliver_first_turn() { + let (run, _) = deliver_first_run( + vec![ModelResponse::assistant("final answer")], + Alongside::SecondGuard, + ) + .await; + + assert_eq!(nudges(&run), 1, "two guard pauses are not a host pause"); + assert_eq!(run.messages.last().unwrap().text(), "final answer"); +} + +#[tokio::test] +async fn a_host_pause_in_the_deliver_first_turn_still_reports_the_guard_halt() { + use crate::terminal::TerminalReason; + let (run, _) = deliver_first_run( + vec![ModelResponse::assistant("final answer")], + Alongside::HostPauseInFinalTurn, + ) + .await; + + assert_eq!(nudges(&run), 1); + assert_eq!( + run.paused + .as_ref() + .and_then(|pause| pause.reason.as_deref()), + Some("host review"), + "the host's pause is kept" + ); + assert_eq!(run.terminal.unwrap().reason, TerminalReason::Halted); + assert!(run.failure_cause.is_some(), "the guard's cause is reported"); +} diff --git a/crates/tinyagents-harness/src/middleware/library/repeat_progress.rs b/crates/tinyagents-harness/src/middleware/library/repeat_progress.rs index 5514c915c..0c172a799 100644 --- a/crates/tinyagents-harness/src/middleware/library/repeat_progress.rs +++ b/crates/tinyagents-harness/src/middleware/library/repeat_progress.rs @@ -216,6 +216,7 @@ impl RepeatProgressMiddleware { ctx.halted_by_guard = Some(first.clone()); ctx.halted_by_guard_cause = cause.filter(|cause| cause.summary == first); } + ctx.guard_pauses += 1; self.handle.send(SteeringCommand::Pause); } } From 82ff67defba909b11399374e4a677589fb2adc4c Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 11 Nov 2026 18:14:23 +0530 Subject: [PATCH 4/4] Keep guard-pause ownership on the steering handle, cleared by a resume The run context counted the pauses guards sent, but a resume cleared only the handle's pause count. A guard pause, a host Resume and a host PauseWith at one checkpoint then left both counts at one, and the deliver-first turn could lift the host's pause. The guards now record their pauses on the steering handle (which their clones share with the run's), and a resume clears that record together with the pause count, so a host pause applied after a resume is never taken for a guard's. A guard pause queued behind a resume is then not counted either, which can only withhold the extra turn. --- .../src/agent_loop/run_loop.rs | 3 +-- crates/tinyagents-harness/src/context/mod.rs | 1 - .../tinyagents-harness/src/context/types.rs | 3 --- .../src/middleware/library/failure_scope.rs | 2 +- .../library/repeat_escalation_loop_tests.rs | 2 +- .../src/middleware/library/repeat_progress.rs | 2 +- crates/tinyagents-harness/src/steering/mod.rs | 18 ++++++++++++++++ .../src/steering/mod_tests.rs | 21 +++++++++++++++++++ .../tinyagents-harness/src/steering/types.rs | 4 ++++ 9 files changed, 47 insertions(+), 9 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/run_loop.rs b/crates/tinyagents-harness/src/agent_loop/run_loop.rs index dcb10c341..b0e5e0993 100644 --- a/crates/tinyagents-harness/src/agent_loop/run_loop.rs +++ b/crates/tinyagents-harness/src/agent_loop/run_loop.rs @@ -397,7 +397,7 @@ impl AgentHarness { let only_guard_pauses = ctx .steering .as_ref() - .is_some_and(|handle| handle.pause_requests() == ctx.guard_pauses); + .is_some_and(|handle| handle.pause_requests() == handle.guard_pauses()); if !(self.policy.deliver_first_on_guard_halt && only_guard_pauses && ctx.halted_by_guard.is_some() @@ -407,7 +407,6 @@ impl AgentHarness { return Ok(LoopExit::Paused(pause)); } ctx.deliver_first_attempted = true; - ctx.guard_pauses = 0; ctx.deferred_halt = Some(crate::context::DeferredHalt { summary: ctx.take_halted_by_guard().unwrap_or_default(), cause: ctx.take_halted_by_guard_cause(), diff --git a/crates/tinyagents-harness/src/context/mod.rs b/crates/tinyagents-harness/src/context/mod.rs index 210ed5e5c..8fd970858 100644 --- a/crates/tinyagents-harness/src/context/mod.rs +++ b/crates/tinyagents-harness/src/context/mod.rs @@ -374,7 +374,6 @@ impl RunContext { turns: crate::agent_loop::TurnTracker::default(), deliver_first_attempted: false, deferred_halt: None, - guard_pauses: 0, 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 7fcfe6955..111f41875 100644 --- a/crates/tinyagents-harness/src/context/types.rs +++ b/crates/tinyagents-harness/src/context/types.rs @@ -541,9 +541,6 @@ pub struct RunContext { /// The guard halt a deliver-first turn deferred. The loop restores it at /// the next checkpoint, so the run still ends halted after that one turn. pub(crate) deferred_halt: Option, - /// Pauses the repeat / no-progress guards sent since the last resume, so - /// the loop can tell guard pauses from a host's at the same checkpoint. - pub(crate) guard_pauses: usize, /// Set by a no-progress / repeat guard that paused the run, holding its /// root-cause summary, so the loop can report `TerminalReason::Halted`. pub(crate) halted_by_guard: Option, diff --git a/crates/tinyagents-harness/src/middleware/library/failure_scope.rs b/crates/tinyagents-harness/src/middleware/library/failure_scope.rs index 293fadb44..5ddeb5bff 100644 --- a/crates/tinyagents-harness/src/middleware/library/failure_scope.rs +++ b/crates/tinyagents-harness/src/middleware/library/failure_scope.rs @@ -328,7 +328,7 @@ impl FailureScopeMiddleware { ctx.halted_by_guard = Some(first); ctx.halted_by_guard_cause = owns_first.then_some(cause).flatten(); } - ctx.guard_pauses += 1; + self.handle.note_guard_pause(); self.handle.send(SteeringCommand::Pause); } diff --git a/crates/tinyagents-harness/src/middleware/library/repeat_escalation_loop_tests.rs b/crates/tinyagents-harness/src/middleware/library/repeat_escalation_loop_tests.rs index 85ccacbd8..3423d6c16 100644 --- a/crates/tinyagents-harness/src/middleware/library/repeat_escalation_loop_tests.rs +++ b/crates/tinyagents-harness/src/middleware/library/repeat_escalation_loop_tests.rs @@ -270,7 +270,7 @@ impl Middleware<(), ()> for GuardPauseAtHalt { if repeat_guard_marker(result) == Some("halted") && !ctx.deliver_first_attempted { ctx.halted_by_guard .get_or_insert_with(|| "second guard".into()); - ctx.guard_pauses += 1; + self.0.note_guard_pause(); self.0.send(crate::steering::SteeringCommand::Pause); } Ok(()) diff --git a/crates/tinyagents-harness/src/middleware/library/repeat_progress.rs b/crates/tinyagents-harness/src/middleware/library/repeat_progress.rs index 0c172a799..fca6691fe 100644 --- a/crates/tinyagents-harness/src/middleware/library/repeat_progress.rs +++ b/crates/tinyagents-harness/src/middleware/library/repeat_progress.rs @@ -216,7 +216,7 @@ impl RepeatProgressMiddleware { ctx.halted_by_guard = Some(first.clone()); ctx.halted_by_guard_cause = cause.filter(|cause| cause.summary == first); } - ctx.guard_pauses += 1; + self.handle.note_guard_pause(); self.handle.send(SteeringCommand::Pause); } } diff --git a/crates/tinyagents-harness/src/steering/mod.rs b/crates/tinyagents-harness/src/steering/mod.rs index a9ae2b474..8a4713e4c 100644 --- a/crates/tinyagents-harness/src/steering/mod.rs +++ b/crates/tinyagents-harness/src/steering/mod.rs @@ -332,6 +332,7 @@ impl SteeringHandle { pub fn resume(&self) -> Option { let cleared = self.lock_paused().take(); *self.lock_pause_requests() = 0; + *self.lock_guard_pauses() = 0; if cleared.is_some() { tracing::debug!(target: "tinyagents::steering", "[steering] pause cleared by resume"); } @@ -415,6 +416,23 @@ impl SteeringHandle { *self.lock_pause_requests() } + /// Records that a repeat / no-progress guard is sending a pause. + pub(crate) fn note_guard_pause(&self) { + *self.lock_guard_pauses() += 1; + } + + /// How many guard pauses were sent since the last resume. + pub(crate) fn guard_pauses(&self) -> usize { + *self.lock_guard_pauses() + } + + fn lock_guard_pauses(&self) -> std::sync::MutexGuard<'_, usize> { + self.local + .guard_pauses + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + fn lock_pause_requests(&self) -> std::sync::MutexGuard<'_, usize> { self.local .pause_requests diff --git a/crates/tinyagents-harness/src/steering/mod_tests.rs b/crates/tinyagents-harness/src/steering/mod_tests.rs index 64784f612..352697a09 100644 --- a/crates/tinyagents-harness/src/steering/mod_tests.rs +++ b/crates/tinyagents-harness/src/steering/mod_tests.rs @@ -926,3 +926,24 @@ fn a_new_run_on_a_reused_handle_does_not_inherit_the_previous_runs_switch() { let second: RunContext = RunContext::new(RunConfig::new("second"), ()).with_steering(handle); assert_eq!(override_of(&second), None); } + +#[test] +fn a_resume_clears_guard_pause_ownership_before_a_later_host_pause() { + // A guard pause, then a host resume and a host pause at one checkpoint: + // the pause left latched is the host's, so none of it is the guard's. + let handle = SteeringHandle::allow_all(); + handle.note_guard_pause(); + handle.send(SteeringCommand::Pause); + handle.send(SteeringCommand::Resume); + handle.send(SteeringCommand::PauseWith { + reason: "host review".into(), + }); + let mut ctx: RunContext = + RunContext::new(RunConfig::new("r"), ()).with_steering(handle.clone()); + + let outcome = apply_pending_steering(&mut ctx, &mut Vec::new()).unwrap(); + + assert_eq!(outcome, SteeringOutcome::Pause); + assert_eq!(handle.pause_requests(), 1); + assert_eq!(handle.guard_pauses(), 0); +} diff --git a/crates/tinyagents-harness/src/steering/types.rs b/crates/tinyagents-harness/src/steering/types.rs index 36708512d..e4b6cf3db 100644 --- a/crates/tinyagents-harness/src/steering/types.rs +++ b/crates/tinyagents-harness/src/steering/types.rs @@ -342,6 +342,10 @@ pub(crate) struct SteeringLocal { /// Pause commands applied since the last resume, so the loop can tell a /// guard's lone pause from one a host sent at the same checkpoint. pub(crate) pause_requests: Mutex, + /// Pauses a repeat / no-progress guard sent since the last resume. A + /// resume clears them with `pause_requests`, so a host pause applied after + /// it is never counted as a guard's. + pub(crate) guard_pauses: Mutex, /// The model a [`SteeringCommand::SwitchModel`] selected for *this* run, /// sticky until replaced or rejected by the model call. Per run like the /// pause latch, so it never crosses a [`SteeringHandle::for_child`]