@juspay/neurolink
Version:
The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002
242 lines (241 loc) • 9.13 kB
JavaScript
/**
* Tool Use/Result Pair Repair
*
* After compaction (or a pointer-based history slice) the message array can
* contain a `tool_call` whose `tool_result` was dropped, or a `tool_result`
* whose `tool_call` was dropped. Providers reject both, so orphans are filled
* with synthetic placeholders.
*
* Pairing is BATCH- and ID-aware, not adjacency-based. A single agent step
* with parallel tool calls is persisted as every `tool_call` followed by every
* `tool_result` (see flushPendingToolData), so `tool_call` is routinely
* followed by another `tool_call` in perfectly healthy history. Matching on
* adjacency treats that as an orphan and injects a bogus "result unavailable"
* over a result that is present a few entries later.
*
* Two modes, chosen per batch:
* - ID mode — `toolCallId` present: pair by id, order-independent.
* - legacy mode — sessions written before `toolCallId` existed: pair
* positionally WITHIN the batch (call[i] ↔ result[i]).
*/
import { randomUUID } from "crypto";
const MISSING_RESULT_CONTENT = "[Tool result unavailable - conversation was compacted]";
/**
* Collect the tool batch starting at `start` (which must index a tool-role
* message). Consumes the maximal run of calls followed by the maximal run of
* results. A leading run of results with no calls (head-cut orphan) yields a
* batch with an empty `calls` array.
*/
function collectBatch(messages, start) {
let i = start;
const calls = [];
const results = [];
while (i < messages.length && messages[i].role === "tool_call") {
calls.push(messages[i]);
i++;
}
while (i < messages.length && messages[i].role === "tool_result") {
results.push(messages[i]);
i++;
}
return { calls, results, endIndex: i };
}
/** Synthetic `tool_result` standing in for a call whose result was dropped. */
function syntheticResult(call) {
return {
id: `repair-result-${randomUUID()}`,
role: "tool_result",
content: MISSING_RESULT_CONTENT,
tool: call.tool,
...(call.toolCallId ? { toolCallId: call.toolCallId } : {}),
timestamp: call.timestamp,
metadata: { truncated: true },
};
}
/** Synthetic `tool_call` standing in for a result whose call was dropped. */
function syntheticCall(result) {
return {
id: `repair-call-${randomUUID()}`,
role: "tool_call",
content: `[Tool call for ${result.tool || "unknown"} - conversation was compacted]`,
tool: result.tool,
...(result.toolCallId ? { toolCallId: result.toolCallId } : {}),
timestamp: result.timestamp,
metadata: { truncated: true },
};
}
/**
* Repair one batch, emitting calls then results with every call matched to
* exactly one result. Returns the rebuilt run plus how many placeholders of
* each kind were needed.
*/
function repairBatch(batch) {
const { calls, results } = batch;
// ID mode requires ids on BOTH sides; a partially-migrated batch (some
// entries written before toolCallId existed) falls back to legacy pairing
// rather than treating the id-less half as universally orphaned.
const useIds = calls.length > 0 &&
results.length > 0 &&
calls.every((m) => !!m.toolCallId) &&
results.every((m) => !!m.toolCallId);
const outCalls = [...calls];
const outResults = [];
let orphanedCallsFixed = 0;
let orphanedResultsFixed = 0;
let changed = false;
if (useIds) {
// Duplicate ids (a retry that re-emitted a result) keep the FIRST result;
// later duplicates are dropped so a call never gains a second result.
const resultById = new Map();
for (const result of results) {
const key = result.toolCallId;
if (!resultById.has(key)) {
resultById.set(key, result);
}
else {
changed = true;
}
}
for (const call of calls) {
const key = call.toolCallId;
const match = resultById.get(key);
if (match) {
outResults.push(match);
resultById.delete(key);
}
else {
outResults.push(syntheticResult(call));
orphanedCallsFixed++;
}
}
// Results with no surviving call — the compactor cut the head of the batch.
for (const leftover of resultById.values()) {
outCalls.push(syntheticCall(leftover));
outResults.push(leftover);
orphanedResultsFixed++;
}
}
else {
// Legacy positional pairing, scoped to the batch.
const paired = Math.min(calls.length, results.length);
for (let i = 0; i < paired; i++) {
outResults.push(results[i]);
}
for (let i = paired; i < calls.length; i++) {
outResults.push(syntheticResult(calls[i]));
orphanedCallsFixed++;
}
for (let i = paired; i < results.length; i++) {
outCalls.push(syntheticCall(results[i]));
outResults.push(results[i]);
orphanedResultsFixed++;
}
}
return {
messages: [...outCalls, ...outResults],
orphanedCallsFixed,
orphanedResultsFixed,
changed,
};
}
/** True for the two roles that make up a tool batch. */
function isToolRole(msg) {
return msg?.role === "tool_call" || msg?.role === "tool_result";
}
/**
* True when a split at `index` would cut a single batch in half.
*
* A batch is calls-then-results, so a `tool_result` FOLLOWED BY a `tool_call`
* is the boundary BETWEEN two batches — a legal place to split. Treating every
* adjacent pair of tool messages as "inside a batch" would fuse a whole
* `call,result,call,result,…` history into one indivisible run, and the walks
* below would then skip past all of it.
*/
function cutsBatch(messages, index) {
const before = messages[index - 1];
const after = messages[index];
if (!isToolRole(before) || !isToolRole(after)) {
return false;
}
return !(before?.role === "tool_result" && after?.role === "tool_call");
}
/**
* Move a summarize/keep split so it never falls INSIDE a tool batch.
*
* `splitIndex` means "messages[0..splitIndex) get summarized" — so the summary
* pointer lands on messages[splitIndex - 1]. Landing mid-batch leaves the
* recent window starting on an orphaned `tool_result`, which providers reject.
*
* Preferred direction is BACKWARD (summarize less, keep the whole batch in the
* recent window) since that never loses detail. When the batch starts at index
* 0 there is nothing left to summarize, so the split moves forward past the
* batch instead — the caller's "at least one message summarized" invariant
* wins over keeping the batch recent.
*/
export function snapSplitToBatchBoundary(messages, splitIndex) {
if (splitIndex <= 0 || splitIndex >= messages.length) {
return splitIndex;
}
if (!cutsBatch(messages, splitIndex)) {
return splitIndex;
}
let start = splitIndex;
while (start > 0 && cutsBatch(messages, start)) {
start--;
}
if (start > 0) {
return start;
}
let end = splitIndex;
while (end < messages.length && cutsBatch(messages, end)) {
end++;
}
return end;
}
/**
* Repair orphaned tool_call/tool_result pairs in a message array.
*
* Guarantees on return: every `tool_call` is followed (within its batch) by
* exactly one `tool_result`, and no `tool_result` precedes its `tool_call`.
* A healthy batch — including a parallel one — is returned untouched.
*/
export function repairToolPairs(messages) {
// Fast path: nothing tool-shaped to repair. Keeps the read-path cost at one
// linear scan for the overwhelmingly common text-only conversation.
const hasToolMessage = messages.some((msg) => msg.role === "tool_call" || msg.role === "tool_result");
if (!hasToolMessage) {
return {
repaired: false,
messages,
orphanedCallsFixed: 0,
orphanedResultsFixed: 0,
};
}
const result = [];
let orphanedCallsFixed = 0;
let orphanedResultsFixed = 0;
let changed = false;
let i = 0;
while (i < messages.length) {
const msg = messages[i];
if (msg.role !== "tool_call" && msg.role !== "tool_result") {
result.push(msg);
i++;
continue;
}
const batch = collectBatch(messages, i);
const repaired = repairBatch(batch);
result.push(...repaired.messages);
orphanedCallsFixed += repaired.orphanedCallsFixed;
orphanedResultsFixed += repaired.orphanedResultsFixed;
changed = changed || repaired.changed;
i = batch.endIndex;
}
const repaired = orphanedCallsFixed > 0 || orphanedResultsFixed > 0 || changed;
return {
repaired,
messages: repaired ? result : messages,
orphanedCallsFixed,
orphanedResultsFixed,
};
}