tool-call id uniquifier: the Lacan fix (owner-approved 2026-10-04)

DeepSeek via OpenRouter returns tool-call ids that reset every response —
a one-call-per-turn session names every call call_0. Everything keys on
toolCallId (the fold's call↔result join, the pending gate, answer
routing, the option-shuffle seed), so the Lacan session corrupted: the
quiz answer overwrote the ask card, the ask question vanished, and the
order scrambled. Record itself was always intact.

- src/server/pi/call-ids.ts (new): ToolCallIdUniquifier at the wire
  boundary — colliding ids remint once (orig~2, ~3…), the same mints at
  settlement; tables key by message OBJECT IDENTITY (streaming messages
  carry no ids; the message_end object is reference-identical to the
  state object). History never rewrites; providers see paired ids, which
  is protocol-legal (pi synthesizes ids for providers that send none).
- ui-stream: runPiTurn remaps events + settles through the uniquifier
  (spec.usedToolCallIds, seeded by chat-handler from the record).
- view.ts: findToolPart scans newest-first — pre-fix duplicate-id
  records (the existing Lacan session) heal on next render.
- tests: 9 unit pins (call-ids.test.ts) + the two-call_0 e2e (chat-flow):
  ask answered as ask, quiz as quiz, ids unique in the record.
This commit is contained in:
amosblomqvist
2026-10-04 01:19:54 +03:00
parent 360d564a6c
commit 17a497a87c
6 changed files with 477 additions and 10 deletions
+7
View File
@@ -17,6 +17,7 @@ import {
unregisterLiveAgent,
} from "./jobs.ts";
import { piTurnResponse, runPiTurn } from "./pi/ui-stream.ts";
import { collectToolCallIds } from "./pi/call-ids.ts";
import { effectiveSettings, stores } from "./stores.ts";
import { searchAvailable, searchProvider } from "./search.ts";
import { providerKeyPresent } from "./keys.ts";
@@ -379,6 +380,12 @@ export async function handleChatInput(
registerLiveAgent(chatId, constructed);
const handle = runPiTurn({
agent: constructed,
// THE LACAN FIX (2026-10-04): seed the uniquifier with every
// tool-call id the record already holds — a provider that resets
// ids per response (DeepSeek) then gets fresh unique ids for this
// turn's calls at the wire boundary, on the wire, in the record,
// and in the next turn's context alike.
usedToolCallIds: new Set(collectToolCallIds(merged)),
// Stream-level failures are provider/transport internals — masked,
// like the SDK defaults. Tool-input validation messages never
// route through here: they ride the error-result path and reach the
+212
View File
@@ -0,0 +1,212 @@
import type { AgentEvent } from "@earendil-works/pi-agent-core";
/**
* TOOL-CALL ID UNIQUIFICATION (owner-approved fix, 2026-10-04 — the Lacan
* incident): some providers (DeepSeek via OpenRouter, observed live) return
* tool-call ids that reset every response — `call_0`, `call_1`, … — so a
* one-call-per-turn session names EVERY call `call_0`. Everything in this
* app keys on toolCallId (the view fold's call↔result join, the pending-
* question gate, answer routing, the option-shuffle seed), so cross-turn
* duplicates corrupted all of them at once: the fold applied the quiz
* answer to the ASK card, the ask question vanished, the order scrambled.
*
* THE LAW: ids are uniquified ONCE, at the wire boundary — this class sits
* inside runPiTurn, sees every AgentEvent before any view, and applies the
* same minting at settlement. The client, the persisted record, the dock,
* the answer flow, and the next turn's provider context (rebuilt from the
* record) all see exactly one set of unique ids. Provider context stays
* internally paired (call and result rewrite together), which the
* OpenAI-compatible APIs accept — pi itself synthesizes ids for providers
* that return none, so arbitrary-but-paired ids are already protocol-legal.
* History is NEVER rewritten: record messages pass through untouched, and
* pre-fix duplicate records heal at the fold (view.ts pairs results
* newest-first).
*
* SCHEME: a per-chat seen-set (seeded from the record at turn start) plus
* per-message tables. Streaming messages carry NO id (ids are stamped at
* persist), so tables key by OBJECT IDENTITY — the message_end event's
* object is reference-identical to the object in agent.state.messages
* (verified against pi-agent-core), and a WeakMap joins the two. A fresh
* table (epoch) opens at each assistant message_start; the update/end
* objects of that message attach to it; mints claim once per message, so
* the same id in a SECOND message of the turn (a retry step) gets its own
* mint. Colliding ids become `${orig}~2`, `~3`, … skipping used ones.
*/
/** Structurally walk any message's content for tool-call blocks. */
function callBlocks(message: unknown): Array<{ id: string } & Record<string, unknown>> {
const content = (message as { content?: unknown } | undefined)?.content;
if (!Array.isArray(content)) return [];
return content.filter(
(b): b is { id: string } & Record<string, unknown> =>
(b as { type?: string } | null)?.type === "toolCall" && typeof (b as { id?: unknown }).id === "string",
);
}
/** The ids every consumer may already have seen: all call ids and result
* ids in a message list (the chat record — including its legacy dupes,
* which is the point: membership is what drives minting). */
export function collectToolCallIds(messages: readonly unknown[]): string[] {
const ids: string[] = [];
for (const m of messages) {
const role = (m as { role?: string }).role;
if (role === "assistant") {
for (const b of callBlocks(m)) ids.push(b.id);
} else if (role === "toolResult") {
const id = (m as { toolCallId?: unknown }).toolCallId;
if (typeof id === "string") ids.push(id);
}
}
return ids;
}
export class ToolCallIdUniquifier {
private readonly seen = new Set<string>();
/** assistant message object → its (origId → uniqueId) table. Weak: the
* agent's turn dies at settle; nothing here outlives it. */
private readonly tables = new WeakMap<object, Map<string, string>>();
/** The current assistant message's table — opened at its message_start,
* shared by its update/end objects, and the resolution scope for its
* tool_execution events (which fire after its message_end but before
* the next message_start). */
private epoch: Map<string, string> | null = null;
constructor(usedIds: Iterable<string>) {
for (const id of usedIds) this.seen.add(id);
}
/** Keep `orig` if never used; otherwise mint an unused `${orig}~n`. */
private claim(orig: string): string {
if (!this.seen.has(orig)) {
this.seen.add(orig);
return orig;
}
for (let n = 2; ; n++) {
const candidate = `${orig}~${n}`;
if (!this.seen.has(candidate)) {
this.seen.add(candidate);
return candidate;
}
}
}
/** Register + rewrite one message's tool-call blocks (idempotent across
* the repeated partials of message_update). Returns a cloned message
* when any id changed, else the original object. */
private remapMessageContent(message: unknown): unknown {
if ((message as { role?: string }).role !== "assistant") return message;
const blocks = callBlocks(message);
if (blocks.length === 0) return message;
// Attach to the current epoch unless this object already has a table
// (a re-emitted update/end). A message event with no open epoch
// (defensive: a stream that skips message_start) opens one.
let table = this.tables.get(message as object);
if (!table) {
table = this.epoch ?? new Map();
this.epoch = table;
this.tables.set(message as object, table);
} else {
this.epoch = table;
}
let changed = false;
const content = (message as { content: unknown[] }).content.map((block) => {
if ((block as { type?: string }).type !== "toolCall") return block;
const orig = (block as { id?: unknown }).id;
if (typeof orig !== "string") return block;
let mapped = table!.get(orig);
if (mapped === undefined) {
mapped = this.claim(orig);
table!.set(orig, mapped);
}
if (mapped === orig) return block;
changed = true;
return { ...(block as object), id: mapped };
});
return changed ? { ...(message as object), content } : message;
}
/** Every AgentEvent, ids uniquified. Structural and total: unknown
* event shapes pass through untouched. */
event(event: AgentEvent): AgentEvent {
switch (event.type) {
case "message_start": {
const e = event as { message: { role?: string } };
// A new assistant message opens a fresh epoch — this is what
// keeps a retried id in a LATER message from reusing the
// earlier message's mint.
if (e.message?.role === "assistant") this.epoch = new Map();
return this.remapEventMessage(event);
}
case "message_update":
case "message_end":
return this.remapEventMessage(event);
case "tool_execution_start":
case "tool_execution_update":
case "tool_execution_end": {
const { toolCallId } = event as { toolCallId: string };
const mapped = this.epoch?.get(toolCallId) ?? toolCallId;
if (mapped === toolCallId) return event;
return { ...(event as object), toolCallId: mapped } as AgentEvent;
}
case "turn_end": {
const e = event as { message: unknown; toolResults: Array<{ toolCallId?: string }> };
const message = this.remapMessageContent(e.message);
const table = this.epoch;
const toolResults = table
? e.toolResults.map((r) =>
typeof r.toolCallId === "string" && table.has(r.toolCallId)
? { ...r, toolCallId: table.get(r.toolCallId)! }
: r,
)
: e.toolResults;
return { ...(e as object), message, toolResults } as unknown as AgentEvent;
}
case "agent_end": {
const e = event as { messages: unknown[] };
return { ...(e as object), messages: this.remapSettled(e.messages) } as unknown as AgentEvent;
}
default:
return event;
}
}
private remapEventMessage(event: AgentEvent): AgentEvent {
const e = event as { message: unknown };
const remapped = this.remapMessageContent(e.message);
if (remapped === e.message) return event;
return { ...(event as object), message: remapped } as AgentEvent;
}
/** The settle seam: the agent's final state, rewritten to the ids the
* wire already showed. Only THIS turn's messages rewrite (the ones
* registered by their event objects); prior-turn history — including
* legacy duplicate-id records — passes through verbatim. Tool results
* pair with the most recent registered assistant message (the loop's
* own structure: results follow their calls). */
remapSettled(messages: readonly unknown[]): unknown[] {
let scope: Map<string, string> | undefined;
return messages.map((m) => {
const role = (m as { role?: string }).role;
if (role === "assistant") {
const table = this.tables.get(m as object);
if (!table) {
// Prior-turn or unregistered: history is never rewritten.
scope = undefined;
return m;
}
scope = table;
return this.remapMessageContent(m);
}
if (role === "toolResult") {
const orig = (m as { toolCallId?: unknown }).toolCallId;
if (scope && typeof orig === "string" && scope.has(orig)) {
return { ...(m as object), toolCallId: scope.get(orig)! };
}
return m;
}
return m;
});
}
}
+21 -9
View File
@@ -1,11 +1,15 @@
import type { Agent, AgentEvent, AgentMessage } from "@earendil-works/pi-agent-core";
import type { SteerMessage } from "../../core/contracts.ts";
import { noteSteerVisible } from "../jobs.ts";
import { ToolCallIdUniquifier } from "./call-ids.ts";
/**
* THE NATIVE WIRE (Wave D, owner-approved 2026-09-26): one agent turn, streamed
* to every view as VERBATIM pi AgentEvents — no translation, no remapping, no
* part assembly. The SSE body is the agent's own event stream:
* to every view as pi AgentEvents — no translation, no part assembly. The one
* sanctioned rewrite (the Lacan fix, 2026-10-04): tool-call IDs uniquify at
* this boundary when the provider resets them per response (DeepSeek's
* `call_0` every turn) — see call-ids.ts and PiTurnSpec.usedToolCallIds.
* The SSE body is the agent's own event stream:
*
* data: {"type":"agent_start"}\n\n
* data: {"type":"message_update","message":{…},"assistantMessageEvent":{…}}\n\n
@@ -57,6 +61,14 @@ export interface PiTurnSpec {
* Receives the agent's FULL final state (native messages) — the caller
* persists the slice past its own base count. */
onFinish: (messages: AgentMessage[], isAborted: boolean) => void;
/** Tool-call ids already used in this chat's record (THE LACAN FIX,
* owner-approved 2026-10-04): when present, a ToolCallIdUniquifier
* sits at this boundary — providers that reset ids every response
* (DeepSeek's `call_0` × every turn) get colliding ids reminted ONCE
* here, so wire views, the persisted record, the dock, answer
* routing, and the next turn's provider context all agree. Absent →
* verbatim passthrough (the protocol law below still holds). */
usedToolCallIds?: ReadonlySet<string>;
}
/** One view onto a running turn: push() replays/streams an event, done()
@@ -82,7 +94,8 @@ export interface PiTurnHandle {
}
export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
const { agent, onError, onFinish } = spec;
const { agent, onError, onFinish, usedToolCallIds } = spec;
const uniquifier = usedToolCallIds ? new ToolCallIdUniquifier(usedToolCallIds) : undefined;
let settled = false;
let sawAbort = false;
@@ -123,15 +136,14 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
// messages of its own (the prompt is composed per turn), so dropping
// the role wholesale is exact; revisit if prompt-updates ever land.
const settleWith = () => {
onFinish(
[...agent.state.messages].filter((m) => (m as { role?: string }).role !== "system"),
sawAbort,
);
const settled_ = [...agent.state.messages].filter((m) => (m as { role?: string }).role !== "system");
onFinish((uniquifier?.remapSettled(settled_) as AgentMessage[]) ?? settled_, sawAbort);
};
const handle = (event: AgentEvent) => {
// VERBATIM: every pi AgentEvent forwards as-is — no remapping.
send(event);
// THE LACAN FIX: ids uniquify HERE, before any view — the one seam
// every consumer downstream shares. No uniquifier → verbatim.
send(uniquifier ? uniquifier.event(event) : event);
if (event.type === "message_end") {
const m = event.message as { role?: string; stopReason?: string } | undefined;
if (m?.role === "assistant" && m.stopReason === "aborted") sawAbort = true;