Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
e972aa3
feat(no_progress): add outcome fingerprinter and volatile span normal…
senamakel Oct 6, 2026
e4be3b0
feat(no_progress): normalize volatile spans in outcome fingerprints
senamakel Oct 6, 2026
9d8fdf9
feat(no-progress): add volatility-aware OutcomeFingerprinter
senamakel Oct 6, 2026
e1b2924
feat(no_progress): add volatility-aware outcome fingerprinting
senamakel Oct 6, 2026
a5706f1
test(repeat-progress): vary tool output per round in recurrence tests
senamakel Oct 6, 2026
e7ebab3
feat(harness): detect repeated successful tool calls as no progress
senamakel Oct 6, 2026
5f835c9
feat(harness): add repeat progress middleware
senamakel Oct 6, 2026
be7c05d
feat(no_progress): detect repeated successful tool calls
senamakel Oct 6, 2026
f379aaa
feat(harness): add no-progress detection middleware
senamakel Oct 6, 2026
d3b535d
feat(no_progress): add fingerprinting for repeated tool calls
senamakel Oct 6, 2026
111f1cc
test(no_progress): use uuid-shaped request ids in recurrence test
senamakel Oct 6, 2026
11cbf79
Merge remote-tracking branch 'refs/remotes/upstream/main' into pr/304
senamakel Oct 7, 2026
e630b07
Fix outcome fingerprint volatility scope
senamakel Oct 7, 2026
ac0a498
Fix volatile clock and duration fingerprints
senamakel Oct 7, 2026
c8c139c
fix(harness): normalize log duration fingerprints
senamakel Oct 7, 2026
a6f3bb4
fix(harness): preserve state-bearing event timestamps
senamakel Oct 7, 2026
477f5d2
Fix state-aware outcome fingerprinting
senamakel Oct 7, 2026
436d608
Clarify verbatim fingerprinter example
senamakel Oct 7, 2026
2ca7e7e
Preserve state timestamps in outcome fingerprints
senamakel Oct 7, 2026
f414e30
Preserve process creation PIDs in outcome fingerprints
senamakel Oct 7, 2026
4e64e6c
Fix JSON fingerprint context handling
senamakel Oct 7, 2026
135b789
Fix volatile fingerprint context handling
senamakel Oct 7, 2026
555f09c
Preserve returned run and session identities
senamakel Oct 7, 2026
d103154
fix(no_progress): recognize state fields with flexible separators
senamakel Oct 7, 2026
073105c
Fix outcome fingerprint context matching
senamakel Oct 7, 2026
7f15159
Fix camelCase state timestamp fingerprints
senamakel Oct 7, 2026
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: 2 additions & 1 deletion crates/tinyagents-harness/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,8 @@ pub use model_registry::{ModelRegistry, ModelSelection, ResolvedModelBinding};
pub use no_progress::{
ClassifiedFailure, ClassifiedFailureTracker, DEFAULT_IDENTICAL_HALT_THRESHOLD,
DEFAULT_REPEAT_CALL_THRESHOLD, DEFAULT_REPEAT_OUTPUT_THRESHOLD, NoProgress, NoProgressTracker,
StreamTextStallDetector, SuccessfulRepeat, SuccessfulRepeatTracker, ToolAttempt,
OutcomeFingerprinter, StreamTextStallDetector, SuccessfulRepeat, SuccessfulRepeatTracker,
ToolAttempt, VolatileSpanNormalizer,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority critical security confident

Keep attempt-label capitalization identical

Exporting VolatileSpanNormalizer exposes the default fingerprinting behavior, whose case-insensitive attempt rule replaces labels such as Attempt 1 and attempt 1 with the same <attempt> placeholder. Those can be distinct tool outcomes, so the no-progress tracker can incorrectly classify progress as a repeat. Preserve the original capitalization when normalizing the attempt label, or otherwise include it in the fingerprint.

[RULE] identity-collision ·

};
pub use observability::replay::{
RunEventsPage, list_active_runs, read_run_events_page, read_run_status,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ use async_trait::async_trait;
use crate::context::RunContext;
use crate::error::Result as TaResult;
use crate::middleware::{Middleware, ToolInvocationIdentity};
use crate::no_progress::{SuccessfulRepeat, SuccessfulRepeatTracker, fingerprint_arguments};
use crate::no_progress::{
OutcomeFingerprinter, SuccessfulRepeat, SuccessfulRepeatTracker, VolatileSpanNormalizer,
fingerprint_arguments,
};
use crate::steering::{SteeringCommand, SteeringHandle};
use tinyinference_llm::message::{ContentBlock, Message};
use tinyinference_llm::model::{ModelRequest, ModelResponse};
Expand Down Expand Up @@ -143,6 +146,8 @@ pub struct RepeatProgressMiddleware {
halt_summary: HaltSummarySlot,
exempt: RepeatExemption,
state: Arc<RepeatState>,
/// Reduces a tool result to the identity the recurrence ledger keys on.
fingerprinter: Arc<dyn OutcomeFingerprinter>,
/// Batch bookkeeping bridging `after_model` → `after_tool` for the call guard.
pending: Mutex<HashMap<u64, PendingCallBatch>>,
}
Expand All @@ -161,10 +166,20 @@ impl RepeatProgressMiddleware {
halt_summary,
exempt,
state: Arc::new(RepeatState::new(DEFAULT_CLEARED_PLACEHOLDER)),
fingerprinter: Arc::new(VolatileSpanNormalizer),
pending: Mutex::default(),
}
}

