Skip to content
Draft
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
3 changes: 3 additions & 0 deletions crates/tinyagents-harness/src/middleware/library/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ Grouped by extension shape:
call-id map. It owns per-run no-progress, classified-recovery, and recoverable
failure trackers, typed halts, and one-shot request nudges. Host policy also
supplies lifecycle callbacks for stop-latch batch/refusal/progress evidence.
Before `finish_run` clears host evidence, `after_agent` preserves a current
classified halt cause on `AgentRun::failure_cause`, retaining any cause the
loop already recorded.
Construct a **new driver per run** because its steering handle and
halt-summary slot are run specific.
- `run_output_ladder` — the shared compact, persist-or-cap, disclose sequence
Expand Down
16 changes: 14 additions & 2 deletions crates/tinyagents-harness/src/middleware/library/failure_scope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,8 @@ pub trait FailureScopePolicy<Ctx: Send + Sync>: FailurePolicy {
}

/// Called when the run ends or errors so a host stop-latch adapter can
/// discard its run-specific evidence.
/// discard its run-specific evidence. On `after_agent`, the driver copies
/// any current classified cause into the run before calling this hook.
fn finish_run(&self, _ctx: &RunContext<Ctx>) {}
}

Expand Down Expand Up @@ -613,8 +614,15 @@ impl<Ctx: Send + Sync> Middleware<(), Ctx> for FailureScopeMiddleware<Ctx> {
&self,
ctx: &mut RunContext<Ctx>,
_state: &(),
_run: &mut crate::middleware::AgentRun,
run: &mut crate::middleware::AgentRun,
) -> Result<()> {
// A host callback may have latched the cause after the loop last
// observed it. Capture it while the policy still owns that evidence;
// finish_run is allowed to clear the latch. Keep a cause already
// recorded by the loop or another middleware authoritative.
if run.failure_cause.is_none() {
run.failure_cause = self.policy.current_halt_cause(ctx);
}
self.policy.finish_run(ctx);
self.clear_run(ctx.instance_id());
Ok(())
Expand All @@ -634,3 +642,7 @@ impl<Ctx: Send + Sync> Middleware<(), Ctx> for FailureScopeMiddleware<Ctx> {
#[cfg(test)]
#[path = "failure_scope_tests.rs"]
mod tests;

#[cfg(test)]
#[path = "failure_scope_finish_tests.rs"]
mod finish_tests;
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
use super::*;

use crate::context::RunConfig;
use crate::middleware::AgentRun;
use crate::no_progress::ClassifiedStopCause;

#[derive(Default)]
struct LatchPolicy {
cause: Mutex<Option<ClassifiedStopCause>>,
}

impl FailurePolicy for LatchPolicy {
fn identical_halt_summary(&self, tool: &str, count: u32, _failure_text: &str) -> String {
format!("{tool} failed {count} times")
}

fn no_progress_halt_summary(&self, count: u32, tool: &str, _failure_text: &str) -> String {
format!("{tool} made no progress in {count} calls")
}
}

impl FailureScopePolicy<()> for LatchPolicy {
fn assess(
&self,
_ctx: &RunContext<()>,
_invocation: &ToolInvocationIdentity,
_call: &ToolCall,
_scope: &FailureScope,
_result: &ToolResult,
) -> FailureAssessment {
FailureAssessment::Ignore
}

fn current_halt_cause(&self, _ctx: &RunContext<()>) -> Option<ClassifiedStopCause> {
self.cause.lock().unwrap().clone()
}

fn finish_run(&self, _ctx: &RunContext<()>) {
*self.cause.lock().unwrap() = None;
}
}

fn cause(class: &str) -> ClassifiedStopCause {
ClassifiedStopCause {
class: class.into(),
operation: "send_message".into(),
summary: format!("Stopped after {class}"),
}
}

async fn finish(
latched: Option<ClassifiedStopCause>,
recorded: Option<ClassifiedStopCause>,
) -> AgentRun {
let policy = Arc::new(LatchPolicy {
cause: Mutex::new(latched),
});
let middleware = FailureScopeMiddleware::new(
SteeringHandle::allow_all(),
Arc::new(Mutex::new(None)),
policy.clone(),
);
let mut ctx = RunContext::new(RunConfig::new("finish-cause"), ());
let mut run = AgentRun {
failure_cause: recorded,
..AgentRun::default()
};

middleware
.after_agent(&mut ctx, &(), &mut run)
.await
.unwrap();

assert!(
policy.cause.lock().unwrap().is_none(),
"finish must still discard the host's per-run latch"
);
run
}

#[tokio::test]
async fn classified_cause_survives_host_latch_cleanup() {
let expected = cause("uncertain_side_effect");
let run = finish(Some(expected.clone()), None).await;
assert_eq!(run.failure_cause, Some(expected));
}

#[tokio::test]
async fn finish_preserves_an_already_recorded_cause() {
let recorded = cause("provider_out_of_budget");
let run = finish(Some(cause("uncertain_side_effect")), Some(recorded.clone())).await;
assert_eq!(run.failure_cause, Some(recorded));
}

#[tokio::test]
async fn unclassified_finish_does_not_invent_a_cause() {
assert_eq!(finish(None, None).await.failure_cause, None);
}
Loading