Skip to content

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

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

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

Conversation

@pdebjyot

@pdebjyot pdebjyot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Scope: this implements #342 item (2) only — the read-side capability (a judge can read a large trace without inlining it). It does not change _run_evaluator's error handling (item (1): telling harness overflow apart from a quality score: 0), and it does not touch the TrajectoryEvaluator path, which still inlines the full trajectory. Both remain open; #342 stays open. ("Addresses" is not a closing keyword.)

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

Branch: feat/trace-index
Builds on: #324 (custom tools= on OutputEvaluator / TrajectoryEvaluator)
Addresses: #342 (item (2); items (1) and the trajectory path remain open)

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
from strands_evals.types import EvaluationData

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

# for_judge() returns both halves atomically so neither is forgotten:
prompt_section, tools = index.for_judge()  # tools: list_spans, get_span, search_spans

evaluator = OutputEvaluator(
    rubric=(
        "Every factual claim must be supported by tool-result evidence in the trace. "
        "Use the trace tools to verify each claim before scoring."
    ),
    tools=tools,
)

# Compact overview in the judged output instead of the full trajectory:
output = f"{agent_answer}\n{prompt_section}"
evaluator.evaluate(EvaluationData(input="(from trace)", actual_output=output))
  • overview() — one compact line per span (index, type, tool name, sizes, preview, tool ok/error status). Paged by span index through max_read_chars, so even the first documented call on a huge trace can't overflow. The header tells the judge previews are truncated and that claims must be verified with the tools before scoring.

  • 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; search_spans is literal by default (opt-in is_regex=True) and searches an unescaped rendering of every visible field, including system_prompt and available_tools, so a literal a judge copies from the overview ($150) matches.

  • Backend-agnostic — consumes any Session a provider/mapper produces; timestamps are tz-normalized before sorting so a mix of naive/aware spans (from different mappers) can't crash construction.

  • for_judge() — returns (prompt_section, tools) together, so the two composition halves (overview into the judged output, tools onto the evaluator) can't be done half-way and silently degrade the judge.

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). These numbers are external motivation from the offline benchmark above — they are not reproducible from anything in-tree:

  • 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 + paging; list/get/search behavior; literal-vs-regex search, per-span match counts, truncation signalling; system_prompt/available_tools exposure; tool-error surfacing; max_read_chars/offset validation; mixed naive/aware timestamps; for_judge() composition; cross-pattern; end-to-end through OutputEvaluator with tools=index.tools) — all pass.
  • 1 integ A/B test (skips without live Bedrock credentials).
  • Full suite 1764 passed; ruff check / ruff format clean.

Revision (in response to review)

  • Blocker 1overview()/list_spans now page by span index through max_read_chars, so the judge's first call can't overflow.
  • Blocker 2search_spans is literal by default (opt-in is_regex), searches an unescaped rendering, counts per-span matches, signals truncation, and no longer false-positives on serialization artifacts like "error": null.
  • Blocker 3get_span/search_spans now expose system_prompt and available_tools.
  • Blocker 4 — module docstring example switched to OutputEvaluator with an explicit TrajectoryEvaluator limitation note.
  • tz crash_flatten_spans sorts via the repo's _to_aware_utc, fixing the mixed naive/aware TypeError (root cause [BUG] SessionMapper.parse_timestamp returns naive datetimes despite docstring promising timezone-aware UTC — mixed sessions break datetime comparisons #372).
  • Yellow: max_read_chars/negative-offset validation, tool ok/ERROR status + error preview and INFERENCE size/preview in the overview, truncation markers on search_spans, and a verify-before-score header the model actually sees.
  • Composition — added for_judge()(prompt_section, tools) so the overview and tools are wired together atomically instead of through two independent, silently-degrading steps.

Checklist

@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
@pdebjyot

Copy link
Copy Markdown
Contributor Author

Thanks — this was an unusually useful review; every 🔴 reproduced against the actual code, and the fixes are pushed. Point by point:

🔴 Blockers — all fixed

  1. list_spans unbounded. Fixed. overview() (and list_spans) now page by span index through max_read_chars: a Showing spans A–B of M banner, a [MORE: … offset=N] continuation marker, and list_spans(offset=…) to follow it — same protocol as get_span. The first documented call can no longer overflow. New tests walk the pages and assert every span appears exactly once.

  2. search_spans can't find quoted literals. Fixed. Search is literal by default with opt-in is_regex=True. It now matches against an unescaped rendering of each span, counts matches per span, signals truncation at max_matches, and an invalid regex returns an actionable error instead of a silent miss. search_spans("$150") and search_spans("refund_amount=$150") both hit now; "error" no longer false-positives on "error": null because the haystack no longer carries JSON keys. (This also removes the regex-stall class you measured.)

  3. system_prompt / available_tools invisible. Fixed. Both get_span (via _span_text) and search (via the new haystack) now include system_prompt and available_tools for AgentInvocationSpan. search_spans("Never issue a refund") finds the system prompt.

  4. Docstring example used the wrong evaluator. Fixed. The module docstring and README now use OutputEvaluator and carry an explicit note that TrajectoryEvaluator inlines actual_trajectory unconditionally and so does not compose with this pattern.

Overlap — the one that was a bug

  • _flatten_spans tz crash. Fixed with the minimal change you suggested: sort via the repo's existing _to_aware_utc, so mixed naive/aware start_time no longer raises TypeError at construction. New test covers it. I've left the shared-helper promotion and the chunking.py token-budget reconciliation as follow-ups rather than folding a refactor into this PR — agree they're worth a separate design call, not tidiness churn here.

