-
[OPIK-5333] Inject trace spans into LLM-as-judge prompts; enrich thread {{context}} with tool calls (#6751)
发布于
2026-05-20 08:16:40 +00:00 - [OPIK-5333] [BE] feat: inject spans into LLM-as-judge prompts via {{spans}} variable
A user mapping a variable to the bare string "spans" (the same sentinel
the Python-metric path already uses) now gets the JSON-serialized spans
list substituted into the rendered prompt at evaluation time. Lets a
trace-level LLM-as-judge metric like "count the spans: {{mySpans}}"
actually see the spans — previously the only ways to get spans into the
prompt were the agentic-tools path (over-threshold or toggle-forced) or
switching to a Python metric.- OnlineScoringEngine.templateReferencesSpans(variables) — public helper
the trace scorer uses to opt-in to the span fetch on the inline path. - OnlineScoringEngine.prepareLlmRequest(...) — both overloads now take a
@NonNull List spans and inject the JSON-serialized list into any
variable mapped to the "spans" sentinel. Empty list / no sentinel =
no-op, so the path stays cheap for the common case. - OnlineScoringLlmAsJudgeScorer.score() — spansNeeded predicate now ORs
in templateReferencesSpans(variables), so the spanService.getByTraceIds
fetch fires whenever the template references {{spans}}, not only when
the agentic-tools path could fire. - 3 new unit tests in OnlineScoringEngineTest covering sentinel
detection, span substitution + sort-by-start_time wire order, and
no-injection when the template doesn't reference the sentinel.
Thread-level LLM-as-judge intentionally NOT updated — threads carry
multiple traces, so {{spans}} would be ambiguous (whose spans?).Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [FE] feat: auto-fill {{spans}} as a reserved trace-evaluator variable
Pairs with the backend's spans-injection feature: users typing
{{spans}}in a trace-scoped LLM-as-judge prompt no longer have to
manually map the variable to the"spans"sentinel — the FE seeds it
automatically, and the schema validator accepts it without complaint.- constants/llm.ts: new RESERVED_LLM_JUDGE_TRACE_VARIABLES map
({ spans: "spans" }) — extensible if we add more sentinels later. - AddEditRuleDialog/LLMJudgeRuleDetails.tsx (v1 + v2): when the
prompt-tag scanner finds a new variable, look it up in the reserved
map for trace-scope rules and seed the path with the sentinel. User-
supplied paths still win — we only fill blanks. - AddEditRuleDialog/schema.ts (v1 + v2): extend the trace-variable
regex to also accept the barespanssentinel; updated the error
message to mention it. Span- and thread-scope schemas unchanged.
Backend already handles the sentinel
(OnlineScoringEngine.injectSpansIntoReplacements substitutes the JSON-
serialized spans list at render time). This commit closes the UX loop:
type{{spans}}, hit Save, done.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [FE] feat: auto-fill
spansreserved arg for Python trace metrics too
Extends the previous LLM-as-judge fix to the Python metric path: a
score(self, spans, ...)parameter on a trace-scoped Python rule now
auto-maps to thespanssentinel, matching the LLM-as-judge{{spans}}
UX. User no longer has to interact with the variable-mapping dropdown
(which only shows input/output/metadata paths and didn't surface
spansat all).- Rename RESERVED_LLM_JUDGE_TRACE_VARIABLES →
RESERVED_TRACE_EVALUATOR_VARIABLES since the same map drives both rule
types; JSDoc updated to document both code paths. - PythonCodeRuleDetails.tsx (v1 + v2): when parsePythonMethodParameters
pulls names off thescoremethod signature, look them up in the
reserved map for trace-scope rules and seed the path with the
sentinel. Preserves any existing user-supplied path. - PythonCodeDetailsTraceFormSchema (v1 + v2): extend the regex to also
accept the barespanssentinel; updated error message to mention it.
Span-scope Python schema unchanged.
UX result: type
def score(self, spans, ...), hit Save, done. Same
recipe as LLM-as-judge.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [FE] feat: hide reserved trace variables (
spans) from the mapping list
Reserved trace-evaluator variables like
{{spans}}are auto-filled with a
fixed sentinel path and never need user input. Hide their row from the UI
so users don't see a selector they can't usefully change. The variable
stays in the form value, so the backend still receives the sentinel and
the substitution still happens.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] fix: render
{{spans}}as[]when trace has no spans
The empty-spans short-circuit in injectSpansIntoReplacements skipped
overwriting the bare "spans" literal that toVariableMapping deposits
into the replacements map, so traces with no children leaked the word
"spans" into the rendered LLM prompt instead of an empty JSON array.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] feat: detect
{{spans}}in templates without sentinel mapping
The FE auto-fills
variables.spans = "spans"whenever the user types
{{spans}}in a prompt, but API-created rules can skip that step and
leave the prompt referencing{{spans}}with no matching entry in the
variables map. In that case the prior gate (variables.containsValue ("spans")) returned false, spans weren't fetched, and the rendered
prompt left{{spans}}unsubstituted.Extend
templateReferencesSpansto OR in a Mustache/Jinja2/Python
parse over the message templates. When a template references{{spans}}
and the variables map doesn't bindspansto anything, mirror the FE
auto-fill server-side: fetch spans and inject the JSON array under the
spanskey. Explicit user mappings (e.g.spans → input.foo) take
precedence — only the unbound case opts in implicitly.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [FE] perf: hoist hiddenVariableNames to a stable module constant
Each render of
LLMJudgeRuleDetails/PythonCodeRuleDetails(v1+v2) was
computingObject.keys(RESERVED_TRACE_EVALUATOR_VARIABLES)inline, allocating
a new["spans"]array on every render and invalidating
LLMPromptMessagesVariables'svariablesListuseMemofor nothing.Pre-compute it once at module scope as a frozen readonly array and reuse the
reference everywhere. The prop type widens toreadonly string[]so callers
can pass the frozen constant; the component only iterates it to build a Set.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] fix: scan multimodal contentArray for {{spans}} + extract shouldFetchSpans helper
Two related fixes pulled out of the PR review:
-
templateReferencesSpansonly walkedLlmAsJudgeMessage.content(the
simple-string field), so{{spans}}inside a multimodal message's
contentArray[*].textwas missed. The renderer happily substitutes
into structured-content text parts, so detection drifted from
rendering and a multimodal prompt with{{spans}}would skip the
fetch and leave the placeholder unsubstituted. Added a
renderableTextOf(message)helper that streams both shapes; both
are now scanned. -
Pulled the spans-fetch routing in
score()into a package-private
shouldFetchSpans(message)helper, mirroring the existing
shouldUseAgenticToolsextraction. Makes the gate unit-testable
without spinning up the full reactive chain — added a 9-case
parameterized truth-table test covering the agentic-tools and
inline-template branches plus the short-circuit interactions.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] address PR comments: hide-by-value, DRY auto-fill, rename tests
Three review findings rolled together:
-
(Logical bug 🟠)
LLMPromptMessagesVariableswas hiding a row whenever
the variable's name matched a reserved name, regardless of value. A
user (or API caller) who mappedspans → input.spanscouldn't see or
edit that row — write-only after first parse. Replaced the
hiddenVariableNames: readonly string[]prop with
reservedSentinels: Readonly<Record<string, string>>; the row is
hidden only when the variable's current value equals the sentinel
for that name. Custom overrides stay visible. -
(DRY 🟢) The auto-fill block
localVariables[v] = variables[v] || reservedDefault || ""was duplicated across v1+v2 ×
{LLMJudgeRuleDetails, PythonCodeRuleDetails}. Extracted into
resolveTraceEvaluatorVariableDefault(name, current, scope)in
lib/llm.ts. Adding a new reserved trace variable now propagates to
all four editors via a single source of truth. -
(Style 🟢) Renamed the test methods I added in this PR from the
testX()form to the scenario-based names per
.agents/skills/opik-backend/testing.md. Pre-existing methods left
as-is to keep the rename limited to my own additions.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] feat: gate
{{spans}}template path under isAgenticToolsEnabled
Ship the inline
{{spans}}template substitution under the same feature
flag as agentic-tools so a single toggle flip turns both pathways on or
off org-wide. WhenisAgenticToolsEnabled=falseand the rule isn't on
the experimentId branch,shouldFetchSpansreturns false and
injectSpansIntoReplacementssubstitutes an empty array into
{{spans}}via the empty list threaded throughprepareLlmRequest—
no I/O, no broken rendering.Truth table updated to cover the new gate: toggle-off + template/sentinel
combinations all expect no fetch; toggle-off + experimentId still fetches
(for the agentic-tools cache seed, with the template substitution
piggy-backing on the same data).Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [FE] feat: gate
{{spans}}auto-fill behind agentic_tools_enabled
Mirror the backend gate (
isAgenticToolsEnabled) on the frontend so the
spans-in-prompts feature ships as a single togglable unit.When the FT is off in the four rule-detail editors (v1+v2 × {LLMJudge,
PythonCode}):- The
spansvariable no longer auto-fills to its sentinel value, so the
user can map it to a custom path like any other variable. LLMPromptMessagesVariablesreceivesreservedSentinels={undefined},
so thespansrow stays visible and editable instead of being hidden.
When the FT is on, behavior is unchanged: auto-fill writes
spans → "spans", the row is hidden, BE substitutes the spans JSON.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] fix: normalize feature-toggle state when backend omits a field
Two small fixes for the type contract on
FeatureToggles:-
FE:
setFeatures(data)was overwriting the entire state object with
whatever the API returned, leaving keys the backend omits as
undefined. A newer FE talking to an older BE could land
AGENTIC_TOOLS_ENABLED = undefinedin state, breaking the
Record<FeatureToggleKeys, boolean>contract. Merge over
DEFAULT_STATEso omitted keys keep their declared defaults. -
BE:
agenticToolsEnabledlacked@NotNullwhile every other toggle
inServiceTogglesConfighas it. Added for consistency — primitive
booleans can't actually be null at runtime, but the annotation
documents the contract and matches the surrounding style.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] code review polish: docstring + empty-mapping handling
Code-review feedback on the branch surfaced two small things worth tightening:
-
OnlineScoringLlmAsJudgeScorer.shouldFetchSpansand
OnlineScoringEngine.injectSpansIntoReplacements: the prior docstring on
the toggle implied a clean "kill switch", but in reality the substitution
runs unconditionally so toggle-off rendersSpans: []rather than the
literal{{spans}}. Documented why: gating the substitution would
resurrect the bare-word leak from rules whose variables map still carries
the sentinel from before the toggle flipped. The current asymmetry is
intentional, just needed to be spelled out. -
resolveTraceEvaluatorVariableDefaultwas usingif (currentMapping),
which treated""as "not set" and re-applied the sentinel auto-fill on
every prompt re-parse — silently overwriting an API caller's deliberate
spans: "". Switched toif (currentMapping !== undefined)so explicit
empty strings stick.
No behavior change in the common paths — existing tests stay green.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] feat: enrich thread {{context}} with each turn's spans (tool calls + I/O)
Thread-scope LLM-as-judge rules had no way to see the agent's actual tool-use
behavior —{{context}}only carried each trace's top-level user/assistant
text, and the only path to spans was the agentic-tools tools-branch (which
only fires for big threads, or on the experimentId test-suite-assertion
path). Normal-sized conversations were effectively unobservable from the
judge's POV.Now: when
isAgenticToolsEnabled=true, the thread scorer fetches every span
across every trace in the thread up front and threads them down through
prepareEvaluation.renderThreadMessagessubstitutes{{context}}with
an enriched shape — each trace's child spans are attached as aspansfield
on the assistant entry. Customer writes{{context}}once and gets the full
conversation + reasoning trace.Wire-shape design (
EnrichedThreadChatMessage): keeps the existing
{role, content}shape, adds optionalspansfield on the assistant entry.
With@JsonInclude(NON_NULL), thespansfield is omitted whenever a trace
has no spans (or the toggle is off and the scorer passed an empty list), so
existing rules see today's wire shape unchanged. Backward-compat guaranteed.Size routing kept honest:
estimateThreadContextTokensnow serializes the
enriched shape, so an enriched-but-big thread routes correctly to the
agentic-tools path (skeleton + ReadTool drill-down) instead of inline-
rendering an oversized prompt.Tools-branch path (
prepareThreadLlmRequestWithTools) is intentionally
unchanged — it still renders the compact skeleton, and the model uses
ReadTool/JqTool to drill into individual traces' spans on demand. Pulling
all spans inline there would defeat the size optimization the tools branch
exists for.Tests cover:
- wire-identical
{role, content}shape when toggle off (empty spans list) - enriched shape with spans sorted by start_time when spans provided
- estimateThreadContextTokens reflects span size for the routing gate
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] polish: thread spans-fetch comment, FQN cleanup, toggle-gate test
Non-functional polish from an independent code review of the thread-scope
enrichment work:- Documented the deliberate "fetch spans before path decision" ordering in
the thread scorer so the size estimate routes honestly. The cost (wasted
I/O when the tools path wins) is now spelled out in-code, not only in
the commit history. - Replaced fully-qualified
java.util.stream.Collectors,com.comet.opik .api.Span,com.comet.opik.domain.SpanTypereferences in the test
file and scorer with the already-imported (or now-imported) short
forms. Pure cleanup. - New scorer-level test
skipsSpanFetchWhenAgenticToolsDisabledproves
the toggle gate by assertingverifyNoInteractions(spanService)on a
full thread-scoring flow with the flag off. Locks in the contract so a
future "simplification" of the gate would fail fast.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] perf: project Span → SpanForLlm before rendering into prompts
The raw
Spanrecord carries ~28 fields — audit metadata (createdBy,
lastUpdatedBy, createdAt), out-of-band annotations (feedbackScores,
comments), cost data (totalEstimatedCost), system IDs (projectId,
projectName), tags, usage, ttft, source, environment — almost none of
which help a judge reason about agent behavior. Pasting them inline
burns tokens on noise and risks confusing the judge ("am I supposed to
defer to these feedback scores?").Introduce
SpanForLlm: a lean projection with just the 11 fields a
judge actually uses — name, type, start/end/duration, in/out, metadata,
model, provider, errorInfo.@JsonInclude(NON_NULL)keeps the per-span
JSON tight (a successful tool span doesn't pad witherror_info: null,
a non-LLM span doesn't carrymodel/provider).Applied on both render paths:
- Trace-scope
{{spans}}:injectSpansIntoReplacementsprojects before
serialization (and before the agentic-tools cap, so the cap reflects
what the judge actually sees). - Thread-scope
{{context}}:EnrichedThreadChatMessage.spansnow
holdsList<SpanForLlm>;fromTraceToThreadEnrichedprojects
per-trace before nesting.
New test
prepareLlmRequestRendersSpansWithLeanProjectionbuilds a Span
populated with every dropped field set to a unique sentinel string and
asserts none of them appear in the rendered prompt. Locks the projection
contract — a future "let me just add this one field" PR breaks loudly.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] feat: nest child spans recursively in SpanForLlm projection
A flat sorted list of spans is fine for "what happened in what order"
evals, but loses the call structure that matters for agentic eval —
"did the planner properly decompose into sub-tools? did sub-agent X
call sub-agent Y, or were they siblings?". The judge would have to
mentally resolve parent-id references that we'd dropped anyway in the
lean projection.Switch SpanForLlm to a recursive shape: each node carries a
spans
field with its children (also SpanForLlm), built from parent_span_id
links. Tree depth matches the actual call hierarchy the SDK recorded.buildSpanTree(spans)reconstructs the tree:- groups input by parent_span_id
- identifies roots (parent null OR parent not in the input set —
orphans get promoted so subtree views stay well-formed) - recursively projects each node, sorting siblings by start_time at
every level so chronology is preserved within each branch
Applied on both render paths:
- Trace-scope
{{spans}}—injectSpansIntoReplacementsbuilds the
tree directly from the per-trace span list. - Thread-scope
{{context}}— per-trace spans in the input group are
fed through buildSpanTree before nesting under the assistant entry.
Leaf
spansare omitted via @JsonInclude(NON_NULL), so the JSON stays
shaped like normal data (no"spans": []padding on every leaf).New test
buildSpanTreeReconstructsHierarchyAndPromotesOrphanscovers
the structural contract: shuffled input → correct two-root tree
(real root + orphan), sibling ordering, leaf with no children, three
levels of nesting (root → child-A → grandchild).Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [NA] [BE] test: bump ExperimentProjectMigrationJobTest startup buffer 3s → 10s
The prime-V1 assertion at line 161 runs at ~T+3.1s on busy CI runners
(when this test happens to share an Integration Group with several other
container-heavy tests), past the prior 3sexperimentProjectMigration. startupDelay— at which point the migration job has already flipped V1
→ V2, the assertion sees V2, and the test fails deterministically rather
than flakily.10s gives ~3× headroom for setup contention without measurably extending
the test (the second phase polls until V2 anyway, so the longer
startupDelay just adds time the test would have spent waiting either way).The test moves between Integration Groups depending on greedy line-count
bin-packing (see .github/scripts/discover-backend-tests.sh) so anyone
whose PR happens to shift this test into a busy group lights the same
flake — this fix is for everyone, not specifically for OPIK-5333.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- Revert "[NA] [BE] test: bump ExperimentProjectMigrationJobTest startup buffer 3s → 10s"
This reverts commit
68a176033b.- [OPIK-5333] [BE] feat: enrich thread Python eval conversation with spans
Closes the gap called out in the PR description: thread-scope Python online
evals were stuck on the legacy[{role, content}, ...]conversation shape
regardless of the agentic-tools feature flag, while LLM-as-judge thread
evals already received the enriched shape with each turn's tool calls and
I/O nested under the assistant entry. Same customer use-case ("evaluate the
agent's tool-use across a conversation"), inconsistent backend.Unify by:
- Promoting
SpanForLlmfrom a nested record insideOnlineScoringEngine
to a top-levelcom.comet.opik.api.SpanForLlm. Lives alongsideSpan
andTraceso both render paths (LLM-as-judge inOnlineScoringEngine,
Python inTraceThreadPythonEvaluatorRequest.ChatMessage) can reference
it without a package cycle. - Extending
TraceThreadPythonEvaluatorRequest.ChatMessagewith an
optionalspans: List<SpanForLlm>field.@JsonInclude(NON_NULL)keeps
the wire shape backward-compatible — when toggle off, the field is
omitted and the JSON is byte-identical to today. - Dropping the redundant
OnlineScoringEngine.EnrichedThreadChatMessage
record.fromTraceToThreadEnrichednow returns
List<TraceThreadPythonEvaluatorRequest.ChatMessage>directly so both
paths share one type. - Injecting
SpanServiceintoOnlineScoringTraceThreadUserDefinedMetric PythonScorer, reactively fetching every span in the thread when the
toggle is on, and routing throughfromTraceToThreadEnrichedinstead
of the legacyfromTraceToThread. Mirrors the LLM-as-judge thread
scorer's pattern exactly.
Python users now get the same nested tool-call tree the LLM judge sees —
theirscore(self, conversation, ...)method receives a list of dicts
where each assistant entry carriesspanswith full input/output, model,
provider, error_info, and nested children. The leanSpanForLlm
projection (11 fields) keeps the payload focused on agent behavior, same
as the trace-scope path.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] test: lock in toggle-on Python thread enrichment
Adds the end-to-end counterpart to skipsSpanFetchWhenAgenticToolsDisabled —
exercises the full toggle-on path:toggle on
→ spanService.getByTraceIds(traceIds) fires once with the right id set
→ fromTraceToThreadEnriched grouops the spans by trace and nests them
→ captured ChatMessage list sent to PythonEvaluatorService has user/assistant
entries with spans populated on the assistant turn onlyCatches future regressions where someone "simplifies" by routing back to
the legacy fromTraceToThread or quietly drops the SpanService fetch.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] feat: unify trace-scope Python
spanskwarg on the lean SpanForLlm projection
Closes the last inconsistency in the spans-render matrix: trace-scope
Python metrics were the only path still sending the full ~28-fieldSpan
record to the runner, while every other path (trace LLM-as-judge
{{spans}}, thread LLM-as-judge {{context}}, thread Python conversation)
already projected throughSpanForLlmandbuildSpanTree.OnlineScoringEngine.toReplacements(variables, trace, spans)now puts
buildSpanTree(spans)under thespanskey instead of the raw flat
list. One shared helper, one wire shape across all four paths.What the customer's Python
score(self, spans, ...)method sees changes:Before: list of dicts with ~28 fields each (id, project_id, trace_id,
parent_span_id, name, type, in/out, metadata, model, provider,
tags, usage, ttft, error_info, source, environment, created_at,
created_by, last_updated_at, last_updated_by, feedback_scores,
comments, total_estimated_cost, ...)After: list of root-span dicts with 11 fields each (name, type,
start_time, end_time, duration, input, output, metadata, model,
provider, error_info) plus a recursivespansfield carrying
child spans — same projection the LLM judge sees.This IS a backward-compatible-breaking change for existing trace-scope
Python metrics that read dropped fields (id, parent_span_id, usage,
feedback_scores, etc.). Worth flagging to anyone with such metrics —
they'll need to update them or pull the dropped data elsewhere. Most
"agent-behavior" metrics only read name/type/input/output anyway, so
the practical break should be narrow.Updated existing
fetchesSpansAndPassesThemAsListWhenSpansArgumentPresent
test to assert the newSpanForLlmshape end-to-end, with a known span
name so the projection contract is explicit in the test.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] perf: move thread Python scorer's blocking prep onto boundedElastic
The Python thread scorer's prepareScoring calls projectService.get(...) —
synchronous JDBC. It was wrapped in Mono.fromCallable but without
.subscribeOn, so the blocking call ran on whichever thread emitted the
upstream Mono. Before this PR that was the consumer loop thread; after
the toggle-on span-pre-fetch landed, it can also be the spanService DB
thread. Both are bad — consumer loop can stall message draining, DB
thread can starve the JDBC pool.Pin the callable to Schedulers.boundedElastic() — the standard reactor
scheduler for wrapping blocking calls in reactive glue.The trace and thread LLM-as-judge scorers are unaffected: trace scorer's
prepareEvaluation is pure CPU and stays on parallel(); thread LLM-as-judge
prepareEvaluation is also pure CPU (its projectService.get() lives in the
post-LLM .map block, which is a separate concern not introduced here).Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
Co-authored-by: Sasha sasha@Sashas-MacBook-Pro.local
Co-authored-by: Claude Opus 4.7 (1M context) noreply@anthropic.com
Co-authored-by: Aliaksandr Pyrkh aliaksandr@comet.com下载附件