src/tool-ids.js
Rama master · réplica de lectura del repositorio original.
// Tool-ID hygiene and tool-pairing repair.
//
// Ported from Any2API `src/translate/hygiene/hygiene.go` and
// `src/translate/toolcall/history.go` with two deliberate changes:
// - valid IDs are identity (no rewrite on the client-visible leg);
// - orphan tool results are folded into text, never dropped.
//
// Request-scoped: no global mutable state.
import { createHash } from 'node:crypto';
import { neutralizeToolResultBody } from './tool-text.js';
export const MAX_TOOL_ID_LEN = 64;
export const INTERRUPTED_RESULT_TEXT = 'Tool call was interrupted before any output was recorded.';
export const EMPTY_RESULT_TEXT = 'Tool result was empty.';
const ID_RE = /^[A-Za-z0-9_-]+$/;
function sha256Hex(value) {
return createHash('sha256').update(value).digest('hex');
}
export function isValidToolId(id, { maxLen = MAX_TOOL_ID_LEN, charsetOnly = false } = {}) {
if (typeof id !== 'string' || id.length === 0) return false;
if (!ID_RE.test(id)) return false;
return charsetOnly ? true : id.length <= maxLen;
}
export function sanitizeToolId(id, { maxLen = MAX_TOOL_ID_LEN, charsetOnly = false } = {}) {
if (id === undefined || id === null || id === '') return '';
const raw = String(id);
if (isValidToolId(raw, { maxLen, charsetOnly })) return raw;
let s = raw.replace(/[^A-Za-z0-9_-]/g, '_');
if (!charsetOnly && s.length > maxLen) {
if (maxLen >= 9) {
const hashLen = Math.min(16, maxLen - 1);
s = `${s.slice(0, Math.max(0, maxLen - hashLen - 1))}_${sha256Hex(s).slice(0, hashLen)}`;
} else {
s = s.slice(0, Math.max(0, maxLen));
}
}
return s;
}
export function synthToolId(base, seed, slot) {
const prefix = `${base || 'call'}_synth_`;
if (seed === undefined || seed === null || seed === '') return `${prefix}${slot}`;
return `${prefix}${sha256Hex(`${seed}\u0000${slot}`).slice(0, 32)}`;
}
export function wireToolId(base, id, seed, slot, opts = {}) {
const { maxLen = MAX_TOOL_ID_LEN } = opts;
const synth = synthToolId(base, seed, slot);
return sanitizeToolId(id, opts) || (maxLen >= 1 ? synth.slice(0, maxLen) : '');
}
// ── Client-visible tool-call id shape (devin family) ────────────────────────
//
// OpenAI clients expect an opaque `call_<opaque>` id. The Devin Connect
// upstream issues its own dialect (`get_weather:0#<uuid>` — live swe-2-medium
// capture, ToolMatrixLive §7) and the ACP runner forwards its
// toolCallId verbatim, so the devin adapters normalize at the CLIENT boundary.
//
// The mapping is REVERSIBLE and STATELESS: a non-conforming upstream id is
// base64url-encoded behind a versioned `call_e1_` marker. No process-global
// map, so a follow-up turn served by another worker (or after a restart) still
// restores the exact upstream id before native history is re-encoded. Ids that
// already match the OpenAI shape pass through untouched.
export const CLIENT_TOOL_CALL_ID_RE = /^call_[A-Za-z0-9_-]+$/;
const CLIENT_TOOL_CALL_ID_PREFIX = 'call_e1_';
export function toClientToolCallId(rawId, fallback = '') {
if (typeof rawId !== 'string' || rawId === '') return fallback;
if (CLIENT_TOOL_CALL_ID_RE.test(rawId)) return rawId;
return CLIENT_TOOL_CALL_ID_PREFIX + Buffer.from(rawId, 'utf8').toString('base64url');
}
export function fromClientToolCallId(clientId) {
if (typeof clientId !== 'string' || !clientId.startsWith(CLIENT_TOOL_CALL_ID_PREFIX)) return clientId;
const encoded = clientId.slice(CLIENT_TOOL_CALL_ID_PREFIX.length);
if (!encoded || !ID_RE.test(encoded)) return clientId;
const decoded = Buffer.from(encoded, 'base64url').toString('utf8');
// Canonical round-trip only: an id that merely LOOKS like our encoding
// (bad length/padding/non-UTF-8) must not be rewritten.
if (!decoded || Buffer.from(decoded, 'utf8').toString('base64url') !== encoded) return clientId;
return decoded;
}
// Inverse of the adapter's egress normalization, applied to a request's history
// immediately before it is encoded for the upstream. Only ids this proxy minted
// are rewritten; every other id passes through byte-identically.
export function restoreUpstreamToolIds(messages) {
if (!Array.isArray(messages)) return messages;
let changed = false;
const out = messages.map((m) => {
if (m?.role === 'assistant' && Array.isArray(m.tool_calls)) {
let localChanged = false;
const calls = m.tool_calls.map((tc) => {
const id = fromClientToolCallId(tc?.id);
if (id === tc?.id) return tc;
localChanged = true;
return { ...tc, id };
});
if (localChanged) {
changed = true;
return { ...m, tool_calls: calls };
}
}
if (typeof m?.tool_call_id === 'string') {
const id = fromClientToolCallId(m.tool_call_id);
if (id !== m.tool_call_id) {
changed = true;
return { ...m, tool_call_id: id };
}
}
return m;
});
return changed ? out : messages;
}
export function foldOrphanResultText(content) {
if (Array.isArray(content)) {
const parts = content.map((part) => {
if (typeof part === 'string') return part;
if (part && typeof part === 'object') {
if (typeof part.text === 'string') return part.text;
if (part.type) return `[${part.type}]`;
}
return '';
}).filter(Boolean).join('\n').trim();
return parts || EMPTY_RESULT_TEXT;
}
if (content && typeof content === 'object') {
try { return JSON.stringify(content); } catch { return String(content); }
}
const text = typeof content === 'string' ? content.trim() : String(content ?? '').trim();
return text || EMPTY_RESULT_TEXT;
}
function appendText(content, text) {
if (typeof content === 'string' || content == null) {
return content ? `${content}\n\n${text}` : text;
}
if (Array.isArray(content)) return [...content, { type: 'text', text }];
return text;
}
export function knownToolCallIds(messages) {
const ids = new Set();
for (const m of messages || []) {
if (m?.role !== 'assistant' || !Array.isArray(m.tool_calls)) continue;
for (const tc of m.tool_calls) {
if (tc?.id) ids.add(tc.id);
}
}
return ids;
}
function uniqueToolId(base, reserved, maxLen) {
const limit = maxLen >= 1 ? maxLen : 1;
const stem = base || 'call';
let wire = stem.slice(0, limit);
let n = 2;
// A suffix must fit inside the same namespace; if it cannot, use the
// base-36 counter itself rather than appending past maxLen. Exhausting a
// tiny namespace is a contract error, not a reason to emit an overlong id.
const maxAttempts = limit <= 6 ? 36 ** limit : Number.MAX_SAFE_INTEGER;
while (reserved.has(wire)) {
if (n > maxAttempts) throw new Error('tool id namespace exhausted');
const token = n.toString(36);
n += 1;
wire = token.length >= limit
? token.slice(-limit)
: `${stem.slice(0, limit - token.length - 1)}_${token}`;
}
reserved.add(wire);
return wire;
}
// Egress-side counterpart to the request-leg repair above. Upstream can reuse
// one call id for two DISTINCT calls (parallel calls or a repeated turn); a
// client that keys tool results by id then cannot tell them apart. Allocate in
// first-arrival order: the first occurrence keeps the raw (sanitized) id, later
// occurrences get the same deterministic suffix the request leg uses, so the
// mapping is stable across turns. `makeFallback` supplies the surface's
// historical generated id when upstream omitted one; without it an absent id
// stays absent.
export function createToolIdAllocator({ maxLen = MAX_TOOL_ID_LEN, charsetOnly = false, makeFallback = null } = {}) {
const opts = { maxLen, charsetOnly };
const reserved = new Set();
let slot = 0;
return function allocateToolId(rawId) {
const base = sanitizeToolId(rawId, opts);
if (base) return uniqueToolId(base, reserved, maxLen);
if (!makeFallback) return '';
const generated = sanitizeToolId(String(makeFallback(slot++) ?? ''), opts);
return uniqueToolId(generated || synthToolId('call', '', slot), reserved, maxLen);
};
}
export function normalizeToolIdsInMessages(messages, { seed = '', maxLen = MAX_TOOL_ID_LEN, charsetOnly = false } = {}) {
if (!Array.isArray(messages)) return messages;
const assistantCalls = [];
const resultIds = [];
for (const m of messages) {
if (m?.role === 'assistant' && Array.isArray(m.tool_calls)) {
for (const tc of m.tool_calls) assistantCalls.push(tc);
} else if (m?.role === 'tool') {
resultIds.push(m.tool_call_id);
}
}
const opts = { maxLen, charsetOnly };
const reserved = new Set();
for (const tc of assistantCalls) {
if (isValidToolId(tc?.id, opts)) reserved.add(tc.id);
}
for (const id of resultIds) {
if (isValidToolId(id, opts)) reserved.add(id);
}
// A raw id may legitimately appear more than once (some clients reuse the
// same id for parallel calls). Valid ids stay identity on the first call;
// later calls get a unique wire id, and results pair FIFO to the calls in
// order. A single raw->wire map would collapse both calls onto one id and
// make the second result indistinguishable from the first.
const wireQueues = new Map();
let slot = 0;
const callWire = new Map();
for (const tc of assistantCalls) {
const raw = tc?.id;
let wire;
if (raw && isValidToolId(raw, opts)) {
const queue = wireQueues.get(raw);
if (!queue) {
wire = raw;
wireQueues.set(raw, [wire]);
} else {
wire = uniqueToolId(raw, reserved, maxLen);
queue.push(wire);
}
} else {
const base = raw ? sanitizeToolId(raw, opts) : synthToolId('call', seed, slot++);
wire = uniqueToolId(base, reserved, maxLen);
if (raw) {
// A malformed raw id can repeat exactly like a valid one (parallel calls
// or successive turns). Keep the same FIFO queue the valid-id branch
// uses; overwriting it would bind every result to the last call.
const queue = wireQueues.get(raw);
if (queue) queue.push(wire);
else wireQueues.set(raw, [wire]);
}
}
callWire.set(tc, wire);
}
const resultWire = new Map();
for (const m of messages) {
if (m?.role !== 'tool' || !m.tool_call_id) continue;
const queue = wireQueues.get(m.tool_call_id);
if (queue && queue.length) {
resultWire.set(m, queue.shift());
continue;
}
const base = sanitizeToolId(m.tool_call_id, opts);
if (base) resultWire.set(m, uniqueToolId(base, reserved, maxLen));
}
let changed = false;
const out = messages.map((m) => {
if (m?.role === 'assistant' && Array.isArray(m.tool_calls)) {
let localChanged = false;
const calls = m.tool_calls.map((tc) => {
const raw = tc?.id;
const id = callWire.get(tc);
if (id && id !== raw) {
localChanged = true;
return { ...tc, id };
}
return tc;
});
if (localChanged) {
changed = true;
return { ...m, tool_calls: calls };
}
}
if (m?.role === 'tool' && m.tool_call_id) {
const id = resultWire.get(m) || m.tool_call_id;
if (id !== m.tool_call_id) {
changed = true;
return { ...m, tool_call_id: id };
}
}
return m;
});
return changed ? out : messages;
}
export function toolHygieneEnabled() {
return process.env.WINDSURFAPI_TOOL_HYGIENE !== '0';
}
export function foldOrphanToolResults(messages) {
if (!Array.isArray(messages)) return messages;
const known = knownToolCallIds(messages);
let changed = false;
const out = [];
let pending = [];
// Fold only after the contiguous tool run that contains the orphan. Inserting
// a user message immediately after an assistant tool_call (while a valid tool
// result for that call is still pending) breaks the required
// assistant(tool_calls) -> tool(result) adjacency and re-orphans the valid
// result. Buffering until the run ends preserves both the content and the
// valid pair.
const flush = () => {
if (!pending.length) return;
const folded = neutralizeToolResultBody(pending.join('\n\n'));
pending = [];
const prev = out[out.length - 1];
if (prev?.role === 'user') {
out[out.length - 1] = { ...prev, content: appendText(prev.content, folded) };
} else {
out.push({ role: 'user', content: folded });
}
};
for (const m of messages) {
if (m?.role === 'tool' && (!m.tool_call_id || !known.has(m.tool_call_id))) {
changed = true;
pending.push(
`${m.tool_call_id ? `[tool result for ${m.tool_call_id}]\n` : '[tool result]\n'}${foldOrphanResultText(m.content)}`,
);
continue;
}
if (m?.role !== 'tool') flush();
out.push(m);
}
flush();
return changed ? out : messages;
}
export function synthesizeMissingResults(messages) {
if (!Array.isArray(messages)) return messages;
let changed = false;
const out = [];
for (let i = 0; i < messages.length; i += 1) {
const m = messages[i];
out.push(m);
if (m?.role !== 'assistant' || !Array.isArray(m.tool_calls) || m.tool_calls.length === 0) continue;
// A trailing assistant call is the normal "awaiting execution" shape.
if (i === messages.length - 1) continue;
const existing = [];
let j = i + 1;
while (j < messages.length && messages[j]?.role === 'tool') {
existing.push(messages[j]);
j += 1;
}
for (const tm of existing) out.push(tm);
const have = new Set(existing.map((tm) => tm.tool_call_id).filter(Boolean));
const missing = m.tool_calls.filter((tc) => tc?.id && !have.has(tc.id));
if (missing.length) {
changed = true;
for (const tc of missing) {
out.push({ role: 'tool', tool_call_id: tc.id, content: INTERRUPTED_RESULT_TEXT });
}
}
i = j - 1;
}
return changed ? out : messages;
}