🟡 Should-fix

  • max_read_chars < 1 now raises ValueError; negative offsets are rejected with an actionable message (both get_span and overview).
  • Overview now surfaces tool ok/ERROR status + error preview, and INFERENCE size + preview.
  • search_spans truncation marker + per-span counts (folded into blocker 2).
  • Overview header now states previews are truncated and that claims must be verified with the tools before scoring, and names max_read_chars — the guidance lives in strings the model is guaranteed to see.
  • Tests strengthened: the tautologies are gone, paging follows the returned hint rather than a hand-computed offset, and the new behaviors (literal/regex, counts, truncation, tz, validation, tool errors) are pinned.

Composition contract / public-API / scope questions

  • Scope (Q4): done — the PR body now leads with "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 [BUG] Judge-based evaluators silently score 0 when a trace exceeds the judge's context window #342 stays open.
  • Composition (Q2): added index.for_judge(), which returns (prompt_section, tools) atomically so the "either half fails silently" footgun is gone — the docstring, README, and example all use it now. The deeper evaluator-side option (OutputEvaluator(rubric=…, trace=index) injected in _build_prompt, which would also let TrajectoryEvaluator adopt it) is the cleaner long-term shape but a bigger public-API commitment; I'd rather land for_judge() now and take the evaluator-side param as a follow-up with your call on it. The residual actual_output concern is real — for_judge() fixes the coupling, trace= would remove the smuggling entirely.
  • Public-API staging (Q3): genuinely your call — export strands_evals.tools properly, or stage under experimental/ (the redteam precedent) while the composition shape settles. Point me either way and I'll follow up.

I left the pre-existing #372/#373 as-is (out of scope), and the non-blocking appendix items noted for follow-up. Full suite 1763 passed, ruff clean. Re-review welcome.

@poshinchen

Copy link
Copy Markdown
Contributor

@strandly-the-agent review the updated changes.

@strandly-the-agent

Copy link
Copy Markdown

TL;DR — all four 🔴 blockers are fixed and verified at 44b6c1f; two residual 🟡 in the search rewrite are worth one more small commit; the rest is maintainer design calls. I re-ran every original repro against the new head rather than trusting the commit message — ledger below.

Status of my previous findings

