Skip to content

fix(adjudicate): declare a verdict tool and stop forcing tool_choice:none - #137

Merged
amiddavid merged 4 commits into
mainfrom
fix/adjudicate-tool
Sep 1, 2026
Merged

fix(adjudicate): declare a verdict tool and stop forcing tool_choice:none#137
amiddavid merged 4 commits into
mainfrom
fix/adjudicate-tool

Conversation

@amiddavid

@amiddavid amiddavid commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

The defect, restated after measurement

internal/cheapmodel.CompletePrefixed set tool_choice to {"type":"none"} on every prefix ask,
with this comment:

tool_choice: free (not in the cache key) and required, or the model answers with a tool_use.

An earlier version of this PR said both halves of that were wrong. That was itself wrong, and a
three-way A/B run for the review shows how. The second half was right about the mechanism and wrong
only about the remedy. The fix is not to suppress the tool call — it is to declare a tool worth
calling. The two changes below are therefore one change, and this PR is not reducible to either
half.

The three-way A/B

Prior measurements compared main against both changes together, so neither could separate them.
The missing arm is tool_choice removed with NO tool declared. Same transcript, same ask model
(aws/claude-sonnet-5, LOCA S2L cg_128k_3), same config, three passes per arm, run sequentially,
passes interleaved by arm so drift over the run lands on all three equally.

1 — origin/main 2 — tool_choice removed, no tool 3 — this PR
requests 117 234 182
asks attempted 20 29 78
  timed out at 90 s 0 5 1
  asks REPLIED ← denominator 20 24 77
    parsed 14 10 70
    sweep_unparseable 6 (30.0%) 14 (58.3%) 7 (9.1%)
    sweep_reply_truncated 0 0 0
replies containing a tool_use 0 0 43 (55.8%)
verdicts returned 168 115 837
coverage of items in replied asks 71.5% 41.2% 90.9%
sweep_prefix_cache_read_ZERO 0 0 0
adjudicate_stray n/a n/a 0, 0, 0

Fisher two-tailed on unparseable: 1 vs 3 p = 0.0245, 2 vs 3 p = 0.0000, 1 vs 2 p = 0.0755.

Arm 2 is the result. Removing tool_choice: none on its own is worse than main, and it is the
only arm that loses asks to llmCallTimeout. The mechanism was measured directly, not inferred, by
logging every reply's content blocks on a dedicated pass with tool_choice removed and no verdict
tool declared:

36 prefix-ask replies
  thinking,text                                        24
  thinking,tool_use:context_guru_expand                  7
  thinking,tool_use:canvas_canvas_list_announcements      2
  thinking,text,tool_use:context_guru_expand             1
  thinking,tool_use:memory_read_graph                    1
  thinking,tool_use:filesystem_read_text_file            1
  -> tool_use with NO text block: 11 of 36 (30.6%)

The ask appends to a prefix carrying the agent's own tools — they are in the cache key, so stripping
them costs the read. Freed to call something and offered only those plus context_guru_expand, the
model calls one of them. CompletePrefixed returns the first non-empty text, so those replies read
as "" and are filed unusable.

Separately: forcing a named tool is not free either — it wrote a second cache entry (8,378 against
the 8,268 already cached), so tool_choice does participate in the key when it names a tool, even
though none does not. Omitting it entirely reads the prefix for free.

What this changes

  • internal/adjudicate (new) declares context_guru_adjudicate. Verdict labels are small
    integers, never opaque tool_use ids: asked for ids the model regularised them
    (toolu_01..07 for toolu_probe_00..07), because reproducing a random identifier from thousands of
    tokens back is a copying task rather than a judgement; with integers it was 0 bad labels across 40+
    trials. The schema's field names are extract.Verdict's own JSON tags, so the existing parser reads
    a tool input unchanged.
  • Injected on every request of an Anthropic route whose pipeline contains extract_llm_sweep, and
    nowhere else. Every-request matters because tools hashes before system and messages, so a tool
    that appears when the sweep fires and vanishes next turn invalidates the prefix from position zero —
    the flap expand's always mode exists to prevent. But that argument only forbids gating on
    something per-turn: pipeline membership is fixed at config load and the provider by the route, so
    both are byte-stable for a session. Injecting without those two conditions cost a measured 946
    bytes
    at the head of the cacheable prefix of every preset — including off, the control arm of
    every published comparison in this repo, and including presets with no sweep at all. Also skipped
    under a forcing tool_choice, on a request with no tools, in observe mode, and on a bypassed
    request — the same gates expand.Inject respects.
  • CompletePrefixed no longer sets tool_choice and prefers our tool's input over text. Only
    our tool by name: the prefix carries the agent's own tools, and returning a Read call's arguments
    would replace a usable prose answer with something that cannot parse.
  • The tool_use is withheld from the client on both wire paths, and answered in band. The SSE
    splicer takes a withhold set rather than one name; advertised covers both proxy-injected tools,
    or a request advertising only this one was never inspected; expand.ResponseCalls takes the
    proxy-owned names so ours is not classified as a CLIENT tool, which is what made the loop bail()
    and hand the call over. adjudicate.AnswerStrayCalls remains as the backstop — for two distinct
    paths, and an earlier draft of this bullet named only the second. (a) A turn that calls this tool
    alongside a client tool: otherTools is true, the response loop bail()s, and our tool_use
    reaches the client raw. That is a deliberate deferral, not a gap — the loop cannot continue a turn whose
    other tool_use only the client can execute without inventing a result for the client's tool or
    dropping its call, and both are worse than one lost turn. The next request's repair fixes it: substitute
    answer in, is_error cleared, the client's own tool_result untouched, stray counted once. Cost is one
    agent turn, not a broken session, and it degrades to exactly the pre-existing expand behaviour. Pinned
    by TestAdjudicateStrayCoCalledWithClientToolLeaks, which asserts the leak rather than wishing it away.
    (b) A round the loop genuinely cannot see: SSE aggregation failed, or maxExpandRounds is spent.
  • sweep_answered_via_tool / _via_prose. Without these, a working sweep and a silently
    prose-answering one are identical in every counter, because extract.ParseVerdicts reads a
    tool_use input and a JSON array in text the same way. This ambiguity is what left two independent
    live measurements — "0 of 5 asks used the tool" and "6 of 6" — unable to be judged against each
    other. Both were consistent with everything published at the time.
  • /stats publishes adjudicate_stray, now also exported as cg_adjudicate_stray_total so
    /metrics matches what routes.md:14 promises. Measured 0 across all nine benchmark passes.
  • Additive. extract.ParseVerdicts and extract.BuildFallbackAsk are untouched, and a model that
    answers in prose anyway is read exactly as before. This changes which reply shape is preferred, not
    which are accepted — 44.2% of arm 3's replies still arrived as prose and parsed fine.

Docs

docs/components/extract_llm_sweep.md stated the inverse of what is measured about
tool_choice: none; replaced with the three-arm table, the measured mechanism, and a section on where
the tool is injected and why both gate conditions are byte-stable. docs/reference/routes.md gains the
missing adjudicate_stray row.

Corrections to this PR's own earlier numbers

  • "0 of 6 verdicts" on main overstated it. Main returns verdicts on 71.5% of the items it asks
    about. The defect is that ~30% of its asks come back unusable, not that all do.
  • The latency claim does not reproduce and has been withdrawn. An earlier run recorded main losing
    9 asks to the 90 s llmCallTimeout against this branch's 0. Here main lost 0. The only arm that
    loses asks that way is tool_choice-removed-without-the-tool, at 5.
  • The prose-mode framing was too narrow. Prose was 1 of main's 6 failures here, not the bulk; the
    dominant residual mode in every arm is a reply with no text block. Filed as extract_llm_sweep: empty and wrong-tool prefix-ask replies are both filed as sweep_unparseable #164.
  • sweep_reply_truncated never fired — 0 of 121 replied asks across all three arms.
  • No cache regression in either direction. sweep_prefix_cache_read_ZERO was 0 in all nine passes.
  • Arm dollar costs are NOT comparable — LOCA trajectories diverge run to run and the arms completed
    different amounts of task work (117 / 234 / 182 requests), so no cost claim is made.
  • Asks-attempted-per-request differed across the arms, and that is not one of the two intended
    changes.
    20/117, 29/234, 78/182 = 0.171 / 0.124 / 0.429 — arm 3 attempted an ask 2.5x more often
    per request than main. The headline survives: sweep_unparseable is a proportion per replied ask,
    the arms are compared on that proportion, and Fisher on proportions is the right test regardless of how
    many asks each arm made. But the divergence is real and unexplained by the diff — the likeliest cause is
    trajectory drift changing how often the sweep's pre-expiry trigger fires, which is upstream of anything
    this PR touches. Recorded because the body already disclaims cost comparability and this deserves the
    same treatment: something beyond the two intended changes differed between arms.

Vacuity check

Standing repo rule: each new test re-run with the code it covers reverted or mutated, and required to
FAIL. Each mutation was also asserted to have landed before running, since a sed that silently
fails to match produces a clean "ok" that looks like a pass.

