Files
OmniRoute/open-sse/services/conversationTracker.ts
adevwithpurpose 12d0acbe06 fix(conversations): bound reconnect walk + memoize turn hashes (#7847) (#10800)
Obrigado — bug de produção real e muito bem raiz-causado: resolveConversationId travava a request path por 10-130s em históricos longos de agente (medido em produção: p50 12.6s / max 130.2s em requests com ≥200 mensagens), por re-hashear o texto completo de cada turno a cada passo do walk de reconexão (O(starts × anchors × walkLength) HMACs síncronos).

Fix cirúrgico: memoiza o hash de cada turno por request + budget de passos compartilhado entre candidatos (degrada como no-match, nunca como attach não verificado ou latência ilimitada). Resultado medido: 17.2s → 0.3s no repro.

Validação (worktree combinado a partir de origin/release/v3.8.50, 0 conflitos):
- typecheck:core limpo, complexity/cognitive-complexity dentro do baseline
- tests/unit/conversationTracker-reconnect-7847.test.ts (novo) — cobre o cap de budget, degradação com budget zero, e guarda de regressão de wall-clock (falha em 17.2s pré-fix)
- 17 testes de semântica pré-existentes + conversationTurnContent (5) — passando sem alteração
2026-08-20 15:34:32 -03:00

566 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Conversation Tracker — assigns a stable conversation id across separate
* HTTP requests that are turns of the same multi-turn agentic conversation.
*
* Clients resend the full growing message/input history on every turn (no
* server-side state dependency). Continuation is detected with a per-turn
* hash chain (each turn's id = sha256(parentId, role, sha256(text)), the
* same idea as a git commit graph): a new request's turns are walked from
* the start against the candidate conversation's existing chain, matching as
* far as they agree. Real agentic-CLI traffic (OpenClaw and similar) often
* edits or duplicates a turn mid-history to keep provider-side prompt caches
* warm — e.g. request 1 has turns `a b c … h i`, request 2 has
* `a b c … h i i j k`. A whole-history hash (the original approach) breaks
* on any such edit and never reconnects.
*
* Every OmniRoute conversation is a single straight line — it never forks.
* When a turn diverges from what's already on file (`c` became `c'`), that
* diverging history becomes its OWN independent conversation, with its own
* id, built fresh from this request's full turn list — not a branch grafted
* onto the old chain (2026-08-06 redesign; the branching model's real
* traffic accumulated dozens of edits per session, and indenting one more
* tree level per edit eventually left no room to show content at all).
* `a b c d` and `a b c' d'` end up as two distinct conversations, sharing no
* further storage after the point they diverge — simpler to store, query,
* and render than a tree, and it matches how the data is actually used: a
* "conversation" here is one continuous transcript, not a version-control
* graph. This is a new, persisted mechanism — separate from
* `sessionManager.ts`'s `generateSessionId()` (in-memory, routing/latency
* only) even though it uses the same sha256-fingerprint style.
*
* @see Issue: X-ConversationId / agentic conversation tracking
*/
import { createHmac, randomUUID } from "node:crypto";
import {
createAgenticConversation,
findAgenticConversationsByFingerprint,
getConversationTurnIndex,
insertConversationTurnNodes,
touchOrCreateExternalConversation,
updateAgenticConversation,
type ConversationTurnIndex,
} from "../../src/lib/db/agenticConversations.ts";
type JsonRecord = Record<string, unknown>;
interface CanonicalTurn {
role: "system" | "user" | "assistant" | "tool";
text: string;
/** 'text' | 'tool_use' | 'tool_result' — carried through to
* conversation_turn_nodes so the tree view (and any other consumer) can
* build the exact NormalizedBlock (src/mitm/inspector/types.ts) the
* request-detail panel already builds from buildRequestTurns/
* buildResponseTurns, rendering tool calls/results through the same
* ChatBubble/MessageContent/ToolCallBlock/ToolResultBlock components
* everywhere instead of a parallel tree-only implementation. */
blockKind: "text" | "tool_use" | "tool_result";
/** Set only when blockKind === "tool_use". */
toolName: string | null;
}
export interface ResolveConversationIdInput {
body: JsonRecord | null | undefined;
model: string | null;
apiKeyId: string | null;
/** Raw `x-omniroute-session-id` header value, if the client supplied one. */
clientSessionIdHeader: string | null;
/**
* call_logs.correlation_id for this request (109_call_logs_correlation_id)
* — generated earlier in the request lifecycle, well before this request's
* own call_logs row/id exists, so it's the only stable identifier
* available here to tag new turn nodes with. The tree API route
* (src/app/api/conversations/[id]/tree/route.ts) joins through it to
* resolve a navigable call_logs.id.
*/
correlationId: string | null;
}
export interface ResolveConversationIdResult {
conversationId: string;
isNewConversation: boolean;
}
// ── Canonicalization ─────────────────────────────────────────────────────
function normalizeRole(raw: unknown): CanonicalTurn["role"] {
if (raw === "system" || raw === "user" || raw === "assistant" || raw === "tool") return raw;
if (raw === "developer") return "system";
if (raw === "model") return "assistant";
if (raw === "function") return "tool";
return "user";
}
/**
* Extract human-readable text from an OpenAI/Anthropic/Responses-API
* `content` value. Chat Completions sends a plain string; Responses API and
* Anthropic send an array of typed blocks (`{type:"text"|"input_text"|
* "output_text", text}`, `tool_use`, `tool_result`, ...) — collapsing that
* array to its text (rather than `JSON.stringify`-ing the whole thing) is
* what feeds both the turn-hash-chain (so the same underlying text chains
* identically regardless of which block-array shape a client used to send
* it) and `text_preview`, which the /dashboard/conversations tree view
* renders directly as markdown — a raw JSON blob there was a real bug, not a
* cosmetic one.
*/
function stringifyContent(content: unknown): string {
if (typeof content === "string") return content;
if (content == null) return "";
if (Array.isArray(content)) {
const parts: string[] = [];
for (const item of content) {
if (typeof item === "string") {
parts.push(item);
continue;
}
const block = item && typeof item === "object" ? (item as JsonRecord) : null;
if (!block) continue;
const type = block.type;
if (
(type === "text" || type === "input_text" || type === "output_text") &&
typeof block.text === "string"
) {
parts.push(block.text);
} else if (type === "tool_use" || type === "function_call") {
const name = typeof block.name === "string" ? block.name : "";
parts.push(`[tool_use ${name}]`);
} else if (type === "tool_result" || type === "function_call_output") {
parts.push(stringifyContent(block.content ?? block.output ?? ""));
} else if (typeof block.text === "string") {
parts.push(block.text);
}
}
return parts.join("\n");
}
try {
return JSON.stringify(content);
} catch {
return "";
}
}
/**
* Flatten a Chat Completions `messages[]` array or a Responses API `input`
* (array, bare string, or single message-shaped object) into a stable,
* format-agnostic turn list. Ignores ids/tool_call_ids/metadata entirely —
* only role + a string projection of content survive, since those are the
* only fields that stay stable across a client's own re-encoding of history.
*/
export function extractCanonicalTurns(body: JsonRecord | null | undefined): CanonicalTurn[] {
if (!body || typeof body !== "object") return [];
let raw: unknown[];
if (Array.isArray(body.messages)) {
raw = body.messages;
} else if (Array.isArray(body.input)) {
raw = body.input;
} else if (typeof body.input === "string") {
raw = [{ role: "user", content: body.input }];
} else if (body.input && typeof body.input === "object") {
raw = [body.input];
} else {
raw = [];
}
const turns: CanonicalTurn[] = [];
for (const item of raw) {
const rec = item && typeof item === "object" ? (item as JsonRecord) : {};
// Responses API function_call/function_call_output items have no `role`
// but do carry stable identifying text — fold them in as "tool" turns so
// tool round-trips still contribute to the continuation signal.
const role = rec.role
? normalizeRole(rec.role)
: rec.type === "function_call" || rec.type === "function_call_output"
? "tool"
: null;
if (!role) continue;
const text = stringifyContent(rec.content ?? rec.text ?? rec.arguments ?? rec.output);
if (!text) continue;
// Chat Completions tool-result messages (role: "tool"/"function") and
// Responses API function_call/function_call_output items are the only
// two shapes this canonicalizer sees for tool activity — everything
// else (including plain assistant/user/system text) is "text".
let blockKind: CanonicalTurn["blockKind"] = "text";
let toolName: string | null = null;
if (rec.type === "function_call") {
blockKind = "tool_use";
toolName = typeof rec.name === "string" ? rec.name : null;
} else if (rec.type === "function_call_output") {
blockKind = "tool_result";
} else if (rec.role === "tool" || rec.role === "function") {
blockKind = "tool_result";
toolName = typeof rec.name === "string" ? rec.name : null;
}
turns.push({ role, text, blockKind, toolName });
}
return turns;
}
// ── Fingerprint (identity, O(1) regardless of history size) ─────────────
// Content fingerprint for conversation identity, not a password/credential hash — keyed with a
// fixed context label so it reads as a domain-separated digest rather than a bare password hash.
function hashHex(text: string): string {
return createHmac("sha256", "omniroute-conversation-fingerprint-v1").update(text).digest("hex");
}
function extractToolNames(body: JsonRecord | null | undefined): string[] {
if (!body || !Array.isArray(body.tools)) return [];
const names: string[] = [];
for (const tool of body.tools as unknown[]) {
const rec = tool && typeof tool === "object" ? (tool as JsonRecord) : {};
const fn = rec.function && typeof rec.function === "object" ? (rec.function as JsonRecord) : {};
const name =
typeof rec.name === "string" ? rec.name : typeof fn.name === "string" ? fn.name : "";
if (name) names.push(name);
}
return names.sort();
}
// Deliberately excludes any message text — both the system prompt (real
// coding-agent CLIs like Claude Code/opencode regenerate it every request
// with live context: timestamp, cwd, git status...) AND, discovered live on
// a real OmniRoute deployment running OpenClaw, the first non-system turn
// too: OpenClaw's sliding context window drops/summarizes the EARLIEST
// turns as a session grows, so `firstNonSystemText` never stays stable
// across requests either — anchoring identity to either one mints a brand
// new conversation (or, worse, finds zero fingerprint candidates at all, so
// the turn-chain match in resolveConversationId never even runs) on every
// single turn for exactly this kind of real traffic, even though the actual
// history is a genuine, unbroken continuation. The bucket only needs to be
// small enough to bound candidate lookup — apiKeyId + model + toolNames is
// stable across a whole session and still narrow in practice; actual
// identity is decided by the turn-chain walk (real content overlap), not by
// this bucket, so widening it here cannot cause a false merge on its own.
export function computeFingerprintHash(input: {
apiKeyId: string | null;
model: string | null;
toolNames: string[];
}): string {
const parts = [input.apiKeyId ?? "", input.model ?? "", input.toolNames.join(",")];
// NOTE: no connectionId — conversation identity must not depend on which
// upstream connection this particular turn happened to be routed to.
return hashHex(parts.join("|"));
}
// ── Turn hash chain (continuation + branch detection) ────────────────────
//
// Each turn gets a stable id chained to its predecessor, the same idea as a
// git commit graph: id = sha256(parentId, role, sha256(text)). A brand-new
// tree's first turn chains off the conversation root id itself (not off
// `null`) so two different, unrelated conversation trees whose first turn
// happens to be byte-identical (e.g. two sessions that both open with "hi")
// never compute the same node id — `conversation_turn_nodes.id` is a global
// primary key, not scoped per conversation_id.
//
// Nodes store identity only (id/parent/content_hash), never the turn's
// actual text/tool-call shape — the dashboard resolves that on demand from
// the call-log pipeline artifact each node's correlation id points at (see
// conversationTurnContent.ts), re-running extractCanonicalTurns over that
// artifact's full, untruncated request body and matching by contentHash.
// Exported so that resolver can compute the same hash for a lookup key.
export function hashTurnContent(turn: CanonicalTurn): string {
return hashHex(`${turn.role} ${turn.text}`);
}
/**
* Upper bound on chain-node id computations a single resolveConversationId
* call may spend across ALL fingerprint candidates, start turns and duplicate
* anchors (#7847-class stall). Real coding-agent histories combine 1000+
* turns with heavily duplicated tool outputs, so the (start × anchor × walk)
* product is unbounded without a cap: measured on production traffic the
* walk blocked the request path for 10-130 s before this bound existed.
* Exhausting the budget degrades exactly like a no-match — the request mints
* a new conversation — never a wrong attachment.
*/
export const DEFAULT_RECONNECT_MAX_STEPS = 150_000;
function chainNodeIdFromHash(parentId: string, turnHash: string): string {
return hashHex(`${parentId} ${turnHash}`);
}
function chainNodeId(parentId: string, turn: CanonicalTurn): string {
return chainNodeIdFromHash(parentId, hashTurnContent(turn));
}
interface NewTurnNode {
id: string;
parentId: string | null;
role: string;
contentHash: string;
}
/** Build the new-node run for turns[fromIndex:], chained off `chainAnchor`. */
function buildNewNodes(
turns: CanonicalTurn[],
fromIndex: number,
chainAnchor: string,
rootId: string,
turnHashes?: string[]
): NewTurnNode[] {
const nodes: NewTurnNode[] = [];
let parent = chainAnchor;
for (let i = fromIndex; i < turns.length; i++) {
const turnHash = turnHashes ? turnHashes[i] : hashTurnContent(turns[i]);
const nodeId = chainNodeIdFromHash(parent, turnHash);
nodes.push({
id: nodeId,
// The root anchor is a hashing seed, not a real node — the first turn
// of a tree has no parent turn.
parentId: parent === rootId ? null : parent,
role: turns[i].role,
contentHash: turnHash,
});
parent = nodeId;
}
return nodes;
}
export interface ReconnectMatch {
/** Index into `chainTurns` where the reconnection was found (turns before
* this index were dropped from the chain's view — a compacted summary the
* client sent instead of resending them verbatim — and are not inserted
* as nodes). */
startIndex: number;
/** How far the match extends past startIndex (>= startIndex + 1). */
matchEndIndex: number;
/** Node id to chain new nodes off (the last matched node). */
anchorNodeId: string;
/** True when `anchorNodeId` already has a recorded child in this chain —
* i.e. turns[matchEndIndex] (if any) would collide with an existing,
* DIFFERENT turn rather than simply being new. See resolveConversationId's
* doc comment for what this distinction now controls. */
anchorHasChild: boolean;
}
/**
* Mutable work budget shared across a single resolveConversationId call's
* candidate walks. `stepsLeft` counts DOWN one chain-node id computation per
* step; `stepsUsed` reports total spend for observability/tests.
*/
export interface ReconnectWalkBudget {
stepsLeft: number;
stepsUsed: number;
}
export interface FindReconnectMatchOptions {
/** Memoized `hashTurnContent` per chain turn, computed once per request. */
turnHashes?: string[];
/** Per-call cap; omit to use a fresh DEFAULT_RECONNECT_MAX_STEPS budget. */
maxSteps?: number;
/** Shared budget across several calls (resolveConversationId's candidate loop). */
budget?: ReconnectWalkBudget;
}
export interface FindReconnectMatchResult {
match: ReconnectMatch | null;
stepsUsed: number;
}
/**
* Find where `chainTurns` reconnects to an existing chain, trying the
* leftmost turn first (so a still-fully-present prefix — the common case —
* matches immediately at the start) and falling back to later turns only
* when earlier ones aren't found anywhere in the chain. This is what makes
* continuation detection survive OpenClaw's sliding context window: once
* the earliest turns are compacted away, turn 0 of a new request is some
* turn from the MIDDLE of the existing chain, not its start — a start-only
* walk (checking only whether turn 0 is the chain's own first turn) would
* find nothing.
*
* Real agentic traffic is full of byte-identical repeated turns — a tool
* polling loop's "Process still running." output, a heartbeat ack, a
* one-word "ok" — so `byContentHash.get(...)` routinely returns MANY
* candidate anchors for the same turn (one real conversation observed 28
* duplicates of a single OpenClaw runtime-context turn). Evaluating only the
* first candidate (as this used to do) meant returning whichever occurrence
* SQLite happened to list first — in practice the OLDEST, most stale one —
* whose recorded next-turn almost never matches the current request, so the
* walk stalled a few turns in and (worse) that stale anchor already has a
* DIFFERENT recorded child, tripping `anchorHasChild` and making
* resolveConversationId treat a genuine continuation as a divergence. Live
* result: a real conversation minted a brand-new copy of its ENTIRE history
* on every single request instead of ever reconnecting (2026-08-06). Every
* candidate anchor for every prefix start is now tried, and the one that
* verifiably extends furthest into the actual request wins — the only
* reliable signal of genuine continuation when content repeats.
*
* #7847-class stall fix: the (start × anchor × walk) product over a long
* duplicate-heavy history is bounded by a step budget (`maxSteps` /
* `DEFAULT_RECONNECT_MAX_STEPS`), and turn content hashes are memoized via
* `turnHashes` so each step hashes ~130 fixed-size bytes instead of re-hashing
* the turn's full text. Budget exhaustion returns the best match verified so
* far (possibly none) — degrading to "new conversation" downstream, never an
* unverified attachment.
*/
export function findReconnectMatch(
chainTurns: CanonicalTurn[],
index: ConversationTurnIndex,
options: FindReconnectMatchOptions = {}
): FindReconnectMatchResult {
const turnHashes = options.turnHashes ?? chainTurns.map(hashTurnContent);
const budget: ReconnectWalkBudget = options.budget ?? {
stepsLeft: options.maxSteps ?? DEFAULT_RECONNECT_MAX_STEPS,
stepsUsed: 0,
};
let best: ReconnectMatch | null = null;
for (let s = 0; s < chainTurns.length; s++) {
if (budget.stepsLeft <= 0) break;
const anchors = index.byContentHash.get(turnHashes[s]);
if (!anchors) continue;
for (const anchorNodeId of anchors) {
if (budget.stepsLeft <= 0) break;
// The anchor claim itself costs one step: with no budget left to claim
// even the hash-bucket anchor, the walker must report no match rather
// than an unverified one.
budget.stepsLeft -= 1;
budget.stepsUsed += 1;
let parent = anchorNodeId;
let matchEndIndex = s + 1;
while (matchEndIndex < chainTurns.length && budget.stepsLeft > 0) {
budget.stepsLeft -= 1;
budget.stepsUsed += 1;
const nodeId = chainNodeIdFromHash(parent, turnHashes[matchEndIndex]);
if (!index.nodeIds.has(nodeId)) break;
parent = nodeId;
matchEndIndex++;
}
const anchorHasChild = index.parentsWithChildren.has(parent);
// Longest verified run wins outright. An equal-length run breaks
// toward anchorHasChild===false: a tie means both candidate anchors'
// recorded next-turn already differs from what's being requested (the
// walk stopped for the same reason on both), so the anchor with NO
// established child is the safe, unambiguous "just append here" — the
// other, having a different recorded child already, would incorrectly
// read as a divergence purely because it happened to be tried first.
const isBetter =
!best ||
matchEndIndex > best.matchEndIndex ||
(matchEndIndex === best.matchEndIndex && !anchorHasChild && best.anchorHasChild);
if (isBetter) {
best = { startIndex: s, matchEndIndex, anchorNodeId: parent, anchorHasChild };
}
// Can't do better than matching every turn through to the end.
if (best && best.matchEndIndex === chainTurns.length) {
return { match: best, stepsUsed: budget.stepsUsed };
}
}
}
return { match: best, stepsUsed: budget.stepsUsed };
}
// ── Orchestration ─────────────────────────────────────────────────────────
const MAX_STORED_ID_LENGTH = 128;
export async function resolveConversationId(
input: ResolveConversationIdInput
): Promise<ResolveConversationIdResult> {
// Client override wins outright — deterministic, zero heuristic risk.
// Same header feature #8249 already reads (chatCore.ts); we don't invent a
// new prefix so the existing header's contract/format stays unchanged.
if (input.clientSessionIdHeader && input.clientSessionIdHeader.trim()) {
const id = input.clientSessionIdHeader.trim().slice(0, MAX_STORED_ID_LENGTH);
touchOrCreateExternalConversation(id, { apiKeyId: input.apiKeyId });
return { conversationId: id, isNewConversation: false };
}
const turns = extractCanonicalTurns(input.body);
const toolNames = extractToolNames(input.body);
const fingerprintHash = computeFingerprintHash({
apiKeyId: input.apiKeyId,
model: input.model,
toolNames,
});
// The turn CHAIN excludes the system message entirely, same reasoning as
// extractFirstNonSystemText above: real coding-agent CLIs regenerate the
// system prompt (timestamp/cwd/git status...) on every single request, so
// treating it as an ordinary chained turn would make turn-0 (or wherever
// it sits) fail to match on every request — reintroducing the exact
// always-new-conversation bug this chain design exists to fix.
const chainTurns = turns.filter((t) => t.role !== "system");
// #7847-class stall fix: hash each turn's content exactly once per request
// and bound the reconnect walk across ALL candidates with one shared budget
// — previously every (start × anchor × walk-step) re-hashed the turn's full
// text twice, which on long duplicate-heavy coding-agent histories blocked
// the pre-routing request path for 10-130 s.
const turnHashes = chainTurns.map(hashTurnContent);
const walkBudget: ReconnectWalkBudget = { stepsLeft: DEFAULT_RECONNECT_MAX_STEPS, stepsUsed: 0 };
const candidates = findAgenticConversationsByFingerprint(fingerprintHash);
for (const candidate of candidates) {
const index = getConversationTurnIndex(candidate.id);
if (index.nodeIds.size === 0) continue;
const { match } = findReconnectMatch(chainTurns, index, {
turnHashes,
budget: walkBudget,
});
// No match anywhere in the chain means this candidate isn't actually
// this conversation's lineage — it only shares the coarse fingerprint
// bucket (apiKeyId/model/toolNames), which real traffic proves is not
// enough to assume overlap on its own (see computeFingerprintHash's doc
// comment) — try the next candidate rather than attaching a completely
// unrelated turn.
if (!match) continue;
if (match.matchEndIndex === chainTurns.length) {
// Every turn from the reconnect point onward already exists on this
// chain (e.g. an exact retry, or the whole request is already fully
// recorded) — a real continuation, nothing new to insert.
updateAgenticConversation(candidate.id, { turnCount: candidate.turnCount + 1 });
return { conversationId: candidate.id, isNewConversation: false };
}
if (!match.anchorHasChild) {
// Genuine tail growth: the reconnect point has no recorded child yet,
// so turns[matchEndIndex:] are simply turns this conversation hasn't
// seen before — append them to this SAME chain. Turns before
// startIndex (a compacted-away prefix, if any) are never inserted —
// they don't represent new content, just the client's own context
// management.
const newNodes = buildNewNodes(
chainTurns,
match.matchEndIndex,
match.anchorNodeId,
candidate.id,
turnHashes
);
insertConversationTurnNodes(candidate.id, input.correlationId, newNodes);
updateAgenticConversation(candidate.id, { turnCount: candidate.turnCount + 1 });
return { conversationId: candidate.id, isNewConversation: false };
}
// The reconnect point already has a DIFFERENT recorded child — this
// request's turn at that position diverges from what's on file (a real
// OpenClaw cache-aware-context edit: turn `c` became `c'`). As of the
// 2026-08-06 redesign, an edited/duplicated turn no longer forks a
// branch inside this conversation's own chain — every OmniRoute
// conversation is now a single straight line, never a tree. The
// diverging history becomes its own independent conversation instead
// (built fresh below, from this request's full turn list) — distinct
// conversation ids for `a b c d` and `a b c' d'`, not one tree with two
// branches. This is both simpler to store/query and fixes a real UX
// problem the branching model had: real OpenClaw traffic accumulates
// dozens of edits per session, and indenting one more level per fork
// eventually left no horizontal space for content at all. Keep checking
// remaining candidates first, though — a later candidate may already BE
// that independent conversation from a previous edit at this same spot
// (e.g. a repeated retry of the edited turn), which should continue
// that one rather than minting yet another new id for it.
}
const id = `conv_${randomUUID()}`;
createAgenticConversation({ id, apiKeyId: input.apiKeyId, fingerprintHash });
insertConversationTurnNodes(
id,
input.correlationId,
buildNewNodes(chainTurns, 0, id, id, turnHashes)
);
return { conversationId: id, isNewConversation: true };
}