From c6892bc748e3146e0ff46c3b680d4fbbbff0531f Mon Sep 17 00:00:00 2001 From: awork Date: Wed, 9 Sep 2026 09:30:30 +0800 Subject: [PATCH 1/4] feat: project thinking as reasoning cores, strip closed-turn replay (needs acp-kernel 0.0.60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #336 (requires acp-kernel PR #225) - entriesToCoreMessages projects assistant thinking blocks as contentType 'reasoning' cores with deterministic base#r sub-ids; companion text/call cores shift to sub-id form when thinking exists so the rebuild can filter per sub-id survival. - coreOutToAgentMessages filters original thinking blocks by surviving base#r sub-ids, and syncs kernel-rewritten compress-call text (live-range filter + summary stubs) back into toolCall arguments. - AdapterConfig.reasoningReplay defaults to 'open-round' (kill-switch 'always'/'never', coreOverrides wins). Measured on the storm session: outgoing view 155K→49K (thinking 75.8K→0.9K). --- src/config.ts | 8 +++ src/messages.ts | 96 ++++++++++++++++++++++----- tests/messages-reasoning.test.ts | 109 +++++++++++++++++++++++++++++++ tests/messages.test.ts | 12 ++-- 4 files changed, 206 insertions(+), 19 deletions(-) create mode 100644 tests/messages-reasoning.test.ts diff --git a/src/config.ts b/src/config.ts index 09aa42f..cba8cee 100644 --- a/src/config.ts +++ b/src/config.ts @@ -188,6 +188,13 @@ export interface AdapterConfig { * Set explicitly for tests/headless runs. */ modelContextLimit?: number; protectedTools?: string[]; + /** Reasoning-block replay policy (kernel reasoningReplay). Default: + * "open-round" — history thinking is stripped from the outgoing view once + * its round closes; providers only require replaying thinking for the + * current unresolved round. Set "always" to restore legacy keep-everything + * behavior, "never" to strip even the open round. Kill-switch for + * billion-context-pi #336. */ + reasoningReplay?: "always" | "open-round" | "never"; preserveRecentMessages?: number; /** Check npm for a newer billion-context-pi on startup and auto-install it. Default: true. * Disable via `autoUpdate: false` or env `ACP_AUTO_UPDATE=0` to avoid all @@ -394,6 +401,7 @@ export function resolveConfig(adapter: AdapterConfig, liveContextLimit: number, preserveRecentMessages: adapter.preserveRecentMessages ?? 5, ...adapter.coreOverrides, }); + config.reasoningReplay = adapter.coreOverrides?.reasoningReplay ?? adapter.reasoningReplay ?? "open-round"; const c = resolveCompress(adapter.compress, provider, modelId); if (c.maxContextLimit !== undefined) config.nudge.maxContextLimitPct = parsePercent(c.maxContextLimit); if (c.emergencyThresholdPercent !== undefined) { diff --git a/src/messages.ts b/src/messages.ts index 35e9d21..5890c11 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -59,6 +59,17 @@ function projectMessage(message: AgentMessage, id: string): CoreMessage[] { }]; } if (role === "assistant") { + // Thinking parts project as `reasoning` cores with deterministic `#r` + // sub-ids so the kernel can see (and, per reasoningReplay, strip) them; + // the rebuild filters the original thinking blocks by sub-id survival. + const thinkingTexts = thinkingBlockTexts(msg.content); + const splitIds = thinkingTexts.length > 0; + const out: CoreMessage[] = []; + thinkingTexts.forEach((text, i) => { + if (text.trim().length > 0) { + out.push({ id: `${id}#r${i}`, role: "assistant", contentType: "reasoning", text }); + } + }); const calls = allToolCalls(msg.content); if (calls.length > 0) { const textParts = extractText(msg.content); @@ -66,9 +77,10 @@ function projectMessage(message: AgentMessage, id: string): CoreMessage[] { const call = calls[0]!; const argStr = stringifyArgs(call.arguments); const text = argStr && textParts ? `${textParts}\n${argStr}` : argStr || textParts; - return [{ id, role: "assistant", contentType: "tool-call", toolName: call.name, toolCallId: call.id, text }]; + out.push({ id: splitIds ? `${id}#${call.id}` : id, role: "assistant", contentType: "tool-call", toolName: call.name, toolCallId: call.id, text }); + return out; } - return calls.map((call) => { + out.push(...calls.map((call) => { const argStr = stringifyArgs(call.arguments); return { id: `${id}#${call.id}`, @@ -78,13 +90,15 @@ function projectMessage(message: AgentMessage, id: string): CoreMessage[] { toolCallId: call.id, text: argStr || textParts, }; - }); + })); + return out; } const text = extractText(msg.content); // Drop thinking-only turns: empty assistant text makes OpenAI-compatible // providers (e.g. GLM) return 400 (no body), which Pi misreads as overflow. if (!text.trim()) return []; - return [{ id, role: "assistant", contentType: "text", text }]; + out.push({ id: splitIds ? `${id}#t0` : id, role: "assistant", contentType: "text", text }); + return out; } const customText = extractText(msg.content) || fallbackText(msg); return customText.length > 0 @@ -92,6 +106,19 @@ function projectMessage(message: AgentMessage, id: string): CoreMessage[] { : []; } +function thinkingBlockTexts(content: unknown): string[] { + if (!Array.isArray(content)) return []; + return content + .filter((block): block is Record => { + if (!block || typeof block !== "object") return false; + return (block as { type?: string }).type === "thinking"; + }) + .map((block) => { + const text = block.thinking ?? block.text; + return typeof text === "string" ? text : ""; + }); +} + function fallbackText(msg: AnyMessage): string { const parts: string[] = []; if (msg.command) parts.push(`$ ${msg.command}`); @@ -245,8 +272,18 @@ export function coreOutToAgentMessages( .map((c) => c.toolCallId) .filter((id): id is string => !!id), ); + const survivingSubIds = new Set( + coreOut + .filter((c) => c.id.startsWith(`${baseId}#`) && !c.id.startsWith("acp_summary_")) + .map((c) => c.id), + ); + const coreTextByCallId = new Map( + coreOut + .filter((c) => c.id.startsWith(`${baseId}#`) && c.toolCallId) + .map((c) => [c.toolCallId as string, c.text]), + ); - out.push(reconstructToolCallMessage(original, core, survivingCallIds)); + out.push(reconstructToolCallMessage(original, core, survivingCallIds, baseId, survivingSubIds, coreTextByCallId)); } return out; @@ -256,22 +293,55 @@ function reconstructToolCallMessage( original: AgentMessage, firstCore: CoreMessage, survivingCallIds: Set, + baseId?: string, + survivingSubIds?: Set, + coreTextByCallId?: Map, ): AgentMessage { const base = original as AnyMessage; const match = firstCore.text ? firstCore.text.match(REF_TAG) : null; const tag = match ? match[0] : null; + const filterBlocks = (blocks: unknown[]): unknown[] => { + let reasoningIndex = 0; + return blocks.filter((block) => { + const b = block as { type?: string; id?: string }; + if (b.type === "toolCall") return survivingCallIds.has(b.id ?? ""); + if (b.type === "thinking") { + const subId = baseId !== undefined && survivingSubIds !== undefined + ? `${baseId}#r${reasoningIndex}` + : null; + reasoningIndex++; + if (subId === null) return true; + return survivingSubIds!.has(subId); + } + return true; + }).map((block) => { + // The kernel may have rewritten a compress call's text (live-range + // filter, summary stubs). Sync it back so the rewritten form — not the + // original full-args JSON — is what the provider receives. + const b = block as { type?: string; id?: string; arguments?: unknown }; + if (b.type !== "toolCall" || !coreTextByCallId) return block; + const rewritten = coreTextByCallId.get(b.id ?? ""); + if (rewritten === undefined) return block; + const argStr = stringifyArgs(b.arguments); + if (rewritten === argStr || !argStr) return block; + const start = rewritten.indexOf("{"); + if (start < 0) return block; + try { + return { ...b, arguments: JSON.parse(rewritten.slice(start)) }; + } catch { + return block; + } + }); + }; + if (base.role === "assistant" || !tag) { const rawBlocks2: unknown[] = Array.isArray(base.content) ? base.content : typeof base.content === "string" ? [{ type: "text", text: base.content }] : []; - const filtered2 = rawBlocks2.filter((block) => { - const b = block as { type?: string; id?: string }; - if (b.type === "toolCall") return survivingCallIds.has(b.id ?? ""); - return true; - }); + const filtered2 = filterBlocks(rawBlocks2); const peeled2 = peelRefTagBlocks(filtered2); return { ...(original as object), content: peeled2 } as AgentMessage; } @@ -282,11 +352,7 @@ function reconstructToolCallMessage( ? [{ type: "text", text: base.content }] : []; - const filtered = rawBlocks.filter((block) => { - const b = block as { type?: string; id?: string }; - if (b.type === "toolCall") return survivingCallIds.has(b.id ?? ""); - return true; - }); + const filtered = filterBlocks(rawBlocks); const peeled = peelRefTagBlocks(filtered); const stableTag = rewriteTagTokens(tag, coreBodyOf(firstCore.text ?? "", tag)); diff --git a/tests/messages-reasoning.test.ts b/tests/messages-reasoning.test.ts new file mode 100644 index 0000000..e05d410 --- /dev/null +++ b/tests/messages-reasoning.test.ts @@ -0,0 +1,109 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createCore, createInitialState, defaultConfig } from "acp-kernel"; +import { entriesToCoreMessages, coreOutToAgentMessages } from "../src/messages.ts"; +import { resolveConfig } from "../src/config.ts"; +import type { AgentMessage, SessionEntry } from "../src/types.ts"; + +const countTokens = (text: string) => Math.ceil(text.length / 4); + +function msgEntry(id: string, message: object): SessionEntry { + return { + type: "message", + id, + parentId: null, + timestamp: new Date().toISOString(), + message: message as AgentMessage, + } as SessionEntry; +} + +function userEntry(id: string, text: string): SessionEntry { + return msgEntry(id, { role: "user", content: [{ type: "text", text }], timestamp: Date.now() }); +} + +function thinkingAssistantEntry(id: string, thinking: string, text: string): SessionEntry { + return msgEntry(id, { + role: "assistant", + content: [ + { type: "thinking", thinking }, + { type: "text", text }, + ], + timestamp: Date.now(), + }); +} + +function rebuiltView(entries: SessionEntry[], reasoningReplay: "always" | "open-round" | "never") { + const core = createCore({ countTokens }); + const config = { ...defaultConfig(262144, { limit: 212992 }), reasoningReplay }; + const turn = core.processTurn({ + messages: entriesToCoreMessages(entries), + state: createInitialState(), + config, + tokenCount: 1000, + }); + const byId = new Map(entries.map((e) => [e.id, (e as { message: AgentMessage }).message])); + return coreOutToAgentMessages(turn.messages, byId); +} + +function thinkingBlocksOf(message: AgentMessage | undefined): string[] { + const content = (message as { content?: unknown }).content; + if (!Array.isArray(content)) return []; + return content + .filter((b): b is { type: string; thinking?: string } => (b as { type?: string }).type === "thinking") + .map((b) => b.thinking ?? ""); +} + +test("open-round strips closed-turn thinking but keeps the open round's", () => { + const entries = [ + userEntry("u1", "first question"), + thinkingAssistantEntry("a1", "stale reasoning about question one", "answer one"), + userEntry("u2", "second question"), + thinkingAssistantEntry("a2", "fresh reasoning about question two", "answer two"), + ]; + const view = rebuiltView(entries, "open-round"); + const a1 = view.find((m) => (m as { id?: string }).id === undefined && JSON.stringify((m as { content?: unknown }).content).includes("answer one")); + const a2 = view.find((m) => JSON.stringify((m as { content?: unknown }).content).includes("answer two")); + assert.deepEqual(thinkingBlocksOf(a1), [], "closed-turn thinking stripped from rebuilt view"); + assert.deepEqual(thinkingBlocksOf(a2), ["fresh reasoning about question two"], "open-round thinking kept"); +}); + +test("always keeps every thinking block in the rebuilt view", () => { + const entries = [ + userEntry("u1", "q"), + thinkingAssistantEntry("a1", "keep me", "answer"), + ]; + const view = rebuiltView(entries, "always"); + assert.deepEqual(thinkingBlocksOf(view[1]), ["keep me"]); +}); + +test("never strips thinking from the open round too", () => { + const entries = [ + userEntry("u1", "q"), + thinkingAssistantEntry("a1", "drop me", "answer"), + ]; + const view = rebuiltView(entries, "never"); + assert.deepEqual(thinkingBlocksOf(view[1]), []); +}); + +test("reasoning core text is never inlined into the rebuilt text block", () => { + const entries = [ + userEntry("u1", "q"), + thinkingAssistantEntry("a1", "secret chain", "answer"), + ]; + const view = rebuiltView(entries, "always"); + const texts = JSON.stringify(view.map((m) => (m as { content?: unknown }).content)); + assert.ok(texts.includes("answer")); + assert.ok(texts.includes("secret chain")); + const a1 = view[1] as unknown as { content: { type: string; text?: string; thinking?: string }[] }; + const textBlock = a1.content.find((b) => b.type === "text"); + assert.equal(textBlock?.text, "answer"); +}); + +test("resolveConfig defaults reasoningReplay to open-round and honors overrides", () => { + const def = resolveConfig({}, 262144); + assert.equal(def.reasoningReplay, "open-round"); + const off = resolveConfig({ reasoningReplay: "always" }, 262144); + assert.equal(off.reasoningReplay, "always"); + const viaCore = resolveConfig({ coreOverrides: { reasoningReplay: "never" } }, 262144); + assert.equal(viaCore.reasoningReplay, "never"); +}); diff --git a/tests/messages.test.ts b/tests/messages.test.ts index f33c91f..980cf41 100644 --- a/tests/messages.test.ts +++ b/tests/messages.test.ts @@ -103,16 +103,20 @@ test("entriesToCoreMessages drops thinking-only assistant turns (no empty assist ); }); -test("entriesToCoreMessages keeps assistant turn that has thinking AND text (text extracted, thinking ignored)", () => { +test("entriesToCoreMessages keeps assistant turn that has thinking AND text (reasoning projected with #r sub-id, text with #t0)", () => { const entries: SessionEntry[] = [ msgEntry("a", assistantThinkingAndText("private reasoning", "visible answer") as object), ]; const core = entriesToCoreMessages(entries); - assert.equal(core.length, 1); + assert.equal(core.length, 2); assert.equal(core[0]!.role, "assistant"); - assert.equal(core[0]!.contentType, "text"); - assert.equal(core[0]!.text, "visible answer", "text kept, thinking block not inlined"); + assert.equal(core[0]!.contentType, "reasoning"); + assert.equal(core[0]!.id, "a#r0"); + assert.equal(core[0]!.text, "private reasoning"); + assert.equal(core[1]!.contentType, "text"); + assert.equal(core[1]!.id, "a#t0"); + assert.equal(core[1]!.text, "visible answer", "text kept, thinking block not inlined"); }); test("entriesToCoreMessages drops assistant turn whose text is whitespace-only", () => { From a5025ffa24a1c5a808637918b5dfd21c4d4a9500 Mon Sep 17 00:00:00 2001 From: awork Date: Wed, 9 Sep 2026 10:29:48 +0800 Subject: [PATCH 2/4] feat: GPT-family providers default reasoningReplay to always (encrypted reasoning, untestable) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explicit reasoningReplay in acp.json (or coreOverrides) always wins; qwen/glm verified open-round safe by live API matrix (delete/truncate/empty × closed and open tool rounds all 200). --- src/config.ts | 20 ++++++++++++++++---- tests/messages-reasoning.test.ts | 14 ++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/config.ts b/src/config.ts index cba8cee..d490c10 100644 --- a/src/config.ts +++ b/src/config.ts @@ -191,9 +191,10 @@ export interface AdapterConfig { /** Reasoning-block replay policy (kernel reasoningReplay). Default: * "open-round" — history thinking is stripped from the outgoing view once * its round closes; providers only require replaying thinking for the - * current unresolved round. Set "always" to restore legacy keep-everything - * behavior, "never" to strip even the open round. Kill-switch for - * billion-context-pi #336. */ + * current unresolved round. GPT-family providers/models default to + * "always" (legacy keep-everything — OpenAI reasoning items are opaque + * and untestable from here). Set "always"/"never" explicitly to override + * either default; coreOverrides wins. Kill-switch for #336. */ reasoningReplay?: "always" | "open-round" | "never"; preserveRecentMessages?: number; /** Check npm for a newer billion-context-pi on startup and auto-install it. Default: true. @@ -401,7 +402,7 @@ export function resolveConfig(adapter: AdapterConfig, liveContextLimit: number, preserveRecentMessages: adapter.preserveRecentMessages ?? 5, ...adapter.coreOverrides, }); - config.reasoningReplay = adapter.coreOverrides?.reasoningReplay ?? adapter.reasoningReplay ?? "open-round"; + config.reasoningReplay = adapter.coreOverrides?.reasoningReplay ?? adapter.reasoningReplay ?? defaultReasoningReplay(provider, modelId); const c = resolveCompress(adapter.compress, provider, modelId); if (c.maxContextLimit !== undefined) config.nudge.maxContextLimitPct = parsePercent(c.maxContextLimit); if (c.emergencyThresholdPercent !== undefined) { @@ -425,3 +426,14 @@ export function parsePercent(v: number | string): number { if (s.endsWith("%")) return Number(s.slice(0, -1)) / 100; return Number(s); } + +// OpenAI reasoning models hand back opaque encrypted reasoning items that +// must be echoed back unmodified (Responses API), and we cannot test that +// path from here — GPT-family providers/models default to the legacy +// keep-everything behavior. Explicit reasoningReplay config always wins. +const GPT_FAMILY = /(^|[^a-z0-9])(gpt-|o[134](-|\d|\/|$)|codex|openai)/i; + +export function defaultReasoningReplay(provider?: string, modelId?: string): "always" | "open-round" { + const hay = `${provider ?? ""} ${modelId ?? ""}`; + return GPT_FAMILY.test(hay) ? "always" : "open-round"; +} diff --git a/tests/messages-reasoning.test.ts b/tests/messages-reasoning.test.ts index e05d410..194733d 100644 --- a/tests/messages-reasoning.test.ts +++ b/tests/messages-reasoning.test.ts @@ -107,3 +107,17 @@ test("resolveConfig defaults reasoningReplay to open-round and honors overrides" const viaCore = resolveConfig({ coreOverrides: { reasoningReplay: "never" } }, 262144); assert.equal(viaCore.reasoningReplay, "never"); }); + +test("reasoningReplay defaults conservatively for GPT-family provider/model", () => { + assert.equal(resolveConfig({}, 262144, "openai", "gpt-5").reasoningReplay, "always"); + assert.equal(resolveConfig({}, 262144, undefined, "o3-mini").reasoningReplay, "always"); + assert.equal(resolveConfig({}, 262144, "openai-compatible", "gpt-4o").reasoningReplay, "always"); + assert.equal(resolveConfig({}, 262144, "zhipuai-lb", "glm-5.3").reasoningReplay, "open-round"); + assert.equal(resolveConfig({}, 262144, undefined, "qwen3.8-27b").reasoningReplay, "open-round"); + assert.equal(resolveConfig({}, 262144, undefined, "llama-3").reasoningReplay, "open-round"); +}); + +test("explicit reasoningReplay beats the GPT-family default", () => { + assert.equal(resolveConfig({ reasoningReplay: "open-round" }, 262144, "openai", "gpt-5").reasoningReplay, "open-round"); + assert.equal(resolveConfig({ reasoningReplay: "always" }, 262144, "zhipuai-lb", "glm-5.3").reasoningReplay, "always"); +}); From 2407041890e496afa033290eec227564aa75190a Mon Sep 17 00:00:00 2001 From: awork Date: Wed, 9 Sep 2026 10:31:05 +0800 Subject: [PATCH 3/4] GPT-family providers default to reasoningReplay=always OpenAI reasoning items are opaque and must round-trip unmodified; untestable from here, so default GPT-family to legacy keep-everything. Explicit reasoningReplay config overrides the family default. --- tmp/ctx-breakdown.mjs | 81 +++++++++++++++++++++++++++++++++++++++++++ tmp/ctx2.mjs | 33 ++++++++++++++++++ tmp/ctx3.mjs | 29 ++++++++++++++++ tmp/ctx4.mjs | 30 ++++++++++++++++ tmp/ctx5.mjs | 31 +++++++++++++++++ tmp/ctx6.mjs | 29 ++++++++++++++++ tmp/inspect.mjs | 20 +++++++++++ tmp/repro-final.mjs | 34 ++++++++++++++++++ tmp/repro-loop.mjs | 78 +++++++++++++++++++++++++++++++++++++++++ 9 files changed, 365 insertions(+) create mode 100644 tmp/ctx-breakdown.mjs create mode 100644 tmp/ctx2.mjs create mode 100644 tmp/ctx3.mjs create mode 100644 tmp/ctx4.mjs create mode 100644 tmp/ctx5.mjs create mode 100644 tmp/ctx6.mjs create mode 100644 tmp/inspect.mjs create mode 100644 tmp/repro-final.mjs create mode 100644 tmp/repro-loop.mjs diff --git a/tmp/ctx-breakdown.mjs b/tmp/ctx-breakdown.mjs new file mode 100644 index 0000000..5125d10 --- /dev/null +++ b/tmp/ctx-breakdown.mjs @@ -0,0 +1,81 @@ +import fs from 'node:fs'; +import { createCore, defaultConfig } from 'acp-kernel'; +import { entriesToCoreMessages } from '../src/messages.ts'; + +const sid = '01a07b3c-ab19-7b19-8290-f7967a8221a6'; +const jsonl = '/home/dog/.pi/agent/sessions/--home-dog-projects-billion-context-paper--/2026-09-07T09-39-28-665Z_' + sid + '.jsonl'; +const sidecar = jsonl + '.acp.json'; + +const raw = fs.readFileSync(jsonl, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)); +const entries = raw.filter(r => r.type === 'message'); +const state = JSON.parse(fs.readFileSync(sidecar, 'utf8')); + +// CJK-aware estimate: CJK chars ~1 token each, others ~chars/4 +function est(text) { + if (!text) return 0; + let cjk = 0, other = 0; + for (const ch of text) { + const c = ch.codePointAt(0); + if ((c >= 0x4E00 && c <= 0x9FFF) || (c >= 0x3000 && c <= 0x30FF) || (c >= 0xFF00 && c <= 0xFFEF)) cjk++; + else other++; + } + return Math.round(cjk + other / 4); +} +const S = (x) => JSON.stringify(x); + +const core = createCore({ countTokens: null }); +const config = defaultConfig(262144, { limit: 212992 }); +const turn = await core.processTurn({ messages: entriesToCoreMessages(entries), state, config, tokenCount: 180000 }); + +const byId = new Map(entries.map(e => [e.id, e])); +const cats = { summary: 0, sysNudge: 0, anchorCall: 0, anchorResult: 0, toolResult: 0, userText: 0, asstText: 0, toolCall: 0, thinking: 0, other: 0 }; +const catMsgs = { ...cats }; +let n = 0; +for (const m of turn.messages) { + n++; + if (m.role === 'system') { const t = est(S(m.content)); cats.sysNudge += t; catMsgs.sysNudge++; continue; } + if (String(m.id || '').startsWith('acp_summary')) { const t = est(typeof m.content === 'string' ? m.content : S(m.content)); cats.summary += t; catMsgs.summary++; continue; } + const base = String(m.id || '').split('#')[0]; + const e = byId.get(base); + const body = e ? e.message : m; + let text = ''; + if (body.role === 'user') { + const c = body.content; + if (typeof c === 'string') text = c; + else if (Array.isArray(c)) text = c.map(p => p.type === 'tool_result' ? (typeof p.content === 'string' ? p.content : S(p.content)) : (p.text || '')).join(' '); + if (Array.isArray(body.content) && body.content.some(p => p.type === 'tool_result')) { + const isCompress = S(body.content).includes('ACP') || S(body.content).includes('compress'); + if (isCompress) { cats.anchorResult += est(text); catMsgs.anchorResult++; } + else { cats.toolResult += est(text); catMsgs.toolResult++; } + continue; + } + cats.userText += est(text); catMsgs.userText++; continue; + } else if (body.role === 'assistant') { + const c = body.content; + if (Array.isArray(c)) { + let callText = '', textText = '', thinkText = ''; + for (const p of c) { + if (p.type === 'tool_call') callText += S(p.args ?? p.input ?? '') + p.name; + else if (p.type === 'thinking') thinkText += p.text || ''; + else if (p.type === 'text') textText += p.text || ''; + } + if (callText.includes('"compress"') || callText.includes("'compress'") || /compress/.test(callText.slice(0, 200))) { cats.anchorCall += est(callText + textText); catMsgs.anchorCall++; } + else { cats.toolCall += est(callText); cats.asstText += est(textText); cats.thinking += est(thinkText); catMsgs.toolCall++; } + } else { cats.asstText += est(S(c)); catMsgs.asstText++; } + continue; + } + cats.other += est(S(body.content)); catMsgs.other++; +} + +console.log('view messages:', n); +let total = 0; +for (const k of Object.keys(cats)) { + total += cats[k]; + console.log(String(k).padEnd(12), String(cats[k]).padStart(7), 'tok,', String(catMsgs[k]).padStart(3), 'msgs'); +} +console.log('VIEW TOTAL (excl. system prompt):', total); + +// active blocks summary mass detail +const active = state.blocks.filter(b => b.active); +const sumTok = active.map(b => est(b.summary || '')).sort((a, b) => b - a); +console.log('active blocks:', active.length, 'summary est (CJK-aware):', sumTok.reduce((a, b) => a + b, 0), 'per-block:', sumTok.join(',')); diff --git a/tmp/ctx2.mjs b/tmp/ctx2.mjs new file mode 100644 index 0000000..a095fd9 --- /dev/null +++ b/tmp/ctx2.mjs @@ -0,0 +1,33 @@ +import fs from 'node:fs'; +import { createCore, defaultConfig } from 'acp-kernel'; +import { entriesToCoreMessages } from '../src/messages.ts'; +const jsonl = '/home/dog/.pi/agent/sessions/--home-dog-projects-billion-context-paper--/2026-09-07T09-39-28-665Z_01a07b3c-ab19-7b19-8290-f7967a8221a6.jsonl'; +const raw = fs.readFileSync(jsonl,'utf8').split('\n').filter(Boolean).map(l=>JSON.parse(l)); +const entries = raw.filter(r=>r.type==='message'); +const state = JSON.parse(fs.readFileSync(jsonl+'.acp.json','utf8')); +const core = createCore({countTokens:null}); +const turn = await core.processTurn({messages: entriesToCoreMessages(entries), state, config: defaultConfig(262144,{limit:212992}), tokenCount:180000}); +function est(t){ if(!t) return 0; let cjk=0,other=0; for(const ch of t){const c=ch.codePointAt(0); if((c>=0x4E00&&c<=0x9FFF)||(c>=0x3000&&c<=0x30FF)||(c>=0xFF00&&c<=0xFFEF)) cjk++; else other++;} return Math.round(cjk+other/4); } +const TAGRE=/^]*>/; +const cats={}; const tag=()=>{let t=0;return (x)=>{t+=x;return t;}}; let tagTotal=0; +const add=(k,tok,msg)=>{ if(!cats[k]) cats[k]=[0,0]; cats[k][0]+=tok; cats[k][1]+=msg; }; +for (const m of turn.messages) { + let text=m.text||''; + let tagTok=0; + const tm=text.match(TAGRE); if(tm){ tagTok=est(tm[0]); text=text.slice(tm[0].length); } + tagTotal+=tagTok; + const body=est(text); + if(m.role==='system'){ add(String(m.id).startsWith('acp_summary')?'summary(压缩摘要)':'nudge(注入)', body,1); } + else if(m.role==='user') add('userText', body,1); + else if(m.role==='tool') add(m.toolName==='compress'?'anchorResult(compress结果)':'toolResult(工具输出)', body,1); + else if(m.role==='assistant') add(m.contentType==='tool-call' ? (m.toolName==='compress'?'anchorCall(compress调用)':'toolCall(其他调用)') : 'asstText(助手文本)', body,1); + else add('other',body,1); +} +console.log('view messages:', turn.messages.length); +let tot=tagTotal; +for(const [k,[t,n]] of Object.entries(cats)){ tot+=t; console.log(k.padEnd(24), String(t).padStart(6),'tok', String(n).padStart(3),'msgs'); } +console.log('acp标签(全部消息)', String(Math.round(tagTotal)).padStart(6),'tok'); +console.log('VIEW TOTAL:', tot, 'tok (CJK-aware, 不含系统提示词)'); +// per-message top10 +const tops=turn.messages.map(m=>({id:m.id,n:m.toolName||m.role,t:est((m.text||'').replace(TAGRE,''))})).sort((a,b)=>b.t-a.t).slice(0,12); +console.log('TOP12:', tops.map(x=>`${x.id.slice(0,12)}(${x.n})=${x.t}`).join(' ')); diff --git a/tmp/ctx3.mjs b/tmp/ctx3.mjs new file mode 100644 index 0000000..479e3ff --- /dev/null +++ b/tmp/ctx3.mjs @@ -0,0 +1,29 @@ +import fs from 'node:fs'; +import { createCore, defaultConfig } from 'acp-kernel'; +import { entriesToCoreMessages, coreOutToAgentMessages, collectOriginals } from '../src/messages.ts'; +const jsonl = '/home/dog/.pi/agent/sessions/--home-dog-projects-billion-context-paper--/2026-09-07T09-39-28-665Z_01a07b3c-ab19-7b19-8290-f7967a8221a6.jsonl'; +const raw = fs.readFileSync(jsonl,'utf8').split('\n').filter(Boolean).map(l=>JSON.parse(l)); +const entries = raw.filter(r=>r.type==='message'); +const state = JSON.parse(fs.readFileSync(jsonl+'.acp.json','utf8')); +const core = createCore({countTokens:null}); +const turn = await core.processTurn({messages: entriesToCoreMessages(entries), state, config: defaultConfig(262144,{limit:212992}), tokenCount:167969}); +const rebuilt = coreOutToAgentMessages(turn.messages, collectOriginals(entries)); +function est(t){ if(!t) return 0; let cjk=0,other=0; for(const ch of t){const c=ch.codePointAt(0); if((c>=0x4E00&&c<=0x9FFF)||(c>=0x3000&&c<=0x30FF)||(c>=0xFF00&&c<=0xFFEF)) cjk++; else other++;} return Math.round(cjk+other/4); } +let thinkTok=0, thinkMsgs=0, otherTok=0; +for (const m of rebuilt) { + const c = m.content; + if (Array.isArray(c)) for (const p of c) { + if (p.type === 'thinking') { thinkTok += est(p.text||''); thinkMsgs++; } + else if (p.type === 'text') otherTok += est(p.text||''); + else if (p.type === 'tool_call') otherTok += est(JSON.stringify(p.args??'')+p.name); + } else if (typeof c === 'string') otherTok += est(c); + else if (c) otherTok += est(JSON.stringify(c)); +} +console.log('rebuilt messages:', rebuilt.length); +console.log('thinking parts:', thinkMsgs, 'thinking tokens:', thinkTok); +console.log('non-thinking tokens:', otherTok); +// biggest thinking blocks in view +const t2=[]; +for (const m of rebuilt) { const c=m.content; if(Array.isArray(c)) for(const p of c) if(p.type==='thinking') t2.push([est(p.text||''), String(m.id).slice(0,10)]); } +t2.sort((a,b)=>b[0]-a[0]); +console.log('top thinkings:', t2.slice(0,8).map(x=>x[1]+'='+x[0]).join(' ')); diff --git a/tmp/ctx4.mjs b/tmp/ctx4.mjs new file mode 100644 index 0000000..08d0390 --- /dev/null +++ b/tmp/ctx4.mjs @@ -0,0 +1,30 @@ +import fs from 'node:fs'; +import { createCore, defaultConfig } from 'acp-kernel'; +import { entriesToCoreMessages, coreOutToAgentMessages } from '../src/messages.ts'; +const jsonl = '/home/dog/.pi/agent/sessions/--home-dog-projects-billion-context-paper--/2026-09-07T09-39-28-665Z_01a07b3c-ab19-7b19-8290-f7967a8221a6.jsonl'; +const raw = fs.readFileSync(jsonl,'utf8').split('\n').filter(Boolean).map(l=>JSON.parse(l)); +const entries = raw.filter(r=>r.type==='message'); +const state = JSON.parse(fs.readFileSync(jsonl+'.acp.json','utf8')); +const core = createCore({countTokens:null}); +const turn = await core.processTurn({messages: entriesToCoreMessages(entries), state, config: defaultConfig(262144,{limit:212992}), tokenCount:167969}); +const byId = new Map(entries.map(e=>[e.id, e.message])); +const rebuilt = coreOutToAgentMessages(turn.messages, byId); +function est(t){ if(!t) return 0; let cjk=0,other=0; for(const ch of t){const c=ch.codePointAt(0); if((c>=0x4E00&&c<=0x9FFF)||(c>=0x3000&&c<=0x30FF)||(c>=0xFF00&&c<=0xFFEF)) cjk++; else other++;} return Math.round(cjk+other/4); } +let thinkTok=0, thinkMsgs=0, otherTok=0, otherDetail={}; +for (const m of rebuilt) { + const c = m.content; + if (Array.isArray(c)) for (const p of c) { + if (p.type === 'thinking') { thinkTok += est(p.thinking||p.text||''); thinkMsgs++; } + else if (p.type === 'text') { otherTok += est(p.text||''); otherDetail['text-'+m.role]=(otherDetail['text-'+m.role]||0)+est(p.text||''); } + else if (p.type === 'toolCall') { const t=est(JSON.stringify(p.args??'')+p.name); otherTok+=t; otherDetail['call-'+(p.name==='compress'?'COMPRESS':p.name)]=(otherDetail['call-'+(p.name==='compress'?'COMPRESS':p.name)]||0)+t; } + else { const t=est(JSON.stringify(p)); otherTok+=t; otherDetail['other-block']=(otherDetail['other-block']||0)+t; } + } else if (typeof c === 'string') { otherTok += est(c); otherDetail['str-'+m.role]=(otherDetail['str-'+m.role]||0)+est(c); } + else if (c) { const t=est(JSON.stringify(c)); otherTok+=t; otherDetail['json-'+m.role]=(otherDetail['json-'+m.role]||0)+t; } +} +console.log('rebuilt:', rebuilt.length, 'msgs'); +console.log('THINKING:', thinkMsgs, 'parts =', thinkTok, 'tok <<< pi 会原样发给模型'); +console.log('non-thinking:', otherTok, 'tok; detail:', JSON.stringify(otherDetail)); +const t2=[]; +for (const m of rebuilt) { const c=m.content; if(Array.isArray(c)) for(const p of c) if(p.type==='thinking') t2.push([est(p.thinking||p.text||''), (m.role||'?')+'#'+rebuilt.indexOf(m)]); } +t2.sort((a,b)=>b[0]-a[0]); +console.log('top thinking:', t2.slice(0,10).map(x=>x[1]+'='+x[0]).join(' ')); diff --git a/tmp/ctx5.mjs b/tmp/ctx5.mjs new file mode 100644 index 0000000..3e850b8 --- /dev/null +++ b/tmp/ctx5.mjs @@ -0,0 +1,31 @@ +import fs from 'node:fs'; +import { createCore, defaultConfig } from 'acp-kernel'; +import { entriesToCoreMessages, coreOutToAgentMessages } from '../src/messages.ts'; +const jsonl = '/home/dog/.pi/agent/sessions/--home-dog-projects-billion-context-paper--/2026-09-07T09-39-28-665Z_01a07b3c-ab19-7b19-8290-f7967a8221a6.jsonl'; +const raw = fs.readFileSync(jsonl,'utf8').split('\n').filter(Boolean).map(l=>JSON.parse(l)); +const entries = raw.filter(r=>r.type==='message'); +const state = JSON.parse(fs.readFileSync(jsonl+'.acp.json','utf8')); +const core = createCore({countTokens:null}); +const turn = await core.processTurn({messages: entriesToCoreMessages(entries), state, config: defaultConfig(262144,{limit:212992}), tokenCount:167969}); +const byId = new Map(entries.map(e=>[e.id, e.message])); +const rebuilt = coreOutToAgentMessages(turn.messages, byId); +function est(t){ if(!t) return 0; let cjk=0,other=0; for(const ch of t){const c=ch.codePointAt(0); if((c>=0x4E00&&c<=0x9FFF)||(c>=0x3000&&c<=0x30FF)||(c>=0xFF00&&c<=0xFFEF)) cjk++; else other++;} return Math.round(cjk+other/4); } +let anchorThink=0, anchorMsgs=0, recentThink=0, recentMsgs=0, otherThink=0; +const rows=[]; +rebuilt.forEach((m,i)=>{ + if(!Array.isArray(m.content)) return; + const th=m.content.filter(p=>p.type==='thinking'); + if(!th.length) return; + const t=th.reduce((a,p)=>a+est(p.thinking||p.text||''),0); + const hasCompress=m.content.some(p=>p.type==='toolCall'&&p.name==='compress'); + rows.push([i, m.role, hasCompress?'ANCHOR':'plain', t, th.length]); + if(hasCompress){anchorThink+=t;anchorMsgs++;} else {recentThink+=t;recentMsgs++;} +}); +console.log('带thinking的可见assistant消息:'); +for(const r of rows) console.log(` #${String(r[0]).padStart(2)} ${r[2].padEnd(6)} thinking=${String(r[3]).padStart(6)} tok (${r[4]}块)`); +console.log(`锚点(compress调用)消息: ${anchorMsgs}条, thinking ${anchorThink} tok`); +console.log(`非锚点消息: ${recentMsgs}条, thinking ${recentThink} tok`); +console.log('合计:', anchorThink+recentThink); +// 时间分布: 这些消息在会话里的位置 +const poss=rows.map(r=>r[0]); +console.log('消息序号位置:', poss.join(',')); diff --git a/tmp/ctx6.mjs b/tmp/ctx6.mjs new file mode 100644 index 0000000..907ed76 --- /dev/null +++ b/tmp/ctx6.mjs @@ -0,0 +1,29 @@ +import fs from 'node:fs'; +import { createCore, createInitialState } from 'acp-kernel'; +import { entriesToCoreMessages, coreOutToAgentMessages } from '../src/messages.ts'; +import { resolveConfig } from '../src/config.ts'; +const jsonl = '/home/dog/.pi/agent/sessions/--home-dog-projects-billion-context-paper--/2026-09-07T09-39-28-665Z_01a07b3c-ab19-7b19-8290-f7967a8221a6.jsonl'; +const raw = fs.readFileSync(jsonl,'utf8').split('\n').filter(Boolean).map(l=>JSON.parse(l)); +const entries = raw.filter(r=>r.type==='message'); +const state = JSON.parse(fs.readFileSync(jsonl+'.acp.json','utf8')); +function est(t){ if(!t) return 0; let cjk=0,other=0; for(const ch of t){const c=ch.codePointAt(0); if((c>=0x4E00&&c<=0x9FFF)||(c>=0x3000&&c<=0x30FF)||(c>=0xFF00&&c<=0xFFEF)) cjk++; else other++;} return Math.round(cjk+other/4); } +const config = resolveConfig({}, 212992); +for (const policy of ['always','open-round']) { + const core = createCore({ countTokens: (t)=>Math.ceil(t.length/4) }); + const cfg = { ...config, reasoningReplay: policy }; + const turn = await core.processTurn({ messages: entriesToCoreMessages(entries), state, config: cfg, tokenCount: 167969 }); + const byId = new Map(entries.map(e=>[e.id, e.message])); + const rebuilt = coreOutToAgentMessages(turn.messages, byId); + let thinkTok=0, otherTok=0, anchorStub=0; + for (const m of rebuilt) { + const c = m.content; + if (Array.isArray(c)) for (const p of c) { + if (p.type === 'thinking') thinkTok += est(p.thinking||p.text||''); + else if (p.type === 'text') otherTok += est(p.text||''); + else if (p.type === 'toolCall') { const t=est(typeof p.arguments==='string'?p.arguments:JSON.stringify(p.arguments??'')); otherTok+=t; if(m.role==='assistant'&&(p.name==='compress')) anchorStub+=t; } + } else if (typeof c === 'string') otherTok += est(c); + } + // summaries + let sumTok=0; for (const m of turn.messages) if (String(m.id).startsWith('acp_summary')) sumTok += est(m.text||''); + console.log(`policy=${policy.padEnd(10)} rebuilt=${rebuilt.length}条 thinking=${thinkTok} 非thinking=${otherTok}(其中compress调用args=${anchorStub}) 摘要=${sumTok} 视图合计≈${thinkTok+otherTok+sumTok}`); +} diff --git a/tmp/inspect.mjs b/tmp/inspect.mjs new file mode 100644 index 0000000..a93cb3a --- /dev/null +++ b/tmp/inspect.mjs @@ -0,0 +1,20 @@ +import fs from 'node:fs'; +import { createCore, defaultConfig } from 'acp-kernel'; +import { entriesToCoreMessages } from '../src/messages.ts'; +const jsonl = '/home/dog/.pi/agent/sessions/--home-dog-projects-billion-context-paper--/2026-09-07T09-39-28-665Z_01a07b3c-ab19-7b19-8290-f7967a8221a6.jsonl'; +const raw = fs.readFileSync(jsonl,'utf8').split('\n').filter(Boolean).map(l=>JSON.parse(l)); +const entries = raw.filter(r=>r.type==='message'); +const state = JSON.parse(fs.readFileSync(jsonl+'.acp.json','utf8')); +const core = createCore({countTokens:null}); +const turn = await core.processTurn({messages: entriesToCoreMessages(entries), state, config: defaultConfig(262144,{limit:212992}), tokenCount:180000}); +const ms = turn.messages; +console.log('count', ms.length); +const shapes = {}; +for (const m of ms) { const k = m.role+':'+Object.keys(m).join('+'); shapes[k]=(shapes[k]||0)+1; } +console.log(shapes); +for (const role of ['system','user','assistant']) { + const m = ms.find(x=>x.role===role); + if (m) console.log('SAMPLE', role, JSON.stringify(m).slice(0,400)); +} +const sm = ms.filter(m=>String(m.id||'').startsWith('acp_summary')); +console.log('summaries in view:', sm.length, sm.slice(0,2).map(m=>JSON.stringify(m).slice(0,200))); diff --git a/tmp/repro-final.mjs b/tmp/repro-final.mjs new file mode 100644 index 0000000..98d1763 --- /dev/null +++ b/tmp/repro-final.mjs @@ -0,0 +1,34 @@ +import fs from 'node:fs'; +import { createCore, defaultConfig } from 'acp-kernel'; +import { entriesToCoreMessages, coreOutToAgentMessages } from '../src/messages.ts'; + +const jsonl = '/home/dog/.pi/agent/sessions/--home-dog-projects-billion-context-paper--/2026-09-07T09-39-28-665Z_01a07b3c-ab19-7b19-8290-f7967a8221a6.jsonl'; +const raw = fs.readFileSync(jsonl, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)); +const entries = raw.filter(r => r.type === 'message'); +const coreMessages = entriesToCoreMessages(entries); +const state = JSON.parse(fs.readFileSync(jsonl + '.acp.json', 'utf8')); +const core = createCore({ countTokens: null }); +const config = defaultConfig(262144, { limit: 212992 }); +const turn = await core.processTurn({ messages: coreMessages, state, config, tokenCount: 117661 }); + +// find all compress-related messages surviving in kernel output +const keep = turn.messages.filter(m => { + const s = JSON.stringify(m); + return s.includes('"compress"') || s.includes('Requested range') || s.includes('41.4K') || s.includes('PAUSED') || s.includes('REJECTED'); +}); +console.log('kernel-kept compress-related msgs:', keep.length, 'of', turn.messages.length); +for (const m of keep) console.log(' ', m.id, m.role, (m.text || '').slice(0, 100).replace(/\n/g, ' ')); + +const originals = new Map(); for (const e of entries) { if (e.message) originals.set(e.id, e.message); } +const rebuilt = coreOutToAgentMessages(turn.messages, originals); +const rk = rebuilt.filter(m => { const s = JSON.stringify(m); return s.includes('Requested range') || s.includes('41.4K') || s.includes('PAUSED') || s.includes('REJECTED'); }); +console.log('rebuilt compress-related msgs:', rk.length, 'of', rebuilt.length); +for (const m of rk) console.log(' ', m.role, JSON.stringify(m.content?.[0]?.text || '').slice(0, 100)); + +// count assistant turns whose thinking mentions rejection keywords +let seen = 0; +for (const e of entries) { + const th = e.message?.content?.find?.(b => b.type === 'thinking'); + if (th && /REJECTED|already compressed|Requested range/.test(th.thinking || '')) seen++; +} +console.log('assistant thinkings mentioning rejection feedback:', seen); diff --git a/tmp/repro-loop.mjs b/tmp/repro-loop.mjs new file mode 100644 index 0000000..59de60c --- /dev/null +++ b/tmp/repro-loop.mjs @@ -0,0 +1,78 @@ +import fs from 'node:fs'; +import { createCore } from 'acp-kernel'; +import { entriesToCoreMessages, coreOutToAgentMessages } from '../src/messages.ts'; + +const sid = '01a07b3c-ab19-7b19-8290-f7967a8221a6'; +const jsonl = '/home/dog/.pi/agent/sessions/--home-dog-projects-billion-context-paper--/2026-09-07T09-39-28-665Z_' + sid + '.jsonl'; +const sidecar = jsonl + '.acp.json'; + +const raw = fs.readFileSync(jsonl, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)); +// Simulate the 01:25:38 turn: everything up to and including the failed retry #1 (lines 1..582) +const entries = raw.slice(0, 582).filter(r => r.type === 'message'); +console.log('total entries:', entries.length, 'raw records kept:', 582); + +const coreMessages = entriesToCoreMessages(entries); +console.log('coreMessages:', coreMessages.length); + +const parsed = JSON.parse(fs.readFileSync(sidecar, 'utf8')); +const state = parsed; +console.log('blocks:', state.blocks.length, 'active:', state.blocks.filter(b => b.active).length); + +import { defaultConfig } from 'acp-kernel'; const config = defaultConfig(262144, { limit: 212992 }); +const core = createCore({ countTokens: null }); const turn = await core.processTurn({ messages: coreMessages, state, config, tokenCount: 117661 }); +console.log('turn.outMessages:', turn.messages.length); + +// entry ids of interest: find compress tool calls + results in the tail +const tail = entries.slice(-10); +for (const e of tail) { + const s = JSON.stringify(e.message?.content ?? '').slice(0, 120).replace(/"/g, "'"); + console.log('TAIL', e.id, e.message.role, s); +} + +// which of the last 6 entries survive kernel output? +const coreIds = new Set(turn.messages.map(m => m.id)); +for (const e of tail.slice(-6)) { + const base = e.id; + const hitUser = coreIds.has(base); + const parts = turn.messages.filter(m => m.id && m.id.startsWith(base + '#')).map(m => m.id.split('#')[1]); + console.log('SURVIVES-KERNEL', base, e.message.role, hitUser || (parts.length ? parts.join(',') : 'NO')); +} + +// adapter reconstruction +const originals = new Map(); for (const e of entries) { if (e.message) originals.set(e.id, e.message); } +const rebuilt = coreOutToAgentMessages(turn.messages, originals); +const rebuiltIds = new Set(rebuilt.map(m => m.id)); +for (const e of tail.slice(-6)) { + console.log('SURVIVES-ADAPTER', e.id, rebuiltIds.has(e.id) ? 'YES' : 'NO'); +} + +// WHY did adapter drop them? Inspect the core ids the kernel emitted for these entries +console.log('\n--- core ids around tail ---'); +for (const m of turn.messages.slice(-12)) { + console.log(JSON.stringify({ id: m.id, role: m.role, toolCallId: m.toolCallId, text: (m.text || '').slice(0, 60) })); +} +console.log('\noriginals has keys?', originals.has('38662d11'), originals.has('261939c7'), originals.has('408ada78')); + +console.log('\n--- rebuilt content scan ---'); +let foundCallA = 0, foundResultA = 0, foundCallB = 0; +for (const m of rebuilt) { + const s = JSON.stringify(m); + if (s.includes('call_7447fa18e4ad4597bd93359e')) { + if (m.role === 'assistant') foundCallA++; + if (m.role === 'user' || m.role === 'tool') foundResultA++; + } + if (s.includes('call_719565ce69a24e208c058a3a')) foundCallB++; +} +console.log({ foundCallA, foundResultA, foundCallB, rebuiltTotal: rebuilt.length }); +const idx = rebuilt.findIndex(m => JSON.stringify(m).includes('call_7447fa18e4ad4597bd93359e')); +console.log('first compress-call msg idx in rebuilt:', idx, '/', rebuilt.length); +if (idx >= 0) console.log('content blocks:', JSON.stringify(rebuilt[idx].content).slice(0, 400)); + +console.log('\n--- result scan by text ---'); +for (const m of rebuilt) { + const s = JSON.stringify(m); + if (s.includes('41.4K') || s.includes('Requested range')) { + console.log('RESULT-FOUND role=', m.role, 'content preview:', s.slice(0, 300)); + } +} +console.log('rebuilt tail roles:', rebuilt.slice(-6).map(m => m.role + ':' + JSON.stringify(m.content?.[0]?.text || m.content?.[0]?.thinking || m.content?.[0]?.type || '').slice(0, 50))); From ed5efc1677b53e468dec6416490a87f7f1eaac28 Mon Sep 17 00:00:00 2001 From: awork Date: Wed, 9 Sep 2026 10:39:24 +0800 Subject: [PATCH 4/4] Move reasoningReplay under the compress three-level config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CompressSettings now carries reasoningReplay, resolved through the existing global → providers. → models. deepest-wins merge, so the GPT-family escape hatch is a plain per-provider setting: { "compress": { "providers": { "openai": { "reasoningReplay": "always" } } } } Precedence: compress (any level) > coreOverrides.reasoningReplay > family default. --- src/config.ts | 21 ++++++++++++--------- tests/messages-reasoning.test.ts | 20 +++++++++++++++++--- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/src/config.ts b/src/config.ts index d490c10..6838f9e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -129,6 +129,15 @@ export interface CompressSettings { * window and would suppress every nudge. Maps to kernel * nudge.minPressureBenefitTokens. */ minPressureBenefitTokens?: number; + /** Reasoning-block replay policy (kernel reasoningReplay), available at all + * three levels (global → provider → model). Default: "open-round" — + * history thinking is stripped from the outgoing view once its round + * closes; providers only require replaying thinking for the current + * unresolved round. GPT-family providers/models default to "always" + * (legacy keep-everything — OpenAI reasoning items are encrypted and + * must round-trip unmodified, untestable from here). Set "always"/"never" + * at any level to override either default. */ + reasoningReplay?: "always" | "open-round" | "never"; } /** Per-provider compression overrides. Carries the same tuning fields as the @@ -188,14 +197,6 @@ export interface AdapterConfig { * Set explicitly for tests/headless runs. */ modelContextLimit?: number; protectedTools?: string[]; - /** Reasoning-block replay policy (kernel reasoningReplay). Default: - * "open-round" — history thinking is stripped from the outgoing view once - * its round closes; providers only require replaying thinking for the - * current unresolved round. GPT-family providers/models default to - * "always" (legacy keep-everything — OpenAI reasoning items are opaque - * and untestable from here). Set "always"/"never" explicitly to override - * either default; coreOverrides wins. Kill-switch for #336. */ - reasoningReplay?: "always" | "open-round" | "never"; preserveRecentMessages?: number; /** Check npm for a newer billion-context-pi on startup and auto-install it. Default: true. * Disable via `autoUpdate: false` or env `ACP_AUTO_UPDATE=0` to avoid all @@ -366,6 +367,7 @@ export function mergeCompress( emergencyThresholdPercent: model?.emergencyThresholdPercent ?? provider?.emergencyThresholdPercent ?? global?.emergencyThresholdPercent, nudgeGrowthTokens: model?.nudgeGrowthTokens ?? provider?.nudgeGrowthTokens ?? global?.nudgeGrowthTokens, minPressureBenefitTokens: model?.minPressureBenefitTokens ?? provider?.minPressureBenefitTokens ?? global?.minPressureBenefitTokens, + reasoningReplay: model?.reasoningReplay ?? provider?.reasoningReplay ?? global?.reasoningReplay, }; } @@ -402,8 +404,9 @@ export function resolveConfig(adapter: AdapterConfig, liveContextLimit: number, preserveRecentMessages: adapter.preserveRecentMessages ?? 5, ...adapter.coreOverrides, }); - config.reasoningReplay = adapter.coreOverrides?.reasoningReplay ?? adapter.reasoningReplay ?? defaultReasoningReplay(provider, modelId); + config.reasoningReplay = adapter.coreOverrides?.reasoningReplay ?? defaultReasoningReplay(provider, modelId); const c = resolveCompress(adapter.compress, provider, modelId); + if (c.reasoningReplay !== undefined) config.reasoningReplay = c.reasoningReplay; if (c.maxContextLimit !== undefined) config.nudge.maxContextLimitPct = parsePercent(c.maxContextLimit); if (c.emergencyThresholdPercent !== undefined) { const pct = parsePercent(c.emergencyThresholdPercent); diff --git a/tests/messages-reasoning.test.ts b/tests/messages-reasoning.test.ts index 194733d..da0191d 100644 --- a/tests/messages-reasoning.test.ts +++ b/tests/messages-reasoning.test.ts @@ -102,10 +102,24 @@ test("reasoning core text is never inlined into the rebuilt text block", () => { test("resolveConfig defaults reasoningReplay to open-round and honors overrides", () => { const def = resolveConfig({}, 262144); assert.equal(def.reasoningReplay, "open-round"); - const off = resolveConfig({ reasoningReplay: "always" }, 262144); + const off = resolveConfig({ compress: { reasoningReplay: "always" } }, 262144); assert.equal(off.reasoningReplay, "always"); const viaCore = resolveConfig({ coreOverrides: { reasoningReplay: "never" } }, 262144); assert.equal(viaCore.reasoningReplay, "never"); + const viaProvider = resolveConfig( + { compress: { providers: { openai: { reasoningReplay: "open-round" } } } }, + 262144, + "openai", + "gpt-5", + ); + assert.equal(viaProvider.reasoningReplay, "open-round"); + const viaModel = resolveConfig( + { compress: { reasoningReplay: "always", providers: { openai: { models: { "gpt-5": { reasoningReplay: "never" } } } } } }, + 262144, + "openai", + "gpt-5", + ); + assert.equal(viaModel.reasoningReplay, "never"); }); test("reasoningReplay defaults conservatively for GPT-family provider/model", () => { @@ -118,6 +132,6 @@ test("reasoningReplay defaults conservatively for GPT-family provider/model", () }); test("explicit reasoningReplay beats the GPT-family default", () => { - assert.equal(resolveConfig({ reasoningReplay: "open-round" }, 262144, "openai", "gpt-5").reasoningReplay, "open-round"); - assert.equal(resolveConfig({ reasoningReplay: "always" }, 262144, "zhipuai-lb", "glm-5.3").reasoningReplay, "always"); + assert.equal(resolveConfig({ compress: { reasoningReplay: "open-round" } }, 262144, "openai", "gpt-5").reasoningReplay, "open-round"); + assert.equal(resolveConfig({ compress: { reasoningReplay: "always" } }, 262144, "zhipuai-lb", "glm-5.3").reasoningReplay, "always"); });