Skip to content

Anthropic stream:false: a model compress call ends the client turn (receipt returned with end_turn, no re-request) #5957

Closed

Description

@luan-cestari-ppro

Symptom

A Claude Code session ends right after a fold. The final result is the compress receipt ([Compressed m00005–m00019 → 1 block(s), ~42144 tokens saved.] · b1 full summary ...), and no upstream request follows. In an agentic Java coding task this left an empty or partial diff.

It happens when the request reaches bili as stream:false. A compression proxy in front of bili can do this. For example, Headroom with CCR turns a client stream:true request into a buffered stream:false request once its retrieve tool is active ("CCR: stream:true request has headroom_retrieve available; using buffered stream:false upstream request").

Environment

  • billion-context master fe963d7f5, and 0.1.212 (daa082ead): same code.
  • Claude Code 2.1.311, Claude Opus 5.5, Anthropic Messages wire.
  • Chain: Claude Code -> Headroom 0.40.0 (CCR on) -> bili -> LiteLLM -> AWS Bedrock.

Minimal reproducer

  1. Start bili with a mock Anthropic upstream that returns one compress tool_use (as in tests/ccr-v2.test.ts, "e2e CCR v2 non-stream").
  2. POST /v1/messages with "stream": false.
  3. bili answers with a text block that holds the full compress result and stop_reason: "end_turn". It sends no second upstream request. The existing test asserts exactly this shape.

With a real model the client stops there. With "stream": true the same response is re-requested and the turn continues.

Root cause

  • src/server/relay.ts:2441 (0.1.212: :2317): the non-stream Anthropic branch calls the one-shot rewriter rewriteJsonResponseAsync.
  • src/stream.ts:1673-1684 (rewriteJsonSteps; 0.1.212: :1566): it replaces each proxy tool_use with a text block of its result. When no client tool call is present, it sets stop_reason = "end_turn" (:1684). Nothing re-requests upstream.
  • The stream lane re-requests after a proxy-only round (src/loop/core.ts:1531, reRequest = proxyResults.length > 0 && realCalls === 0), so the model sees the result and continues.

This is the Anthropic analogue of #4019. PR #4037 adds a non-stream proxy-tool loop for OpenAI chat-completions only.

Fix approach