mutation test result
Inject loses the idempotency guard TestInjectIsByteStableAndIdempotent FAILS
Inject no longer appends last TestInjectIsByteStableAndIdempotent FAILS
Inject drops the forcing-tool_choice/no-tools guards TestInjectRefusesWhenItWouldPerturbSelection FAILS
AnswerStrayCalls no longer restricts to our own tool TestAnswerStrayCalls* FAILS
AnswerStrayCalls stops counting TestAnswerStrayCallsLeavesRealResultsAlone FAILS
AnswerStrayCalls leaves is_error set TestAnswerStrayCallsLeavesRealResultsAlone FAILS
HasTool reads the wrong dialect field TestHasTool FAILS
proxy injects even on a bypassed compaction TestAdjudicateToolNotAdvertisedOnAnAgentCompaction FAILS
proxy no longer answers stray calls on the request path TestAdjudicateStrayCallIsAnsweredOnTheRequestPath FAILS
tool_choice:none put back (the original defect) TestCompletePrefixedAppendsWithoutDisturbingThePrefix FAILS
the tool_use preference removed TestCompletePrefixedPrefersOurToolInputOverText FAILS
ANY tool_use preferred, including the agent's own TestCompletePrefixedPrefersOurToolInputOverText FAILS
adjudicate_stray removed from /stats TestStatsShapeIsUnchanged FAILS
gate removed, inject unconditionally TestAdjudicateToolNotAdvertisedWhenThePipelineCannotAdjudicate FAILS
gate removed, inject unconditionally TestAdjudicateToolNotAdvertisedOnANonAnthropicRoute FAILS
splicer withholds expand only TestAdjudicateStrayCallDoesNotReachTheClientOnTheSSEPath FAILS
in-band answering removed TestAdjudicateStrayCallDoesNotReachTheClientOnTheJSONPath FAILS
our tool counted as a CLIENT tool again TestAdjudicateStrayCallDoesNotReachTheClient (both) FAILS
sweep stops recording the reply shape TestSweepCountsWhetherTheAnswerCameViaTheToolOrProse FAILS
fallback IS attributed a reply shape TestSweepDoesNotAttributeAReplyShapeToTheFallback FAILS
CompletePrefixed stops setting ViaTool TestCompletePrefixedPrefersOurToolInputOverText FAILS (see below)
promexport.go back to float64(s.AdjudicateStray), adjudicate import dropped, exemption KEPT TestAdjudicateStraySeriesRender FAILS
same, plus the notExportedWhy entry dropped (the reviewer's exact revert) TestAdjudicateStraySeriesRender FAILS — and it is the only failure in ./proxy, where before this test the whole suite stayed green
the otherTools deferral removed from the response loop's bail TestAdjudicateStrayCoCalledWithClientToolLeaks FAILS (loop spins to maxExpandRounds, 4 rounds, instead of 1)
AnswerStrayCalls neutered to return body, 0 TestAdjudicateStrayCoCalledWithClientToolLeaks FAILS (all four repair assertions: refusal forwarded, no substitute answer, is_error still set, counted 0 not 1)
pass(body) called with an empty withhold set compile-time not enough arguments in call to sp.pass — no longer representable

Four honest notes on that table:

  • The row for commit 3 was missing entirely, and that was the tell. The table shipped with no
    mutation for the stray-counter fix because no test would have caught it: this PR took the
    notExportedWhy exemption half of the TestExpandUnresolvedSeriesRender pattern and skipped the
    guard half. TestAdjudicateStraySeriesRender adds it — the line renders at zero and the value
    moves when adjudicate.StrayAnswered() does, baseline-relative because the counter is process-wide and
    shared across the test binary.

  • TestAdjudicateToolAdvertisedOnEveryTurn was asserting the defect — it built with pipeline: []
    and passed because the tool reached a pipeline that could never adjudicate. Rewritten to keep the
    every-turn property on a sweep-bearing Anthropic route.

  • The last row is a gap the mutation run itself exposed: dropping u.ViaTool = true originally broke
    nothing, because no test asserted the wiring. Both cheapmodel prefix-ask tests now assert the
    reported shape, in each direction.

  • TestCompletePrefixedStillReadsTextWhenNoToolWasCalled is a deliberate control, not a vacuity
    failure: it guards the RETAINED text path, so it passes with the fix removed. That is the point of it.

The SSE leak test initially failed for the wrong reason — its fixture answered a stream: true request
with JSON on round 2, a documented anomaly path that cannot splice and therefore bails and hands the
withheld events back. The fixture now streams both rounds and says why.

gofmt clean, go build ./... and the full go test ./... clean on the eval box (Go 1.26.4,
CGO_ENABLED=1).

Run setup, and the one knob not left at its default

  • Task set: cg_128k_3.json — 3 LOCA S2L tasks, aws/claude-sonnet-5, 128k window,
    --max-workers 8, three passes per arm, pooled.
  • pre_expiry_seconds: 7200, against a default of 60. sweeping() fires only while
    0 < (CacheTTLMs − IdleMs) <= pre_expiry. A benchmark agent turning every few seconds keeps IdleMs
    in single digits, so the default 60 s window never opens. This measures the parse rate of asks,
    deliberately not how often the window opens in production. Worth flagging on its own: at the
    shipped default this component is close to inert on an actively-turning agent.
  • block_fallback: true. fallbackAsk() calls model.Complete(), not CompletePrefixed(), so it
    cannot differ between arms and would only dilute them.
  • min_tokens: 400 (default 1000), a sample-size fix only: maxAskItems caps every ask at 12 items
    regardless, so reply length and truncation rate are unchanged.
  • Benchmark traffic went to the plain gateway, not through Context Guru. Total cost $59.38.

Follow-ups, deliberately not folded in

@OsherElhadad

Copy link
Copy Markdown
Collaborator

Reviewed against main e88f5d8 in a clean worktree, plus live runs against the real gateway on
both the streaming and non-streaming paths.

Good news first, since the findings below are heavy: the sweep works when it fires. Forced
into its window it dropped 12 spent outputs, acted=1, saved_tokens=18658, 82,499 B → 7,511 B,
valid JSON and UTF-8, with a real recoverable marker — and streaming was exercised
(sse_streamed=1). The verdicts were correct, 12/12 with verbatim quotes. Everything fails
open
: 400/429/500/timeout/malformed JSON/undeclared tool name/thinking-only/empty content all
degrade to an unparseable reply and forward the agent's request untouched, with no panic on
out-of-range indices (extract_sweep.go:747 bounds-checks, :761 de-dupes; i:99999 and i:-3
both gated). And no aliasing — Inject is an sjson.SetRawBytes byte splice, not
append(req.Tools, …), so the caller's slice is byte-unchanged.

1. Blocking — the verdict tool is injected on every request of every preset, including ones that contain no sweep

proxy/proxy.go:1157 calls adjudicate.Inject unconditionally inside
if tn.Mode != ModeObserve && !bypassed. Measured on the wire against a recording upstream, a
client declaring one tool:

codesmart  -> upstream tools: ['Read', 'context_guru_expand', 'context_guru_adjudicate']
housellm   -> upstream tools: ['Read', 'context_guru_expand', 'context_guru_adjudicate']
off        -> upstream tools: ['Read', 'context_guru_expand', 'context_guru_adjudicate']
main, off  -> upstream tools: ['Read', 'context_guru_expand']

codesmart contains no extract_llm_sweep, so the tool is advertised on a pipeline that can never
adjudicate. off is the A/B control arm — every published comparison in this repo now has its
baseline perturbed by ~1,130 bytes at the head of the cacheable prefix. The PR's own test asserts
this: proxy/adjudicatetool_test.go:27 builds with pipeline: [] and
TestAdjudicateToolAdvertisedOnEveryTurn passes.

The rationale at :1150-1157 is sound and I want to credit it — tools hashes before system and
messages, so a tool that appears on the turn a sweep fires and vanishes on the next invalidates
the prefix from position zero. But that argument only forbids gating on something per-turn.
Pipeline membership is fixed at config load, so it is byte-stable across a session:

if h.sweepEnabled && provider == bschemas.Anthropic { body, _ = adjudicate.Inject(...) }

The provider half matters equally: prefixAskerFor returns nil for non-Anthropic and
cheapmodel/openai.go has no CompletePrefixed at all, so the ~217-token OpenAI definition is
unreachable by construction and is pure waste today.

Cost note: because the injection happens after apply returns, the never-worse machinery cannot
see or revert it — invariant 2's guard does not apply to this path.

2. Blocking — the mechanism did not reproduce live. 0 of 5 real asks used the declared tool.

This is the PR's central claim, and two independent live attempts could not observe it.

rev137, aws/claude-sonnet-5, tool declared, no tool_choice, 30,115 cache-read:
  blocks=[('text',None)]   no tool_use anywhere   11/11 verdicts, 21,317 tokens saved
rev137, claude-haiku-4-5, 27,042 cache-read:  prose + a stray Read call -> 0/11
livecc, 3 asks (prefix / fallback / stream):  content block types = ['thinking','text'] on all three
  no tool_use on any    12/12 verdicts correct

In every case the verdicts came back through the pre-existing prose parser this PR does not
touch
. proxy/proxy.go:1153-1160 justifies the unconditional injection with "6 of 6 verdicts on
4 of 4 trials"
; on this gateway that is 0 of 5.

Caveats stated fairly: n=5 across two agents, and one used a synthetic transcript. But the PR's own
table gives n for only one of three rows and never names the ask model, so the claim is not
reproducible from its artifacts either. As it stands the PR pays prefix tokens on every request for
a reply shape nobody has observed, while the savings arrive by the old path.

Please land a paired A/B — same transcript and model, declared vs not, n≥10, one timeline — or
soften the commit message and both code comments to what was actually observed. And add
sweep_answered_via_tool vs _via_prose so this is measurable rather than argued; without that
distinction a working sweep and a silently-prose-answering one look identical.

3. Blocking — the injected tool's tool_use reaches the client on both wire paths

proxy/proxy.go:1531sp.pass(resp.Body, expand.ToolName) withholds only expand. :1436's
ResponseCalls classifies ours as otherToolsbail(). Verified with httptest on both paths:

CLIENT RESPONSE: {...,"content":[{"type":"tool_use","id":"toolu_x","name":"context_guru_adjudicate",...}]}
LEAK (streaming): the proxy-injected tool_use was streamed to the CLIENT

This is the #103 class recurring. Unlike expand, an agent call here is never useful — every
invocation is a defect by construction. AnswerStrayCalls repairs the next request, so the cost
is one lost agent turn per occurrence, and only if the client returns a tool_result rather than
aborting.

Withhold symmetrically: add the name to the SSE splicer's withhold set and answer in-band, making
AnswerStrayCalls a backstop rather than the primary defence.

4. Wanted before merge — the sweep never fires on real Claude Code traffic at shipped defaults

Five real captured turns replayed through housellm:

requests=5  extract_llm_sweep: {"runs":5,"acted":0,"saved_tokens":0,
  "gates":{"not_in_pre_expiry_window":5,"empty_or_marker_present":9}}

sweeping() needs CacheTTL − Idle ∈ (0, preExpiry]. With defaultPreExpiry = 1min inside a 5min
TTL, a turn must land in the 60s window 4:00–5:00 after the previous one and carry ≥10 deep
outputs of ≥1000 tokens. Claude Code turns arrive seconds apart. Not a defect — the code's own
comment calls the window width "the only unmeasured number in this component" — but it means
yield on Claude Code traffic is zero while the cost in finding 1 is unconditional on every
preset.
That asymmetry is the strongest argument for the gate in finding 1.

5. Wanted before merge — without client cache breakpoints the prefix ask pays fresh rates twice

Paired A/B, identical bodies, only cache_control differing:

NO breakpoints, llm_calls=2: PREFIX-ASK cache_read=0 input=31226 | FALLBACK input=10709
  -> ~42k fresh input tokens, acted=0, saved=0, +22.1s on the user's turn
  gate: sweep_prefix_cache_read_ZERO
WITH breakpoints, llm_calls=1: PREFIX-ASK cache_read=29780 cache_write=0 input=1481
  event: sweep_prefix_cache_read_ok   (agent's next turn still read 29780 -> invariant 5 survives)

BlockFallback defaults false, and the comment prices the fallback in isolation — but by then the
prefix ask has already been paid at fresh rates. sweep_prefix_cache_read_ZERO detects the
condition and does not decline. Claude Code sets breakpoints so its own traffic is fine; the
OpenAI route, vLLM/llm-d, the harbor arms and bare API users are exposed. Either decline on that
gate, or document the prerequisite.

Documentation — this PR touches zero docs, and one page now states the inverse of what it measured

  • docs/components/extract_llm_sweep.md:95-97 currently says tool_choice: none is "not in the
    cache key so forcing it is free, and necessary, or the prefix's tools make the model answer
    with a tool_use"
    . internal/adjudicate/tool.go:5-24 measures the opposite, and answering with a
    tool_use is now the goal. The page presents this as measured and test-backed, so a reviewer
    trusts it. Replace it with the PR's own three-row table.
  • Same file needs a new section: context_guru_adjudicate is appended to every non-observe,
    non-bypassed request's tools array independent of the pipeline. That is user-visible in every
    transcript.
  • docs/reference/routes.md:33adjudicate_stray is missing from the /stats field tables.
    It is also absent from proxy/promexport.go, so /metrics lacks it while routes.md:14 says
    "the same counters as Prometheus text". Export it or qualify that sentence. (The same hole
    already swallows expand_unresolved_malformed/_missing, documented in metrics.go as "the
    ALERTABLE one" — pre-existing, and worth a reflection test asserting every Snapshot json tag is
    exported or explicitly exempt.)

Credit where due: the PR did correctly extend stats_golden_test.go with rationale rather than
loosening it. The golden is not the gap; the exporter is.

Nit

The new failure cases land in sweep_unparseable, which mislabels "the model called somebody else's
tool" as a prompt problem.

Verdict

Needs changes — findings 1–3. Finding 1 is a two-condition gate, finding 3 is a withhold-set
entry, and finding 2 is a measurement the PR should already have. Findings 4 and 5 are about whether
this component earns its cost at all, and they are worth answering before the injection ships to
every preset.

Not a risk, checked and cleared: no merge-order hazard with #136. applySweepDrop rewrites tool text
in place and never changes the message count, summarize is the only component that reassigns
req.Input, and no shipped preset pairs them — so the role:"tool" wire leak that
apply/apply.go:1400-1403 describes is not reachable from this PR. Note that #136's new shape
validator could not catch it either (normalize() maps the leaked and legal wires onto an identical
list), so do not merge on the belief that it guards this.

@amiddavid

Copy link
Copy Markdown
Collaborator Author

Thank you — this was a genuinely load-bearing review. Three blockers are fixed in feb82a9, and the
measurement you asked for changed what this PR claims. One of your findings is refuted; the
methodological complaint underneath it was right, and acting on it is what turned up the real result.

The three-way A/B

Both prior measurements — the PR body's and yours — compared main against (a)+(b) together, so
neither could separate them. The missing arm is (a) alone. Same transcript, same ask model
(aws/claude-sonnet-5, LOCA S2L cg_128k_3), same config, n = 3 passes per arm, run
sequentially, passes interleaved by arm in round-robin order so drift over the two hours lands on
all three arms equally rather than loading onto whichever ran last.

1 — origin/main 2 — (a) alone, no tool 3 — this PR (a)+(b)
requests 117 234 182
asks attempted 20 29 78
  timed out at 90 s 0 5 1
  asks REPLIED ← denominator 20 24 77
    parsed 14 10 70
    sweep_unparseable 6 (30.0%) 14 (58.3%) 7 (9.1%)
    sweep_reply_truncated 0 0 0
replies containing a tool_use 0 0 43 (55.8%)
verdicts returned 168 115 837
coverage of items in replied asks 71.5% 41.2% 90.9%
sweep_prefix_cache_read_ZERO 0 0 0
adjudicate_stray n/a n/a 0, 0, 0

Fisher two-tailed on unparseable: 1 vs 3 p = 0.0245, 2 vs 3 p = 0.0000, 1 vs 2 p = 0.0755.

Your finding 2, refuted — and the counter that settles it

43 of 77 replies (55.8%) came back as a tool_use. Your 0-of-5 was a small-sample artefact; at
n=5 that outcome is unremarkable even at a true rate above one in two. Per-pass it was 1/8, 27/49,
15/20 — the variance across passes is large, which is exactly why n=5 could not see it.

I want to be clear that the methodological half of that finding was correct and is what produced
this result: sweep_answered_via_tool / _via_prose now exist, because
extract.ParseVerdicts reads a tool_use input and a JSON array in reply text identically and
therefore hid the difference. Your reading and this PR's were both consistent with every
published counter. They are not any more.

Arm 2 is the finding, and it inverts the "drop the tool" option

Removing tool_choice: none on its own is worse than main — 58.3% unparseable against 30.0%,
and it is the only arm that lost asks to llmCallTimeout (5, against 0 and 1).

So this PR's own headline was wrong, in the more interesting direction. The comment it deleted —
"or the model answers with a tool_use instead of the verdicts" — was right about the mechanism
and wrong only about the remedy. I measured the mechanism directly rather than inferring it, with a
dedicated pass logging every reply's content blocks (tool_choice removed, no verdict tool):

36 prefix-ask replies
  thinking,text                                        24
  thinking,tool_use:context_guru_expand                  7
  thinking,tool_use:canvas_canvas_list_announcements      2
  thinking,text,tool_use:context_guru_expand             1
  thinking,tool_use:memory_read_graph                    1
  thinking,tool_use:filesystem_read_text_file            1
  -> tool_use with NO text block: 11 of 36 (30.6%)

Freed to call something and offered only the agent's own tools plus context_guru_expand, the model
calls one of those — including the task's own canvas_*, memory_*, filesystem_* tools.
CompletePrefixed returns the first non-empty text, so those read as "" and are filed unusable.
tool_choice: none was suppressing a real failure mode; declaring a tool worth calling removes the
mode instead of trading it for prose.

(a) and (b) are therefore one change, not two, and reducing this PR to (a) would ship a
regression. That is the opposite of what I expected going in.

What reproduces, and what does not

  • Reproduces: main 30.0% against this branch 9.1% (body claimed 26.1% → 7.3%); coverage
    71.5% → 90.9% (claimed 74.2% → 92.6%). sweep_reply_truncated 0 everywhere. adjudicate_stray 0.
  • Does NOT reproduce — the timeout claim. main lost 0 asks to the 90 s timeout here, not 9.
    The only arm that loses asks that way is (a)-alone. I have removed that claim.
  • Your finding 4 confirmed, emphatically. Even at pre_expiry_seconds: 7200 the window is the
    binding constraint; at the shipped 60 s this component is close to inert on an actively-turning
    agent. Worth its own change, not this one.

The blockers

1 — injected where it cannot be used. Fixed as you proposed:
provider == Anthropic && tn.Pipe.Has("extract_llm_sweep"). Both conditions are fixed at config
load / by the route, so the prefix stays byte-stable and the cache-flap argument is satisfied — it
only ever forbade gating on something per-turn, and you were right to draw that line. I measured the
cost you estimated: 946 bytes on the wire per request (you said ~1,130). And you were right that
the PR's own test asserted the defect — TestAdjudicateToolAdvertisedOnEveryTurn built with
pipeline: []; it is rewritten to keep the every-turn property on a pipeline that can actually
adjudicate, with two new tests for the two halves of the gate.

3 — the tool_use reached the client on both paths. Fixed as you proposed, plus one thing your
report implied but did not name: advertised was expand.HasTool alone, so a request advertising
only the adjudication tool was never inspected at all. Now:

  • the SSE splicer takes a withhold set (startsExpandCallstartsProxyToolCall);
  • expand.ResponseCalls takes a variadic list of proxy-owned names, because classifying ours as
    otherTools is precisely what made the loop bail() and hand the call over;
  • strays are answered in band on the response path before the client is written to, so
    AnswerStrayCalls is the backstop it was always described as.

2 — measurability. sweep_answered_via_tool / _via_prose, as above.

Docs. extract_llm_sweep.md stated the inverse of what is measured; replaced with the three-arm
table, the mechanism, and a section on where the tool is injected and why both gate conditions are
byte-stable. routes.md gains the adjudicate_stray row, and I exported
cg_adjudicate_stray_total so /metrics matches what routes.md:14 promises.

Declined, with reasons

  • Your finding 5 (no client cache breakpoints → the prefix ask pays fresh twice). Not addressed
    here. sweep_prefix_cache_read_ZERO was 0 in all nine passes, so I could not reproduce the
    condition to verify a fix against, and "decline on that gate" is a behaviour change to the
    component's cost model that belongs with the pre_expiry work, not bundled into a tool-shape fix.
    Your paired A/B stands on its own and I have not contradicted it.
  • Your nit — that these failures land in sweep_unparseable, which mislabels "called somebody
    else's tool" as a prompt problem. Agreed, and the block-shape data above shows it is the dominant
    residual mode, not an edge case. Filed as extract_llm_sweep: empty and wrong-tool prefix-ask replies are both filed as sweep_unparseable #164 rather than fixed here, because splitting that counter
    changes main's numbers too and I did not want it inside this comparison.
  • The sweep_quote_fabricated regression. Filed as sweep_quote_fabricated swings 3x between runs and inverts between arms, so it cannot gate a PR #165, and it needs a warning: it did not just
    fail to reproduce, it inverted. main 24.4% of verdicts against this branch's 8.4%, where the
    earlier run recorded 15.8% → 24.9%. Both arms moved ~3x and swapped order, so that metric currently
    cannot gate a PR in either direction.
  • The reflection test you suggested (every Snapshot json tag exported or explicitly exempt).
    Not done — it is the right idea and it would have caught this, but it is a repo-wide change with
    pre-existing failures to triage (expand_unresolved_*), so it should not ride along here.

gofmt clean, go build ./... and full go test ./... clean on the eval box (Go 1.26.4,
CGO_ENABLED=1). All 7 new-test mutations verified to FAIL with their subject reverted, each mutation
asserted to have landed first; one gap the mutation run itself exposed — nothing asserted
CompletePrefixed sets ViaTool — is closed in both directions.

Benchmark traffic went to the plain gateway, not through Context Guru. Total cost $59.38.

Not merging or closing — over to you.

@amiddavid

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (dc29f22) — feb82a97f278bf, force-pushed, so anchors on the previous SHA are stale. Conflict was in proxy/promexport.go; proxy/proxy.go merged cleanly and I verified the gate, the stray answering and the withhold set all survived rather than trusting the merge.

The rebase found a real defect in this PR, and main had just fixed the same bug twice.

cg_adjudicate_stray_total read float64(s.AdjudicateStray). But renderMetrics holds the bare
aggregator
snapshot, and Snapshot.AdjudicateStray is filled only by the /stats handler
afterwards — so that promLine would have exported a hard-wired 0 on every scrape, however often
the agent called the tool. Main documents exactly this class in promexport.go while fixing
cg_expand_unresolved_total and cg_frozen_decisions_total, the latter having shipped with it:

"Snapshot.ExpandUnresolved* are host-filled and the only host that fills them is the /stats
handler, so a promLine off s here would export a hard-wired 0 forever."

Now sourced from adjudicate.StrayAnswered(), as main sources those.

And main's new TestEverySnapshotFieldIsExportedOrExempt caught it — its header names this PR as
expected-to-fail, which is the reflection test @OsherElhadad asked for in the review, already landed.
Resolved the way its second group prescribes rather than by weakening it: AdjudicateStray is listed
in notExportedWhy with the series it feeds and where the value comes from.

Worth noting the limit the test itself concedes: a promLine off s passes reflection whatever
the value, so it cannot catch this class alone. That is why the exemption records the source rather
than merely asserting that an export exists — and why reading s.AdjudicateStray would have been the
version that looked correct and shipped a permanent zero.

Verified on the rebased tree: gofmt clean, go build ./... clean, internal/adjudicate and
internal/cheapmodel ok, the SSE/prefix-ask/stats-golden suites pass, TestEverySnapshotFieldIsExportedOrExempt
and TestExpandUnresolvedSeriesRender pass, full go test ./... green.

@OsherElhadad OsherElhadad left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed, built, and ran locally against fake upstreams (no live provider spend). Summary: the core change is correct and well-gated, and it does not repeat #161's tool-leak mistake — context_guru_adjudicate is correctly absent from every non-sweep pipeline I tried, including live requests against the built binary. All statistical claims in the PR body reproduce exactly (recomputed the Fisher p-values from the raw counts: 0.0245 / 2e-06 / 0.0755, all matching). Six of the vacuity-check table's mutations plus one I added myself were applied by hand and reverted; every named test failed and came back green as claimed.

Two things I'd want before merging, then a handful of cheap cleanups.

Before merge

1. The "withheld on both wire paths, answered in band" claim has a real hole — the co-called-client-tool case.

proxy/proxy.go:1572:

if (len(calls) == 0 && len(strays) == 0) || otherTools {
    bail() // hands `withheld` back to the client

When the model calls context_guru_adjudicate and a client tool in the same assistant turn, otherTools is true, the loop bails, and the adjudicate tool_use streams to the client raw. Reproduced live: one upstream round, client receives the block verbatim, no in-band answer that turn. AnswerStrayCalls correctly repairs it on the next turn (verified: "not found" replaced, is_error cleared, client's own tool_result left intact, counter incremented) — so fail-open holds and the cost is one agent turn, not a broken session. But this is a path the response loop does see and deliberately defers to the backstop, which is the opposite of what "answered in band on both paths, backstop only for a path the loop doesn't see" says. No test covers the co-call case, and given the PR's own precedent for what a leaked proxy tool_use costs an agent, it deserves one pinning the known behavior rather than leaving it to be "fixed" wrongly later. As a minor side effect, bail()'s RecordSSEExpandAfterStream() call conflates an adjudicate leak with an expand leak in that metric.

2. Commit 3's stray-counter fix ships with no regression guard.

Reverted proxy/promexport.go:439 to float64(s.AdjudicateStray) and dropped its notExportedWhy entry — the entire ./proxy suite stayed green (only the unrelated TestCtlGetCampaignAggregatesPredictedAndRealPerTenant flake failed, and that's pre-existing/unrelated — see below). The series would silently export a permanent 0 on every scrape with nothing complaining. The precedent this commit cites, TestExpandUnresolvedSeriesRender (proxy/promexport_coverage_test.go:152-178), asserts both that the line renders and that the value moves when the counter increments — the PR adopted the exemption half of that pattern but not the guard half, and the vacuity table lists no mutation for commit 3 at all, consistent with there being no test that would catch it. A ~10-line TestAdjudicateStraySeriesRender closes this.

Cleanups

3. The byte-stability premise is stated too strongly. proxy/proxy.go:1172-1173 and internal/adjudicate/tool.go:36-37 say pipeline membership is "fixed at config load," but proxy/tenancy.go:561-587 rebuilds a tenant's *Pipeline when the config document changes — mid-session — by its own design. The conclusion survives (anything that flips this gate already invalidates the prefix for much bigger reasons), but "fixed at config load" should read "fixed per config document" so the claim matches the code.

4. Two test comments still cite the numbers the PR body itself retracts. proxy/adjudicatetool_test.go:112 and proxy/prefixask_test.go:127-130 both still say "0 of 6 verdicts" / "6 of 6," which the body corrects to "main returns verdicts on 71.5% of the items it asks about." Worth fixing since these comments are what a future reader trusts.

5. internal/adjudicate/tool.go:176-190AnswerStrayCalls's doc comment runs straight into // ResponseCallIDs returns… with no blank line, so godoc attributes the whole 14-line block to the wrong function and AnswerStrayCalls ends up undocumented. One blank line fixes it.

6. proxy/ssepeek.go:179pass(body io.Reader, expandTool string) became pass(body io.Reader, proxyTools ...string). An empty variadic silently disables the whole leak defense (found=false) and still compiles, where the old signature made that impossible. A len(proxyTools) == 0 guard (or keeping one required name) would preserve the old compile-time safety.

7. Worth a line acknowledging: asks-attempted-per-request is 0.171 / 0.124 / 0.429 across the three arms — arm 3 attempts asks 2.5x more often per request than main. Doesn't invalidate the headline (the unparseable rate is a per-ask proportion and Fisher on proportions is still the right test), but something beyond the two intended changes differed between arms, and the PR disclaims cost comparability without disclaiming this.

Not this PR's problem

go test ./... has one failure, TestCtlGetCampaignAggregatesPredictedAndRealPerTenant (proxy/campaign_test.go:627) — not touched by this branch, passed 3/3 in isolation and on a full re-run. The branch is ~7 commits behind main; rebase will likely make this moot.

Fail-open holds throughout otherwise: adjudicate.Inject sits inside the existing recover() block and returns the original body on any trouble, AnswerStrayCalls is byte-identical when uninvolved, and the one response-corruption path (finding 1) degrades to the pre-existing expand behavior and self-repairs. One counter-accuracy nit: NoteAnsweredInBand (proxy.go:1585-1588) increments before expand.Continuation can fail, so a Continuation failure could double-count that stray alongside the request-path backstop — narrow, accuracy-only.

Solid, unusually well-tested change. Requesting changes for 1-2; 3-7 can ride along in the same push.

amiddavid added a commit that referenced this pull request Sep 1, 2026
…ct both claims

Responds to the second review on #137. Rebased onto main first, which moots the
unrelated TestCtlGetCampaignAggregatesPredictedAndRealPerTenant failure the review
saw: the full suite is green on the rebase alone, before any change here.

FINDING 1 — the co-called-client-tool hole. When the model calls
context_guru_adjudicate AND a client tool in the same assistant turn, otherTools is
true, the response loop bail()s, and our tool_use streams to the client raw.
AnswerStrayCalls repairs it on the next request, so fail-open holds and the price is
one agent turn. Documented and PINNED, not changed: the loop cannot continue a turn
whose other tool_use only the CLIENT can execute without inventing a result for the
client's tool or dropping its call, and both are worse than one lost turn.

  - TestAdjudicateStrayCoCalledWithClientToolLeaks asserts what happens today: the
    leak this turn, the repair next turn, the client's own tool_result untouched, and
    the stray counted exactly once.
  - Comments corrected wherever they overclaimed. The old wording said the tool was
    answered in band with the backstop only for "a path this loop does not see" —
    false for this path, which the loop DOES see and defers deliberately. Fixed in
    proxy.go (both the response loop and the request-path repair), in
    internal/adjudicate/tool.go (ResponseCallIDs and AnswerStrayCalls), in
    docs/reference/routes.md (which claimed every stray "costs the agent nothing"),
    and in docs/components/extract_llm_sweep.md.

FINDING 2 — the stray counter shipped with no regression guard. Commit 3 changed
cg_adjudicate_stray_total to read adjudicate.StrayAnswered() because
Snapshot.AdjudicateStray is filled by the /stats handler after renderMetrics takes
its snapshot, so a promLine off `s` exports a permanent 0. The review reverted that
and the whole ./proxy suite stayed green. TestAdjudicateStraySeriesRender adds the
missing half of the TestExpandUnresolvedSeriesRender pattern: the line renders at
zero AND the value moves when the counter does. Baseline-relative, because
strayAnswered is process-wide and shared across the test binary.

CLEANUPS
  3. "fixed at config load" -> "fixed per config DOCUMENT" in proxy.go,
     internal/adjudicate/tool.go and docs/components/extract_llm_sweep.md.
     tenancy.go rebuilds a tenant's *Pipeline when the config document changes,
     mid-session. The conclusion survives; the premise now matches the code.
  4. proxy/adjudicatetool_test.go and proxy/prefixask_test.go no longer cite the
     "0 of 6" / "6 of 6" verdict counts the PR body retracts. Replaced with the
     three-arm measurement (9.1% vs 30.0% unparseable, Fisher p = 0.0245, 55.8% of
     replies carrying a tool_use) and the note that main answers 71.5% of the items
     it asks about. The 8,378-vs-8,268 cache-key finding is un-retracted and kept.
  5. AnswerStrayCalls's doc comment was sitting above ResponseCallIDs, so godoc
     attributed it to the wrong function. MOVED to its own declaration rather than
     just separated by a blank line, which the review suggested: a blank line alone
     would have left AnswerStrayCalls undocumented and the block floating.
  6. sseSplicer.pass takes one required tool name plus a variadic tail. A bare
     `proxyTools ...string` compiled with NO names and silently disabled the entire
     leak defence (found stays false, every proxy tool_use streams through).

ALSO
  - NoteAnsweredInBand moved after expand.Continuation succeeds. It previously
    incremented before Continuation could fail, so a failure bailed, the tool_use
    reached the client, and the request-path repair counted the same stray a second
    time. Now exactly one count per stray however it was answered.
  - bail()'s RecordSSEExpandAfterStream conflation is documented rather than split,
    with the reasons in place: the field is /stats-only and reaches no dashboard or
    alert, two of the three bail sites cannot attribute the leak because nothing has
    parsed the turn yet, and a leak can be both kinds at once — so the honest fix is
    two counters and a new exported family with its own render and vacuity guard,
    which is a metrics change rather than this PR's subject.

VACUITY CHECK — every mutation asserted to have LANDED before running.

  promexport.go back to float64(s.AdjudicateStray), adjudicate import dropped,
  notExportedWhy entry KEPT:
    TestAdjudicateStraySeriesRender FAILS —
      still reads "cg_adjudicate_stray_total 0" after two answered strays — the
      series is not reading adjudicate.StrayAnswered() (Snapshot.AdjudicateStray is
      filled only by /stats, so a promLine off `s` exports a permanent 0)

  Same, plus the notExportedWhy entry dropped (the review's exact revert):
    TestAdjudicateStraySeriesRender FAILS, and is the ONLY failure in ./proxy —
    where before this test the entire suite stayed green under that revert.

  The otherTools deferral removed from the response loop's bail condition:
    TestAdjudicateStrayCoCalledWithClientToolLeaks FAILS —
      expected the loop to bail on otherTools after ONE round, got 4 — the co-call
      path no longer defers, so this test's premise is gone

  AnswerStrayCalls neutered to `return body, 0`:
    TestAdjudicateStrayCoCalledWithClientToolLeaks FAILS on all four repair
    assertions — refusal forwarded unchanged, no substitute answer, is_error still
    set, and "the leaked stray was counted 0 times, want exactly 1".

  sp.pass called with an empty withhold set:
    compile error, "not enough arguments in call to sp.pass" — no longer
    representable, which is the property the two-argument signature used to give.

The double-count fix carries NO test: reaching it needs expand.Continuation to fail
on a response that already parsed as containing a tool_use, and there is no fixture
seam for that at the handler boundary. Stated rather than papered over.

gofmt clean, go build ./... clean, go vet ./... clean, full go test ./... green
(Go 1.26.4, CGO_ENABLED=1). No benchmarks and no live provider spend.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
@amiddavid

Copy link
Copy Markdown
Collaborator Author

Thanks — the two before-merge items were both right, and finding 2 in particular caught a real gap in my own vacuity discipline. All seven are addressed in one push (55f2a62), on top of a rebase.

Step 0 — rebase

Rebased onto origin/main (was 9 behind, not 7). Clean, and you were right that it moots the flake: go test ./... is fully green on the rebase alone, before any change here — TestCtlGetCampaignAggregatesPredictedAndRealPerTenant included. I checked the resolution semantically rather than trusting the clean merge, since the previous rebase of this branch had a real conflict in proxy/promexport.go where main had independently fixed the same class of bug; nothing in main this time touches the same lines.

1 — the co-called-client-tool hole

Pinned, and the comments corrected. Behaviour unchanged, which I think is right and not just conservative: answering in band means continuing the turn upstream, and the loop cannot continue a turn whose other tool_use only the client can execute. It would have to invent a result for the client's tool or drop the client's call, and both are worse than the one lost turn the deferral costs. So the deferral is the correct behaviour, and what was wrong was only the documentation.

  • TestAdjudicateStrayCoCalledWithClientToolLeaks asserts what happens today, in two turns: exactly one upstream round (a precondition — a continuation would mean the premise is gone), our tool_use reaching the client, the client's own tool_use surviving, and then on the next request the substitute answer landing, is_error cleared, the client's own tool_result byte-intact with no invented is_error, and the stray counted exactly once. It carries a note that if the deferral is ever replaced by a real in-band answer, this test should fail and be rewritten — that being the point of pinning it.
  • The overclaiming wording is gone everywhere it appeared, not just at the one site: the response loop and the request-path repair in proxy.go, ResponseCallIDs and AnswerStrayCalls in internal/adjudicate/tool.go, docs/components/extract_llm_sweep.md, and the PR body bullet that said the same thing. Also docs/reference/routes.md, which you didn't flag but had the same defect in a worse place — it told operators every stray "costs the agent nothing now that it is answered in band". That's the sentence someone reads off a dashboard, so it now names the co-call exception and its one-turn cost explicitly.

The RecordSSEExpandAfterStream() split I declined, with the reasoning recorded in a comment at bail() and filed as #174. Two of the three bail() sites cannot attribute the leak — a round that failed SSE aggregation and a spent maxExpandRounds both bail before anything has parsed the turn, so there is no calls/strays split to read — and in the otherTools bail a round can carry expand calls and adjudicate strays at once, so a boolean either/or would be wrong anyway. Doing it properly means attributing at the peek (pass returning which names matched) plus a new exported family with its own render and vacuity guard. Given SSEExpandAfterStream is notExportedWhy-listed as "NOT EXPORTED YET" and so reaches no dashboard or alert, that is a metrics change rather than this PR's subject. #174 says it must be resolved before the field is promoted to a series, so the conflated number never gets exported.

2 — the stray-counter guard

You were right and the diagnosis was exactly right: I took the notExportedWhy exemption half of the TestExpandUnresolvedSeriesRender pattern and skipped the guard half, and the missing row in the vacuity table was the tell.

TestAdjudicateStraySeriesRender now asserts both halves — the line renders at zero (a family that appears only once something breaks renders "No data", which reads as healthy) and the value moves when adjudicate.StrayAnswered() does. Baseline-relative rather than absolute, because strayAnswered is process-wide and the proxy_test adjudicate tests share this test binary with it.

Verified against your exact revert, both ways:

revert result
promexport.gofloat64(s.AdjudicateStray), adjudicate import dropped, exemption kept TestAdjudicateStraySeriesRender FAILS
same, plus the notExportedWhy entry dropped (yours) TestAdjudicateStraySeriesRender FAILS — and is the only failure in ./proxy, where before this test the whole suite stayed green

The failure message under the revert:

still reads "cg_adjudicate_stray_total 0" after two answered strays — the series is not reading
adjudicate.StrayAnswered() (Snapshot.AdjudicateStray is filled only by /stats, so a promLine off
`s` exports a permanent 0)

3-7

3. "fixed at config load" → "fixed per config document", in proxy.go, internal/adjudicate/tool.go and docs/components/extract_llm_sweep.md (which had it too). Each site now names tenancy.go's mid-session *Pipeline rebuild and says why the conclusion survives it.

4. Both comments fixed. Replaced with the three-arm numbers — 9.1% unparseable (7 of 77) against 30.0% (6 of 20), Fisher p = 0.0245, 55.8% of replies carrying a tool_use — plus the arm-2 result, since that is what makes the declaration rather than the tool_choice removal the thing that earns its place. Each says explicitly that the old counts were a six-item hand pass and are retracted. I kept the 8,378-vs-8,268 cache-key finding in prefixask_test.go: it's a separate, un-retracted measurement and it's the reason the test asserts no tool_choice at all.

5. One correction to the ask: a blank line alone would not have fixed this. The comment block sits above func ResponseCallIDs, and func AnswerStrayCalls is ~40 lines further down with no comment of its own. Inserting a blank line stops godoc misattributing the block, but leaves it floating and attached to nothing, and leaves AnswerStrayCalls still undocumented. So I moved it to its own declaration, which gets both functions documented correctly.

6. Restored as a required name plus a variadic tail — pass(body io.Reader, proxyTool string, moreProxyTools ...string) — which needed no call-site changes and makes the empty set unrepresentable again. Verified with a throwaway compile probe: sp.pass(strings.NewReader("")) now fails with not enough arguments in call to sp.pass. The comment spells out what the empty set silently did (startsProxyToolCall never matches, found stays false, cut stays -1, every proxy tool_use streams through while the call site reads as protected).

7. Added to the body, with the arithmetic: 20/117, 29/234, 78/182 = 0.171 / 0.124 / 0.429, arm 3 attempting an ask 2.5× more often per request. It says the headline survives (per-replied-ask proportion, Fisher on proportions is right regardless of ask volume) and that the divergence is nonetheless real and unexplained by the diff — likeliest trajectory drift changing how often the sweep's pre-expiry trigger fires, which is upstream of anything here. Also added the missing commit-3 row to the vacuity table, and the four new mutations.

Closing nit — NoteAnsweredInBand double-count

Fixed. The increment now happens after expand.Continuation succeeds. Your read was right: it fired when the answer was composed, so a Continuation failure bailed, the tool_use reached the client, and the request-path repair counted the same stray again on the next turn. Now exactly one count per stray however it was answered.

This one ships without a test, deliberately and stated rather than glossed. Reaching it needs Continuation to fail on a response that already parsed as containing a tool_use, and there is no fixture seam for that at the handler boundary — Continuation fails on a missing content or an sjson error, neither of which is reachable once a stray has been detected in that same content. I'd rather say so than add a test that exercises a different path and looks like cover.

Vacuity check, per test

mutation (each asserted to have landed first) test result
promexport.gofloat64(s.AdjudicateStray), import dropped, exemption kept TestAdjudicateStraySeriesRender FAILS
same + notExportedWhy entry dropped (your exact revert) TestAdjudicateStraySeriesRender FAILS, and the only failure in ./proxy
otherTools deferral removed from the bail condition TestAdjudicateStrayCoCalledWithClientToolLeaks FAILS — "expected the loop to bail on otherTools after ONE round, got 4"
AnswerStrayCalls neutered to return body, 0 TestAdjudicateStrayCoCalledWithClientToolLeaks FAILS on all four repair assertions, incl. "counted 0 times, want exactly 1"
sp.pass called with an empty withhold set compile-time not enough arguments in call to sp.pass

The 4-round result on the third row is worth noting on its own: without the deferral a co-called turn doesn't just answer in band, it spins the loop to maxExpandRounds — the deferral is load-bearing, not incidental.

gofmt clean, go build ./... clean, go vet ./... clean, full go test ./... green on Go 1.26.4 with CGO_ENABLED=1. No benchmarks, no live provider spend — fake upstreams throughout, as you did.

Not merging; over to you.

…none

CompletePrefixed set tool_choice to {"type":"none"} on every prefix ask, with a comment
claiming that was "free (not in the cache key) and required, or the model answers with a
tool_use". Both halves were wrong, and the measurement is the same prefix with only
tool_choice varying:

  tool_choice          reply shape             cache               verdicts returned
  {"type":"none"}      prose / thinking only   read 8,268 (free)   0 of 6
  {"type":"tool",name} tool_use                MISS, wrote 8,378   6 of 6
  (omitted)            tool_use                read 8,268 (free)   6 of 6, on 4 of 4 trials

So `none` is what DROVE the model into prose. A sampled reply reasoned correctly under the
criterion and simply said so in sentences ("the task is not yet complete, and no summary of
this raw data has been recorded elsewhere") -- which the contract itself calls a valid
answer -- and the caller then scored it as an unparseable failure. Forcing a NAMED tool is
not free either: it wrote a second cache entry, so tool_choice does participate in the key
when it names a tool even though "none" does not. Merely DECLARING a structured-answer tool,
with no tool_choice at all, gets a schema-shaped answer for the whole batch at cache-read
price.

internal/adjudicate declares that tool. Its verdict labels are small INTEGERS, never opaque
tool_use ids: asked for ids the model regularised them (toolu_01..07 for toolu_probe_00..07),
because reproducing a random identifier from thousands of tokens back is a copying task
rather than a judgement; with integers it was 0 bad labels across 40+ trials. Its field names
are extract.Verdict's own JSON tags, so the existing parser reads a tool input unchanged.

The tool is injected on EVERY request, next to expand.Inject, not only when the sweep is
about to ask: `tools` hashes before system and messages, so a tool that appears on the turn a
sweep fires and disappears on the next invalidates the prefix from position zero -- the flap
expand's `always` mode exists to prevent.

Stray calls the AGENT makes are answered on the request path, mirroring
expand.RepairToolResults. The client cannot execute a tool the proxy injected, so it answers
"not found" and the agent loses a turn to a dead end. Not defensive: a model was directly
observed calling context_guru_expand at step 2 of a run. Measured at 0 strays across ~4,900
requests with the "do not call this yourself" description, so /stats publishes
adjudicate_stray -- that counter is the only thing that can say the description stopped
working.

Additive, deliberately: extract.ParseVerdicts and extract.BuildFallbackAsk are untouched and
a model that answers in prose anyway is read exactly as before. This changes which reply
shape is PREFERRED, not which ones are accepted.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
…w answers arrive

Addresses the review's three blockers. The measurement it asked for was run, and it
changed what this PR claims: the tool is NOT inert, and removing tool_choice:none
WITHOUT it is worse than main.

Both prior measurements compared main against (a)+(b) together, so neither could tell
which half worked. Same transcript, same ask model (aws/claude-sonnet-5, LOCA S2L,
cg_128k_3), three passes per arm, run sequentially, passes interleaved by arm so any
drift over the run lands on all three equally.

| arm | build | asks replied | timed out | unusable | via tool_use | coverage |
|---|---|---|---|---|---|---|
| 1 | origin/main | 20 | 0 | 6 (30.0%) | 0 | 71.5% |
| 2 | (a) alone: tool_choice removed, NO tool | 24 | 5 | 14 (58.3%) | 0 | 41.2% |
| 3 | this PR, (a)+(b) | 77 | 1 | 7 (9.1%) | 43 (55.8%) | 90.9% |

Fisher two-tailed: 1 vs 3 p = 0.0245, 2 vs 3 p = 0.0000, 1 vs 2 p = 0.0755.

Two claims are refuted and one is confirmed:

- The review's "0 of 5 asks used the declared tool" does NOT hold at n=77: 43 replies
  (55.8%) came back as a tool_use. n=5 was the artefact. The counter added here is what
  makes this observable at all.
- This PR's own "tool_choice:none is what produced prose, and both halves of the old
  comment were wrong" is ALSO wrong, in the more interesting direction. Arm 2 is the
  worst of the three. The old comment ("or the model answers with a tool_use instead of
  the verdicts") was RIGHT about the mechanism and wrong only about the remedy: freed to
  call a tool and offered only the agent's own plus context_guru_expand, the model calls
  one of those. Logging every reply's content blocks caught it directly, 5 of 20 replies
  in a dedicated pass, as `thinking,tool_use:context_guru_expand` with NO text block --
  which the text-only extraction reads as "" and files as unusable.
- main's rate (30.0%) and this branch's (9.1%) reproduce the PR body's 26.1% -> 7.3%,
  and coverage 71.5% -> 90.9% reproduces 74.2% -> 92.6%.

So (a) and (b) are one change, not two, and the PR is NOT reducible to (a).

The PR body's timeout claim does not reproduce: main lost 0 asks to the 90 s
llmCallTimeout here, not 9. The only arm that lost asks that way is (a)-alone, at 5.

Now gated on `provider == Anthropic && tn.Pipe.Has("extract_llm_sweep")`. Neither
condition varies per turn -- pipeline membership is fixed at config load, the provider by
the route -- so the prefix stays byte-stable and the cache-flap argument is satisfied;
that argument only ever forbade gating on something per-turn. Injecting unconditionally
cost a measured 946 bytes at the head of the cacheable prefix of every preset, including
`off`, the control arm of every published comparison in this repo.

TestAdjudicateToolAdvertisedOnEveryTurn was asserting exactly that defect (it built with
`pipeline: []`); it is rewritten to keep the every-turn property on a pipeline that can
actually adjudicate.

The SSE splicer took one tool name; it now takes the withhold SET, and proxy.go passes
both proxy-injected tools. `advertised` covers both, or a request advertising only the
adjudication tool was never inspected. expand.ResponseCalls gained a variadic list of
proxy-owned names so a second one is not misclassified as a CLIENT tool -- that
misclassification was what made the loop bail and hand the call over. Stray calls are now
answered IN BAND on the response path, before the client is written to;
adjudicate.AnswerStrayCalls stays as the backstop it was described as.

components.PrefixUsage.ViaTool, plus sweep_answered_via_tool / _via_prose. Without this a
working sweep and a silently-prose-answering one are identical in every counter, because
extract.ParseVerdicts reads a tool_use input and a JSON array in text the same way. This
is the only reason the two conflicting readings above could be settled.

- docs/components/extract_llm_sweep.md stated the INVERSE of what is measured. Replaced
  with the three-arm table, the mechanism, and a section on where the tool is injected.
- docs/reference/routes.md gains the `adjudicate_stray` row, and the counter is now
  exported as cg_adjudicate_stray_total so /metrics matches what routes.md:14 promises.

Every new test was re-run with its subject reverted and had to FAIL. All 7 mutations did,
and each mutation was asserted to have landed before running:

| mutation | test | result |
|---|---|---|
| gate removed, inject unconditionally | ...NotAdvertisedWhenThePipelineCannotAdjudicate | FAILS |
| gate removed, inject unconditionally | ...NotAdvertisedOnANonAnthropicRoute | FAILS |
| splicer withholds expand only | ...DoesNotReachTheClientOnTheSSEPath | FAILS |
| in-band answering removed | ...DoesNotReachTheClientOnTheJSONPath | FAILS |
| our tool counted as a CLIENT tool again | ...DoesNotReachTheClient (both) | FAILS |
| sweep stops recording the reply shape | TestSweepCountsWhetherTheAnswerCame... | FAILS |
| fallback IS attributed a reply shape | TestSweepDoesNotAttributeAReplyShape... | FAILS |

One gap the mutation run itself exposed and closed: dropping `u.ViaTool = true` from
CompletePrefixed broke nothing, because no test asserted the wiring. Both cheapmodel
prefix-ask tests now assert the reported shape, in each direction.

The SSE leak test initially failed for the wrong reason -- its fixture answered a
stream:true request with JSON on round 2, a documented anomaly path that cannot splice and
so bails and hands the withheld events back. The fixture now streams both rounds, and says
why.

`gofmt` clean, `go build ./...` and the full `go test ./...` clean on the eval box
(Go 1.26.4, CGO_ENABLED=1).

Not folded in, recorded as follow-ups: the empty-reply failure mode (a thinking block and
no answer, present in every arm) and the fabricated-quote rate, which moved in the
OPPOSITE direction to the PR body's report here (main 24.4% of verdicts against this
branch's 8.4%, versus the 15.8% -> 24.9% recorded earlier) and so is workload-dependent
rather than a regression this change causes.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
…apshot

Rebasing onto main surfaced that main had just fixed, twice, the exact bug this change
had introduced a third instance of.

cg_adjudicate_stray_total read float64(s.AdjudicateStray). But renderMetrics holds the
bare aggregator snapshot, and Snapshot.AdjudicateStray is filled only by the /stats
handler afterwards, so that promLine would have exported a hard-wired 0 on every scrape
however often the agent called the tool. Main documents the class in promexport.go while
fixing cg_expand_unresolved_total and cg_frozen_decisions_total, the latter having shipped
with the defect. Sourced now from adjudicate.StrayAnswered(), as main sources those.

Main's new TestEverySnapshotFieldIsExportedOrExempt caught it, and its header names this
PR as expected-to-fail. Resolved the way its second group prescribes rather than by
weakening the test: the field is listed in notExportedWhy with the series it feeds and
where the value comes from. Worth noting the test cannot catch this class on its own -- a
promLine off `s` passes reflection whatever the value -- which is why the exemption records
the source rather than merely asserting an export exists.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
…ct both claims

Responds to the second review on #137. Rebased onto main first, which moots the
unrelated TestCtlGetCampaignAggregatesPredictedAndRealPerTenant failure the review
saw: the full suite is green on the rebase alone, before any change here.

FINDING 1 — the co-called-client-tool hole. When the model calls
context_guru_adjudicate AND a client tool in the same assistant turn, otherTools is
true, the response loop bail()s, and our tool_use streams to the client raw.
AnswerStrayCalls repairs it on the next request, so fail-open holds and the price is
one agent turn. Documented and PINNED, not changed: the loop cannot continue a turn
whose other tool_use only the CLIENT can execute without inventing a result for the
client's tool or dropping its call, and both are worse than one lost turn.

  - TestAdjudicateStrayCoCalledWithClientToolLeaks asserts what happens today: the
    leak this turn, the repair next turn, the client's own tool_result untouched, and
    the stray counted exactly once.
  - Comments corrected wherever they overclaimed. The old wording said the tool was
    answered in band with the backstop only for "a path this loop does not see" —
    false for this path, which the loop DOES see and defers deliberately. Fixed in
    proxy.go (both the response loop and the request-path repair), in
    internal/adjudicate/tool.go (ResponseCallIDs and AnswerStrayCalls), in
    docs/reference/routes.md (which claimed every stray "costs the agent nothing"),
    and in docs/components/extract_llm_sweep.md.

FINDING 2 — the stray counter shipped with no regression guard. Commit 3 changed
cg_adjudicate_stray_total to read adjudicate.StrayAnswered() because
Snapshot.AdjudicateStray is filled by the /stats handler after renderMetrics takes
its snapshot, so a promLine off `s` exports a permanent 0. The review reverted that
and the whole ./proxy suite stayed green. TestAdjudicateStraySeriesRender adds the
missing half of the TestExpandUnresolvedSeriesRender pattern: the line renders at
zero AND the value moves when the counter does. Baseline-relative, because
strayAnswered is process-wide and shared across the test binary.

CLEANUPS
  3. "fixed at config load" -> "fixed per config DOCUMENT" in proxy.go,
     internal/adjudicate/tool.go and docs/components/extract_llm_sweep.md.
     tenancy.go rebuilds a tenant's *Pipeline when the config document changes,
     mid-session. The conclusion survives; the premise now matches the code.
  4. proxy/adjudicatetool_test.go and proxy/prefixask_test.go no longer cite the
     "0 of 6" / "6 of 6" verdict counts the PR body retracts. Replaced with the
     three-arm measurement (9.1% vs 30.0% unparseable, Fisher p = 0.0245, 55.8% of
     replies carrying a tool_use) and the note that main answers 71.5% of the items
     it asks about. The 8,378-vs-8,268 cache-key finding is un-retracted and kept.
  5. AnswerStrayCalls's doc comment was sitting above ResponseCallIDs, so godoc
     attributed it to the wrong function. MOVED to its own declaration rather than
     just separated by a blank line, which the review suggested: a blank line alone
     would have left AnswerStrayCalls undocumented and the block floating.
  6. sseSplicer.pass takes one required tool name plus a variadic tail. A bare
     `proxyTools ...string` compiled with NO names and silently disabled the entire
     leak defence (found stays false, every proxy tool_use streams through).

ALSO
  - NoteAnsweredInBand moved after expand.Continuation succeeds. It previously
    incremented before Continuation could fail, so a failure bailed, the tool_use
    reached the client, and the request-path repair counted the same stray a second
    time. Now exactly one count per stray however it was answered.
  - bail()'s RecordSSEExpandAfterStream conflation is documented rather than split,
    with the reasons in place: the field is /stats-only and reaches no dashboard or
    alert, two of the three bail sites cannot attribute the leak because nothing has
    parsed the turn yet, and a leak can be both kinds at once — so the honest fix is
    two counters and a new exported family with its own render and vacuity guard,
    which is a metrics change rather than this PR's subject.

VACUITY CHECK — every mutation asserted to have LANDED before running.

  promexport.go back to float64(s.AdjudicateStray), adjudicate import dropped,
  notExportedWhy entry KEPT:
    TestAdjudicateStraySeriesRender FAILS —
      still reads "cg_adjudicate_stray_total 0" after two answered strays — the
      series is not reading adjudicate.StrayAnswered() (Snapshot.AdjudicateStray is
      filled only by /stats, so a promLine off `s` exports a permanent 0)

  Same, plus the notExportedWhy entry dropped (the review's exact revert):
    TestAdjudicateStraySeriesRender FAILS, and is the ONLY failure in ./proxy —
    where before this test the entire suite stayed green under that revert.

  The otherTools deferral removed from the response loop's bail condition:
    TestAdjudicateStrayCoCalledWithClientToolLeaks FAILS —
      expected the loop to bail on otherTools after ONE round, got 4 — the co-call
      path no longer defers, so this test's premise is gone

  AnswerStrayCalls neutered to `return body, 0`:
    TestAdjudicateStrayCoCalledWithClientToolLeaks FAILS on all four repair
    assertions — refusal forwarded unchanged, no substitute answer, is_error still
    set, and "the leaked stray was counted 0 times, want exactly 1".

  sp.pass called with an empty withhold set:
    compile error, "not enough arguments in call to sp.pass" — no longer
    representable, which is the property the two-argument signature used to give.

The double-count fix carries NO test: reaching it needs expand.Continuation to fail
on a response that already parsed as containing a tool_use, and there is no fixture
seam for that at the handler boundary. Stated rather than papered over.

gofmt clean, go build ./... clean, go vet ./... clean, full go test ./... green
(Go 1.26.4, CGO_ENABLED=1). No benchmarks and no live provider spend.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
@amiddavid

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (eae7027) — 55f2a62fc25a85, force-pushed. The approval was on 55f2a62, so it is now stale and this needs a re-approve on fc25a85. Sorry for the extra round; the conflict arrived from main rather than from anything here.

The conflict was with #161, which is the sibling of this PR's own fix. Main gated expand.Inject so
the expand tool is not advertised where no marker can exist; this PR gates adjudicate.Inject so the
verdict tool is not advertised where nothing can adjudicate. Same defect class, same reasoning — #161's
comment even makes the same point about off being the A/B control arm that this PR's finding 1 made.
They compose, and both gates are now present and independently tested:

TestExpandToolIsAdvertisedOnlyWhereMarkersCanExist          PASS   (main's gate)
TestAdjudicateToolNotAdvertisedWhenThePipelineCannotAdjudicate  PASS   (this PR's gate)
TestAdjudicateToolNotAdvertisedOnANonAnthropicRoute         PASS
TestAdjudicateToolNotAdvertisedOnAnAgentCompaction          PASS
TestAdjudicateStrayCoCalledWithClientToolLeaks              PASS   (finding 1, pinned)
TestAdjudicateStraySeriesRender                             PASS   (finding 2, the guard)
TestEverySnapshotFieldIsExportedOrExempt                    PASS

Resolution detail worth recording, since two commits touched the same lines: for each conflicted hunk I
kept main's gated expand.Inject block verbatim and re-applied this branch's adjudicate block on
top, rather than letting either side's version of the other's call survive. The intermediate commit's
"fixed at config load" wording is still corrected to "fixed per config document" by the last commit, as
the review asked — verified after the rebase in both proxy/proxy.go and internal/adjudicate/tool.go,
because a rebase is exactly where a later correction can quietly fail to apply.

Re-verified on the rebased tree: gofmt clean, go build ./... clean, go vet clean, full
go test ./... green — no exceptions, so the unrelated TestCtlGetCampaignAggregates… failure you
saw is indeed gone with the rebase.

No functional change from the review response. This is the rebase and the conflict resolution only.

@amiddavid
amiddavid merged commit 3278cbd into main Sep 1, 2026
6 checks passed
@github-project-automation github-project-automation Bot moved this from New/ToDo to Done in Rossoctl Issue Prioritization Sep 1, 2026
amiddavid added a commit that referenced this pull request Sep 2, 2026
…atency claim does not

45 runs, three arms, 64k, and the first iteration in this series with a clean
error column: 0 errored runs and 0 HTML 400s in every arm. That matters because
errored runs score 0 in ITT and had been the dominant noise term.

THE MECHANISM GATE PASSED, and that is this iteration's real product. The
pre-flight had both econ counters at 0/0 -- the trigger was never reached. After
amendment 1 it was evaluated 30 times and repaid 30 times, with 0 declines, 30 real
adjudications, 0 unparseable replies, and an index record for every candidate.
coref acted 391 times, so min_batch_frac clears at 64k. The offline feasibility
estimate (3-4x margin) was not merely directionally right.

THE LATENCY CLAIM DOES NOT REPLICATE, and this is the endpoint iteration 021 could
not produce. Turns per run, split by outcome: arm B is 18.9 against A's 20.7
overall, but among SOLVED runs it is 20.1 -> 19.8, a 1.5% difference inside noise,
while among UNSOLVED runs it is 21.1 -> 18.5. B also solved fewer tasks. So the
shape of B's efficiency is giving up sooner on tasks it fails, not solving faster.
Iteration 021's -28% requests was read as a latency win; its own text attributed it
to "fewer runaway sessions" and it could not distinguish the two. Measured with the
split it lacked, on a run with ZERO errors, the gain is on the failure path.

On wall clock the direction is worse rather than absent: B adds 1,396 ms per
request against A's 203 ms -- about 1.2 s on every request -- to buy 1.5% fewer
turns on the runs that matter, at 10x its own model spend. Arm C went the other
way: +26% turns and 62% more LOCA cost.

REWARD IS NULL BOTH WAYS and the pre-registered harm gate blocks both arms: B is
-2 solves (p = 0.6250, bound 48.1%), C is +1 (p = 1.0000, bound 31.9%), against a
25% blocking threshold declared before the run. The structural fact this exposes is
more useful than either p-value: at n=15 with one seed the best achievable bound is
21.8%, with ZERO worsened pairs. This design can essentially never license a
positive claim, which is the argument for five seeds rather than more tasks.

One defect filed rather than footnoted: expand_unresolved_missing = 60 in Cp1
against 0 everywhere else -- 60 of that pass's 112 expand calls unresolvable, in
the pass that was also arm C's worst. An unresolvable expand is the one failure the
reversibility invariant exists to prevent, and the correlation is unexplained.

Also recorded, both firsts: extract_llm_sweep as SHIPPED never fires under
continuous load (its only trigger needs an idle gap, and 8 workers never leave
one), so arm A's sweep is inert and B-A is "a sweep that runs" versus "a sweep that
cannot"; and sweep_answered_via_tool is 0 against via_prose 30 -- the verdict tool
#137 added is offered every time and never used.

The index row for iterations 016-021 is still missing from experiments/README.md;
that gap predates this commit and is not addressed here.

Signed-off-by: David Amid <david.amid@il.ibm.com>
Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants