Skip to content

feat(tools): add TraceIndex for progressive trace disclosure in judge-based evaluators - #343

Open
pdebjyot wants to merge 4 commits into
strands-agents:mainfrom
pdebjyot:feat/trace-index
Open

feat(tools): add TraceIndex for progressive trace disclosure in judge-based evaluators#343
pdebjyot wants to merge 4 commits into
strands-agents:mainfrom
pdebjyot:feat/trace-index

Conversation

@pdebjyot

@pdebjyot pdebjyot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

feat(tools): TraceIndex — progressive trace disclosure for judge-based evaluators

Branch: feat/trace-index
Builds on: #324 (custom tools= on OutputEvaluator / TrajectoryEvaluator)
Addresses: #342

Problem

When the trajectory handed to a judge-based evaluator is larger than the judge model's context window, the judge call raises ContextWindowOverflowException. Experiment._run_evaluator catches it under error isolation and records the case as score: 0, test_pass: False — indistinguishable from a genuine quality failure. A correct agent response gets a false-negative failing score purely because its trace was too big for the judge to read.

This isn't a theoretical edge. We observe it on real production agent traces, where routine multi-step sessions serialize past a 200K-token judge window and the largest run into the millions of tokens — so under a default Sonnet-class judge, correct agents are being silently scored as failures today. The problem is stack-agnostic: we reproduced identical overflow behavior across strands-evals, DeepEval, and a Langfuse-style managed judge on a shared Bedrock judge model (three distinct error signatures, same trace-size cliff).

The measured evidence below is from a synthetic, deterministic, offline benchmark — no real data — so it's independently reproducible.

What this PR adds

TraceIndex — an in-memory index over a Session that lets a judge read a large trace without inlining it. It is an established pattern: MLflow's Agent-as-a-Judge trace scorers hand the judge ListSpans / GetSpan / SearchTraceRegex, and Zhuge et al.'s Agent-as-a-Judge (arXiv:2410.10934) uses retrieve/read/locate modules to pull only the relevant segments.

from strands_evals.evaluators import OutputEvaluator
from strands_evals.tools.trace_index import TraceIndex

index = TraceIndex(session)  # session: a Session from any provider/mapper

evaluator = OutputEvaluator(
    rubric="Every factual claim must be supported by tool-result evidence in the trace.",
    tools=index.tools,  # list_spans, get_span, search_spans
)

# Compact overview in the prompt instead of the full trajectory:
output = f"{agent_answer}\n\n<TraceOverview>\n{index.overview()}\n</TraceOverview>"
evaluator.evaluate(EvaluationData(input="(from trace)", actual_output=output))
  • overview() — one compact line per span (index, type, tool name, sizes, preview). Always fits the judge context; substituted for the full trajectory.
  • list_spans / get_span / search_spans — discovery tools the judge calls on demand (mirrors the MLflow triad). get_span pages oversized spans via max_read_chars (default 8000) + offset, so no single tool return can itself overflow the judge.
  • Backend-agnostic — consumes any Session a provider/mapper produces.