Add a non-stream proxy-tool loop for Anthropic, with the design of PR #4037:

  • Proxy-only round: execute the calls through the existing rewriter, append the original assistant content and a tool_result user turn to the forwarded body, and re-request. Repeat until the model answers or calls a client tool, bounded by MAX_LOOP_ROUNDS.
  • A round with a client tool call, or with no proxy call, keeps the current behaviour.
  • No receipt text goes to the client on this lane, because the non-stream tag-echo strip removes marker lines. A prepended marker became an empty text block.
  • Like PR [PR] fix: execute proxy-owned tools on the non-stream OpenAI path (#4019) #4037, the re-request resends the unfolded body. The fold takes effect from the next client request.
diff --git a/src/compress-loop-anthropic.ts b/src/compress-loop-anthropic.ts
new file mode 100644
index 000000000..35047b3c3
--- /dev/null
+++ b/src/compress-loop-anthropic.ts
@@ -0,0 +1,64 @@
+import { MAX_LOOP_ROUNDS } from "./loop/index.js";
+import { isProxyToolFor } from "./absorb.js";
+import { withSessionLock } from "./session.js";
+import { fetchWithRetry, UpstreamHttpError } from "./fetch-util.js";
+import { proxyDispatcher } from "./upstream-proxy.js";
+import { rewriteJsonResponseAsync, type RewriteCtx } from "./stream.js";
+
+type Block = { type?: string; name?: string; id?: string; text?: string };
+
+interface LoopOptions {
+    proxyUrl?: string;
+    signal?: AbortSignal;
+}
+
+interface RequestOptions {
+    url: string;
+    headers: Record<string, string>;
+    wireTransform?: (body: Record<string, unknown>) => Record<string, unknown>;
+    resign?: (headers: Record<string, string>, body: string | Buffer) => void;
+}
+
+export async function compressLoopAnthropicJson(
+    initial: Record<string, unknown>,
+    ctx: RewriteCtx,
+    options: LoopOptions,
+    requestBody: Record<string, unknown>,
+    requestOptions: RequestOptions,
+): Promise<Record<string, unknown>> {
+    let current = initial;
+    const isProxy = (b: Block) => b.type === "tool_use" && typeof b.name === "string" && isProxyToolFor(b.name, ctx.session, ctx.config);
+    for (let round = 1; round < MAX_LOOP_ROUNDS; round++) {
+        const content = Array.isArray(current.content) ? (current.content as Block[]) : [];
+        const proxyIdx = content.flatMap((b, i) => (isProxy(b) ? [i] : []));
+        if (proxyIdx.length === 0 || content.some((b) => b.type === "tool_use" && !isProxy(b))) break;
+        const original = structuredClone(content);
+        await withSessionLock(ctx.session, () => rewriteJsonResponseAsync(current, ctx, options.signal));
+        const rewritten = current.content as Block[];
+        const results = proxyIdx.map((i) => ({ call: original[i]!, text: rewritten[i]?.text ?? "" }));
+        const messages = Array.isArray(requestBody.messages) ? requestBody.messages : [];
+        requestBody.messages = [
+            ...messages,
+            { role: "assistant", content: original },
+            { role: "user", content: results.map((r) => ({ type: "tool_result", tool_use_id: r.call.id, content: r.text })) },
+        ];
+        const roundBody = JSON.stringify(requestOptions.wireTransform ? requestOptions.wireTransform(requestBody) : requestBody);
+        requestOptions.resign?.(requestOptions.headers, roundBody);
+        ctx.log(`[acp-proxy: non-stream anthropic proxy-tool round ${round}; re-requesting so the model sees the result]`);
+        const result = await fetchWithRetry(requestOptions.url, {
+            method: "POST",
+            headers: requestOptions.headers,
+            body: roundBody,
+            ...(options.proxyUrl ? { dispatcher: proxyDispatcher(options.proxyUrl) } : {}),
+        }, undefined, options.signal).catch((e) => {
+            if (e instanceof UpstreamHttpError) ctx.log(`[acp-proxy: non-stream anthropic loop upstream error ${e.status}: ${e.body.slice(0, 200)}]`);
+            throw e;
+        });
+        try {
+            current = (await result.response.json()) as Record<string, unknown>;
+        } finally {
+            result.clearTimer();
+        }
+    }
+    return current;
+}
diff --git a/src/server/relay.ts b/src/server/relay.ts
index d56c3ce81..0baa12570 100644
--- a/src/server/relay.ts
+++ b/src/server/relay.ts
@@ -19,6 +19,7 @@ import { applyCompatDropFields, dropCompatFieldsJson, resolveCompatDropFields }
 import { applyCompatRoles, applyCompatRolesJson, detectRoleRejection, detectSystemPlacementError, hasOffHeadSystem, resolveCompatRoles, type CompatRoles } from "../compat-roles.js";
 import { builtinCompatRoles, builtinRoleRestorePoints, withRestoredRoles } from "../kiro.js";
 import { compressLoopResponsesJson } from "../compress-loop-responses.js";
+import { compressLoopAnthropicJson } from "../compress-loop-anthropic.js";
 import { resolveCompress } from "../compress-settings.js";
 import { buildAbsorbSystemPrompt, buildCompressHybridSystemPrompt, buildCompressSystemPrompt, withMarkerIntegrityNote, withMcpDialectNote, withProjectAttribution, withSummaryBudgetNote } from "../compress-tool.js";
 import { detectGenerationBoundary, GENERATION_BOUNDARY_NOTE } from "../generation-boundary.js";
@@ -2393,6 +2394,16 @@ async function forwardCaptured(
                         { url: upstreamUrl, headers: requestHeaders, wireTransform, resign: applyResign },
                     );
                 }
+                if (prepared.protocol === "anthropic") {
+                    const requestBody = JSON.parse(typeof body === "string" ? body : body.toString("utf8")) as Record<string, unknown>;
+                    json = await compressLoopAnthropicJson(
+                        json,
+                        ctx,
+                        { proxyUrl, signal: clientAbort.signal },
+                        requestBody,
+                        { url: upstreamUrl, headers: buildForwardHeaders(headers), wireTransform, resign: applyResign },
+                    );
+                }
                 // Capture upstream usage so tokenCount (which drives nudge +
                 // emergency-truncate) reflects reality for non-streaming
                 // sessions too. The streaming loops do this in their SSE

Tests:

  • New tests/bug4-nonstream-anthropic-proxy-loop.test.ts: a proxy-only round re-requests, sends a tool_result for the call id, and returns the continuation. A mixed round and a round without proxy calls do not re-request.
  • tests/ccr-v2.test.ts "e2e CCR v2 non-stream" pinned the old one-shot shape. We changed its mock to answer a tool_result with text. The test now checks that the receipt and the decompress content reach the model in the re-request, and that the client gets the final text.
  • Kernel 1,621/1,621 pass. The root suite shows no new failures. npx tsc --noEmit is clean.

Measured (agentic Java coding task, Headroom CCR in front, Opus 5.5)

