@juspay/neurolink 10.10.1 → 10.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,62 +1,237 @@
1
1
  /**
2
2
  * Tool Use/Result Pair Repair
3
3
  *
4
- * After compaction, validates that every tool_use (tool_call) has a
5
- * corresponding tool_result and vice versa. Inserts synthetic
6
- * placeholders for orphaned entries.
4
+ * After compaction (or a pointer-based history slice) the message array can
5
+ * contain a `tool_call` whose `tool_result` was dropped, or a `tool_result`
6
+ * whose `tool_call` was dropped. Providers reject both, so orphans are filled
7
+ * with synthetic placeholders.
8
+ *
9
+ * Pairing is BATCH- and ID-aware, not adjacency-based. A single agent step
10
+ * with parallel tool calls is persisted as every `tool_call` followed by every
11
+ * `tool_result` (see flushPendingToolData), so `tool_call` is routinely
12
+ * followed by another `tool_call` in perfectly healthy history. Matching on
13
+ * adjacency treats that as an orphan and injects a bogus "result unavailable"
14
+ * over a result that is present a few entries later.
15
+ *
16
+ * Two modes, chosen per batch:
17
+ * - ID mode — `toolCallId` present: pair by id, order-independent.
18
+ * - legacy mode — sessions written before `toolCallId` existed: pair
19
+ * positionally WITHIN the batch (call[i] ↔ result[i]).
7
20
  */
8
21
  import { randomUUID } from "crypto";
22
+ const MISSING_RESULT_CONTENT = "[Tool result unavailable - conversation was compacted]";
9
23
  /**
10
- * Repair orphaned tool_use/tool_result pairs in a message array.
11
- *
12
- * Ensures every tool_call has a following tool_result and vice versa.
24
+ * Collect the tool batch starting at `start` (which must index a tool-role
25
+ * message). Consumes the maximal run of calls followed by the maximal run of
26
+ * results. A leading run of results with no calls (head-cut orphan) yields a
27
+ * batch with an empty `calls` array.
13
28
  */
