diff --git a/crates/tinyagents-harness/src/middleware/library/README.md b/crates/tinyagents-harness/src/middleware/library/README.md index 2d5b1acff..29d9b0291 100644 --- a/crates/tinyagents-harness/src/middleware/library/README.md +++ b/crates/tinyagents-harness/src/middleware/library/README.md @@ -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 diff --git a/crates/tinyagents-harness/src/middleware/library/failure_scope.rs b/crates/tinyagents-harness/src/middleware/library/failure_scope.rs index 34b3172f8..7b0581046 100644 --- a/crates/tinyagents-harness/src/middleware/library/failure_scope.rs +++ b/crates/tinyagents-harness/src/middleware/library/failure_scope.rs @@ -211,7 +211,8 @@ pub trait FailureScopePolicy: 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) {} } @@ -613,8 +614,15 @@ impl Middleware<(), Ctx> for FailureScopeMiddleware { &self, ctx: &mut RunContext, _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(()) @@ -634,3 +642,7 @@ impl Middleware<(), Ctx> for FailureScopeMiddleware { #[cfg(test)] #[path = "failure_scope_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "failure_scope_finish_tests.rs"] +mod finish_tests; diff --git a/crates/tinyagents-harness/src/middleware/library/failure_scope_finish_tests.rs b/crates/tinyagents-harness/src/middleware/library/failure_scope_finish_tests.rs new file mode 100644 index 000000000..a7f60199d --- /dev/null +++ b/crates/tinyagents-harness/src/middleware/library/failure_scope_finish_tests.rs @@ -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>, +} + +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 { + 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, + recorded: Option, +) -> 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); +}