diff --git a/crates/tinyagents-harness/src/agent_loop/run_loop.rs b/crates/tinyagents-harness/src/agent_loop/run_loop.rs index 11b333e55..b0e5e0993 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 { /// Whether this run collapses the `tool_search` results it wrote when it /// ends: discovery is on and the session's overrides resolve @@ -129,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() @@ -342,7 +359,23 @@ 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); } @@ -355,7 +388,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 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() == handle.guard_pauses()); + if !(self.policy.deliver_first_on_guard_halt + && only_guard_pauses + && 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 dc4d37f0e..8fd970858 100644 --- a/crates/tinyagents-harness/src/context/mod.rs +++ b/crates/tinyagents-harness/src/context/mod.rs @@ -372,6 +372,8 @@ impl RunContext { host_authority: None, 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 a53e3de29..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,6 +534,13 @@ 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`. pub(crate) halted_by_guard: Option, @@ -685,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/failure_scope.rs b/crates/tinyagents-harness/src/middleware/library/failure_scope.rs index 34b3172f8..5ddeb5bff 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(); } + 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 7b5ddeff4..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 @@ -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,234 @@ 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(()) + } +} + +/// 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()); + self.0.note_guard_pause(); + 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, + alongside: Alongside, +) -> (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), + ))); + 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 + .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")], + Alongside::Nothing, + ) + .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")], + Alongside::Nothing, + ) + .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")], + 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..fca6691fe 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); } + self.handle.note_guard_pause(); self.handle.send(SteeringCommand::Pause); } } 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..8a4713e4c 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,8 @@ impl SteeringHandle { /// pause is in effect. 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"); } @@ -408,6 +411,35 @@ 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() + } + + /// 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 + .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/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 d75684865..e4b6cf3db 100644 --- a/crates/tinyagents-harness/src/steering/types.rs +++ b/crates/tinyagents-harness/src/steering/types.rs @@ -339,6 +339,13 @@ 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, + /// 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`]