Repository navigation
[PR] fix: insert Anthropic ephemeral tails before trailing client systems (#5432) - #5441
Conversation
📦 Built Package ArtifactBranch: Latest build — install this exact versionnpm install -g billion-context@0.0.8894-pr.5441.978ce32This exact version is immutable and reproducible. The rolling Recent builds (commit → version)
Download artifact
tar xzf billion-context-pr5441.tgz
npm install -g packageInstall instructions are refreshed on each push. |
(ai: vllm-qwen/qwen3.8-27b) 查重: 不重复。 与 #5432 / #4570 / #4457 同症状家族(Anthropic system 位置 400),但与家族内其他条目机制不同:本 PR 只修 case 2 —— 宿主侧 ephemeral 尾部注入落在客户端 trailing system run 之后;#4715(open PR)与 #5438(kernel 授权申请,p1)处理的是 case 1 —— kernel 标签: 分支已拉取,代码审核进行中——结构化八问结论随后补上。 |
(ai: vllm-qwen/qwen3.8-27b) PR Review 结论 — case-2 宿主侧止血① 原 issue 方向(最高优先):#5432 case 2 —— 宿主在 kernel ② 回归审查(既有行为清点):
③ 代码:helper 最小且正确——空 additions 返回原数组(identity 不复制);从尾部回扫连续 system 在其前插入;全 system 输入 additions 居首(合法形状);顺序批保持相对序。本地 typecheck 干净。helper 不校验 additions 必为 user,但调用点全传 user 且函数名已声明意图,不算缺陷。 ④ 测试有效性:8 个测试双向(#3617 纪律)——正向钉新位置、负向钉 legacy dead-last 行为,断言有牙(回退修法则 steady-path 正向必挂:末元素变成 nudge user 而非 client system)。steady-path 走真 server(startServer hermetic harness + 捕获的 upstream body),不是对受测逻辑打 mock;两轮 arm+inject 设计沿用 #3910 纪律。本地定向跑:新文件 8/8 + issue3910/issue1470/loop-adapters/issue2499 合计 52/52 绿。 结构化八问:
CI:运行中(本条发出时 0 failed / 13 pending / guards 全绿;full-suite 判定以此 head 的 test lanes 为准)。若任一 test lane 红,按 #4599 跟进。 判定:可合(wire/message-shape 变更按 §7.4 属 human-only,请 owner 在 CI 转绿后自行合并)。 |
(ai: vllm-qwen/qwen3.8-27b) CI 已全绿(head |
9d9a982 to
d0237cd
Compare
|
(ai: vllm-qwen/qwen3.8-27b)
独立复审 @ head
|
[bot] 🏷 (ai: vllm-qwen/qwen3.8-27b) 复审完成:对 head |
|
(ai: anthropic/claude) Independent e2e A/B verification of #5441 (head Field symptom on 0.1.212. Claude Code 2.1.309 remote session ( Repro.
On 0.1.212, every rejected body ends Tests. On the PR checkout, Caveats.
Conclusion: #5441 fixes the trailing-system + nudge-append shape from #5432 in this environment. mock_anthropic.py#!/usr/bin/env python3
# Mock Anthropic /v1/messages: validates role order like the real API, logs each request's shape (roles only), streams a tiny reply.
import json, sys, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
LOG = sys.argv[2] if len(sys.argv) > 2 else "mock.log"
def violations(msgs):
out = []
for i, m in enumerate(msgs):
if m.get("role") != "system": continue
if isinstance(m.get("content"), list) and len(m["content"]) == 0 and m.get("output_config") is not None: continue
nxt = msgs[i+1] if i+1 < len(msgs) else None
if nxt is not None and nxt.get("role") != "assistant":
out.append(f"messages.{i}: role 'system' must precede an 'assistant' message or end the array")
return out
def shape(msgs):
s = []
for m in msgs:
c = m.get("content"); kinds = []
if isinstance(c, list): kinds = [b.get("type","?") for b in c]
else: kinds = ["str"]
s.append(m.get("role","?")[0].upper() + ":" + ",".join(sorted(set(kinds))))
return s
class H(BaseHTTPRequestHandler):
def log_message(self, *a): pass
def do_POST(self):
n = int(self.headers.get("content-length", 0)); body = json.loads(self.rfile.read(n) or b"{}")
msgs = body.get("messages", []); v = violations(msgs)
tools = [t.get("name") for t in body.get("tools", []) if isinstance(t, dict)]
rec = {"t": time.strftime("%H:%M:%S"), "n": len(msgs), "bytes": n, "violations": v, "shape": shape(msgs),
"bili_tools": [t for t in tools if t and ("compress" in t or "acp" in t or "context" in t)],
"sys_global": any(isinstance(b, dict) and (b.get("cache_control") or {}).get("scope") == "global" for b in (body.get("system") or []) if isinstance(body.get("system"), list))}
open(LOG, "a").write(json.dumps(rec) + "\n")
if v:
err = json.dumps({"type":"error","error":{"type":"invalid_request_error","message": v[0]}}).encode()
self.send_response(400); self.send_header("content-type","application/json"); self.send_header("content-length",str(len(err))); self.end_headers(); self.wfile.write(err); return
inp = max(1, n // 4)
ev = [("message_start", {"type":"message_start","message":{"id":"msg_mock","type":"message","role":"assistant","model":body.get("model"),"content":[],"stop_reason":None,"usage":{"input_tokens":inp,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}),
("content_block_start", {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}),
("content_block_delta", {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Noted. Continuing."}}),
("content_block_stop", {"type":"content_block_stop","index":0}),
("message_delta", {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}),
("message_stop", {"type":"message_stop"})]
self.send_response(200); self.send_header("content-type","text/event-stream"); self.end_headers()
for name, data in ev: self.wfile.write(f"event: {name}\ndata: {json.dumps(data)}\n\n".encode())
ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), H).serve_forever()drive.py (usage: drive.py <bili origin> <upstream origin> <session id> <turns> hooks|start|nohooks)#!/usr/bin/env python3
# Replays a growing Claude-Code-shaped conversation through a test billion-context instance in plugin mode.
import json, sys, urllib.request, random, string
BILI, UP, SID, TURNS, MODE = sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4]), sys.argv[5]
def post(url, body, headers):
r = urllib.request.Request(url, data=json.dumps(body).encode(), headers={"content-type":"application/json", **headers}, method="POST")
try:
with urllib.request.urlopen(r, timeout=120) as resp: return resp.status, resp.read().decode(errors="ignore")
except urllib.error.HTTPError as e: return e.code, e.read().decode(errors="ignore")
print("register:", post(f"{BILI}/__bili/plugin/register", {"conversationId": SID, "agent": "mcp", "identity": True}, {})[0])
random.seed(7)
filler = lambda k: " ".join("".join(random.choice(string.ascii_lowercase) for _ in range(7)) for _ in range(k))
system = [{"type":"text","text":"You are Claude Code. " + filler(400),"cache_control":{"type":"ephemeral","scope":"global"}},
{"type":"text","text":"Environment and CLAUDE.md. " + filler(300),"cache_control":{"type":"ephemeral"}}]
tools = [{"name": n, "description": "tool", "input_schema": {"type":"object","properties":{}}} for n in
["Read","Bash","Edit","mcp__bili__compress","mcp__bili__decompress","mcp__bili__search_context","mcp__bili__acp_status"]]
msgs = []
def hook(text): return {"role":"system","content":[{"type":"text","text":text}]}
for t in range(TURNS):
msgs.append({"role":"user","content":[{"type":"text","text":f"Step {t}: read the next file and summarise it."}]})
if (MODE == "hooks") or (MODE == "start" and t == 0): msgs.append(hook(("SessionStart hook additional context: <context_window_protection> route big outputs through ctx tools </context_window_protection>" if t == 0 else "UserPromptSubmit hook additional context: <routing> prefer ctx_execute </routing>")))
body = {"model":"claude-opus-5-5","max_tokens":32000,"stream":True,"system":system,"tools":tools,"messages":msgs}
code, text = post(f"{BILI}/bili/{UP}/v1/messages", body, {"x-claude-code-session-id": SID, "anthropic-version":"2023-06-01", "x-api-key":"mock-not-a-secret"})
if code != 200: print(f"turn {t}: HTTP {code} {text[:200]}")
tid = f"toolu_{t:04d}"
msgs.append({"role":"assistant","content":[{"type":"text","text":"Reading."},{"type":"tool_use","id":tid,"name":"Read","input":{"path":f"/tmp/f{t}.md"}}]})
msgs.append({"role":"user","content":[{"type":"tool_result","tool_use_id":tid,"content": filler(1400)}]})
msgs.append({"role":"assistant","content":[{"type":"text","text":f"File {t} summarised: " + filler(60)}]})
print("done", TURNS, "turns") |
[bot] 🏷 (ai: vllm-qwen/qwen3.8-27b) 感谢这份独立 e2e A/B 证据——与本次复审的结论互相印证:
评审结论维持:可合,待 owner 手动批准合并(GitHub 禁止 agent 自批本人账号的 PR)。 |
…i#5438 two-sided heal; keep final-array backstop; kernel 0.0.181) Conflict resolution: master shipped the sibling fix for this family (ranxianglei#5438 via PR ranxianglei#5522: two-sided heal inside coreToAnthropic, demote-only) plus the insertBeforeTrailingSystemRun helper (host wiring pending in ranxianglei#5441). Take master's approved heal semantics wholesale; keep ranxianglei#4715's residual contribution: the healAnthropicSystemPositions standalone export + the three host final-array call sites + WC-023 pins (ledger entry kept from master's side, one entry per id). Branch move-past-next-user design superseded by the owner-approved ranxianglei#5438 approach. Kernel bumped 0.0.180 -> 0.0.181 (kernel-change-vs-base rule).
|
Tested on master Error without the PR Claude Code ends some requests with its silent-turn reminder as a mid-conversation Rates
Sites on master that append a tail without
Timing note In the first case we traced (on 0.1.212), bili logged Conflicts on
messages: insertBeforeTrailingSystemRun(dedupeAdjacentOutputConfig(rebuiltMessages), [{ role: "user", content: buildDecisionPrompt(ranges) }]),After that, |
[bot] 🏷 (ai: vllm-qwen/qwen3.8-27b) Thank you for the Bedrock/LiteLLM evidence — it independently corroborates the review verdict on three axes at once: the exact 400 shape ( Conflicts: confirmed and resolved. I merged current master (
I verified the ordering against New head: Review verdict stands: mergeable, pending owner approval (GitHub blocks self-approval on this account). |
[ework] CI fix verified green ✓ |
[bot] 🏷 (ai: vllm-qwen/qwen3.8-27b) Closing summary for this review round — post-conflict re-verification complete: What was done. The two trivial conflicts reported against master Status (re-verified upstream today):
结构化八问(答案与 head
判定:可合。 Wire/message-shape 变更按 §7.4 属 human-only 合并——请 owner 手动合并(https://github.com/ranxianglei/billion-context/pull/5441)。CI 在 exact head 全绿,无需再等。 |
…5432) Anthropic's mid-conversation system rule is two-sided: an entry must FOLLOW a user message AND PRECEDE an assistant message or end the array. The host appended its ephemeral tail users (sysNotes/nudge/decide/statusLine/retrieval notes) AFTER coreToAnthropic's position heal, so a client history ending in a legal trailing system (Claude Code's 'user hasn't heard from you' reminder) got a user after it and 400'd ('role system must precede an assistant message or end the array'). The round-2 compress-loop adapter had the same shape. New src/util.ts helper insertBeforeTrailingSystemRun inserts the injections BEFORE the trailing system run: the client's systems stay verbatim and still end the array, and the new user also repairs the run's follow side when its predecessor was an assistant. All seven steady-path sites in prepare-anthropic plus the adapter notes site now go through it. Sessions without a trailing system run emit byte-identical bodies (pinned by negative tests). This is the host-side half of #5432 (case 2). Case 1 — a fold orphaning a sidecar system right after an assistant — needs the kernel heal to enforce the follow side too; that change is gated on owner approval per the Kernel Boundary (see the kernel authorization request issue). No kernel files are touched here.
…ms (#5432) - wire-body appendTrailingUserText (fake-completion hint / degenerate-turn retry nudge): insert the user turn BEFORE a trailing system run on the anthropic wire instead of pushing after it; openai bytes unchanged (no such rule there). - chain-checkpoint insertCheckpointCarrier: an anthropic body ending in a trailing system run admits no carrier slot under the frozen #1395/#1421 contract (recognition only sees the trailing USER run); return null instead of appending after the run (the exact 400 shape) — relay forwards unstamped (best-effort). - util: factor trailingSystemRunStart out of insertBeforeTrailingSystemRun so every post-assembly mutator shares one definition. - tests: +6 two-direction rows covering both paths plus the openai no-drift pin. Refs #5432
…l two-sided heal landed (#5432)
…e host interim copy (#5432) The kernel now exports the same helper (single source of truth for the trailing-system-run ordering rule, #5438/#5432); route the steady path, the round-2 adapter and the regression tests through acp-kernel/wire instead of the interim src/util.ts copy. trailingSystemRunStart stays host-side (wire-body splice + chain-checkpoint null check have no kernel counterpart). Behavior unchanged.
aaaa7a9 to
7513559
Compare
📦 Merged Build PublishedThis merge to master was published as npm install -g billion-context@0.0.1175-master.31684f3 # this exact build
npm install -g billion-context@master # always the newest merged build
|
master moved past the previous sync (dc041dd) by the web handoff-render (#6260) + anthropic trailing-system (#5432/#5441) + snapshot-write-lifecycle (#6186) landings. None of those commits modify src/plugin.ts or src/agent/fork-adopt.ts (master's version of both == merge-base), so this merge is clean and preserves the #5123/#4266 union resolution untouched.
Master advanced past the dc041dd merged earlier (web handoff rendering, anthropic trailing-system tails #5432/#5441, snapshot write lifecycle #6186, handoff-render #6260). Clean auto-merge — none of those commits touch the side-lane surface (handle.ts / side-request.ts / side-lane tests), so no conflicts and no logic change.
… round 9) Master advanced 33104b1 -> c26c31a (ranxianglei#6260, ranxianglei#5441, ranxianglei#6186, ranxianglei#5774, ranxianglei#6166 et al.). Clean auto-merge, no conflicts: the new window's blockOps work (ranxianglei#5765/ranxianglei#6166) and this PR's decide-lane keys land in different regions of every shared file. Also completes the round-8 docs regeneration that was left unstaged: the committed head carried the decide-lane keys in the compress.yaml seed but not in the generated output. Re-ran node tools/gen-config-docs.mjs generate on the merged seed (341 entries; i18n ok: 256 keys, EN/ZH equal) — generated output now matches the seed in docs/configuration[.zh-CN].md, website/main.js and website/docs/index.html (blockOps + decide-lane keys, both languages). Verified: tsc --noEmit clean; unused-exports gate clean; targeted suites green (nudge-decide + issue3004 selfheal + config-surface-guard + nudge-model-decided e2e). Full-suite verdict = remote CI.
What this fixes
Host-side half of #5432 — every place bili appends or mutates a trailing user message into an Anthropic-wire body after position healing has run. Two 400s were observed (claude-code lane, proxy mode, upstream Anthropic with
mid-conversation-tool-changes-2026-07-01), e.g. ~0.5 s after everynudge INJECT:Root cause: Anthropic's mid-conversation system rule is two-sided — an entry must FOLLOW a user message (or an assistant ending in a server tool result) AND PRECEDE an assistant message or end the array (directive-only systems exempt). When the client history ends in a legal trailing system (Claude Code's "The user hasn't heard from you…" reminder; field reports from the claude-code and omp lanes both show host requests ending in such an entry), any user appended behind it makes the array illegal and the whole request 400s. Four host-side producers did exactly that:
src/server/prepare-anthropic.ts— seven ephemeral-appends (sysNotes / nudge fallback / legacy nudge / decide prompt / decide directive / statusLine carrier / retrieval notes) aftercoreToAnthropic().src/loop/adapter-anthropic.ts— its notes append.src/wire-body.tsappendTrailingUserText(continuation / fake-completion retries) — pushed the continuation text at array end.src/chain-checkpoint.tsinsertCheckpointCarrier— its frozen contract (design: Chain Checkpoint — first-processor-wins idempotent interop (Step 2/3) #1395 decision (a) / [design] chain checkpoint step 3: carrier stamp shape on role-alternation-strict wires (anthropic/google) #1421) admits the carrier ONLY inside "the trailing run of user-role entries after the last non-user entry"; a system-final body has no legal slot for it, so stamping now skips such bodies (returns null → relay forwards unstamped).Fix
Injected users land before any trailing run of system messages instead of after it, via the kernel's exported
insertBeforeTrailingSystemRun(kernel/src/wire/anthropic.ts, #5438/#5432 — single source of truth for the rule): sites 1–3 route through it; site 4 refuses to mutate (skip-stamp). A small host-sidetrailingSystemRunStart(messages)insrc/util.tssupplies the run index for the two splice/null-check sites that have no kernel counterpart. The client's systems stay verbatim and still end the array (legal); without a trailing run the result is byte-identical to a plain append. Sites 1–2 additionally keep master's#4570heal-backstop as the final pass over the array actually sent (union of intent from the rebase conflict resolution); the decide side call also keeps its#5688output_config dedupe ahead of the insertion.Kernel half status (case 1)
The follow-side heal requested in #5438 was approved and already landed on master:
ca532d4f3(two-sidedhealIllegalSystemPositions) +f06619ffc(legitimates the real*_tool_resultserver-block family on the follow side), with a dedicated regression suite (kernel/tests/issue5432-follow-side-heal.test.ts). Case 1 (must follow a 'user' messageafter a fold orphaned the sidecar) is covered there; nothing in this PR depends on further kernel work. Note: the originally planned "post-merge follow-up PR swapping the host helper for the kernel export" is absorbed into this PR — since the kernel export is on master, the interim host copy was deleted here instead (one less PR).Behavior change (disclosed per policy)
messages; new = inserted before a trailing system run when one exists. Bodies whose rebuilt history does NOT end in a system run emit byte-identical output (negative tests pin this). Cache marks ride on the last stable message insidecoreToAnthropic, so breakpoints are untouched.Tests
New
tests/issue5432-trailing-system-tails.test.ts(13 tests, both directions per #3617 discipline):buildRequest: note lands before the trailing client system (verbatim, still last); negative — no trailing system → note dead-last as before. Fixture uses the legal-follow shape required since the two-sided heal landed on master;Local targeted runs green on the rebased head: this file (13/13) plus issue3910 lane-nudge wiring, chain step2/step3, issue3159, issue1683, fake-completion and degenerate-retry families — 89/89 combined; typecheck clean. Full-suite verdict = CI on the pushed head. CI note:
cache-proof.test.tstest 7 was red on the previous base independent of this PR (verified in a clean master worktree with its own deps); reported in #4599 and did not reproduce on the prior head's CI run. This head is rebased linearly onto current master (541cf260c4).No config surface changes. No version bump.
Refs #5432 (host-side half; the kernel half landed via #5438).