-
[OPIK-5333][BE]: agentic tools online scoring; (#6649)
发布于
2026-05-15 10:15:43 +00:00 - [OPIK-5333] [BE] feat: add agentic-tools online scoring config flags
Adds OnlineScoringConfig.agenticToolsEnabled (default true) and
OnlineScoringConfig.agenticToolsThresholdTokens (default 50000), wired
through config.yml as ONLINE_SCORING_AGENTIC_TOOLS_ENABLED and
ONLINE_SCORING_AGENTIC_TOOLS_THRESHOLD_TOKENS env vars.These gate the size-based routing decision in subsequent commits — when
the rendered context for a trace or thread exceeds the threshold, the
scorer flips into the read/jq/search agentic-tools path instead of
rendering the full content inline.- [OPIK-5333] [BE] feat: ReadTool output safety cap + thread-redirect error message
ReadTool: add OUTPUT_SAFETY_CHARS (40 K, ~20 K tokens) per-call cap with
guardOutput() that walks FULL → MEDIUM → SKELETON until the payload fits.
Drives off the LAST REQUESTED tier rather than current.tier() so the loop
terminates cleanly when GenericCompressor collapses MEDIUM and SKELETON
to the same returned tier.ToolArgs: replace the bare 'type=thread is not supported' rejection with
a redirect that tells the model what to do instead (use read(type=trace,
id=...) on the trace ids in the thread skeleton, or jq for path-targeted
lookups). Misrouted calls now cost one wasted round, not many.- [OPIK-5333] [BE] feat: estimateTraceContextTokens + supportsToolCalling helpers
Adds two utility methods on OnlineScoringEngine that the size-based
agentic-tools routing depends on:-
estimateTraceContextTokens(trace, spans, traceCompressor) — char-based
token estimate for the {trace, spans} composite. Used to decide whether
the inline-rendered prompt would risk overflowing the model's window
and the scorer should flip into the read/jq/search agentic path. -
supportsToolCalling(provider) — allowlist of LLM providers known to
support tool calling. Providers outside the list fall back to the
inline path even when context exceeds the size threshold.
- [OPIK-5333] [BE] feat: route huge contexts through agentic-tools path
Trace-level LLM-as-judge online scoring now flips into the read/jq/search
agentic path when the rendered context is estimated to exceed the configured
threshold (default 50 K tokens) — until now the agentic path was reserved
for the test-suite assertion case (experimentId != null). Both gates are
unified in LlmAsJudgeToolsMode.shouldUseTools(message, estimatedTokens, config).Below the threshold, behaviour is unchanged: the inline path renders trace
input/output verbatim and uses provider-native structured output.Above the threshold:
- User-mapped variables (input/output/metadata) are capped at MAX_PROMPT_FIELD_CHARS
so a huge field doesn't pre-load context. - Drill-down hint points the model at tier=MEDIUM (full structure with
per-string truncation + jq pointers) instead of tier=FULL which could
overflow the window. ReadTool's per-call output cap silently downgrades
a tier=FULL request anyway, but this avoids a wasted round. - Provider tool-calling support gates the path: a model whose provider
doesn't support tools falls back to inline with a warning rather than
requesting tool specs the API will reject.
Diagnostic logs at the gate decision so operators can correlate scoring
failures with mode flips:- 'Trace context exceeds N tokens; switching to agentic-tools mode for traceId X'
- 'Trace context exceeds N tokens but provider for model Y does not support
tool calling; falling back to inline path'
Threshold and master switch are env-configurable via
ONLINE_SCORING_AGENTIC_TOOLS_THRESHOLD_TOKENS and
ONLINE_SCORING_AGENTIC_TOOLS_ENABLED (added in earlier commit).- [OPIK-5333] [BE] feat: cumulative tool-output budget + defensive messages copy
Adds CUMULATIVE_TOOL_OUTPUT_BUDGET_CHARS (150 K, ~75 K tokens) cap on the
total size of tool results across all rounds of handleToolCalls. Once
exhausted, further tool calls return a budget-exhausted sentinel so the
judge has to compose its final answer from data already gathered, rather
than overflowing the model's context window with one more huge result.Pairs with ReadTool's per-call OUTPUT_SAFETY_CHARS (40 K): up to ~7 max-cap
reads fit before the cumulative budget kicks in.Also defensive-copies the messages list when building per-round followUp
and finalRequest. ChatRequestBuilder.messages stores by reference; a later
iteration mutating the list would retroactively change what an async chat
client sees in earlier requests. Snapshot per round.- [OPIK-5333] [BE] feat: inject built-in spans variable into trace-level Python evaluators
Adds OnlineScoringEngine.toReplacements(variables, trace, spans) overload
that auto-injects the built-in 'spans' variable holding the trace's spans
serialized as a compact JSON array (sorted by start_time). User-defined
spans mappings win over the auto-injection — built-in only fires when
the variable name is otherwise unclaimed.OnlineScoringUserDefinedMetricPythonScorer pre-fetches spans before
evaluating so metrics that reference {{spans}} can compute aggregates
across the full execution trace (error counts, span-type stats, etc.).
Metrics that don't reference 'spans' pay only the cost of the fetch +
serialization.- [OPIK-5333] [BE] test: ReadTool guardOutput regression coverage
Two cases for the per-call output cap (OUTPUT_SAFETY_CHARS):
-
oversizedFullSpanDowngradesToMedium — a single huge string forces the
walk from FULL → MEDIUM and asserts the downgraded tier in the response. -
guardOutputTerminatesEvenWhenCompressorCollapsesTiers — regression for
an infinite-loop bug. GenericCompressor.pickTier collapses MEDIUM,
SKELETON, and SUMMARY requests all to MEDIUM. If guardOutput drove its
walk off current.tier() (which is always MEDIUM after the first
downgrade), it would keep asking for SKELETON, getting MEDIUM back, and
looping forever. Many fields × moderate size each is the data shape
that surfaces it.
-
[OPIK-5333] [BE] chore: spotless formatting
-
[OPIK-5333] [BE] fix: review feedback + CI config validation
- OnlineScoringConfig: drop @Builder.Default on agenticToolsEnabled and
agenticToolsThresholdTokens, use plain field initializers. The @Builder
default only applies when using the builder; Dropwizard YAML
deserialization uses the no-args constructor, where field initializers
apply directly. This was causing 26 CI failures: config-test.yml has no
onlineScoring.agenticToolsThresholdTokens, the field stayed at Java
default 0, and the @Min(1) validator rejected it on every test that
loaded the config. - config-test.yml: add explicit agenticToolsEnabled and agenticToolsThresholdTokens
values so the test config matches main config.yml's shape. - OnlineScoringLlmAsJudgeScorer.evaluate: pre-fetch spans up-front so
estimateTraceContextTokens accounts for big spans on small traces (PR
review #1, High). The big-spans-on-small-trace shape would have skipped
the gate without this. Cost: one DB query per scoring run, negligible
versus the LLM call. - Log lines: quote interpolated values as '{}' to match the
.agents/skills/opik-backend/SKILL.md convention (PR review #2 + #4).
- [OPIK-5333] [BE] fix: update test for SpanService constructor arg
Adds the missing SpanService mock + constructor argument to
OnlineScoringUserDefinedMetricPythonScorerTest. The test was missed when
the scorer's constructor gained a SpanService parameter (commit f55e8cd5fc,
spans-injection feature) — local 'mvn test -Dtest=ReadToolTest' didn't
catch it because it wasn't in scope, but 'mvn install' / CI 'mvn package'
fails testCompile on the mismatched arity.Also stubs spanService.getByTraceIds(any()) to return Flux.empty() in the
two ScoringTests that exercise score() — without the stub, the new
prepareData call to spanService NPEs on the unmocked Flux.This is the actual root cause of the 25 CI failures: testCompile failed,
no surefire reports were produced, every test job reported failure with
'Could not find any files for **/target/surefire-reports/TEST-*.xml'.- [OPIK-5333] [BE] fix: align test with defensive messages copy
The existing handleToolCallsAccumulatesResultsAndFinalizesWithStructuredRequestShape
test asserted that the round-1 captured ChatRequest's messages had size 4 —
which only held because handleToolCalls used to share the mutable list by
reference (the original comment in the test acknowledged this:
'ArgumentCaptor captures the request by reference, so by assertion time
the captured round-1 messages list reflects the final 4-element state').Commit d0aa21aff6 (cumulative tool-output budget + defensive messages copy)
intentionally fixed that reference-sharing — every round now passes a
fresh ArrayList to ChatRequestBuilder. The captured round-1 request is
correctly a 3-element snapshot now (original UserMessage + AiMessage with
tool calls + ToolExecutionResult). The forcing UserMessage that pushed the
old assertion to 4 is only present in the final structured re-issue.Updated round-1 size assertion to 3 and rewrote the comment to explain
the snapshot semantics. Final-request assertions unchanged.- [OPIK-5333] [BE] refactor: make spans fetch opt-in for Python metrics
Only call spanService.getByTraceIds when the user's metric explicitly
maps aspansargument. Most trace-level Python metrics use only
input/output/metadata, so skipping the fetch for them removes a DB
query the evaluator never reads.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] fix: address spans serialization, log redaction, branch coverage
Pass the trace's
spansto the Python evaluator as a typed JSON array
instead of a JSON string. WidensPythonEvaluatorRequest.datato
Map<String, Object>sojson.loads(argv[2])in the runner yields
spansas a list of dicts that maps cleanly onto the metric's
score(..., spans)signature.Replace the verbatim INFO dump of the rendered evaluator input with a
shape-only summary (keys + per-argument sizes), mirroring the LLM
scorer'ssummarizeRequestredaction. The rendered values are user
trace content (input/output/metadata/spans) and were landing in clear
text downstream of the user-facing log sinks.Cover both branches of the
arguments.containsKey("spans")gate with
explicit tests: one verifiesspanService.getByTraceIdsis skipped when
absent, the other asserts the data sent to the evaluator carries spans
as aList<Span>.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] chore: clean up imports, field placement, and verbose comments
Replace fully-qualified type references with proper imports, lift static
fields back to the top of their classes, fix a missing EOF newline, and
trim a few comment blocks that had grown past their useful weight (the
duplicated @Builder.Default rationale and the OUTPUT_SAFETY_CHARS
calibration breakdown). Drops one unnecessary @SuppressWarnings on a
String captor.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] fix: redact span scorer log and unblock CI
Move summarizeEvaluatorInput to OnlineScoringEngine and use it from the
span scorer too — the trace scorer was already redacted, the span side
was still dumping the full rendered map. Widen the integration test
matcher in AutomationRuleEvaluatorsResourceTest to accept the new
'Sending traceId ... to Python evaluator: arguments=[...]' summary
format alongside the LLM scorer's summary.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] chore: drop unused size-aware shouldUseTools overload
The 3-arg shouldUseTools(message, tokens, config) was never wired up.
The scorer combines size and provider locally because its diagnostic
logs need both booleans separately. Trim the doc-claimed single source
of truth framing to match what is actually centralized.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] refactor: move agenticToolsEnabled to serviceToggles
The boolean lives with the rest of the feature flags now
(TOGGLE_AGENTIC_TOOLS_ENABLED, defaults to true) instead of on the
onlineScoring config bucket. The numeric threshold stays on
OnlineScoringConfig since it is a tunable, not a toggle. The LLM scorer
injects ServiceTogglesConfig and reads the gate from there.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] chore: default TOGGLE_AGENTIC_TOOLS_ENABLED to false
The agentic-tools online-scoring path is dark-launched off so existing
deployments stay on the inline behavior until the toggle is flipped per
environment.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] fix: stamp projectName onto traces from the update path
TraceDAO.findByIds (used by OnlineScoringSampler.onTracesUpdated) sets
projectId but leaves projectName null because the ClickHouse traces
table doesn't carry the name. Downstream, FeedbackScoreService.
processScoreBatch groups by projectName and re-resolves projectId from
it via retrieveByNamesOrCreate, so a null name routes every score
through WorkspaceUtils.getProjectName(null) which returns
"Default Project". The end result: scores from any trace completed via
PATCH (the SDK's trace.end() flow) land in Default Project instead of
the trace's actual project.Resolve the missing names once per batch via ProjectService.findByIds
and stamp them onto the traces before publishing the scoring event.
The create-path was already correct (TraceService.create explicitly
sets projectName before posting TracesCreated) and is untouched.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] refactor: reuse findIdToNameByIds + cover enrichment path
Swap the local stampProjectNames helper to use the existing
ProjectService.findIdToNameByIds (which already returns the projectId ->
name map we were building by hand), warn-log when a lookup misses
instead of silently dropping the name, and add unit coverage for the
three branches: enriched, miss with passthrough, and the CreatedPath
shortcut where every trace already carries a name.The warn-log path avoids fail-fast on transient lookup misses — losing
the score entirely is worse than landing on the existing Default
Project fallback that the score service applies downstream.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] test: cover the LLM-as-judge agentic-tools routing gate
Extract the inline gate decision in OnlineScoringLlmAsJudgeScorer.evaluate
into a package-private shouldUseAgenticTools(message, tokens, model) so it
can be unit-tested independently of the rest of the scoring flow. Behavior
is unchanged; the diagnostic warn/info logs move with the gate so operators
still see the same routing decisions.Add a parameterized truth-table covering the gate's four inputs
(experimentId / toggle / tokens vs threshold / provider tool support).
Nine rows: the three experimentId-true cases (always tools regardless of
other inputs), the size-based path with each precondition flipped, and
a no-preconditions baseline.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] fix: thread spans from evaluate into handleToolCalls
The size-estimate fetch in evaluate(...) was discarded; handleToolCalls
re-fetched spans inside its body, doubling the DB round-trip. Pass the
already-fetched list in instead. Drops the now-redundant spanService
stubs from three handleToolCalls tests (Mockito strict mode flagged
them once the production code stopped calling getByTraceIds).Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] refactor: make LLM-as-judge online scoring fully reactive
Reviewer feedback on #6649 flagged that PR #6511 (OPIK-5745) reintroduced
the blocking pattern that OPIK-6308 had eliminated from the online
evaluator path. The size-based agentic-tools branch was making it worse
by routing more traffic through the offending fetchSpans .block().Pushes the scoring chain to be reactive end-to-end:
- ToolExecutor.execute returns Mono; ReadTool's I/O composes via
flatMap with no .block() helpers. - handleToolCalls + the per-round tool dispatch become a recursive Mono
chain with concatMap to preserve message ordering. - aiProxyService.scoreTrace (sync LangChain4j) wrapped in fromCallable +
subscribeOn(boundedElastic) as a per-call boundary, replacing the
whole-evaluate wrapper. - prepareEvaluation scheduled on Schedulers.parallel() since it's
CPU-bound; spans pre-fetch in the Python scorer also moved to reactive. - toolCallLoop side effects deferred via Mono.defer so list mutation
only fires at subscription time. - MDC threaded through handleToolCalls/toolCallLoop/executeToolOrBudget-
Exhausted so internal slf4j logs keep their workspace/trace/rule tags.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] fix: address remaining andrescrz review comments
Five comments on #6649:
- ReadTool.guardOutput: replace while loop with a bounded for loop capped
at CompressionTier.values().length so a future tier change can't turn
the recompress walk into an infinite loop, even if downgradeTierOrSame's
contract drifts. Demote the per-iteration "downgrading" log from info
to debug — a many-rounds-per-evaluation agent could trigger 2-3 per
read call; the terminal warn covers the case that actually matters. - ToolArgs.parseType: switch the thread-rejection error from string
concatenation to a Java text block. - OnlineScoringEngine: swap jakarta.validation.constraints.NotNull for
lombok.NonNull (Jakarta annotation does nothing on plain Java methods;
Lombok generates a real runtime null check). Add @NonNull on missing
arguments of the new public methods (toReplacements/spans variant,
summarizeEvaluatorInput, supportsToolCalling). - OnlineScoringEngine.estimateTraceContextTokens: make the 4-chars/token
ratio a parameter sourced from a new onlineScoring.agenticToolsCharsPer-
Token config entry (env var ONLINE_SCORING_AGENTIC_TOOLS_CHARS_PER_TOKEN,
default 4). Lets operators tune for code/JSON-heavy workloads where the
natural-language ratio under-estimates.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] chore: minor cleanups in guardOutput + ToolArgs
- ReadTool.guardOutput: cache CompressionTier.values().length in a local
so the bound + trailing log don't each pay an Enum.values() array alloc. - ToolArgs.parseType: add a one-line comment explaining the
\line
continuations in the text block so a reader doesn't wonder why we
aren't letting the block break naturally.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] fix: address LifeXplorer review nits
Three comments on #6649:
- OnlineScoringEngine: the supportsToolCalling Javadoc was orphaned —
two /** */ blocks landed adjacent so the first attached to nothing
and the actual method ended up undocumented. Move the supportsTool-
Calling Javadoc to immediately precede its method. - OnlineScoringConfig.agenticToolsThresholdTokens: bump @Min(1) →
@Min(1000). With MAX_PROMPT_FIELD_CHARS = 4000 (~1K tokens per
template variable), any threshold below ~1K tokens would flip the
whole online-scoring fleet onto the agentic-tools path. Dropwizard
now fails fast on a typo'd env var instead of silently degrading. - OnlineScoringSamplerTest: lift com.comet.opik.domain.ProjectService
out of the inline FQN reference and into the imports section,
matching surrounding style.
- [OPIK-5333] [BE] fix: add agenticToolsCharsPerToken to config-test.yml
config-test.yml lists every onlineScoring field explicitly (matching the
all-fields-set pattern rather than relying on Java field initializers
to survive Dropwizard's YAML deserialization). I missed adding the new
agenticToolsCharsPerToken entry, which made OnlineScoringEngineTest fail
at startup with "must be greater than or equal to 1" — Bean Validation
running against the unset field at value 0.Production config.yml is unaffected — it has the env-var-default
${ONLINE_SCORING_AGENTIC_TOOLS_CHARS_PER_TOKEN:-4} so operators who
don't override the env var still get 4.- [OPIK-5333] [BE] fix: redact raw exception message in ToolRegistry error envelope
ToolRegistry.execute was echoing e.getMessage() back to the LLM through
the {"error": ...} envelope when a tool implementation threw. That can
carry ClickHouse query fragments, internal paths, or any other detail
the failing tool surfaced in its exception text — info the model has no
business seeing.Mirror ReadTool's exception-path pattern: generate a UUID correlation
id, log the full exception under that id at warn, and return the id +
tool name to the model. An operator can grep the warn log when the
model reports a ref:.Tests updated accordingly: assert tool name + ref: marker are present,
assert the raw exception message ("kaboom") is NOT present.Flagged by baz-reviewer on the current refactor. Behavior preexisted
from PR #6511 (OPIK-5745); the reactive refactor preserved the leak and
this commit closes it.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] fix: address remaining PR review nits
- Gate experimentIdPath on provider tool-call support so non-tool-calling
providers (e.g. OLLAMA) fall back to inline scoring with experiments. - Build fullJson once in prepareEvaluation and pre-seed it into the
tool-call cache, avoiding a duplicate traceCompressor.buildFullJson on
the agentic-tools path. - Log toolsEnabled reflecting the actual useTools decision (which factors
in size threshold + provider support) rather than just shouldUseTools. - Quote the evaluator-input summary placeholder in Python scorer logs
for readability and grep-ability.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] fix: Preconditions + log regex update for Python scorers
- Replace manual if/throw in estimateTokensFromJson with Guava
Preconditions.checkArgument per andrescrz review nit. - Update AutomationRuleEvaluatorsResourceTest regex to match the new
quoted 'arguments=[...]' format emitted by the Python scorers after
the placeholder-quoting fix landed; restores Integration Group 13.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] chore: address andrescrz nits on tools/
- Annotate public-method parameters with @NonNull on the ToolExecutor
SPI (ctx), the three tool impls, ToolRegistry, and ToolArgs helpers
(errorJson, cacheMiss, parseType, requireString). Documents the
contract where it wasn't enforced. - Extract the thread-type-rejection text block in ToolArgs to a private
constant (THREAD_TYPE_REJECTION_TEMPLATE). - Drop the (JsonNode) casts on ObjectMapper#valueToTree in ReadTool —
the lambda return type pins the generic inference, no cast needed. - Replace the multi-part runtime string concat in ToolRegistry's
exception fallback with String.formatted; tidy the warn-log format
string into a single line. - Drop the isDebugEnabled() guard in GetTraceSpansTool — the placeholder
args are all cheap (length / size lookups), so SLF4J's deferred
substitution already handles the level check.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] docs: clarify OUTPUT_SAFETY_CHARS is best-effort, log overshoot
Addresses the Baz Reviewer comment on guardOutput: when the smallest
tier (SKELETON) is still over the cap, we return the over-cap payload
rather than hard-truncating the JSON envelope, so the final response
can technically exceed OUTPUT_SAFETY_CHARS. Hard-truncating would
corrupt the envelope and break the agent's tool round, so the cap is
deliberately a heuristic.- Document the best-effort contract on OUTPUT_SAFETY_CHARS and
guardOutput's Javadoc. - Include the actual payload size in the terminal-branch WARN so
operators can see how much we overshot and tighten the per-tier
limits in TraceCompressor / GenericCompressor if needed.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] refactor: address remaining Baz Reviewer comments
- Remove @NonNull from ToolExecutor.execute(ctx) per
.agents/skills/opik-backend/SKILL.md ("don't put validation
annotations on interface method parameters"). Enforcement still
lives on the four implementing classes. The interface Javadoc now
documents the contract explicitly. - Extract logAndPrepareEvaluatorInput on OnlineScoringEngine and use
it from both Python scorers (trace + span). Removes the duplicated
MDC scope, "Evaluating X 'id' sampled by rule" entry log, "Sending X
'id' to Python evaluator: ''" exit log, and error-rethrow
fallback. Callers now supply only what differs: entity label
("traceId" / "spanId"), id, rule name, and a Supplier that builds
the rendered evaluator input.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-5333] [BE] fix: address two LifeXplorer review nits
- Move the "Shape-only summary..." javadoc back to where it belongs, on
summarizeEvaluatorInput. It got orphaned above logAndPrepareEvaluatorInput
when that helper was inserted earlier; the two javadoc blocks were
sitting back-to-back with the first one describing the wrong method. - Drop @NotNull on the new agenticToolsEnabled primitive boolean. Jakarta
@NotNull has no runtime effect on primitives (they can't be null and
default to false). Pre-existing booleans in ServiceTogglesConfig still
carry @NotNull for consistency with the file's prior convention; not
reworking those in this PR.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
Co-authored-by: aadereiko aliaksandr@comet.com
Co-authored-by: Claude Opus 4.7 (1M context) noreply@anthropic.com
Co-authored-by: Sasha sasha@Sashas-MacBook-Pro.local下载附件