/// Replaces the fingerprinter the recurrence ledger uses to compare tool
/// results. The default ignores volatile spans (timestamps, durations,
/// request ids), so a result that differs only by those still counts as
/// the same result.
pub fn with_fingerprinter(mut self, fingerprinter: Arc<dyn OutcomeFingerprinter>) -> Self {
self.fingerprinter = fingerprinter;
self
}

/// Override the placeholder body treated as an evicted tool result. Must be
/// called before [`eviction_observer`](Self::eviction_observer).
pub fn with_cleared_placeholder(mut self, placeholder: impl Into<String>) -> Self {
Expand Down Expand Up @@ -340,6 +355,8 @@ impl<C: Send + Sync> Middleware<(), C> for RepeatProgressMiddleware {
) -> TaResult<()> {
let tool_name = invocation.tool_name();
let call_id = invocation.call_id().to_string();
// Fingerprint outside the mutexes below: it scans the whole result.
let identity = (!result.is_error).then(|| self.fingerprinter.fingerprint(&result.output()));
Comment thread
senamakel marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority medium tests uncertain

Fingerprint error results instead of collapsing them to empty

Error results still bypass the fingerprinter and are recorded under the empty identity (identity.as_deref().unwrap_or_default()), so every erroring call with the same call signature hashes identically in the recurrence ledger. Three calls that fail with different error outputs now count as three recurrences of "the same result" and can trip a successful-repeat halt, whereas the previous code compared the raw outputs and kept distinct failures distinct. Fingerprinting result.output() unconditionally — the tracker's failure ladder already handles errors separately — would restore the old behaviour; alternatively skip recording error results in the recurrence ledger entirely if that is the intent.

Suggested change
let identity = (!result.is_error).then(|| self.fingerprinter.fingerprint(&result.output()));
let identity = self.fingerprinter.fingerprint(&result.output());

[RULE] error-collapsed-identity ·

// Fold this result into the pending batch; the call guard only acts once
// the batch is complete so it sees whole-batch success.
let (already_halted, recurrence, completed) = {
Expand All @@ -362,7 +379,7 @@ impl<C: Send + Sync> Middleware<(), C> for RepeatProgressMiddleware {
recurrence = trackers
.entry(ctx.instance_id())
.or_default()
.record_call_outcome(&sig, &result.output());
.record_call_identity(&sig, identity.as_deref().unwrap_or_default());
}
if let Ok(mut recorded) = self.state.recorded.lock() {
recorded
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,3 +263,73 @@ async fn successful_repeat_tracker_resets_failed_and_exempt_batches() {
"polling tools remain exempt from successful-repeat halts"
);
}

// ── Volatility-aware outcome fingerprinting ─────────────────────────────

#[tokio::test]
async fn identical_results_with_fresh_timestamps_halt_on_recurrence() {
let handle = SteeringHandle::allow_all();
let summary = Arc::new(std::sync::Mutex::new(None));
let mw = new_mw(handle.clone(), summary.clone());
for i in 0..DEFAULT_REPEAT_CALL_THRESHOLD {
run_alternating_round(
&mw,
&format!("doc fetched at 2026-10-06T12:00:{:02}Z in {}ms", i, 10 + i),
&format!("hits-{i}"),
Comment thread
senamakel marked this conversation as resolved.
)
.await;
}
assert_eq!(
drain_pause_count(&handle),
1,
"a result that differs only by timestamp and duration is the same result"
);
}

#[tokio::test]
async fn custom_fingerprinter_is_honored_by_the_middleware() {
struct Verbatim;
impl crate::no_progress::OutcomeFingerprinter for Verbatim {
fn fingerprint(&self, outcome: &str) -> String {
outcome.to_string()
}
}
let handle = SteeringHandle::allow_all();
let mw = new_mw(handle.clone(), Arc::new(std::sync::Mutex::new(None)))
.with_fingerprinter(Arc::new(Verbatim));
for i in 0..DEFAULT_REPEAT_CALL_THRESHOLD * 2 {
run_alternating_round(
&mw,
&format!("doc fetched at 2026-10-06T12:00:{i:02}Z"),
&format!("hits-{i}"),
)
.await;
}
assert_eq!(
drain_pause_count(&handle),
0,
"with a verbatim fingerprinter, fresh timestamps keep each result distinct"
);
}

#[tokio::test]
async fn distinct_hex_only_results_do_not_halt_on_recurrence() {
let handle = SteeringHandle::allow_all();
let mw = new_mw(handle.clone(), Arc::new(std::sync::Mutex::new(None)));
for (i, sha) in [
"0123456789abcdef0123456789abcdef01234567",
"fedcba9876543210fedcba9876543210fedcba98",
"a1b2c3d4e5f60718293a4b5c6d7e8f9012345678",
"99999999aaaaaaaa99999999aaaaaaaa99999999",
]
.into_iter()
.enumerate()
{
run_alternating_round(&mw, sha, &format!("hits-{i}")).await;
}
assert_eq!(
Comment thread
senamakel marked this conversation as resolved.
drain_pause_count(&handle),
0,
"three different commit ids are three different results"
);
}
38 changes: 38 additions & 0 deletions crates/tinyagents-harness/src/no_progress/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,44 @@ hook" section in `mod.rs` for that contract.
state for one turn. `new(identical_halt_threshold)` builds it,
`record(step, &ToolAttempt) -> NoProgress` feeds one outcome and returns the
verdict, `reset()` clears all counters (called internally after a halt).
- [`OutcomeFingerprinter`] — pluggable reduction of a tool outcome to the
identity the trackers compare. The default, [`VolatileSpanNormalizer`],
blanks timestamps, clock times, 10/13-digit epochs that are the value of a
time-like key (`ts=`, `"timestamp":`, `updated_at:`; a bare 10-digit number
is a byte count or an id as often as a clock), measurement durations (`took
123ms`, `duration=1.2s`), `attempt N` / `retry N of M`, diagnostic `pid N`
values (while preserving PIDs in process-creation results), and UUIDs in
request/trace-style fields, and leaves every other
number alone, including long hex ids such as commit SHAs and checksums, which
are usually the result itself. An outcome that is *only*
volatile (a bare commit id or checksum: fewer than four alphanumeric
characters left outside the spans) is compared verbatim. The normalizer is
deliberately identity-preserving for explicit state timestamps in
`event_at`, `eventat`, `created_at`, `updated_at`, and `timestamp` fields;
this includes quoted JSON forms and whitespace before the colon.
text-based and does not parse JSON, so a volatile field is only blanked when
its value matches one of those patterns (a counter or opaque message id
under an arbitrary key is not). `NoProgressTracker` uses it on the first
error line (identical-failure rung) and `SuccessfulRepeatTracker` on the
Comment thread
senamakel marked this conversation as resolved.
result; both take a replacement through `with_fingerprinter`, and a host
that wants the previous byte-for-byte behavior passes a verbatim
fingerprinter. `NoProgressTracker` fingerprints the complete error message,
including multiline tails, so changing diagnostic detail on a later line is
treated as a different failure. For example:

```rust
struct VerbatimFingerprinter;

impl OutcomeFingerprinter for VerbatimFingerprinter {
fn fingerprint(&self, outcome: &str) -> String {
outcome.to_string()
}
}

let tracker = NoProgressTracker::new(3).with_fingerprinter(
std::sync::Arc::new(VerbatimFingerprinter),
);
```
- [`ClassifiedFailureTracker`] — an additive ledger for equivalent failures
keyed by class, operation, and resource or permission scope. `record` accepts
a class-specific recovery budget; `clear` removes one group only after an
Expand Down
Loading
Loading