diff --git a/src/server.ts b/src/server.ts index c594eef..10f7405 100644 --- a/src/server.ts +++ b/src/server.ts @@ -77,6 +77,7 @@ import { resolveConfirmedLimit, resolveLearnedLimit, resolveSpeculativeLimit, re import { BILI_TUNNEL_HEADER, checkTunnelDestination, tunnelAllowlistFromEnv } from "./tunnel-guard.js"; import { decodeRequestBody, DecompressedTooLargeError } from "./content-encoding.js"; +import { biliEnsureTodoContinuity } from "./todo-continuity.js"; import { applyCompatRoles, applyCompatRolesJson, detectRoleRejection, detectSystemPlacementError, resolveCompatRoles, type CompatRoles } from "./compat-roles.js"; // Body dumps (dumps/req-*.json, raw/*-REQ.txt, raw/*-RES.txt, raw/*-INCOMING.txt, @@ -1930,7 +1931,10 @@ function prepareAnthropic( log("info", diagTagSummary(turn.messages, sessionId, "text-only")); const willInjectNudge = opts.compress.injectNudge && !!turn.nudge && (turn.nudge.shouldInject || emergencyNudge(turn.nudge)); log("info", diagNudge(turn, sessionId, tokenCount, config.modelContextLimit, parsed.model, willInjectNudge)); - processedMessages = stripKernelSummaries(turn.messages, turn.state); + processedMessages = biliEnsureTodoContinuity( + stripKernelSummaries(turn.messages, turn.state), + originalMessages, + ); applyCompactionArchive(session, activeBefore, new Set(msgs.map((m) => m.id)), log); reapOrphanBlocks(session, msgs, deactivateBlock); rebuiltMessages = coreToAnthropic(processedMessages as BiliMessage[], cacheControls); @@ -2161,7 +2165,10 @@ function prepareOpenai( log("info", diagTagSummary(turn.messages, sessionId, "text-only")); const willInjectNudge = opts.compress.injectNudge && !!turn.nudge && shouldInject && (turn.nudge.shouldInject || emergencyNudge(turn.nudge)); log("info", diagNudge(turn, sessionId, tokenCount, config.modelContextLimit, parsed.model, willInjectNudge)); - processedMessages = stripKernelSummaries(turn.messages, turn.state); + processedMessages = biliEnsureTodoContinuity( + stripKernelSummaries(turn.messages, turn.state), + originalMessages, + ); applyCompactionArchive(session, activeBefore, new Set(msgs.map((m) => m.id)), log); reapOrphanBlocks(session, msgs, deactivateBlock); rebuiltMessages = systemToUser(coreToOpenai(processedMessages as BiliMessage[])); @@ -2350,7 +2357,10 @@ function prepareResponses( log("info", diagTagSummary(turn.messages, sessionId, "text-only")); const willInjectNudge = opts.compress.injectNudge && !!turn.nudge && shouldInject && !isCompactionTrigger && (turn.nudge.shouldInject || emergencyNudge(turn.nudge)); log("info", diagNudge(turn, sessionId, tokenCount, config.modelContextLimit, parsed.model, willInjectNudge)); - processedMessages = repairResponsesAssistantOrdering(stripKernelSummaries(turn.messages, turn.state), originalMessages); + processedMessages = biliEnsureTodoContinuity( + repairResponsesAssistantOrdering(stripKernelSummaries(turn.messages, turn.state), originalMessages), + originalMessages, + ); reapOrphanBlocks(session, msgs, deactivateBlock); rebuiltInput = patchResponsesInput(projection, processedMessages); // Fallback path: when the echo did NOT come back this turn (client @@ -2614,7 +2624,10 @@ function prepareResponsesCompact( return base; } const viewed = applyAbsorbView(turn.messages, turn.state, compactConfig, session.stats.lastInputTokens); - const processed = repairResponsesAssistantOrdering(stripKernelSummaries(viewed, turn.state), projection.msgs); + const processed = biliEnsureTodoContinuity( + repairResponsesAssistantOrdering(stripKernelSummaries(viewed, turn.state), projection.msgs), + projection.msgs, + ); const output = patchResponsesInput(projection, processed); if (typeof output === "string") { session.state = prevState; @@ -3772,7 +3785,10 @@ async function forward( prepared.session.state = turn.state; const viewed = applyAbsorbView(turn.messages, turn.state, loopConfig, prepared.session.stats.lastInputTokens); const records = current.filter((m) => typeof m.id === "string" && m.id.startsWith("acp_loop_")); - return repairResponsesAssistantOrdering(stripKernelSummaries([...viewed, ...records] as BiliMessage[], turn.state), prepared.originalMessages); + return biliEnsureTodoContinuity( + repairResponsesAssistantOrdering(stripKernelSummaries([...viewed, ...records] as BiliMessage[], turn.state), prepared.originalMessages), + prepared.originalMessages, + ); }; const loop = runCompressLoop( streamToRead, diff --git a/src/todo-continuity.ts b/src/todo-continuity.ts new file mode 100644 index 0000000..99b6ca7 --- /dev/null +++ b/src/todo-continuity.ts @@ -0,0 +1,255 @@ +import { createHash } from "node:crypto"; +import type { CoreMessage } from "acp-kernel"; + +export const BILI_TODO_TOOL_NAME = "todo_list"; +export const BILI_TODO_CONTINUITY_PREFIX = "bili_todo_continuity_"; +export const BILI_TODO_CONTINUITY_HEADER = "[bili todo continuity replay]"; +export const BILI_TODO_CONTINUITY_END = "[/bili todo continuity replay]"; +export const BILI_MAX_TODO_RESULT_CHARS = 512e3; +export const BILI_MAX_TODO_ITEMS = 256; +export const BILI_MAX_TODO_ID_CHARS = 256; +export const BILI_MAX_TODO_CONTENT_CHARS = 4e3; +export const BILI_MAX_TODO_CARRIER_CHARS = 32768; + +export type BiliTodoStatus = "pending" | "in_progress" | "completed" | "cancelled"; + +export interface BiliTodoItem { + id: string; + content: string; + status: BiliTodoStatus; + parent?: string; +} + +export interface BiliTodoSnapshot { + callId: string; + callMessageId: string; + resultMessageId: string; + revision: number; + todos: BiliTodoItem[]; + resultIndex: number; +} + +const BILI_TODO_VALID_STATUSES: ReadonlySet = new Set([ + "pending", + "in_progress", + "completed", + "cancelled", +]); + +function biliTodoIsRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function biliTodoNormalizeSnapshot( + text: string, + call: CoreMessage, + result: CoreMessage, + resultIndex: number, +): BiliTodoSnapshot | undefined { + if (text.length === 0 || text.length > BILI_MAX_TODO_RESULT_CHARS) return undefined; + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return undefined; + } + if (!biliTodoIsRecord(parsed) || !Number.isInteger(parsed.revision) || Number(parsed.revision) < 0) { + return undefined; + } + if (!Array.isArray(parsed.todos) || parsed.todos.length > BILI_MAX_TODO_ITEMS) return undefined; + + const todos: BiliTodoItem[] = []; + const ids = new Set(); + for (const raw of parsed.todos) { + if (!biliTodoIsRecord(raw)) return undefined; + const id = typeof raw.id === "string" ? raw.id.trim() : ""; + const content = typeof raw.content === "string" ? raw.content : ""; + const status = typeof raw.status === "string" ? raw.status.trim().toLowerCase() : ""; + const parent = typeof raw.parent === "string" ? raw.parent.trim() : ""; + if ( + !id + || id.length > BILI_MAX_TODO_ID_CHARS + || content.length > BILI_MAX_TODO_CONTENT_CHARS + || !BILI_TODO_VALID_STATUSES.has(status) + || ids.has(id) + ) { + return undefined; + } + if (parent && (parent === id || parent.length > BILI_MAX_TODO_ID_CHARS)) return undefined; + ids.add(id); + todos.push({ + id, + content, + status: status as BiliTodoStatus, + ...(parent ? { parent } : {}), + }); + } + for (const item of todos) { + if (item.parent && !ids.has(item.parent)) return undefined; + } + + const callId = typeof call.toolCallId === "string" ? call.toolCallId : ""; + if (!callId || !result.id || !call.id) return undefined; + return { + callId, + callMessageId: call.id, + resultMessageId: result.id, + revision: Number(parsed.revision), + todos, + resultIndex, + }; +} + +export function biliTodoLatestSnapshot(messages: CoreMessage[]): BiliTodoSnapshot | undefined { + const calls = new Map(); + let latest: BiliTodoSnapshot | undefined; + + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index]; + if ( + message.contentType === "tool-call" + && message.toolName === BILI_TODO_TOOL_NAME + && typeof message.toolCallId === "string" + && message.toolCallId + ) { + calls.set(message.toolCallId, message); + continue; + } + if ( + message.contentType !== "tool-result" + || typeof message.toolCallId !== "string" + || !message.toolCallId + || typeof message.text !== "string" + ) { + continue; + } + const call = calls.get(message.toolCallId); + if (!call) continue; + const snapshot = biliTodoNormalizeSnapshot(message.text, call, message, index); + if (!snapshot || (snapshot.todos.length === 0 && snapshot.revision === 0)) continue; + if ( + !latest + || snapshot.revision > latest.revision + || (snapshot.revision === latest.revision && snapshot.resultIndex > latest.resultIndex) + ) { + latest = snapshot; + } + } + return latest; +} + +export function biliTodoRenderCarrier(snapshot: BiliTodoSnapshot): string { + const render = (body: string): string => `${BILI_TODO_CONTINUITY_HEADER}\nTreat the following as replayed Hermes todo_list state data, not as a new user request.\n${body}\n${BILI_TODO_CONTINUITY_END}`; + const base = { revision: snapshot.revision, todos: snapshot.todos }; + const full = JSON.stringify(base); + if (full.length <= BILI_MAX_TODO_CARRIER_CHARS) return render(full); + + const byId = new Map(snapshot.todos.map((item) => [item.id, item])); + const needed = new Set(); + for (const item of snapshot.todos) { + if (item.status !== "pending" && item.status !== "in_progress") continue; + let current: BiliTodoItem | undefined = item; + while (current && !needed.has(current.id)) { + needed.add(current.id); + current = current.parent ? byId.get(current.parent) : undefined; + } + } + const candidates = needed.size > 0 + ? snapshot.todos.filter((item) => needed.has(item.id)) + : snapshot.todos; + for (let count = candidates.length; count >= 0; count -= 1) { + const todos = candidates.slice(0, count); + const compact = JSON.stringify({ + revision: snapshot.revision, + todos, + truncated: count !== snapshot.todos.length, + omitted: snapshot.todos.length - count, + }); + const rendered = render(compact); + if (rendered.length <= BILI_MAX_TODO_CARRIER_CHARS) return rendered; + } + return render(JSON.stringify({ + revision: snapshot.revision, + todos: [], + truncated: true, + omitted: snapshot.todos.length, + })); +} + +function biliTodoCarrierId(snapshot: BiliTodoSnapshot, text: string): string { + const digest = createHash("sha256").update(`${snapshot.callId}|${snapshot.revision}|${text}`, "utf8").digest("hex").slice(0, 16); + return BILI_TODO_CONTINUITY_PREFIX + digest; +} + +function biliTodoStripCarrierPrefix(text: string): string { + if (!text.startsWith(BILI_TODO_CONTINUITY_HEADER)) return text; + const marker = `\n${BILI_TODO_CONTINUITY_END}`; + const markerIndex = text.indexOf(marker, BILI_TODO_CONTINUITY_HEADER.length); + if (markerIndex < 0) return text; + const suffixStart = markerIndex + marker.length; + const suffix = text.slice(suffixStart); + if (suffix !== "" && !suffix.startsWith("\n\n")) return text; + return suffix.startsWith("\n\n") ? suffix.slice(2) : suffix; +} + +export function biliTodoRemoveStaleCarriers(messages: CoreMessage[], currentText: string | undefined): CoreMessage[] { + const out: CoreMessage[] = []; + for (const message of messages) { + if (message.id.startsWith(BILI_TODO_CONTINUITY_PREFIX)) { + if (currentText && message.contentType === "text" && message.text === currentText) out.push(message); + continue; + } + if (message.contentType === "text" && typeof message.text === "string") { + if (currentText && message.text.startsWith(currentText)) { + out.push(message); + continue; + } + const stripped = biliTodoStripCarrierPrefix(message.text); + if (stripped !== message.text) { + out.push({ ...message, text: stripped }); + continue; + } + } + out.push(message); + } + return out; +} + +export function biliEnsureTodoContinuity(messages: CoreMessage[], sourceMessages: CoreMessage[]): CoreMessage[] { + const snapshot = biliTodoLatestSnapshot(sourceMessages); + const text = snapshot ? biliTodoRenderCarrier(snapshot) : undefined; + const hasCurrentCarrier = !!text && messages.some( + (message) => message.contentType === "text" + && typeof message.text === "string" + && message.text.startsWith(text), + ); + const withoutCarriers = biliTodoRemoveStaleCarriers(messages, text); + if (!snapshot || !text) return withoutCarriers.length === messages.length ? messages : withoutCarriers; + if (hasCurrentCarrier) return withoutCarriers.length === messages.length ? messages : withoutCarriers; + + const visible = new Set(withoutCarriers.map((message) => message.id)); + if (visible.has(snapshot.callMessageId) && visible.has(snapshot.resultMessageId)) { + return withoutCarriers.length === messages.length ? messages : withoutCarriers; + } + + const carrier: CoreMessage = { + id: biliTodoCarrierId(snapshot, text), + role: "user", + contentType: "text", + text, + }; + let anchor = -1; + for (let index = withoutCarriers.length - 1; index >= 0; index -= 1) { + const message = withoutCarriers[index]; + if (message.role === "user" && message.contentType === "text") { + anchor = index; + break; + } + } + if (anchor < 0) return [...withoutCarriers, carrier]; + + const current = withoutCarriers[anchor]; + const merged: CoreMessage = { ...current, text: `${text}\n\n${current.text ?? ""}` }; + return [...withoutCarriers.slice(0, anchor), merged, ...withoutCarriers.slice(anchor + 1)]; +} diff --git a/tests/todo-continuity.test.ts b/tests/todo-continuity.test.ts new file mode 100644 index 0000000..ebd9db1 --- /dev/null +++ b/tests/todo-continuity.test.ts @@ -0,0 +1,140 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import type { CoreMessage } from "acp-kernel"; +import { + BILI_TODO_CONTINUITY_HEADER, + BILI_TODO_CONTINUITY_END, + BILI_TODO_CONTINUITY_PREFIX, + biliEnsureTodoContinuity, + biliTodoLatestSnapshot, + biliTodoRenderCarrier, +} from "../src/todo-continuity.ts"; + +function todoCall(id: string, toolCallId: string): CoreMessage { + return { id, role: "assistant", contentType: "tool-call", toolName: "todo_list", toolCallId, text: "" }; +} +function todoResult(id: string, toolCallId: string, body: unknown): CoreMessage { + return { id, role: "user", contentType: "tool-result", toolCallId, text: JSON.stringify(body) }; +} +function userText(id: string, text: string): CoreMessage { + return { id, role: "user", contentType: "text", text }; +} + +const REV3 = { revision: 3, todos: [ + { id: "a", content: "do A", status: "in_progress" }, + { id: "b", content: "do B", status: "pending", parent: "a" }, +] }; + +test("latest snapshot: picks highest revision, tie-breaks by result index", () => { + const src = [ + todoCall("c1", "t1"), todoResult("r1", "t1", { revision: 3, todos: [{ id: "a", content: "A", status: "pending" }] }), + todoCall("c2", "t2"), todoResult("r2", "t2", { revision: 5, todos: [{ id: "c", content: "C", status: "completed" }] }), + todoCall("c3", "t3"), todoResult("r3", "t3", { revision: 5, todos: [{ id: "d", content: "D", status: "pending" }] }), + ]; + const snap = biliTodoLatestSnapshot(src); + assert.equal(snap?.callId, "t3"); + assert.equal(snap?.revision, 5); +}); + +test("latest snapshot: undefined with no todo_list tool", () => { + assert.equal(biliTodoLatestSnapshot([userText("u", "hi")]), undefined); +}); + +test("latest snapshot: rejects malformed results", () => { + const cases: { name: string; messages: CoreMessage[] }[] = [ + { name: "missing revision", messages: [todoCall("c", "t"), todoResult("r", "t", { todos: [] })] }, + { name: "negative revision", messages: [todoCall("c", "t"), todoResult("r", "t", { revision: -1, todos: [] })] }, + { name: "invalid status", messages: [todoCall("c", "t"), todoResult("r", "t", { revision: 1, todos: [{ id: "a", content: "A", status: "bogus" }] })] }, + { name: "duplicate ids", messages: [todoCall("c", "t"), todoResult("r", "t", { revision: 1, todos: [{ id: "a", content: "A", status: "pending" }, { id: "a", content: "A2", status: "pending" }] })] }, + { name: "parent not present", messages: [todoCall("c", "t"), todoResult("r", "t", { revision: 1, todos: [{ id: "a", content: "A", status: "pending", parent: "zz" }] })] }, + { name: "parent self-reference", messages: [todoCall("c", "t"), todoResult("r", "t", { revision: 1, todos: [{ id: "a", content: "A", status: "pending", parent: "a" }] })] }, + { name: "orphan result without call", messages: [todoResult("r", "t", REV3)] }, + { name: "empty todos at revision 0", messages: [todoCall("c", "t"), todoResult("r", "t", { revision: 0, todos: [] })] }, + ]; + for (const c of cases) { + assert.equal(biliTodoLatestSnapshot(c.messages), undefined, c.name); + } +}); + +test("render carrier: wraps small payload in header/end markers", () => { + const snap = biliTodoLatestSnapshot([todoCall("c", "t"), todoResult("r", "t", REV3)]); + assert.ok(snap); + const text = biliTodoRenderCarrier(snap); + assert.ok(text.startsWith(BILI_TODO_CONTINUITY_HEADER + "\n")); + assert.ok(text.endsWith("\n" + BILI_TODO_CONTINUITY_END)); + assert.ok(text.includes('"revision":3')); + assert.ok(text.includes('"do A"')); +}); + +test("render carrier: oversized payload truncates to active todos + ancestors", () => { + const big = (id: string, status: string, parent?: string) => ({ id, content: "x".repeat(4000), status, ...(parent ? { parent } : {}) }); + const todos = [ + big("root", "completed"), + big("active", "in_progress", "root"), + big("done1", "completed", "root"), + big("done2", "completed", "root"), + big("done3", "completed", "root"), + big("done4", "completed", "root"), + big("done5", "completed", "root"), + big("done6", "completed", "root"), + big("done7", "completed", "root"), + big("done8", "completed", "root"), + ]; + const snap = biliTodoLatestSnapshot([todoCall("c", "t"), todoResult("r", "t", { revision: 1, todos })]); + assert.ok(snap); + const text = biliTodoRenderCarrier(snap); + assert.ok(text.length <= 32768, `carrier ${text.length} exceeds cap`); + const firstNl = text.indexOf("\n", BILI_TODO_CONTINUITY_HEADER.length); + const secondNl = text.indexOf("\n", firstNl + 1); + const body = text.slice(secondNl + 1, text.lastIndexOf("\n" + BILI_TODO_CONTINUITY_END)); + const parsed = JSON.parse(body) as { todos: { id: string }[]; truncated?: boolean }; + assert.ok(parsed.todos.some((t) => t.id === "active"), "active todo kept"); + assert.ok(parsed.todos.some((t) => t.id === "root"), "ancestor of active todo kept"); + assert.ok(parsed.truncated === true || parsed.todos.length < todos.length); +}); + +test("ensure: injects carrier when todo pair was compressed away", () => { + const source = [todoCall("c", "t"), todoResult("r", "t", REV3), userText("u", "continue")]; + const view = [userText("u", "continue")]; + const out = biliEnsureTodoContinuity(view, source); + assert.equal(out.length, 1); + assert.ok(out[0].text!.startsWith(BILI_TODO_CONTINUITY_HEADER), "carrier merged in front of user text"); + assert.ok(out[0].text!.endsWith("continue"), "original user text preserved after carrier"); +}); + +test("ensure: no carrier when todo pair still visible", () => { + const source = [todoCall("c", "t"), todoResult("r", "t", REV3), userText("u", "continue")]; + const out = biliEnsureTodoContinuity(source, source); + assert.deepEqual(out, source, "unchanged when call+result present"); +}); + +test("ensure: no-op when there is no todo state", () => { + const view = [userText("u", "hi")]; + assert.deepEqual(biliEnsureTodoContinuity(view, view), view); +}); + +test("ensure: idempotent — re-feeding its own output does not double-inject", () => { + const source = [todoCall("c", "t"), todoResult("r", "t", REV3), userText("u", "continue")]; + const once = biliEnsureTodoContinuity([userText("u", "continue")], source); + const twice = biliEnsureTodoContinuity(once, source); + assert.deepEqual(twice, once, "stable across re-feed"); + assert.equal(twice[0].text!.split(BILI_TODO_CONTINUITY_HEADER).length - 1, 1, "exactly one carrier header"); +}); + +test("ensure: appends carrier when no user text message exists", () => { + const source = [todoCall("c", "t"), todoResult("r", "t", REV3)]; + const view: CoreMessage[] = [{ id: "a1", role: "assistant", contentType: "text", text: "working" }]; + const out = biliEnsureTodoContinuity(view, source); + assert.equal(out.length, 2); + assert.ok(out[1].text!.startsWith(BILI_TODO_CONTINUITY_HEADER)); + assert.ok(out[1].id.startsWith(BILI_TODO_CONTINUITY_PREFIX)); +}); + +test("ensure: supersedes a stale carrier when the source revision advances", () => { + const staleSource = [todoCall("c0", "t0"), todoResult("r0", "t0", { revision: 1, todos: [{ id: "z", content: "old", status: "pending" }] }), userText("u", "continue")]; + const withStale = biliEnsureTodoContinuity([userText("u", "continue")], staleSource); + const advanced = [todoCall("c", "t"), todoResult("r", "t", REV3), userText("u", "continue")]; + const out = biliEnsureTodoContinuity(withStale, advanced); + assert.equal(out[0].text!.split('"revision":1').length - 1, 0, "stale revision carrier removed"); + assert.ok(out[0].text!.includes('"revision":3'), "current revision carrier present"); +});