14
- export function repairToolPairs(messages) {
15
- const result = [];
29
+ function collectBatch(messages, start) {
30
+ let i = start;
31
+ const calls = [];
32
+ const results = [];
33
+ while (i < messages.length && messages[i].role === "tool_call") {
34
+ calls.push(messages[i]);
35
+ i++;
36
+ }
37
+ while (i < messages.length && messages[i].role === "tool_result") {
38
+ results.push(messages[i]);
39
+ i++;
40
+ }
41
+ return { calls, results, endIndex: i };
42
+ }
43
+ /** Synthetic `tool_result` standing in for a call whose result was dropped. */
44
+ function syntheticResult(call) {
45
+ return {
46
+ id: `repair-result-${randomUUID()}`,
47
+ role: "tool_result",
48
+ content: MISSING_RESULT_CONTENT,
49
+ tool: call.tool,
50
+ ...(call.toolCallId ? { toolCallId: call.toolCallId } : {}),
51
+ timestamp: call.timestamp,
52
+ metadata: { truncated: true },
53
+ };
54
+ }
55
+ /** Synthetic `tool_call` standing in for a result whose call was dropped. */
56
+ function syntheticCall(result) {
57
+ return {
58
+ id: `repair-call-${randomUUID()}`,
59
+ role: "tool_call",
60
+ content: `[Tool call for ${result.tool || "unknown"} - conversation was compacted]`,
61
+ tool: result.tool,
62
+ ...(result.toolCallId ? { toolCallId: result.toolCallId } : {}),
63
+ timestamp: result.timestamp,
64
+ metadata: { truncated: true },
65
+ };
66
+ }
67
+ /**
68
+ * Repair one batch, emitting calls then results with every call matched to
69
+ * exactly one result. Returns the rebuilt run plus how many placeholders of
70
+ * each kind were needed.
71
+ */
72
+ function repairBatch(batch) {
73
+ const { calls, results } = batch;
74
+ // ID mode requires ids on BOTH sides; a partially-migrated batch (some
75
+ // entries written before toolCallId existed) falls back to legacy pairing
76
+ // rather than treating the id-less half as universally orphaned.
77
+ const useIds = calls.length > 0 &&
78
+ results.length > 0 &&
79
+ calls.every((m) => !!m.toolCallId) &&
80
+ results.every((m) => !!m.toolCallId);
81
+ const outCalls = [...calls];
82
+ const outResults = [];
16
83
  let orphanedCallsFixed = 0;
17
84
  let orphanedResultsFixed = 0;
18
- for (let i = 0; i < messages.length; i++) {
19
- const msg = messages[i];
20
- const nextMsg = i + 1 < messages.length ? messages[i + 1] : undefined;
21
- if (msg.role === "tool_call") {
22
- result.push(msg);
23
- // Check if next message is the corresponding tool_result
24
- if (!nextMsg || nextMsg.role !== "tool_result") {
25
- // Insert synthetic tool_result
26
- result.push({
27
- id: `repair-result-${randomUUID()}`,
28
- role: "tool_result",
29
- content: "[Tool result unavailable - conversation was compacted]",
30
- tool: msg.tool,
31
- timestamp: msg.timestamp,
32
- metadata: { truncated: true },
33
- });
34
- orphanedCallsFixed++;
85
+ let changed = false;
86
+ if (useIds) {
87
+ // Duplicate ids (a retry that re-emitted a result) keep the FIRST result;
88
+ // later duplicates are dropped so a call never gains a second result.
89
+ const resultById = new Map();
90
+ for (const result of results) {
91
+ const key = result.toolCallId;
92
+ if (!resultById.has(key)) {
93
+ resultById.set(key, result);
94
+ }
95
+ else {
96
+ changed = true;
35
97
  }
36
98
  }
37
- else if (msg.role === "tool_result") {
38
- // Check if previous message was the corresponding tool_call
39
- const prevMsg = result.length > 0 ? result[result.length - 1] : undefined;
40
- if (!prevMsg ||
41
- (prevMsg.role !== "tool_call" && prevMsg.role !== "tool_result")) {
42
- // Insert synthetic tool_call before this result
43
- result.push({
44
- id: `repair-call-${randomUUID()}`,
45
- role: "tool_call",
46
- content: `[Tool call for ${msg.tool || "unknown"} - conversation was compacted]`,
47
- tool: msg.tool,
48
- timestamp: msg.timestamp,
49
- metadata: { truncated: true },
50
- });
51
- orphanedResultsFixed++;
99
+ for (const call of calls) {
100
+ const key = call.toolCallId;
101
+ const match = resultById.get(key);
102
+ if (match) {
103
+ outResults.push(match);
104
+ resultById.delete(key);
105
+ }
106
+ else {
107
+ outResults.push(syntheticResult(call));
108
+ orphanedCallsFixed++;
52
109
  }
53
- result.push(msg);
54
110
  }
55
- else {
111
+ // Results with no surviving call — the compactor cut the head of the batch.
112
+ for (const leftover of resultById.values()) {
113
+ outCalls.push(syntheticCall(leftover));
114
+ outResults.push(leftover);
115
+ orphanedResultsFixed++;
116
+ }
117
+ }
118
+ else {
119
+ // Legacy positional pairing, scoped to the batch.
120
+ const paired = Math.min(calls.length, results.length);
121
+ for (let i = 0; i < paired; i++) {
122
+ outResults.push(results[i]);
123
+ }
124
+ for (let i = paired; i < calls.length; i++) {
125
+ outResults.push(syntheticResult(calls[i]));
126
+ orphanedCallsFixed++;
127
+ }
128
+ for (let i = paired; i < results.length; i++) {
129
+ outCalls.push(syntheticCall(results[i]));
130
+ outResults.push(results[i]);
131
+ orphanedResultsFixed++;
132
+ }
133
+ }
134
+ return {
135
+ messages: [...outCalls, ...outResults],
136
+ orphanedCallsFixed,
137
+ orphanedResultsFixed,
138
+ changed,
139
+ };
140
+ }
141
+ /** True for the two roles that make up a tool batch. */
142
+ function isToolRole(msg) {
143
+ return msg?.role === "tool_call" || msg?.role === "tool_result";
144
+ }
145
+ /**
146
+ * True when a split at `index` would cut a single batch in half.
147
+ *
148
+ * A batch is calls-then-results, so a `tool_result` FOLLOWED BY a `tool_call`
149
+ * is the boundary BETWEEN two batches — a legal place to split. Treating every
150
+ * adjacent pair of tool messages as "inside a batch" would fuse a whole
151
+ * `call,result,call,result,…` history into one indivisible run, and the walks
152
+ * below would then skip past all of it.
153
+ */
154
+ function cutsBatch(messages, index) {
155
+ const before = messages[index - 1];
156
+ const after = messages[index];
157
+ if (!isToolRole(before) || !isToolRole(after)) {
158
+ return false;
159
+ }
160
+ return !(before?.role === "tool_result" && after?.role === "tool_call");
161
+ }
162
+ /**
163
+ * Move a summarize/keep split so it never falls INSIDE a tool batch.
164
+ *
165
+ * `splitIndex` means "messages[0..splitIndex) get summarized" — so the summary
166
+ * pointer lands on messages[splitIndex - 1]. Landing mid-batch leaves the
167
+ * recent window starting on an orphaned `tool_result`, which providers reject.
168
+ *
169
+ * Preferred direction is BACKWARD (summarize less, keep the whole batch in the
170
+ * recent window) since that never loses detail. When the batch starts at index
171
+ * 0 there is nothing left to summarize, so the split moves forward past the
172
+ * batch instead — the caller's "at least one message summarized" invariant
173
+ * wins over keeping the batch recent.
174
+ */
175
+ export function snapSplitToBatchBoundary(messages, splitIndex) {
176
+ if (splitIndex <= 0 || splitIndex >= messages.length) {
177
+ return splitIndex;
178
+ }
179
+ if (!cutsBatch(messages, splitIndex)) {
180
+ return splitIndex;
181
+ }
182
+ let start = splitIndex;
183
+ while (start > 0 && cutsBatch(messages, start)) {
184
+ start--;
185
+ }
186
+ if (start > 0) {
187
+ return start;
188
+ }
189
+ let end = splitIndex;
190
+ while (end < messages.length && cutsBatch(messages, end)) {
191
+ end++;
192
+ }
193
+ return end;
194
+ }
195
+ /**
196
+ * Repair orphaned tool_call/tool_result pairs in a message array.
197
+ *
198
+ * Guarantees on return: every `tool_call` is followed (within its batch) by
199
+ * exactly one `tool_result`, and no `tool_result` precedes its `tool_call`.
200
+ * A healthy batch — including a parallel one — is returned untouched.
201
+ */
202
+ export function repairToolPairs(messages) {
203
+ // Fast path: nothing tool-shaped to repair. Keeps the read-path cost at one
204
+ // linear scan for the overwhelmingly common text-only conversation.
205
+ const hasToolMessage = messages.some((msg) => msg.role === "tool_call" || msg.role === "tool_result");
206
+ if (!hasToolMessage) {
207
+ return {
208
+ repaired: false,
209
+ messages,
210
+ orphanedCallsFixed: 0,
211
+ orphanedResultsFixed: 0,
212
+ };
213
+ }
214
+ const result = [];
215
+ let orphanedCallsFixed = 0;
216
+ let orphanedResultsFixed = 0;
217
+ let changed = false;
218
+ let i = 0;
219
+ while (i < messages.length) {
220
+ const msg = messages[i];
221
+ if (msg.role !== "tool_call" && msg.role !== "tool_result") {
56
222
  result.push(msg);
223
+ i++;
224
+ continue;
57
225
  }
226
+ const batch = collectBatch(messages, i);
227
+ const repaired = repairBatch(batch);
228
+ result.push(...repaired.messages);
229
+ orphanedCallsFixed += repaired.orphanedCallsFixed;
230
+ orphanedResultsFixed += repaired.orphanedResultsFixed;
231
+ changed = changed || repaired.changed;
232
+ i = batch.endIndex;
58
233
  }
59
- const repaired = orphanedCallsFixed > 0 || orphanedResultsFixed > 0;
234
+ const repaired = orphanedCallsFixed > 0 || orphanedResultsFixed > 0 || changed;
60
235
  return {
61
236
  repaired,
62
237
  messages: repaired ? result : messages,
@@ -1406,6 +1406,10 @@ User message: "${userMessage}"`;
1406
1406
  role: "tool_call",
1407
1407
  content: "", // Can be empty for tool calls
1408
1408
  tool: toolName,
1409
+ // Persisted so repairToolPairs can pair by ID rather than adjacency —
1410
+ // a parallel batch writes all calls before any result, so position
1411
+ // carries no pairing information.
1412
+ ...(toolCallId ? { toolCallId } : {}),
1409
1413
  args: (toolCall.args ||
1410
1414
  toolCall.arguments ||
1411
1415
  toolCall.parameters ||
@@ -1493,6 +1497,10 @@ User message: "${userMessage}"`;
1493
1497
  role: "tool_result",
1494
1498
  content: serializedResult, // Full output (was "")
1495
1499
  tool: toolName,
1500
+ // Only a REAL id is persisted: the "unknown" sentinel above would
1501
+ // otherwise collide across every unidentifiable result and pair them
1502
+ // to each other. Absent id falls back to legacy positional pairing.
1503
+ ...(toolCallId && toolCallId !== "unknown" ? { toolCallId } : {}),
1496
1504
  result,
1497
1505
  metadata,
1498
1506
  };
@@ -412,6 +412,18 @@ export type RepairResult = {
412
412
  orphanedCallsFixed: number;
413
413
  orphanedResultsFixed: number;
414
414
  };
415
+ /**
416
+ * One contiguous tool batch: the run of `tool_call` messages emitted by a
417
+ * single agent step, plus the run of `tool_result` messages that follows it.
418
+ * A step with parallel tool calls writes every call before any result, so the
419
+ * batch — not adjacency — is the unit that pairing and truncation operate on.
420
+ * `endIndex` is exclusive.
421
+ */
422
+ export type RepairToolBatch = {
423
+ calls: ChatMessage[];
424
+ results: ChatMessage[];
425
+ endIndex: number;
426
+ };
415
427
  /** Options for summarization prompt building. */
416
428
  export type SummarizationPromptOptions = {
417
429
  /**
@@ -284,6 +284,17 @@ export type ChatMessage = {
284
284
  timestamp?: string;
285
285
  /** Tool name (optional) - for tool_call/tool_result messages */
286
286
  tool?: string;
287
+ /**
288
+ * Provider tool-call correlation ID, carried on BOTH the `tool_call` and its
289
+ * matching `tool_result`. This is the only reliable way to pair the two:
290
+ * a step with parallel tool calls is persisted as every `tool_call` followed
291
+ * by every `tool_result` (see flushPendingToolData), so adjacency does
292
+ * NOT imply pairing and position-based matching corrupts the batch.
293
+ *
294
+ * Optional for backward compatibility — sessions written before this field
295
+ * existed pair positionally within a batch (see repairToolPairs legacy mode).
296
+ */
297
+ toolCallId?: string;
287
298
  /** Tool arguments (optional) - for tool_call messages */
288
299
  args?: Record<string, unknown>;
289
300
  /** Tool result metadata (optional) - for tool_result messages */
@@ -9,6 +9,7 @@ import { safeDebugSerialize, sanitizeRecord } from "./logSanitize.js";
9
9
  import { DEFAULT_FALLBACK_THRESHOLD, getConversationMemoryDefaults, MEMORY_THRESHOLD_PERCENTAGE, } from "../config/conversationMemory.js";
10
10
  import { getAvailableInputTokens } from "../constants/contextWindows.js";
11
11
  import { buildSummarizationPrompt } from "../context/prompts/summarizationPrompt.js";
12
+ import { repairToolPairs } from "../context/toolPairRepair.js";
12
13
  import { logger } from "./logger.js";
13
14
  const memoryTracer = tracers.memory;
14
15
  /**
@@ -164,8 +165,23 @@ export async function getConversationMessages(conversationMemory, options) {
164
165
  // against any future "fabricate-on-error" regression. Telemetry
165
166
  // attributes record how many turns were dropped so polluted sessions
166
167
  // are visible in Langfuse traces.
167
- const messages = rawMessages.filter((msg) => !isPollutedAssistantTurn(msg));
168
- const droppedCount = rawMessages.length - messages.length;
168
+ const filtered = rawMessages.filter((msg) => !isPollutedAssistantTurn(msg));
169
+ // Pair repair on READ, not just after compaction. buildContextFromPointer
170
+ // slices the history at the summary pointer, and a session interrupted
171
+ // mid-tool-batch is stored with calls whose results never arrived —
172
+ // either way the provider receives an orphan and hard-rejects the turn.
173
+ // No-ops (single linear scan) when the slice holds no tool messages.
174
+ const repair = repairToolPairs(filtered);
175
+ const messages = repair.messages;
176
+ if (repair.repaired) {
177
+ span.setAttribute("neurolink.memory.tool_pairs_repaired", repair.orphanedCallsFixed + repair.orphanedResultsFixed);
178
+ logger.debug("[conversationMemoryUtils] Repaired orphaned tool pairs on read", {
179
+ sessionId,
180
+ orphanedCallsFixed: repair.orphanedCallsFixed,
181
+ orphanedResultsFixed: repair.orphanedResultsFixed,
182
+ });
183
+ }
184
+ const droppedCount = rawMessages.length - filtered.length;
169
185
  if (droppedCount > 0) {
170
186
  // Span attribute is always set so polluted sessions stay visible in
171
187
  // Langfuse traces on every read — that's the persistent debugging
@@ -412,6 +412,18 @@ export type RepairResult = {
412
412
  orphanedCallsFixed: number;
413
413
  orphanedResultsFixed: number;
414
414
  };
415
+ /**
416
+ * One contiguous tool batch: the run of `tool_call` messages emitted by a
417
+ * single agent step, plus the run of `tool_result` messages that follows it.
418
+ * A step with parallel tool calls writes every call before any result, so the
419
+ * batch — not adjacency — is the unit that pairing and truncation operate on.
420
+ * `endIndex` is exclusive.
421
+ */
422
+ export type RepairToolBatch = {
423
+ calls: ChatMessage[];
424
+ results: ChatMessage[];
425
+ endIndex: number;
426
+ };
415
427
  /** Options for summarization prompt building. */
416
428
  export type SummarizationPromptOptions = {
417
429
  /**
@@ -284,6 +284,17 @@ export type ChatMessage = {
284
284
  timestamp?: string;
285
285
  /** Tool name (optional) - for tool_call/tool_result messages */
286
286
  tool?: string;
287
+ /**
288
+ * Provider tool-call correlation ID, carried on BOTH the `tool_call` and its
289
+ * matching `tool_result`. This is the only reliable way to pair the two:
290
+ * a step with parallel tool calls is persisted as every `tool_call` followed
291
+ * by every `tool_result` (see flushPendingToolData), so adjacency does
292
+ * NOT imply pairing and position-based matching corrupts the batch.
293
+ *
294
+ * Optional for backward compatibility — sessions written before this field
295
+ * existed pair positionally within a batch (see repairToolPairs legacy mode).
296
+ */
297
+ toolCallId?: string;
287
298
  /** Tool arguments (optional) - for tool_call messages */
288
299
  args?: Record<string, unknown>;
289
300
  /** Tool result metadata (optional) - for tool_result messages */
@@ -9,6 +9,7 @@ import { safeDebugSerialize, sanitizeRecord } from "./logSanitize.js";
9
9
  import { DEFAULT_FALLBACK_THRESHOLD, getConversationMemoryDefaults, MEMORY_THRESHOLD_PERCENTAGE, } from "../config/conversationMemory.js";
10
10
  import { getAvailableInputTokens } from "../constants/contextWindows.js";
11
11
  import { buildSummarizationPrompt } from "../context/prompts/summarizationPrompt.js";
12
+ import { repairToolPairs } from "../context/toolPairRepair.js";
12
13
  import { logger } from "./logger.js";
13
14
  const memoryTracer = tracers.memory;
14
15
  /**
@@ -164,8 +165,23 @@ export async function getConversationMessages(conversationMemory, options) {
164
165
  // against any future "fabricate-on-error" regression. Telemetry
165
166
  // attributes record how many turns were dropped so polluted sessions
166
167
  // are visible in Langfuse traces.
167
- const messages = rawMessages.filter((msg) => !isPollutedAssistantTurn(msg));
168
- const droppedCount = rawMessages.length - messages.length;
168
+ const filtered = rawMessages.filter((msg) => !isPollutedAssistantTurn(msg));
169
+ // Pair repair on READ, not just after compaction. buildContextFromPointer
170
+ // slices the history at the summary pointer, and a session interrupted
171
+ // mid-tool-batch is stored with calls whose results never arrived —
172
+ // either way the provider receives an orphan and hard-rejects the turn.
173
+ // No-ops (single linear scan) when the slice holds no tool messages.
174
+ const repair = repairToolPairs(filtered);
175
+ const messages = repair.messages;
176
+ if (repair.repaired) {
177
+ span.setAttribute("neurolink.memory.tool_pairs_repaired", repair.orphanedCallsFixed + repair.orphanedResultsFixed);
178
+ logger.debug("[conversationMemoryUtils] Repaired orphaned tool pairs on read", {
179
+ sessionId,
180
+ orphanedCallsFixed: repair.orphanedCallsFixed,
181
+ orphanedResultsFixed: repair.orphanedResultsFixed,
182
+ });
183
+ }
184
+ const droppedCount = rawMessages.length - filtered.length;
169
185
  if (droppedCount > 0) {
170
186
  // Span attribute is always set so polluted sessions stay visible in
171
187
  // Langfuse traces on every read — that's the persistent debugging
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "10.10.1",
3
+ "version": "10.10.2",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "TypeScript AI SDK with 24+ LLM providers behind one consistent API. MCP-native (connect any MCP server), voice TTS/STT/realtime, RAG, agents, memory, context compaction. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · DeepSeek · NVIDIA NIM and more.",
6
6
  "author": {
@@ -82,6 +82,7 @@
82
82
  "test:media": "npx tsx test/continuous-test-suite-media-gen.ts",
83
83
  "test:litellm-parity": "npx tsx test/continuous-test-suite-litellm-parity.ts",
84
84
  "test:memory": "npx tsx test/continuous-test-suite-memory.ts",
85
+ "test:tool-pairing": "npx tsx test/continuous-test-suite-tool-pairing.ts",
85
86
  "test:middleware": "npx tsx test/continuous-test-suite-middleware.ts",
86
87
  "test:observability": "npx tsx test/continuous-test-suite-observability.ts",
87
88
  "test:ppt": "npx tsx test/continuous-test-suite-ppt.ts",