Two disclosure strategies compose from these pieces: index (substitute overview() into the prompt — portable, works with any judge) and explore (index + discovery tools via #324's tools= — Strands-native, best accuracy).

Evidence

Cross-framework matrix (1344 cells). 200-trace labeled corpus × 3 frameworks (strands-evals, DeepEval, Langfuse-style) × 4 metrics (groundedness, accuracy, trajectory, tool_use) × inline/index/explore, all bound to one shared Bedrock judge. Each metric scored as a binary classifier against planted ground truth (overflow ⇒ wrong prediction):

  • The overflow cliff is stack-agnostic and lands at the same trace size for all three frameworks.
  • index and explore hold accuracy on traces that fit and recover it on traces that overflow (inline is unusable in the overflow bucket; index/explore score correctly).
  • The judge-side explore tools beat a bare index when the decisive fact is buried in a large tool result: on wrong_tool traces the deciding refund amount sits inside a large search result that overview() elides, so the index judge false-fails groundedness (0.83); get_span/search_spans let the judge retrieve it → groundedness 1.00.
  • No degradation on traces that fit: index/explore ≈ inline everywhere inline still works.

Ground-truth A/B (grounded / fabricated claims, evidence buried mid-trace): the index-equipped judge separates grounded from fabricated where the inline judge overflows — captured as the integ test test_judge_reliability_inline_vs_explore.

Known limitation (called out honestly)

TrajectoryEvaluator inlines actual_trajectory unconditionally (case_prompt_template.py:51), so the trajectory metric still overflows on large traces even with the index — the substitution only reaches evaluators that route through the caller-controlled actual_output (the output-family metrics, fixed today). Making the trajectory metric disclosure-aware needs a template change and is proposed as a follow-up; this PR does not change that path.

Separately, this PR adds the capability but does not change _run_evaluator's error handling — distinguishing harness overflow from a quality score: 0 is tracked in #342 as an independent change.

Testing

  • 34 unit tests (overview formatting; list/get/search behavior; offset paging on oversized spans; cross-pattern; end-to-end through OutputEvaluator with tools=index.tools) — all pass.
  • 1 integ A/B test (skips without live Bedrock credentials).
  • ruff check / ruff format clean.

Checklist

… agents

Large agent trajectories overflow a judge model's context window when inlined
into the evaluation prompt, forcing Experiment error-isolation to record the
case as score:0 / test_pass:False — a false failure for a correct agent.

TraceIndex builds an in-memory index over a Session and exposes:
  - overview(): one compact line per span (index, type, tool, sizes, preview)
    that always fits the judge context, substituted for the full trajectory.
  - three discovery tools the judge calls on demand: list_spans / get_span /
    search_spans (mirrors MLflow's ListSpans/GetSpan/SearchTraceRegex).

get_span pages oversized spans via max_read_chars + offset so no single tool
return can itself overflow the judge. Backend-agnostic: consumes any Session
produced by a provider/mapper.
… TraceIndex

Covers overview() formatting, list_spans/get_span/search_spans behavior,
offset paging on oversized spans, and end-to-end use through OutputEvaluator
with tools=index.tools.
Compares OutputEvaluator judging grounded vs fabricated claims two ways —
full trajectory inlined vs overview + discovery tools — asserting the
index-equipped judge separates grounded from fabricated where inline overflows.
Skips without live Bedrock credentials.
@pdebjyot
pdebjyot requested a review from a team as a code owner August 3, 2026 18:42
@pdebjyot
pdebjyot requested a review from mehtarac August 3, 2026 18:42
@github-actions github-actions Bot added area-evaluators Evaluators: output, trajectory, tool use, interactions, and LLM-as-judge quality metrics area-tracing Trace/session ingestion: providers, session mappers, extractors, telemetry/OTEL enhancement New feature or request labels Aug 3, 2026
@mehtarac

Copy link
Copy Markdown
Member

/strands review

@poshinchen

Copy link
Copy Markdown
Contributor

@strandly-the-agent Review the changes and provide consolidate feedbacks. Also, I can see that the tool contains certain existing logic in strands-evals. What's your suggestion here?

@strandly-the-agent

Copy link
Copy Markdown

TL;DR — request changes. The list/get/search shape is the right primitive (MLflow-style progressive disclosure), the diff is tight and additive, and the paranoid read is clean. But four verified defects make the judge score silently wrong — the failure mode this PR exists to eliminate — and #342's actual bug (overflow reported as score: 0) still ships unfixed. Details, repros, and suggested fixes below; answer to poshinchen's overlap question at the end.

Verified: head 6017825; pytest tests/strands_evals/tools/ → 33 passed, 1 error (sandbox missing the langfuse extra; CI installs it — non-finding); every 🔴/🟡 below reproduced by running code, not eyeballing. Review pipeline: 6 independent passes (correctness, API design, tests, docs, LLM-context, issue-alignment) + aggregation; the adversarial pass failed to complete (infra), but its ground — regex stalls, paging loops, mixed-tz, tool-input coercion — was covered by the correctness pass and re-verified independently.

🔴 Blockers

  1. list_spans is unbounded — max_read_chars doesn't cap it, contradicting the docstring and README (trace_index.py:117-122, :164-168 vs :103-105, README). Measured: 2,000 spans → ~347K chars (~90K tokens); ~45 spans already exceeds the 8,000-char "cap on any single tool return". It's the judge's first documented call, on exactly the large sessions this feature targets — so the tool re-creates the overflow it was built to prevent. Fix: page overview() through _window with an offset param (same protocol as get_span) + a showing spans A–B of M header.
  2. search_spans can't find the literal text a judge quotes — grounded claims get scored as fabricated (trace_index.py:148, :54-71). pattern goes straight into re.compile, and $ ( ) . ? [ | are valid regex, so the re.error fallback never fires: search_spans('$150')No matches on a fixture shaped like this PR's own integ test (the flagship "$150 refund" case). Compounding: _span_text double-encodes nested-JSON tool results, so text copied from the overview ("refund_amount": 150) can't match the escaped bytes; and 'error' false-positives on "error": null in every successful span. Your own test hand-escapes (test_trace_index.py:96) — the model gets no such hint. Fix: literal by default + opt-in is_regex, search an unescaped rendering, count per-span matches. (Bonus: a literal default also removes the regex-stall class — a valid nested-quantifier pattern measured 15.5s on one 35K-char span.)
  3. The judge can never see system_prompt or available_tools — through any channel (trace_index.py:64-68, :88-92 vs types/trace.py:120-121). get_span returns only user_prompt/agent_response despite promising "the full content"; search_spans shares _span_text, so search_spans("never issue a refund")No matches even when it's in the system prompt. Any instruction-following or tool-selection rubric is actively misled — and it's a regression vs the inline path, which shows tool configs (evaluator.py:158-180). Fix: drop the special case and fall through to model_dump() (:71), or add the fields explicitly.
  4. The module docstring's headline example uses the one evaluator this pattern does not work with (trace_index.py:19-27). TrajectoryEvaluator inlines actual_trajectory unconditionally (case_prompt_template.py:51) — reproduced: followed literally it raises; with a trajectory supplied, the 24K-char tool result is inlined anyway (25,915-char prompt vs a 195-char overview). The PR body admits this limitation; the docstring demonstrates it. Fix: switch the example to OutputEvaluator (as the README does) + an explicit limitation note.

On the overlap with existing strands-evals logic (your question)

Six touchpoints; one is worth fixing now because it's a bug, two deserve a design call, three only look similar — leave them alone. No case for a big shared-helper refactor.

  • Fix now: _flatten_spans (trace_index.py:47-51) duplicates detectors/utils.py:57-59 _flatten_traces_to_spans character-for-character, and its added sort lacks the tz-normalization the repo already wrote (extractors/trace_extractor.py:24-28 _to_aware_utc) — verified crash: mixed naive/aware start_timeTypeError at TraceIndex() construction, reachable via the openinference/langchain/adk mappers (root cause filed as [BUG] SessionMapper.parse_timestamp returns naive datetimes despite docstring promising timezone-aware UTC — mixed sessions break datetime comparisons #372). Minimum: spans.sort(key=lambda s: _to_aware_utc(s.span_info.start_time)). Better: promote a shared flatten_spans() + to_aware_utc() helper consumed by all three call sites — justified because it fixes a crash, not for tidiness.
  • Design call: detectors/chunking.py already owns "session exceeds LLM context" with token-accurate budgeting (estimate_tokens, would_exceed_context) while TraceIndex budgets in chars — different strategy (split-and-reprompt vs pull-on-demand), both legitimate, but the repo shouldn't have two disagreeing answers to "how big is this trace for a model". Reusing estimate_tokens is the concrete first step. Also tools/ has no __init__.py and no top-level export, so the README-blessed deep import becomes de-facto public API — either export it properly or stage under experimental/ while the composition shape settles.
  • Leave alone: _span_text vs detectors/utils._serialize_spans (same idiom, different field-selection needs), Evaluator._format_* (different input types and goals), mapper-side sorts (ingest normalization, different responsibility).
🟡 Should-fix (6)
  • Composition contract: judge context is smuggled through actual_output, and either half fails silently (README.md:209-222, tests_integ/…:127). actual_output has other owners — compared to expected_output, string-matched by deterministic evaluators (deterministic/output.py:16), persisted in reports — so in an Experiment where evaluators share a Case, the injected overview corrupts them. And the two setup steps are uncoupled: overview without tools= → judge scores off 120-char previews; tools= without overview → judge scores the bare answer. Neither raises. Would one atomic composition point be better — prompt_section, tools = index.for_judge() now, or evaluator-side OutputEvaluator(rubric=…, trace=index) injected in _build_prompt (which would also let TrajectoryEvaluator adopt it later)? See Questions. Also: the README snippet assigns evaluation_output and stops — two undefined names, no wiring to EvaluationData.
  • max_read_chars unvalidated; 0 or a negative offset livelocks the judge (trace_index.py:108, :170-177). Verified: max_read_chars=0[TRUNCATED: … call again with offset=0]; offset=-10 → empty window advising offset=-10. Fix: ValueError in __init__ for < 1; reject negative offsets with an actionable message.
  • The overview hides tool failures and inference content (trace_index.py:83-94). _describe never reads tool_result.error (populated by 5 mappers), so a ConnectionError renders as -> result: 0 chars:; INFERENCE 2 messages has no size/preview/tool names, so the judge fetches or skips blindly. Fix: ok/ERROR status + error preview on TOOL lines; size + preview on INFERENCE lines.
  • Nothing the model sees says previews are truncated or that claims must be verified before scoring. The judge system prompt (prompt_templates.py:1-12) never mentions tools; the integ test only works because its rubric hand-carries "Verify claims against the trace evidence" (tests_integ/…:44) — the README rubric doesn't, so a README user gets a judge scoring off previews. Fix: put the guidance in the tool descriptions/overview header (the only strings guaranteed to reach the model), and state max_read_chars so the N chars counts become actionable.
  • search_spans caps and counts wrongly, unsignalled (trace_index.py:151-160): one excerpt per matching span (docstring says per match), hard stop at 20 with no truncation marker (get_span has one), IGNORECASE undocumented, max_matches=0 returns 1 hit. Folds into blocker 2's rewrite.
  • The suite doesn't pin the behaviours the feature sells — mutation score 11/27 killed (41% survive). Two tautologies (test_trace_index.py:109 — every possible return passes; :130 — a bare lambda passes); paging past page 1 unproven (the scripted loop computes offset += max_read_chars instead of following the returned hint, so a broken hint stays green); max_matches untested; the :50 sort is deletable; every overview number unasserted. Integ A/B: the inline control arm is computed and logged but never asserted, the fixture is ~65K tokens (33% of a 200K window — can't overflow, so the A/B shows nothing), paging never fires (spans 6.5K < 8K default), and absolute thresholds on n=1 with an unpinned model are the flake source (tk_grounded > tk_fabricated is the durable assertion). Priority list available if useful.
Questions

Blocking

  1. Should [BUG] Judge-based evaluators silently score 0 when a trace exceeds the judge's context window #342 item (1) land first, on its own? Experiment._run_evaluator still records overflow as score: 0, test_pass: False, and detectors/utils.py:33-54 _is_context_exceeded already implements exactly the detection [BUG] Judge-based evaluators silently score 0 when a trace exceeds the judge's context window #342 describes — a handful of lines of reuse. That's the silent-corruption bug; this PR's capability only reaches users who already know they have the problem (no in-library consumer: grep -rn TraceIndex src/ matches only the module itself).
  2. Composition shape — caller-side-but-atomic (index.for_judge()) now, or evaluator-side (trace= param at prompt-build time) as the target? Either beats routing judge context through actual_output. Related: was feat(evaluators): allow custom tools on judge-based evaluators (Trajectory, Output, Multimodal) #324's tools= intended as the extension point for judge context?
  3. Is strands_evals.tools meant to be public API (no __init__.py, absent from top-level __all__, README deep-imports it)? Export properly, or stage under experimental/ (the redteam precedent) while 1–2 settle? Given a new public primitive and no needs-api-review label in this repo, the design label seems like the right flag.
  4. Scope clarity: suggest stating in the PR body's first line "implements [BUG] Judge-based evaluators silently score 0 when a trace exceeds the judge's context window #342 item (2) only; item (1) and the trajectory path remain open", and a keep-open note on [BUG] Judge-based evaluators silently score 0 when a trace exceeds the judge's context window #342 — the body currently reproduces [BUG] Judge-based evaluators silently score 0 when a trace exceeds the judge's context window #342's entire bug up top while both deferrals sit far below, which invites a skimming reader to believe the bug is fixed. ("Addresses" is not a closing keyword, so [BUG] Judge-based evaluators silently score 0 when a trace exceeds the judge's context window #342 will mechanically stay open — the risk is human.)

Non-blocking
5. Chars or tokens for max_read_chars? (chunking.py:143-146 documents compact JSON underestimating 30–50% vs what's sent.)
6. Naming: TraceIndex indexes a Session, not a trace — SessionIndex? Cheap now, breaking later. (list_spans/get_span/search_spans are good names; keep.)
7. Is _span_text dropping span_info (timestamps, durations, span_id) deliberate context economy or an oversight? Latency/ordering rubrics need at least duration.
8. The 200-trace corpus / 1,344-cell matrix / accuracy numbers in the body aren't reproducible from anything in-tree — no in-tree artifact even reaches the overflow threshold. The mechanism is real; suggest labelling those numbers as external motivation so they carry the right weight.

Appendix — non-blocking (11)
  • ⚪ Multi-trace sessions interleave with no trace/turn marker (trace_index.py:47-51, :167) — "in the second turn…" rubrics unanswerable; prefix (trace N) when >1 trace.
  • overview()/_span_text recomputed per call (0.77s per list_spans on 2,000×8KB spans); build once in __init__.
  • ⚪ Empty session → ERROR: index 0 out of range (0..-1) (:134) — inverted range in model-facing text.
  • ⚪ Unbounded regex work: valid nested-quantifier patterns stall a worker thread (measured ~4× per size doubling, 15.5s at 35K chars); blocker 2's literal default removes the class.
  • ⚪ Paged content lacks a position header — add [span content chars A–B of N].
  • .tools is a mutable list, no order/identity contract; two indexes on one judge collide silently (identical tool names) — tuple + "one index per judge" note.
  • trace_index.py:13-15 "pattern skills use" — internal jargon with no referent here; the MLflow comparison from the PR body is the better anchor.
  • ⚪ Model-facing tool-call vocabulary now has a fourth spelling (TOOL … vs Action:/Tool: and Tool call:/Tool result: in evaluator.py).
  • tests_integ/…:48-57 fires a live STS call at collection time and except Exception silently skips; STS success ≠ Bedrock access — gate on an env var in a fixture like test_cloudwatch_provider.py:40. Also test_trace_index_patterns.py:126's defensive pytest.skip can make a real conversion regression vanish, and :123 reaches into private _convert_observations.
  • Pre-existing, filed [BUG] OutputEvaluator(tools=...) breaks Experiment.to_file() / to_dict() — TypeError: DecoratedFunctionTool is not JSON serializable #373: OutputEvaluator(tools=…) breaks Experiment.to_file() (TypeError: DecoratedFunctionTool is not JSON serializable) — from feat(evaluators): allow custom tools on judge-based evaluators (Trajectory, Output, Multimodal) #324, not this PR; but this PR's README is the first to document the crashing path, so one caveat sentence there is warranted.
  • Pre-existing, filed [BUG] SessionMapper.parse_timestamp returns naive datetimes despite docstring promising timezone-aware UTC — mixed sessions break datetime comparisons #372: SessionMapper.parse_timestamp returns naive datetimes despite promising aware UTC — the upstream source of the mixed-tz crash; the one-line _to_aware_utc fix in this PR stands regardless.

Also checked, clean: no network/env/subprocess/telemetry anywhere in the diff — trace_index.py imports only json, re, strands.tool, and repo types; the only network call in the PR is the intended Bedrock judge invocation in tests_integ/. Non-int/non-str tool inputs are safely rejected through the real strands invocation path. Docstring style matches repo precedent.

I'm an AI reviewer — treat this as prepared input for a human decision, not a gate. Happy to re-review on update.

@pdebjyot pdebjyot changed the title feat(tools): TraceIndex — progressive trace disclosure for judge-based evaluators feat(tools): add TraceIndex for progressive trace disclosure in judge-based evaluators Aug 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-evaluators Evaluators: output, trajectory, tool use, interactions, and LLM-as-judge quality metrics area-tracing Trace/session ingestion: providers, session mappers, extractors, telemetry/OTEL enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants