Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 74 additions & 2 deletions crates/tinyagents-harness/src/agent_loop/run_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<State: Send + Sync, Ctx: Send + Sync> AgentHarness<State, Ctx> {
/// Whether this run collapses the `tool_search` results it wrote when it
/// ends: discovery is on and the session's overrides resolve
Expand Down Expand Up @@ -129,6 +138,14 @@ impl<State: Send + Sync, Ctx: Send + Sync> AgentHarness<State, Ctx> {
// 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()
Expand Down Expand Up @@ -342,7 +359,23 @@ impl<State: Send + Sync, Ctx: Send + Sync> AgentHarness<State, Ctx> {
// 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()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
{
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);
}
Expand All @@ -355,7 +388,46 @@ impl<State: Send + Sync, Ctx: Send + Sync> AgentHarness<State, Ctx> {
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 => {}
}
Expand Down
2 changes: 2 additions & 0 deletions crates/tinyagents-harness/src/context/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,8 @@ impl<Ctx> RunContext<Ctx> {
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),
Expand Down
16 changes: 15 additions & 1 deletion crates/tinyagents-harness/src/context/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -534,6 +534,13 @@ pub struct RunContext<Ctx = ()> {
/// 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<DeferredHalt>,
/// 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<String>,
Expand Down Expand Up @@ -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<crate::no_progress::ClassifiedStopCause>,
pub(crate) pause: PauseState,
}
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ impl<Ctx: Send + Sync> FailureScopeMiddleware<Ctx> {
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);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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};

Expand Down Expand Up @@ -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<ModelResponse>,
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::<Vec<_>>(),
)),
)));
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");
}
Loading
Loading