Build Model folds followed by a request Runs with a non-empty final result and diff
0.1.212 + local fixes for #4885/#5432/#5604 0/1 0/1 (final result = receipt)
master + #4898 + #5441 + #5605 0/1 0/1 (final result = receipt, empty diff)
same + this fix (+ a small trailing-assistant guard, see the PR #5605 comment) 5/5 6/6 (the agent reports a passing build in every run)

Scenario regression with the fix: 0 upstream 400s in 432 Haiku and 48 Opus requests. All 51 client-visible folds were followed by more output or a tool call.

Activity

  1. ranxianglei commented on Nov 8, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    (ai: vllm-qwen/qwen3.8-27b)

    查重:不重复。 Closest items are #4019 (stream:false OpenAI chat-completions — proxy tools passed through unexecuted, answer lost) and its still-open PR #4037. Same failure family (the non-stream lane lacks the proxy-tool loop the stream lane has), different wire: this report is the Anthropic Messages lane, which #4037 explicitly does not touch. No open issue/PR or mirror [PR] row targets this thread.

    Verified against master (3ac3e95c) — the mechanism you describe is exactly what the code does:

    • Non-stream Anthropic falls into the final else rewriter at src/server/relay.ts:2413-2414 → one-shot rewriteJsonResponseAsync.
    • src/stream.ts:1684: if (converted && !sawRealToolUse) b.stop_reason = "end_turn" — each proxy tool_use becomes its receipt text and the turn ends; nothing re-requests upstream.
    • The stream-lane gate at src/loop/core.ts:1531 (reRequest = proxyResults.length > 0 && realCalls === 0) confirms the stream/non-stream asymmetry.

    Approach assessment: your fix direction is right — port the #4037 non-stream proxy-tool loop to the Anthropic lane with the same three-way gate (proxy-only round → execute + append original assistant content and a tool_result user turn + re-request; mixed or proxy-free round → keep today's one-shot behavior untouched). Two implementation notes for my pass:

    1. Like [PR] fix: execute proxy-owned tools on the non-stream OpenAI path (#4019) #4037, the re-request resends the unfolded body; the fold materializes on the next client request. Your trailing-assistant concern ([PR] Failed-compress recovery: end the re-request on a user turn, not an assistant prefill (fixes upstream 400) #5605) is structurally covered here: our continuation always ends on the tool_result user turn, never an assistant prefill.
    2. Pending retrieval injections (Feature: content-addressed message store — ID-referenced tool results + acp_retrieve (built-in CCR) #1097) ride the re-request after their ack, matching the stream lane (core.ts:1467); decompress/retrieve results already inline theirs via executeAnthropicProxyTool, so there is no double delivery.

    标签: bug · claude-code · scope/wiring · p1 — when a fold fires on a buffered (stream:false) anthropic-wire turn, the agent's turn dies with zero output; core-promise failure under that condition, not cosmetic.

    Implementing now: new src/compress-loop-anthropic.ts + relay wiring + tests (a unit suite mirroring issue4019-nonstream-proxy-tools.test.ts, plus updating the ccr-v2 non-stream e2e, which currently pins the buggy shape). PR follows in this thread.

  2. ranxianglei commented on Nov 8, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    (ai: vllm-qwen/qwen3.8-27b)

    Fixed — PR: #5966 (labels applied: bug, p1; CI just kicked off — no check results in the mirror cache yet, I'll follow up when they land).

    What changed. Non-stream (stream:false) Anthropic responses that call a proxy tool are now handled by a small loop (src/compress-loop-anthropic.ts, wired into src/server/relay.ts for the anthropic protocol), porting the streaming lane's decision (core.ts:1531):

    • Proxy-only round (only compress/acp_status/search_context/decompress tool_use blocks): each call executes through the SAME executors the one-shot rewriter uses, the original assistant turn plus a tool_result user turn are appended to the forwarded body, and the proxy re-requests upstream so the model actually sees the result.
    • Mixed or tool-free rounds: returned unrewritten — the existing one-shot rewriter chain is untouched and handles those cases exactly as before. Each upstream response is executed by exactly ONE executor, so nothing runs twice.
    • Bounded by MAX_LOOP_ROUNDS; at the limit the last response degrades to the pre-fix shape (receipt + end_turn) — never an undeclared proxy tool_use handed to the client.

    Signed thinking blocks ride the replayed assistant turn verbatim (KDD #10, test-pinned); the continuation always ends on the tool_result USER turn (#5605); like #4037 the re-request resends the UNFOLDED body, so the fold materializes on the next client request (prefix-cache friendly).

    Behavior change (disclosed). Old: a non-stream anthropic turn whose model called e.g. compress ended with the fold receipt as the turn's ONLY output (stop_reason: end_turn) — the model never saw the fold result. New: such a round makes one extra upstream request per proxy-call round and the client receives the model's real post-compress answer instead. Client-visible turn output changes from receipt text to the final answer; cost is one extra upstream call on affected turns only. This is the fix itself, not drift.

    Tests. New tests/issue5957-nonstream-anthropic-proxy-loop.test.ts (8 tests, including negative gates: real client tool_use / plain text / mixed rounds → zero fetches, byte-identical passthrough). tests/ccr-v2.test.ts's non-stream e2e previously PINNED THE BUGGY SHAPE and now pins the fixed one: the client gets the answer (no tool_use block), the receipt rides the re-request's tool_result, unfolded history is resent on the re-request, the folded view materializes on turn-2 outbound, the range restore rides the turn-2 re-request. Local targeted runs: typecheck clean; issue5957 suite 8/8; ccr-v2 16/16; marker + image-ref-dispatch suites green. Full-suite verdict comes from CI.

    Review note: open PR #4037 touches the same relay.ts non-stream region (OpenAI lane) — no textual conflict at this base, but worth reviewing together if they merge close in time.


    中文八问(供 reviewer)

    1. 是什么问题:非流式(stream:false)Anthropic 请求里,模型调用代理工具(compress/acp_status/search_context/decompress)时,一次性改写器把每个 tool_use 替换成结果文本并强制 end_turn——压缩轮直接以"回执"作为该回合并唯一输出而结束,模型永远看不到折叠结果,回合内后续内容全部丢失。
    2. 什么原因导致的:流式车道有压缩循环(core.ts:1531 的 reRequest 门控:纯代理工具轮⇒执行+重请求直到模型给出文本或真实客户端工具调用),而非流式车道只接了一次性改写器(rewriteJsonSteps),没有等价循环——两条车道的决策不对称,这就是根因。
    3. 影响范围与严重程度:所有走非流式 Anthropic wire 的客户端(stream:false 的 claude-code 等场景),触发条件=一次折叠恰好落在缓冲回合上;严重度 p1——该回合作废(只有回执、没有答案),核心承诺失灵;但不丢数据、不坏会话,下一回合可正常继续。
    4. 怎么修复的:把流式车道的判定对称移植到非流式车道——新增 compressLoopAnthropicJson:纯代理工具轮用与一次性改写器完全相同的执行器执行(compress 走 applyConfiguredCompression,其余走 executeAnthropicProxyTool,原始结果文本与流式车道载体一致),把原 assistant 轮+tool_result user 轮追加到转发体后重请求上游;混合/无工具轮原样返回,交给未动的一次性改写器;每份上游响应只被一个执行器执行;MAX_LOOP_ROUNDS=10 封顶,到限降级为修复前形态。
    5. 是否有回归问题:终端/混合/到限路径仍走原封未动的一次性改写器链(与 [PR] fix: execute proxy-owned tools on the non-stream OpenAI path (#4019) #4037 不同,刻意保留其后的 else 分支);thinking 签名块逐字回放(KDD release v0.1.3 #10,有 deepEqual 测试钉死);continuation 恒以 user 轮结尾([PR] Failed-compress recovery: end the re-request on a user turn, not an assistant prefill (fixes upstream 400) #5605 结构性覆盖);重放体=上一轮出站体+续写,前缀字节稳定。已知且已披露的行为变更仅一处:受影响回合的输出从回执变为最终答案(见上)。本地定向跑了 ccr-v2、marker 两件套、image-ref-dispatch 均绿;全量由 CI 把关。
    6. 是否破坏缓存:不破坏。重请求沿用同一 session 与前缀,cache_control 锚点在全部 4 次上游调用中存活(ccr-v2 逐次断言);折叠仍在下一个客户端请求才物化(与 [PR] fix: execute proxy-owned tools on the non-stream OpenAI path (#4019) #4037 相同取舍,前缀缓存友好)。
    7. 是否改了设计方向:没有。仅把既有流式车道的决策补到非流式车道,不改架构/边界/数据流。
    8. 是否引入了新的配置:没有。无新 env/config/CLI 面。

    收工判定:代码已交付(PR #5966,基于当前 master e6cb37e 之上),待 CI 绿 + reviewer 过目即可合入。

  3. ranxianglei commented on Nov 8, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    (ai: vllm-qwen/qwen3.8-27b)

    CI update for #5966 (head e6cb37e, verified live just now): all green — 132 check runs on this SHA, zero failures, nothing still running; every workflow lane (CI test shards on ubuntu/windows × node 22/24, static-gates, architecture-docs, config-surface-guard, docs-coverage, gate, and all e2e lanes incl. hermetic registry + dsh plugin lane + real-codex E2E) has a completed/successful run. A first wave of runs was auto-cancelled by the repo's concurrency re-trigger right after PR open — each cancelled lane was superseded by a successful sibling run, so the cancellations are orchestration noise, not signal.

    PR is ready for review/merge (merge stays human-only per repo rules).

  4. added 2 commits that reference this issue on Nov 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions