-
[OPIK-5333] [BE]: agentic tools online scoring threads; (#6719)
发布于
2026-05-15 14:04:46 +00:00 - [OPIK-5333] [BE] feat: agentic-tools branch for thread LLM-as-judge scoring
Extends the size-based agentic-tools branch from trace-level scoring to
thread-level (OnlineScoringTraceThreadLlmAsJudgeScorer). Enormous threads
no longer try to inline every trace into the prompt: when the
inline-rendered context would exceed the configured threshold, the model
gets a compact per-trace skeleton (id, name, start/end, duration, span
counts) plus ReadTool/JqTool/SearchTool to drill into any specific trace
on demand. Spans for any one trace are fetched reactively, only when the
model actually asks for that trace.Key pieces:
- OnlineScoringEngine: new estimateThreadContextTokens (size estimate on
the trace-list JSON, no spans), prepareThreadLlmRequestWithTools
(skeleton + drill-down hint replacing the {{context}} variable), and a
ThreadTraceSkeleton record describing the per-trace summary shape. - OnlineScoringTraceThreadLlmAsJudgeScorer: full reactive refactor of
evaluate -> Mono<List<...>>, prepareEvaluation/handleToolCalls/
toolCallLoop mirroring the trace-level scorer, scoreTraceReactive
wrapping the sync LangChain4j chat on boundedElastic, shouldUseAgentic-
Tools gating on toggle + threshold + provider-supports-tools. - TraceToolContext: new forThread(workspaceId, userName) factory + has-
ActiveTrace() accessor for thread-scoped evaluations that lack a single
active trace. - GetTraceSpansTool: returns a clear redirect error when invoked on a
thread context, pointing the model at read(type=trace, id=X) instead
of NPEing. - Tests: thread-routing-gate truth table, handleToolCalls no-op short-
circuit, existing scoring tests updated for the wider constructor.
Reuses the existing toggle (TOGGLE_AGENTIC_TOOLS_ENABLED), threshold
(ONLINE_SCORING_AGENTIC_TOOLS_THRESHOLD_TOKENS), and chars-per-token
(ONLINE_SCORING_AGENTIC_TOOLS_CHARS_PER_TOKEN) config; no new env vars.Threads don't have an experimentId-driven branch (no test-suite-assertion
equivalent), so the routing decision is purely size-based + toggle +
provider-supports-tools.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] chore: defer handleToolCalls side effects to subscription time
Both scorers' handleToolCalls were allocating TraceToolContext + the
messages list + ToolOutputBudget at method-invocation time rather than
when the returned Mono was subscribed. Works correctly under the current
single-subscriber call shape, but a future caller that composes the
returned Mono differently (or subscribes twice) would observe the side
effects out of sync with the chain.Wrap everything below the early-return guard in Mono.defer in both
OnlineScoringLlmAsJudgeScorer and OnlineScoringTraceThreadLlmAsJudgeScorer.
The earlyMono.just(chatResponse)stays outside the defer because it's
cold and pure — only the mutable allocations move inside.No behavior change for the current callers. 79 targeted tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] refactor: extract shared reactive tool-call loop + tighten thread skeleton naming + more thread tests
Three pre-merge cleanups for the thread agentic-tools branch:
-
Extract the reactive tool-call loop (toolCallLoop, executeToolOr-
BudgetExhausted, the Budget holder, MAX_TOOL_CALL_ROUNDS,
CUMULATIVE_TOOL_OUTPUT_BUDGET_CHARS, BUDGET_EXHAUSTED_MESSAGE) into
a new ToolCallLoop utility class shared by both the trace- and
thread-level LLM-as-judge scorers. Each scorer used to carry its own
~60 lines of identical recursive loop machinery; only the message
type differed. The new helper takes a Function<ChatRequest, Mono<
ChatResponse>> from each caller so the scorer threads its own
message through scoreTraceReactive — clean separation of "what to
call for the next LLM round" from "how to drive the loop." -
Tighten ThreadTraceSkeleton field naming. Added @JsonNaming(snake_-
case) and renamed durationMs -> duration so the wire shape matches
Trace's serialization. The model previously sawdurationMsin the
skeleton butdurationin any read(type=trace, id=X) response — a
mild schema split that could confuse the agent. Both surfaces now
emit the same field names. -
Three new thread-side handleToolCalls tests mirroring the trace
scorer's coverage: accumulate-and-finalize (multi-round happy path
with structured wrap-up), propagate-scoreTrace-failure-mid-loop
(failure escapes; redelivery contract holds), and cap-at-MAX_TOOL_-
CALL_ROUNDS-and-still-wrap-up (loop terminates after 10 rounds and
the wrap-up structured call still fires).
98 targeted unit tests pass + the 80-test OnlineScoringEngineTest
integration suite. No behavior change — pure refactor + naming +
test coverage.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] test: dedicated coverage for ToolCallLoop
Adds ToolCallLoopTest with five focused cases for the shared reactive
tool-call loop extracted in the previous commit:- Early return when the initial response has no tool calls (skip the
loop entirely; scoreTrace never invoked). - Round cap at MAX_TOOL_CALL_ROUNDS — model keeps emitting tool calls
every round; loop terminates after exactly 10 follow-up scoreTrace
invocations and returns the cap-round response. - Budget exhaustion — a single payload at CUMULATIVE_TOOL_OUTPUT_BUDGET
forces the next round to skip the registry and emit the budget
sentinel; registry dispatch counter stays at 1, exhaustedLogged flips. - Defensive message copies — each follow-up ChatRequest holds a distinct
list instance, post-loop mutation of the caller-sidemessagesdoes
not bleed into already-captured requests. - Tool ordering — three tool calls in a single round produce
ToolExecutionResultMessages in the same order via concatMap (OpenAI
rejects out-of-order tool results).
Previously, ToolCallLoop was only covered indirectly via the trace and
thread scorer tests; this makes the contract independently checked.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] chore: tighten ToolCallLoopTest helpers
- Collapse the two stubTool variants into one parameterized
stubTool(toolName, result) helper; call sites pass TOOL_NAME
explicitly when they don't need a custom name. - Drop a numeric "(3 vs 5 in the current shape)" aside from a comment
that would rot if the loop's message-shape ever changes; the
preceding assertion already guarantees the relationship.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] refactor: address Baz Reviewer comments on thread scorer
Five fixes prompted by PR #6719 review:
- Extract summarizeResponse(ChatResponse) and addToolSpecs(request,
toolChoice, toolRegistry) to OnlineScoringEngine; both trace and
thread scorers now share the helpers instead of duplicating ~20 lines. - Replace the thread scorer's "Received response for threadId '{}':\n\n{}"
log (which dumped the full ChatResponse, leaking assistant text + tool
args into user-facing logs) with the shape-only summarizeResponse —
same redaction the trace scorer already applies. - Fix the error-log Throwable handling in both scorers' prepareEvaluation
fallbacks: was passing exception.getMessage() (string), so SLF4J
dropped the stack trace; now passes the exception as the last arg. - Tighten ThreadTraceSkeleton with @Builder + @NonNull on the only
required field (id) per .agents/skills/opik-backend/SKILL.md. - Detect multimodal templates in OnlineScoringTraceThreadLlmAsJudgeScorer
.prepareEvaluation and fall back to the inline path with a user-facing
warn, instead of letting renderThreadMessagesWithReplacement throw
UnsupportedOperationException downstream and break the whole
evaluation. Mirrors the provider-doesn't-support-tools branch.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] refactor: fold multimodal check into shouldUseAgenticTools + cover with test
Two small follow-ups to the previous Baz Reviewer-comment commit:
- Move the multimodal-template check from the prepareEvaluation caller
into shouldUseAgenticTools alongside the toggle / size / provider
checks. Previously the info log "switching to agentic-tools mode for
threadId X" could fire from shouldUseAgenticTools and then be
invalidated by the caller flipping the decision back to inline on
multimodal templates. Now all routing decisions live in one place and
the info log is the last thing emitted, so the log is always accurate. - Add a RoutingGateTests case for the multimodal branch — every other
precondition holds but contentArray-bearing template messages force
the inline fallback. The truth-table parameterized test still covers
the size + provider permutations.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] chore: mark ThreadTraceSkeleton.startTime as @NonNull
Trace.startTime is @NotNull at the API boundary, so a persisted trace
always carries a non-null startTime. The skeleton field should mirror
that contract — failing fast on construction if a caller ever assembles
a ThreadTraceSkeleton without one. The other timestamp fields (endTime,
duration) remain nullable since in-flight traces legitimately lack them,
and name stays nullable to match the source.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] refactor: dedupe renderThreadMessages via renderMessagesWithReplacements
renderThreadMessages carried ~40 lines of rendering loop (string-content
branch, structured-content fan-out, role mapping, fall-through warn)
that exactly mirrored renderMessagesWithReplacements. The only thread-
specific piece is assembling the replacements map for the context
variable — everything after that is identical to the trace / span
flows. Delegate to the shared helper so role-fan-out and multimodal
handling stay in one place; future changes to the render loop won't
need to be made twice. No behavior change: the shared helper's 2-arg
overload defaults to PromptType.MUSTACHE, which is what
renderThreadMessages hard-coded.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] refactor: quote log placeholders + split user-facing vs internal error logger
Three more Baz Reviewer comments addressed:
- Wrap the summarizeResponse(...) placeholder in single quotes on both
trace and thread "Received response" logs, per SKILL.md's "values in
single quotes" convention (log.info("...: '{}'", value)). - Update the matching integration-test regex in
AutomationRuleEvaluatorsResourceTest to expect the quoted shape. - Extract OnlineScoringEngine.logPreparingLlmRequestError that emits a
sanitized one-liner to userFacingLogger (no Throwable, so the stack
trace doesn't leak into the user-facing sink) AND the full stack
trace to the scorer's standard slf4j logger. Both scorers' catch
blocks in prepareEvaluation now call the helper — dedups the catch
body and cleans up the privacy boundary at the same time.
Skipped: the shouldUseAgenticTools dedup comment. The truly-shared
computation is two one-liners; the decision logic and log conditions
genuinely differ between trace (experimentId-override) and thread
(multimodal-block), so any helper would be roughly break-even on
lines and add indirection.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] refactor: address 3 Baz Reviewer comments on thread scorer
- Skip the thread-JSON serialization in prepareEvaluation when the
agentic-tools toggle is off — the resulting token estimate would be
thrown away (shouldUseAgenticTools re-checks the toggle and returns
false). Avoids an MB-scale OBJECT_MAPPER.writeValueAsString per
evaluation on the inline path. - Extract OnlineScoringEngine.summarizeRequest (model-name-as-string
variant); replace the thread scorer's full-ChatRequest "Sending
threadId" user-facing log with the shape-only summary. Matches the
trace scorer's existing redaction and stops leaking the rendered
prompt + skeleton into the user-facing log sink. Trace scorer's
private summarizeRequest now delegates to the shared helper. - Move the post-loop wrap-up (force-closure UserMessage + final
structured re-issue) into ToolCallLoop.runWithWrapUp. Both scorers
shed ~15 lines of identical wrap-up flatMap. The force-closure
message itself becomes a single private constant in ToolCallLoop.
The lower-level run(...) overload stays for ToolCallLoopTest.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] fix: keep terminal AiMessage in wrap-up + ID thread fallback warns
-
ToolCallLoop: when the model stops on its own (no-tool-calls early
return), append the terminal AiMessage tomessagesbefore returning.
Otherwise runWithWrapUp's structured re-issue is missing the
assistant's last turn, and the forcing user message lands in a
conversation history where the model's final reasoning is gone.Kept the cap-reached early return as-is: that response may carry
unfulfilled tool_executions_requests, and appending an AiMessage with
tool_calls but no matching ToolExecutionResultMessage would produce
a malformed message sequence that OpenAI / Anthropic reject.
Documented the asymmetry in both branches. -
OnlineScoringTraceThreadLlmAsJudgeScorer.shouldUseAgenticTools: add
threadId to the two fallback warn messages (provider doesn't support
tools, multimodal template). Operators chasing why a specific thread
fell back to inline can now grep by id. -
Updated ToolCallLoopTest + both handleToolCalls integration tests for
the new "append on no-tool-calls" behavior (final structured re-issue
now carries one extra message: the terminal AiMessage).
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] fix: split user-facing vs internal error logger in Python scorer prep
logAndPrepareEvaluatorInput was the missing instance of the same fix
already applied to logPreparingLlmRequestError on the LLM-as-judge
path: passing exception.getMessage() (string) dropped the stack trace
AND duplicated the exception text in the body. Apply the same split —
userFacingLogger gets a sanitized one-liner (no Throwable, so internal
class names / paths don't leak into the user-facing log sink) and the
scorer's internal slf4j logger gets the full Throwable for diagnosis.Both Python scorer call sites (trace + span) updated to pass
logas
the new internalLogger arg.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] refactor: address 3 LifeXplorer review nits
- summarizeRequest: drop the "~N chars" field that called m.toString()
on every ChatMessage just to measure its length. On a multi-MB
rendered prompt that allocated the full string (~2x prompt-size heap
churn per evaluation) even at INFO. Kept the cheap shape info
(model, message count, tool count, useTools). Integration-test
regex updated to match. - Move toolRegistry.execute(...) inside the try-with-resources MDC
scope in ToolCallLoop so the synchronous logs from ToolRegistry
itself (e.g. the "Unknown tool requested by judge" warn) carry the
trace/thread/rule tags. Async logs from inside the tool's subscribed
Mono still need reactor-context propagation — documented inline. - Replace the fully-qualified @JsonNaming annotation on
ThreadTraceSkeleton with proper imports for JsonNaming +
PropertyNamingStrategies.
Skipped the "drop this.trace = null in TraceToolContext's private
ctor" suggestion: trace and spans are final fields, so the compiler
requires explicit initialization in every constructor.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] refactor: collapse TraceToolContext to one ctor + 2 factories
Addresses LifeXplorer's "no need to set nulls" comment more directly:
the two-constructor pattern had a thread-scoped private ctor that
assignedthis.trace = null;andthis.spans = null;purely to satisfy
the Java compiler's definite-assignment rule for final fields. Code
smell even though the assignments were required as-written.Collapsed to a single nullable-accepting private constructor + two
static factories:- forActiveTrace(trace, spans, ws, user) for trace-scoped contexts
- forThread(ws, user) for thread-scoped contexts
The explicit nulls now live at the forThread call site where the
absence of an active trace is the point, not buried in a parallel
constructor. All 8 call sites (1 prod + 7 test) updated to use the
factory; the 4-arg public constructor is gone.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] refactor: dedup renderThreadMessagesWithReplacement + tidy Sending log
-
renderThreadMessagesWithReplacement no longer carries its own string-
content rendering loop. It now asserts string-only templates (the
caller's shouldUseAgenticTools already detects multimodal and falls
back to inline upstream) and delegates to renderMessagesWithReplacements
so role-switch / template-engine logic stays in one place. Same dedup
pattern as renderThreadMessages. -
Drop the now-stale isInfoEnabled guard + comment on the "Sending
traceId/threadId to LLM" log lines. The guard existed because the
earlier summarizeRequest streamed over the message list to total up
character counts; that field was already dropped, so the helper is
cheap and the guard is dead weight.
LifeXplorer also suggested demoting the "Sending" log to DEBUG. Keeping
at INFO: the cost concern that motivated the suggestion is resolved by
the prior chars-count drop, the line completes the
Evaluating → Sending → Received chain operators rely on in the UI logs,
and the integration test (AutomationRuleEvaluatorsResourceTest$GetLogs)
asserts 4 INFO entries per evaluation — demoting would break that
contract.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下载附件