Finding Status
🔴 list_spans unbounded Fixed & verified — paged through max_read_chars; I walked a 2,000-span session by following only the returned [MORE: … offset=N] hints: 48 pages, max 8,008 chars/page, every span exactly once, terminates. New test follows the hint too (test_trace_index.py:83-98) — the old hand-computed-offset weakness is gone.
🔴 search_spans regex-eats literals Fixed & verifiedsearch_spans("$150") and "refund_amount=$150" both hit; "error" no longer false-positives on tool spans; invalid regex returns an actionable error. But two residuals below.
🔴 system_prompt/available_tools invisible Fixed & verified — both reachable via get_span and searchable ("Never issue a refund" → hit).
🔴 docstring example uses TrajectoryEvaluator FixedOutputEvaluator + explicit limitation note in docstring and README.
🟡 mixed-tz construction crash Fixed & verified — sort now goes through _to_aware_utc; my original repro no longer raises; regression test added.
🟡 max_read_chars=0 / negative offset livelock Fixed & verifiedValueError at construction; negative offsets rejected with actionable errors in both get_span and overview.
🟡 tool errors / INFERENCE invisible in overview Fixed & verified — `[ERROR] …
🟡 no model-facing verification guidance Fixed — overview header + tool descriptions now carry it, and state max_read_chars.
🟡 unit-test tautologies / unpinned behaviours Fixed — both tautologies replaced; paging, counts, truncation, tz, validation all pinned (49 passed locally).
🟡 composition footgun (Q2) Addressedfor_judge() returns both halves atomically; docstring/README/tests use it. Evaluator-side trace= stays the cleaner long-term shape, as you noted.
🟡 integ A/B (inline arm unasserted, fixture below overflow, STS at collection) Still opentests_integ/ is unchanged in this delta. Fine as a follow-up, just don't read the A/B as evidence yet.
Q1 (#342 item 1 first), Q3 (export vs experimental/), tokens-vs-chars With maintainers — on Q3, since pdebjyot asked: my suggestion is tools/__init__.py + top-level export rather than experimental/for_judge() has settled the caller contract enough that a later evaluator-side trace= param can land without breaking these callers.

🟡 New (residuals of the search fix — attack-the-fix findings)

  1. A phrase copied from an overview preview still misses when the original text has a newline (trace_index.py:95-120 vs :124). Previews collapse whitespace but the search haystack keeps raw \n — verified: content "Refund approved.\nAmount: $150 total." shows in the overview as …approved. Amount…, and search_spans("approved. Amount")No matches. This is the one compounding cause from the original search blocker that didn't get fixed, and copy-from-preview is exactly how a judge forms queries. Suggestion: whitespace-normalize both haystack and needle the way _preview does (re.sub(r"\s+", " ", …)); costs exactness of offset math nothing since excerpts are re-previewed anyway.
  2. InferenceSpan haystack is str(m.model_dump()) — Python repr with dict keys (trace_index.py:118-119). Verified: search_spans("content"), "text", and "role" all false-positive on every inference span ([0] (3 matches) …{'role': <Role.USER: 'user'>, 'content': [{…), and excerpts show repr noise. The "error"-artifact fix reached tool spans but not this branch. Suggestion: extract the message text content (role + text fields) for the haystack, keeping model_dump only in _span_text for get_span.
Appendix — non-blocking (5)
  • is_regex=True still allows catastrophic backtracking — (a+)+$ against a single 3KB span hung >55s in my sandbox (:215-220). Opt-in now, so acceptable, but a one-line docstring warning (or a pattern-length cap) is cheap.
  • ⚪ Empty session: get_span(0) still returns ERROR: index 0 out of range (0..-1) (:199-200) — the inverted-range message from my first review's appendix survives the rewrite.
  • search_spans("") matches every span with inflated counts (:222-229) — an explicit ERROR: empty pattern would be kinder to a confused judge.
  • max_matches=0 short-circuits to No matches for … (:233,:245-246) — misleading, though nobody sane passes 0.
  • for_judge() returns self.tools by reference (:304) — caller mutation mutates the index; a tuple or copy would pin the contract. Also the [MORE: … call again with offset=N] marker inside the prompt-embedded overview reads as "call again" without naming list_spans — one word (call list_spans with offset=N) removes the ambiguity.
Evidence ledger / review shape
  • Head reviewed: 44b6c1f (delta 6017825..44b6c1f, +414/−70 across trace_index.py, test_trace_index.py, README, one integration-test tweak).
  • pytest tests/strands_evals/tools/49 passed, 1 error — the error is the known langfuse-extra gap in my sandbox (CI installs it), same as last round. ruff/full-suite claims not re-run here (no hatch in sandbox); CI is the arbiter for those.
  • 11-repro battery: every original 🔴/🟡 repro re-executed against the new head (results in the table above), plus new probes: 2,000-span page-walk via returned hints; max_read_chars=1 floor (bounded ~244-char return, terminates); Unicode case-folding (strasseSTRASSE hits); cross-field newline joins (no space-vs-\n false positive across fields); mixed naive/aware construction.
  • Review shape: per-fix verification + attack-the-fix run by me first-party (this is a fixes-only delta responding to my review); an additional independent adversarial pass was still in flight when I posted — if it surfaces anything material beyond the two 🟡 above I'll add a short follow-up, otherwise silence means it agreed.

I'm an AI reviewer — treat this as prepared input for a human decision. With the two 🟡 addressed (or explicitly deferred), I have nothing further blocking; the open design calls (Q1/Q3, tokens-vs-chars) are poshinchen's to make.

@strandly-the-agent

Copy link
Copy Markdown

Addendum — the independent adversarial pass finished, and three things are material beyond my comment above (it otherwise agreed: paging survived a 300-session fuzz with zero exactly-once violations, get_span reconstruction is byte-exact, and the tz fix holds for naive + UTC + +05:00 mixes).

  1. 🟡 search_spans is the one tool max_read_chars doesn't bound — and its own truncation marker tells the judge to blow past it (trace_index.py:231-249 vs the claim at :158/README). Measured on an 800-span session with max_read_chars=8000: max_matches=100 → 12,614 chars, max_matches=800101,269 chars (13× the cap) — while overview() and get_span on the same session stay ≤8K. Hitting the default 20-span cap is normal on a long trace, and the marker says "raise max_matches for more". Suggestion: stop appending once the accumulated return would exceed max_read_chars and say "budget reached", rather than trusting max_matches alone.
  2. 🟡 My residual Case generation #2 is worse than I stated — on real mapper output, error search is fully inverted. Reproduced on the repo's own ADK fixture (adk_live_spans.json, no failed tools): search_spans("error") → false positive via 'error': None inside an inference span's repr. And in a session with a genuinely failed tool, the real failure is unfindable: "error", "ERROR", and "[ERROR]" (copied from the overview) all miss it, because the tool haystack carries only the error's value (CardDeclined: insufficient funds). A judge asking "did any tool fail?" gets 100% false positives and misses the one real failure. Reaches any ADK/CloudWatch/LangChain-mapped session (their inference messages carry tool content). Suggestion: render inference messages from fields (role + text + tool name/args/result) like the other two branches, and emit a searchable error: token in the tool branch so the overview's [ERROR] vocabulary and the search index speak the same language.
  3. 🟡 Anchored regexes silently never match — the haystack is newline-joined but is_regex=True compiles without re.MULTILINE, so "^Refunded" / "…TKT-1$"No matches (:215-220). LLMs write anchored regexes, and the judge reads that as "claim unsupported". One flag fixes it.
Smaller items + test gaps the pass found (6)
  • ⚪ False [stopped at N spans…] marker when the scan ended exactly on the last match (:233,:247) — truncated is set before checking whether anything further matches; wastes a judge turn.
  • ⚪ Literal-match offsets are computed on text.lower() but sliced from text, so excerpts drift (and can come back empty) when U+0130 İ precedes the match — Turkish traces. Cleanest fix also simplifies the code: replace the hand-rolled matcher with one re.compile(re.escape(pattern), re.IGNORECASE) path, whose offsets are always valid.
  • ⚪ Overview result: N chars counts raw text while get_span returns json.dumps(… ensure_ascii=True) — non-ASCII content under-reports ~6×, so the judge's paging estimate is wrong (degrades gracefully via the TRUNCATED marker). Report len(_span_text(span)) instead.
  • max_read_chars is a soft cap at tiny values (header counted; Showing… line and markers not; first line appended unconditionally): max_read_chars=200 → 428-char page. ~1% slippage at the default — fine — but worth one docstring word ("approximate").
  • Test gaps: test_search_does_not_false_positive_on_serialization_artifacts has no InferenceSpan in its fixture — reverting the entire inference-haystack fix passes 25/25; "Matching is case-insensitive" is unpinned (dropping both .lower() calls passes); the negative-offset assertion at test_trace_index.py:172 is vacuous (the error string contains no offset=, so the guarded assert always takes the True branch); :314's assert tools is index.tools locks in the live-list aliasing my earlier appendix flagged — prefer == + a copy.
  • Staleness note: the index snapshots the Session at construction (consistently — fuzzing found no corruption); one docstring line saying so would complete the "read-only" claim.

Repro script + full output are uploaded as review artifacts (adversarial-repros.py, adversarial-repro-output.log). With #1/#2 folded into the same small commit as the two residuals above, I have nothing further — the verdict from my previous comment stands.

return "\n".join(parts)
if isinstance(span, InferenceSpan):
return "\n".join(_render_message(m) for m in span.messages)
return str(span.model_dump())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Issue (Important): The search haystack joins fields with \n and preserves the original interior whitespace, but the overview preview collapses all whitespace via _preview (re.sub(r"\s+", " ", ...)). So a phrase a judge copies from a preview misses whenever the source text contained a newline.

Verified on 05c5f2a:

# tool_result.content = "Refund approved.\nAmount: $150 total."
# overview shows: "...approved. Amount..."
search_spans("approved. Amount")  # -> "No matches"

Copy-from-preview is exactly how a judge forms a query, so this reintroduces the "grounded claim scored as fabricated" failure the search rewrite set out to fix — just for phrases that straddle a line break.

Suggestion: Normalize whitespace on both the haystack and the needle the same way _preview does (re.sub(r"\s+", " ", ...)) before matching. Excerpts are re-previewed anyway, so offset exactness costs nothing.

return this._window(_span_text(this._spans[index]), offset)

@tool
def search_spans(pattern: str, max_matches: int = 20, is_regex: bool = False) -> str:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Issue (Suggestion): search_spans("") matches every span with an inflated match count (verified: [0] (42 matches) ...) — an empty needle matches at every character boundary. A confused judge that searches for an empty/whitespace-only string gets meaningless "evidence" rather than a corrective signal.

Suggestion: Return an actionable ERROR: empty pattern (mirroring the existing ERROR:-style messages) when pattern is empty after stripping.

offset: Character offset for paging through oversized spans.
"""
if not 0 <= index < len(this._spans):
return f"ERROR: index {index} out of range (0..{len(this._spans) - 1})"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Issue (Suggestion): On an empty session, get_span(0) returns ERROR: index 0 out of range (0..-1) — the inverted 0..-1 range is model-facing and confusing. (Verified.)

Suggestion: Special-case the empty index with a clearer message, e.g. ERROR: trace has no spans.

Comment thread src/strands_evals/tools/trace_index.py Outdated
output; ``tools`` is `self.tools`.
"""
prompt_section = f"<TraceOverview>\n{self.overview()}\n</TraceOverview>"
return prompt_section, self.tools

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Issue (Suggestion): for_judge() is annotated -> tuple[str, list] and returns self.tools by reference. The bare list loses the element type, and handing back the live attribute means a caller mutating the returned list mutates the index's tool set (and two indexes wired to one judge would share/collide).

Suggestion: Parameterize the annotation (tuple[str, list[...]], using the concrete tool type) and return a copy or a tuple so the contract is pinned.

@github-actions

Copy link
Copy Markdown

Assessment: Comment

I re-verified this at head 05c5f2a against the prior review history. The four original blockers and the three adversarial-pass residuals (search output now bounded by max_read_chars, inference error search rendered from fields with no repr leak, regex anchors honored via re.MULTILINE) are all fixed and confirmed by running the code. The list/get/search shape is a solid, well-motivated primitive and the diff is clean and additive. Remaining items are narrow.

Review themes
  • Search fidelity (Important): One compounding cause from the original search blocker is still open — the haystack keeps interior newlines while previews collapse whitespace, so a phrase copied from a preview across a line break returns No matches. This is the same "grounded claim scored as fabricated" failure mode, narrowed to multi-line spans.
  • API surface (Important): TraceIndex is documented as a public import but has no tools/__init__.py, no __all__ export, and no API/design label — the surface decision (export vs. stage under experimental/) should be made deliberately.
  • Robustness edges (Suggestion): empty-pattern search matches everything with inflated counts; empty-session get_span(0) shows an inverted 0..-1 range; for_judge() returns a bare-typed, by-reference tool list.
  • Testing: Coverage is strong (55 passing locally; the single error is the known langfuse-extra gap installed in CI). Worth adding a regression test for the cross-newline search case above.
  • Scope: ~1364 additions across 7 files exceeds the ~400-line guideline, but it's a cohesive single module plus its tests, so splitting isn't warranted here.

The progressive-disclosure design is sound and the responsiveness to prior review has been excellent.

@poshinchen

Copy link
Copy Markdown
Contributor

@strandly-the-agent why can't this be a built-in functions when the context / traces are too large? It seems like having these as tool / plugin is not ideal? Investigate the approaches thoroughly.

@strandly-the-agent

Copy link
Copy Markdown

TL;DR — it can and should be built in. The PR conflates two things: the mechanism (an index the judge LLM queries through list_spans/get_span/search_spans) and the wiring (the caller composes it via for_judge() + tools= + pasting the overview into actual_output). The tool-calls part is intrinsic — a judge can't read what isn't in its context, so anything that isn't lossy truncation or N×-cost chunking has to give the judge retrieval, and retrieval means tools on the judge agent. The plugin/caller-composed part is the design choice, and it's the wrong one: it can never reach TrajectoryEvaluator or the built-in trace-level judges (Helpfulness, Faithfulness, …), it smuggles judge context through a data field, and it only helps users who already know they have the problem. The repo already has the exact built-in pattern to copy — detect_failures preflights with would_exceed_context, falls back on _is_context_exceeded, and the SDK can't rescue a single oversized message on its own.

Recommendation: keep TraceIndex as an internal engine; add automatic disclosure in the evaluator base — inline exactly as today when the trace fits, substitute the paged overview + attach the tools to the judge Agent only when it wouldn't — and land #342 item (1) (report overflow as an error, not score: 0) first and separately. Concretely for this PR: re-scope so the README stops advertising strands_evals.tools.trace_index + for_judge() as the user story, and either add the ~60-line base-class wiring here or stage the engine under experimental/ until the wiring PR lands. Details below.

What is built in today — and where oversized traces currently go (verified)
  • Every judge inlines unconditionally, no size check anywhere. compose_test_prompt writes <Trajectory>{actual_trajectory}</Trajectory> (prompt_templates/case_prompt_template.py:48-51); OutputEvaluator._build_prompt writes <Output>{actual_output}</Output> (output_evaluator.py:45-56); the trace-level judges format the whole session history line-by-line (evaluator.py:224-244 _format_trace_level_prompt, :182-192 _format_session_history) and hand it to Agent(...) with no tools (helpfulness_evaluator.py:64-65). grep -rn would_exceed_context src/strands_evals/evaluators → nothing.
  • The SDK can't save a one-shot prompt. Agent catches ContextWindowOverflowException, calls conversation_manager.reduce_context, and retries (strands/agent/agent.py:1678-1687), but SlidingWindowConversationManager trims older messages / truncates tool results; with a single oversized user message it raises "Unable to trim conversation context!" (sliding_window_conversation_manager.py:247,262). So the judge's one big prompt has no SDK-level escape.
  • The overflow then becomes a quality score. Experiment._run_evaluator catches every exception and records test_pass: False, score: 0, reason: "Evaluator error: …" (experiment.py:447-456) — [BUG] Judge-based evaluators silently score 0 when a trace exceeds the judge's context window #342 item (1), still open.
  • The repo already has a built-in pattern for exactly this problem — in detectors/, not evaluators/. detect_failures preflights would_exceed_context(user_prompt) and routes to _detect_chunked, and also falls back reactively when the call raises and _is_context_exceeded(e) (failure_detector.py:84-94); the chunked path is split_spans_by_tokens → per-chunk model call → merge_chunk_failures (:118-150). analyze_root_cause has a three-tier direct → prune → chunk fallback (AGENTS.md:302). Token estimation is tiktoken-accurate via the SDK (chunking.py:32-60). None of this is wired into the judge evaluators.
Approach comparison
Approach Reaches Fidelity Cost Verdict
A Built-in automatic progressive disclosure — base evaluator measures the rendered trajectory (estimate_tokens); fits → inline as today; doesn't → substitute paged overview into the slot the evaluator owns (<Trajectory>, # Conversation History, the trajectory part of <Output>) and add the discovery tools to the Agent the evaluator already builds all judge evaluators incl. TrajectoryEvaluator, trace-level, multimodal high — judge pulls exact spans on demand; no lossy cut tool round-trips only when the alternative is a guaranteed failure; one estimate_tokens per eval (detectors already pay this) Recommended
B Built-in chunk-and-aggregate (reuse split_spans_by_tokens) all lossy for grounding: claim in chunk 3, evidence in chunk 1; and "aggregate a 0–1 rubric score across chunks" has no clean semantics (mean? min? any-fail?) N× judge calls Right for per-span outputs (detectors merge failure lists naturally); wrong for one holistic score. Not the default
C Built-in truncate/summarize all lossy in exactly the wrong direction for grounding rubrics (the one span that supports/contradicts a claim is what gets cut — #342's argument) 1 call Last-resort only, and must be flagged in reason
D Status quo: caller-composed plugin (for_judge() + tools= + paste into actual_output) OutputEvaluator family only; never TrajectoryEvaluator (case_prompt_template.py:51) or trace-level judges (no tools=, helpfulness_evaluator.py:65) high when wired; silently degraded when half-wired Keep at most as an escape hatch for custom/non-Strands judges; not the product story
E Fix the reporting first — _is_context_exceeded → distinct error status in _run_evaluator instead of score: 0 all ~10 lines Land first, regardless

Why D isn't ideal, in the maintainer's terms: discoverability (deep import, no export, no in-library consumer — grep -rn TraceIndex src/ hits only the module itself), a two-step footgun (each half fails silently), and actual_output has other owners (compared to expected_output, string-matched by deterministic evaluators, persisted in reports) so the overview corrupts sibling evaluators sharing a Case.

Minimal built-in shape (A + E) — sketch and touch points

Mirror failure_detector.py:84-94 exactly: preflight, plus reactive fallback.

# evaluators/evaluator.py (base) — one helper
def _disclose(self, session: Session, inline: str) -> tuple[str, list]:
    """Inline when it fits; otherwise paged overview + discovery tools."""
    if self.disclosure == "never" or not would_exceed_context(self.system_prompt + inline):
        return inline, []
    section, tools = _TraceIndex(session).for_judge()   # internal engine
    logger.info("trajectory exceeds judge context; using progressive disclosure")
    return section, list(tools)
# each judge, at the Agent construction it already has (output_evaluator.py:74, helpfulness_evaluator.py:65, trajectory_evaluator.py:73, multimodal_*)
prompt, disclosure_tools = self._disclose(session, rendered)
agent = Agent(model=self.model, tools=[*(self.tools or []), *disclosure_tools], system_prompt=self.system_prompt, ...)
try:
    result = agent(prompt, structured_output_model=EvaluationOutput)
except Exception as e:                       # reactive path, like failure_detector.py:87-94
    if not disclosure_tools and _is_context_exceeded(e):
        prompt, disclosure_tools = _TraceIndex(session).for_judge()
        result = Agent(..., tools=[*(self.tools or []), *disclosure_tools])(prompt, ...)
    else:
        raise
  • Slots: compose_test_prompt gets an optional pre-rendered trajectory (or the caller passes the overview in place of actual_trajectory) so <Trajectory> is the substitution point for TrajectoryEvaluator; _format_trace_level_prompt substitutes the # Conversation History block for trace-level judges; OutputEvaluator only when a Session is present on the case.
  • Public API: none required for default-on-overflow behaviour. One optional knob, disclosure: Literal["auto", "always", "never"] = "auto" on the base, covers opt-out and A/B testing. No behaviour change for anything that fits today.
  • Observability: when disclosure triggers, say so in EvaluationOutput.reason/result metadata so a user knows the judge read via tools.
  • What TrajectoryEvaluator loses: nothing — its deterministic scorers take the small tool-name lists the judge extracts; those fit and stay inline.
  • Cost when it triggers: a few tool round-trips versus a certain overflow → score: 0. Not a trade-off.
  • E, independently: _run_evaluatorif _is_context_exceeded(e): status="error" (or score=None) instead of score: 0; detectors/utils.py:33-54 already has the predicate.
What this means for PR #343 and why it wasn't built in
  • pdebjyot's stated reason (08-27) was that evaluator-side integration is "a bigger public-API commitment" and feat(evaluators): allow custom tools on judge-based evaluators (Trajectory, Output, Multimodal) #324's tools= was the extension point available. Fair as a scoping call, but the analysis above says the evaluator-side shape is smaller in public surface (one optional knob vs a new documented class + method + import path) and larger in reach.
  • The engine in this PR is now solid (paging fuzzed, search fixed) and would be the internal implementation of A essentially unchanged — for_judge() is exactly the hook _disclose needs. What should change: it becomes private (_trace_index.py or an underscore class), the README section goes (or moves to an "advanced: custom judges" note), and the composition moves into the evaluators.
  • Two ways to land it: (1) re-scope this PR to engine + base-class wiring (OutputEvaluator + TrajectoryEvaluator + trace-level base, ~60–100 lines) — my preference, since the engine is ready and the wiring is where the value is; or (2) merge the engine under experimental/ now with no public story, and do the wiring as the next PR. Either way, E first.

Verified against 05c5f2a and main; the SDK behaviour is from the installed strands-agents in my sandbox. An independent design pass is still running — I'll add a note only if it disagrees materially.

@strandly-the-agent

Copy link
Copy Markdown

Addendum — the independent design pass agrees with the recommendation above, and corrects one detail in my sketch:

  1. Scope correction. OutputEvaluator never inlines the trajectory — compose_test_prompt only writes <Trajectory> when uses_trajectory=True (case_prompt_template.py:48-51), and OutputEvaluator calls it without that flag (output_evaluator.py:56-61). Its overflow comes from a huge actual_output string, which an index over a Session can't help with. So automatic substitution honestly applies to TrajectoryEvaluator's <Trajectory> slot + the 15 trace/tool-level judges that render via _format_trace_level_prompt / _format_session_history / _format_tool_level_prompt (all built with Agent(...) and no tools today) — which is exactly where the silent overflow lives. For OutputEvaluator, built-in disclosure is additive (it needs actual_trajectory=Session on the case to have anything to disclose) and should stay opt-in (disclosure="always"). Strengthens the case for A rather than weakening it: the evaluators D can never reach are the ones that need it most.
  2. Measured magnitude. A 41-span session with ~1.2 MB of tool results renders to 407,203 tokens inline (would_exceed_context → True) vs 2,756 tokens as a TraceIndex overview — 148×. Probe + log uploaded as review artifacts (judge-context-probe.py/.log).
  3. Two things to know when reusing the detectors' preflight: (a) would_exceed_context budgets against DEFAULT_MAX_INPUT_TOKENS=200_000 × PREFLIGHT_SAFETY_MARGIN=0.65 → effective ~130K (detectors/constants.py:8-11), so an evaluator-side version should take the judge model's window rather than hard-code it; the chunking.py:69,123 docstrings still cite 0.85/0.70 — stale vs constants.py, pre-existing. (b) The SDK's only automatic lever is _truncate_tool_results — an oversized payload inside a toolResult block gets middle-out truncated (4,000,000 → 452 chars, verified), which is approach C's fidelity; a plain-text user message just raises. Neither changes the recommendation.

@poshinchen

Copy link
Copy Markdown
Contributor

@strandly-the-agent your answer is too tedious: #343 (comment). Summarize it, be neat, simple and clear to understand. What's the recommendation and what will the devx look like. What are the recommended changes?

@strandly-the-agent

Copy link
Copy Markdown

Fair — here's the short version.

Recommendation: make it built in. Evaluators should handle oversized traces themselves; users shouldn't have to know TraceIndex exists.

DevX — today (this PR):

index = TraceIndex(session)
prompt_section, tools = index.for_judge()
evaluator = OutputEvaluator(rubric=..., tools=tools)
evaluator.evaluate(EvaluationData(input=..., actual_output=f"{answer}\n{prompt_section}"))

DevX — proposed:

evaluator = TrajectoryEvaluator(rubric=...)   # or HelpfulnessEvaluator(), etc. — unchanged
evaluator.evaluate(case)                       # trace fits → inlined as today
                                               # trace too big → judge gets a paged overview + list/get/search tools, automatically

No new imports, no new class to learn. One optional knob if wanted: disclosure="auto" | "always" | "never" (default auto). Behaviour for traces that fit today is unchanged.

Recommended changes, in order:

  1. Fix the reporting ([BUG] Judge-based evaluators silently score 0 when a trace exceeds the judge's context window #342 item 1), separate small PR. In Experiment._run_evaluator, a context overflow becomes an error result, not score: 0. The predicate already exists (detectors/utils.py:_is_context_exceeded).
  2. Make TraceIndex internal — keep the code (it's solid now), drop the README user story and the public strands_evals.tools.trace_index import.
  3. Wire it into the evaluators — in the base class: measure the rendered trace with the existing would_exceed_context; if it fits, inline as today; if not, substitute the paged overview and add the three tools to the judge Agent the evaluator already builds. Applies to TrajectoryEvaluator and the trace/tool-level judges (Helpfulness, Faithfulness, …) — the ones that overflow silently today and that the current plugin approach can never reach. Same preflight-then-fallback pattern detect_failures already uses.

For this PR specifically: either re-scope it to (2)+(3), or merge just the engine under experimental/ and do (3) next. Not as a public plugin.

@pdebjyot

Copy link
Copy Markdown
Contributor Author

Agreed — this should be built in, not a plugin the caller wires up. Confirming the plan and re-scoping accordingly.

The split: the mechanism (the judge reads a large trace by calling list_spans/get_span/search_spans) is intrinsic — a judge is an LLM and can't score what isn't in its context, so any non-lossy path has to give the judge retrieval tools. What's wrong is the wiring: making the caller compose for_judge() + tools= + paste the overview into actual_output. That never reaches TrajectoryEvaluator or the built-in trace/tool-level judges (Helpfulness, Faithfulness, …) — which are exactly the ones that overflow silently today — and it only helps users who already know they have the problem.

Plan (three steps, in order):

  1. Fix the reporting first — separate small PR ([BUG] Judge-based evaluators silently score 0 when a trace exceeds the judge's context window #342 item 1), now up as feat: report judge context overflow as could-not-evaluate, not score-0 #399. In Experiment._run_evaluator, a context overflow becomes a distinct could-not-evaluate result instead of score: 0 / test_pass: False, so an overflow is no longer indistinguishable from a genuine quality failure and can't silently drag down overall_score or pass rate. It gates strictly on the existing _is_context_exceeded predicate, so a genuine evaluator bug still surfaces as a failure rather than being hidden. This is worth landing on its own regardless of the disclosure work.

  2. Make TraceIndex internal. Keep the engine (it's solid now), drop the public strands_evals.tools.trace_index import and the README user story so we're not committing to a plugin surface.

  3. Wire it into the evaluators. In the judge-building path, preflight the rendered prompt with the existing would_exceed_context (taking the judge model's window, not a hard-coded 200K); if it fits, inline exactly as today; if not, substitute the paged overview and attach the three tools to the judge Agent the evaluator already constructs. Applies to TrajectoryEvaluator + the trace/tool-level judges.

DevX — unchanged for callers:

evaluator = TrajectoryEvaluator(rubric=...)   # or HelpfulnessEvaluator(), etc.
evaluator.evaluate(case)   # trace fits → inlined as today
                           # trace too big → judge auto-gets overview + list/get/search tools

One optional knob if you want it: disclosure="auto" | "always" | "never" (default auto). Behaviour for traces that fit today is unchanged.

One correction to my earlier sketch: OutputEvaluator doesn't inline the trajectory (compose_test_prompt only writes <Trajectory> when uses_trajectory=True, which it doesn't set) — its overflow comes from a large actual_output string, which a Session index can't help. So automatic disclosure honestly targets TrajectoryEvaluator + the trace/tool-level judges; for OutputEvaluator it's additive and stays opt-in.

For this PR: (1) is already up as #399. I'll re-scope this PR to (2)+(3) — stage the engine as internal and add the base-class wiring. Sound right before I push the changes?

@poshinchen

Copy link
Copy Markdown
Contributor

@strandly-the-agent review the changes based on the discussions and proposed structure.

@strandly-the-agent

Copy link
Copy Markdown

TL;DR — there's nothing new to review yet. The PR head is still 05c5f2a (Sep 3, the commit I reviewed last round); the re-scope pdebjyot laid out on Sep 14 hasn't been pushed — his comment ended with "Sound right before I push the changes?" and is waiting on a yes.

From my side: yes, the plan is right — it matches what was agreed (engine internal, wiring in the evaluator base, #399 first). pdebjyot, go ahead. What I'll check the push against is below so it isn't a surprise.

One heads-up: a disclosure= kwarg on the judge evaluators is new public surface, so the re-scoped PR should get needs-api-review when it lands (the current PR has no API label).

What the re-scoped push should contain (review checklist)
  1. Engine goes internal. Move tools/trace_index.py → e.g. evaluators/_trace_index.py (or experimental/); drop the README section and the public for_judge() story. tests_integ/test_trace_index_judge_reliability.py can go too or stay as a follow-up — it's still unasserted on the inline arm.
  2. Wiring lives in the base class, once. 21 evaluator files build their own Agent(...) (e.g. trajectory_evaluator.py:72,94, helpfulness_evaluator.py:65,81), so the preflight must be a single base-class helper, not per-evaluator copy-paste. Preflight with the existing would_exceed_context(rendered_prompt, max_input_tokens=…) — pass the judge model's window rather than the detector default (DEFAULT_MAX_INPUT_TOKENS × 0.65). Fits → prompt is byte-identical to today. Doesn't → overview replaces the trajectory slot (_format_trace_level_prompt evaluator.py:254, _format_session_history :202, _format_tool_level_prompt :214, and TrajectoryEvaluator's <Trajectory>) and the three tools go on that Agent.
  3. Tests prove the switch, not the mock. (a) fits-case: rendered prompt equals today's, no tools on the agent; (b) overflow-case: overview + exactly three tools; (c) disclosure="never" restores today's behaviour so the overflow reaches feat: report judge context overflow as could-not-evaluate, not score-0 #399's not-applicable path; (d) disclosure="always" on OutputEvaluator with actual_trajectory=Session.
  4. The 4 open inline threads still apply once internal — especially the whitespace-normalize one (trace_index.py:150): the judge copies phrases from the overview preview regardless of who wired the tools. Empty-pattern and empty-session messages are two-liners. The for_judge() by-reference thread becomes moot if the method goes private.
  5. Ordering. feat: report judge context overflow as could-not-evaluate, not score-0 #399 (c954fe3) implements step 1 as agreed and is independent of this PR; it should merge first. I've only skimmed it (gates on _is_context_exceeded, emits a NOT_APPLICABLE row) — not a review.

I'm an AI reviewer — this is prepared input for a human decision.

… 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.
…, expose system_prompt/tools, tz-safe sort, add for_judge()
…gex anchors

Addresses the adversarial review's three residual yellows on TraceIndex, plus
the pre-existing mypy failures on this branch (CI runs ruff + mypy; only ruff
was green before).

- search_spans now caps its accumulated output at max_read_chars (not
  max_matches alone), so a generous max_matches on a long trace can no longer
  return 13x the per-call budget. Emits a 'budget reached' marker distinct from
  the max_matches marker.
- Inference-span search rendered messages via model_dump(), leaking 'error': None
  into the haystack (false positive on every inference-span 'error' search) while
  a genuinely failed tool inside an inference span was unfindable. Messages are
  now rendered from their fields (role + text + tool name/args/result), and tool
  errors carry a searchable 'error:' token in both the tool and inference
  branches, matching the overview's [ERROR] vocabulary.
- Regex search compiles with re.MULTILINE so ^/$ anchor to line boundaries in
  the newline-joined haystack instead of silently never matching.
- mypy: annotate hits/lines empty-list locals and rename the regex exception
  variable so it no longer collides with the match-position unpacking.

README and search_spans docstring updated to state search is bounded too.
…ch, empty-pattern/empty-session guards, for_judge copy)
…al TraceIndex

Wire progressive trace disclosure into every judge evaluator behind a new
`disclosure` kwarg ("auto" | "always" | "never", default "auto"). When a
Session trajectory would overflow the judge's context window, the judge now
receives a compact <TraceOverview> plus the list_spans / get_span /
search_spans tools and reads the trace on demand, instead of silently
mis-scoring a correct agent on a truncated/overflowed prompt.

- Relocate the disclosure engine from the public tools/ surface to the
  internal evaluators/_trace_index module; it is an evaluator implementation
  detail, not user-facing API.
- Add the disclosure seam to the base Evaluator (_render_with_disclosure,
  _resolve_disclosure_index, _disclosed_trace_section, disclosure-aware
  _format_* helpers, _tool_level_render, _validate_disclosure) and wire all
  18 evaluators through it, including the bespoke composers
  (compose_test_prompt trajectory_override; per-skill/per-tool resolution
  shared once per case).
- "auto" discloses only on preflight overflow; "always" whenever a Session is
  present; "never" always inlines (real overflow -> could-not-evaluate).
  A fitting case stays byte-identical to before with no extra tools.
- Add unit + end-to-end disclosure tests; update README to the automatic story.

Adds a public `disclosure` kwarg -> needs-api-review.
- Type disclosure kwarg as DisclosureMode (Literal) across all 18 evaluators
  instead of bare str, so misuse is caught at type-check time.
- Resolve TraceIndex once per case in tool-level and skill evaluators rather
  than re-probing inside the per-tool / per-skill loop.
- Skill probe uses the full inline prompt (body + response), not just the
  trajectory, so the auto threshold reflects real prompt size.
- Skip the inline-render probe for always/never modes (probe is irrelevant).
- Fix README + docstrings: genuine overflow under "never" surfaces as a judge
  context-length error (the could-not-evaluate path is strands-agents#399, not this branch).
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