@juspay/neurolink 10.10.1 → 10.10.3
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.
- package/CHANGELOG.md +12 -0
- package/dist/browser/neurolink.min.js +369 -369
- package/dist/context/budgetChecker.d.ts +16 -0
- package/dist/context/budgetChecker.js +32 -0
- package/dist/context/stages/structuredSummarizer.js +15 -3
- package/dist/context/summarizationEngine.js +19 -7
- package/dist/context/toolPairRepair.d.ts +34 -5
- package/dist/context/toolPairRepair.js +218 -43
- package/dist/core/redisConversationMemoryManager.js +8 -0
- package/dist/lib/context/budgetChecker.d.ts +16 -0
- package/dist/lib/context/budgetChecker.js +32 -0
- package/dist/lib/context/stages/structuredSummarizer.js +15 -3
- package/dist/lib/context/summarizationEngine.js +19 -7
- package/dist/lib/context/toolPairRepair.d.ts +34 -5
- package/dist/lib/context/toolPairRepair.js +218 -43
- package/dist/lib/core/redisConversationMemoryManager.js +8 -0
- package/dist/lib/neurolink.js +28 -6
- package/dist/lib/types/context.d.ts +12 -0
- package/dist/lib/types/conversation.d.ts +11 -0
- package/dist/lib/utils/conversationMemory.js +18 -2
- package/dist/lib/utils/tokenEstimation.d.ts +12 -0
- package/dist/lib/utils/tokenEstimation.js +46 -2
- package/dist/neurolink.js +28 -6
- package/dist/types/context.d.ts +12 -0
- package/dist/types/conversation.d.ts +11 -0
- package/dist/utils/conversationMemory.js +18 -2
- package/dist/utils/tokenEstimation.d.ts +12 -0
- package/dist/utils/tokenEstimation.js +46 -2
- package/package.json +3 -1
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* Extracted from ConversationMemoryManager and RedisConversationMemoryManager
|
|
5
5
|
* to eliminate code duplication. Both managers delegate to this engine.
|
|
6
6
|
*/
|
|
7
|
-
import { TokenUtils } from "../constants/tokens.js";
|
|
8
7
|
import { buildContextFromPointer, generateSummary, } from "../utils/conversationMemory.js";
|
|
9
8
|
import { RECENT_MESSAGES_RATIO } from "../config/conversationMemory.js";
|
|
9
|
+
import { snapSplitToBatchBoundary } from "./toolPairRepair.js";
|
|
10
|
+
import { estimateMessageTokens, estimateMessagesTokens, } from "../utils/tokenEstimation.js";
|
|
10
11
|
import { withSpan } from "../telemetry/withSpan.js";
|
|
11
12
|
import { tracers } from "../telemetry/tracers.js";
|
|
12
13
|
import { logger } from "../utils/logger.js";
|
|
@@ -121,9 +122,11 @@ export class SummarizationEngine {
|
|
|
121
122
|
* @returns Estimated token count
|
|
122
123
|
*/
|
|
123
124
|
estimateTokens(messages) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
125
|
+
// Delegates to the shared estimator so this threshold agrees with the
|
|
126
|
+
// budget checker and the compactor. The previous content-only sum scored
|
|
127
|
+
// tool calls (whose payload lives in `args`) at zero, so a Write/Edit-heavy
|
|
128
|
+
// session never reached the summarization threshold at all.
|
|
129
|
+
return estimateMessagesTokens(messages);
|
|
127
130
|
}
|
|
128
131
|
/**
|
|
129
132
|
* Find split index to keep recent messages within target token count.
|
|
@@ -136,15 +139,24 @@ export class SummarizationEngine {
|
|
|
136
139
|
let recentTokens = 0;
|
|
137
140
|
let splitIndex = messages.length;
|
|
138
141
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
139
|
-
const msgTokens =
|
|
142
|
+
const msgTokens = estimateMessageTokens(messages[i]);
|
|
140
143
|
if (recentTokens + msgTokens > targetRecentTokens) {
|
|
141
144
|
splitIndex = i + 1;
|
|
142
145
|
break;
|
|
143
146
|
}
|
|
144
147
|
recentTokens += msgTokens;
|
|
145
148
|
}
|
|
146
|
-
// Ensure at least one message is summarized
|
|
147
|
-
|
|
149
|
+
// Ensure at least one message is summarized, then snap off any tool batch
|
|
150
|
+
// the boundary would otherwise cut in half (which would leave the recent
|
|
151
|
+
// window opening on an orphaned tool_result).
|
|
152
|
+
const snapped = snapSplitToBatchBoundary(messages, Math.max(1, splitIndex));
|
|
153
|
+
// A forward snap can consume the ENTIRE array when the batch starts at
|
|
154
|
+
// index 0. Summarizing everything would leave no recent window at all and
|
|
155
|
+
// move the pointer to the last message, so the next read returns a summary
|
|
156
|
+
// and nothing else. Decline instead — `summarizeSession` skips a round on
|
|
157
|
+
// an empty slice. `structuredSummarizer` already guards this case; this is
|
|
158
|
+
// the same guard for the pointer-based path.
|
|
159
|
+
return snapped >= messages.length ? 0 : snapped;
|
|
148
160
|
}
|
|
149
161
|
}
|
|
150
162
|
//# sourceMappingURL=summarizationEngine.js.map
|
|
@@ -1,14 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tool Use/Result Pair Repair
|
|
3
3
|
*
|
|
4
|
-
* After compaction
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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 type { ChatMessage, RepairResult } from "../types/index.js";
|
|
9
22
|
/**
|
|
10
|
-
*
|
|
23
|
+
* Move a summarize/keep split so it never falls INSIDE a tool batch.
|
|
24
|
+
*
|
|
25
|
+
* `splitIndex` means "messages[0..splitIndex) get summarized" — so the summary
|
|
26
|
+
* pointer lands on messages[splitIndex - 1]. Landing mid-batch leaves the
|
|
27
|
+
* recent window starting on an orphaned `tool_result`, which providers reject.
|
|
28
|
+
*
|
|
29
|
+
* Preferred direction is BACKWARD (summarize less, keep the whole batch in the
|
|
30
|
+
* recent window) since that never loses detail. When the batch starts at index
|
|
31
|
+
* 0 there is nothing left to summarize, so the split moves forward past the
|
|
32
|
+
* batch instead — the caller's "at least one message summarized" invariant
|
|
33
|
+
* wins over keeping the batch recent.
|
|
34
|
+
*/
|
|
35
|
+
export declare function snapSplitToBatchBoundary(messages: ChatMessage[], splitIndex: number): number;
|
|
36
|
+
/**
|
|
37
|
+
* Repair orphaned tool_call/tool_result pairs in a message array.
|
|
11
38
|
*
|
|
12
|
-
*
|
|
39
|
+
* Guarantees on return: every `tool_call` is followed (within its batch) by
|
|
40
|
+
* exactly one `tool_result`, and no `tool_result` precedes its `tool_call`.
|
|
41
|
+
* A healthy batch — including a parallel one — is returned untouched.
|
|
13
42
|
*/
|
|
14
43
|
export declare function repairToolPairs(messages: ChatMessage[]): RepairResult;
|
|
@@ -1,62 +1,237 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tool Use/Result Pair Repair
|
|
3
3
|
*
|
|
4
|
-
* After compaction
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
if (
|
|
41
|
-
(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
};
|
package/dist/lib/neurolink.js
CHANGED
|
@@ -21,7 +21,7 @@ import { EventEmitter } from "events";
|
|
|
21
21
|
import pLimit from "p-limit";
|
|
22
22
|
import { ErrorCategory, ErrorSeverity } from "./constants/enums.js";
|
|
23
23
|
import { CIRCUIT_BREAKER, CIRCUIT_BREAKER_RESET_MS, MEMORY_THRESHOLDS, NANOSECOND_TO_MS_DIVISOR, PERFORMANCE_THRESHOLDS, PROVIDER_TIMEOUTS, RETRY_ATTEMPTS, RETRY_DELAYS, TOOL_TIMEOUTS, } from "./constants/index.js";
|
|
24
|
-
import { checkContextBudget } from "./context/budgetChecker.js";
|
|
24
|
+
import { checkContextBudget, resolveHistoryBudget, } from "./context/budgetChecker.js";
|
|
25
25
|
import { ContextCompactor } from "./context/contextCompactor.js";
|
|
26
26
|
import { InvalidToolInputError, NoSuchToolError, } from "./utils/generationErrors.js";
|
|
27
27
|
import { emergencyContentTruncation } from "./context/emergencyTruncation.js";
|
|
@@ -4896,7 +4896,14 @@ Current user's request: ${currentInput}`;
|
|
|
4896
4896
|
});
|
|
4897
4897
|
const actualTokens = actualOverflow?.actualTokens ?? recoveryBudget.estimatedInputTokens;
|
|
4898
4898
|
const budgetTokens = actualOverflow?.budgetTokens ?? recoveryBudget.availableInputTokens;
|
|
4899
|
-
|
|
4899
|
+
// Target the HISTORY's share, not the whole budget: the compactor's stage
|
|
4900
|
+
// gates measure messages only, so handing them the full figure lets an
|
|
4901
|
+
// over-budget request through untouched. The 0.7 factor stays on top as
|
|
4902
|
+
// recovery headroom — this path runs only after the provider has already
|
|
4903
|
+
// rejected the request once, so aiming well under is deliberate.
|
|
4904
|
+
const recoveryOverhead = (recoveryBudget.breakdown?.systemPrompt ?? 0) +
|
|
4905
|
+
(recoveryBudget.breakdown?.currentPrompt ?? 0);
|
|
4906
|
+
const compactionTarget = Math.max(0, Math.floor((budgetTokens - recoveryOverhead) * 0.7));
|
|
4900
4907
|
const requiredReduction = actualTokens > 0
|
|
4901
4908
|
? (actualTokens - compactionTarget) / actualTokens
|
|
4902
4909
|
: 0.5;
|
|
@@ -5534,13 +5541,14 @@ Current user's request: ${currentInput}`;
|
|
|
5534
5541
|
availableTools,
|
|
5535
5542
|
conversationMessages,
|
|
5536
5543
|
availableInputTokens: budgetResult.availableInputTokens,
|
|
5544
|
+
historyBudget: resolveHistoryBudget(budgetResult),
|
|
5537
5545
|
usageRatio: budgetResult.usageRatio,
|
|
5538
5546
|
estimatedInputTokens: budgetResult.estimatedInputTokens,
|
|
5539
5547
|
compactionSessionId,
|
|
5540
5548
|
});
|
|
5541
5549
|
}
|
|
5542
5550
|
async compactMCPConversationForBudget(context) {
|
|
5543
|
-
const { options, requestId, providerName, enhancedSystemPrompt, availableTools, conversationMessages, availableInputTokens, usageRatio, estimatedInputTokens, compactionSessionId, } = context;
|
|
5551
|
+
const { options, requestId, providerName, enhancedSystemPrompt, availableTools, conversationMessages, availableInputTokens, historyBudget, usageRatio, estimatedInputTokens, compactionSessionId, } = context;
|
|
5544
5552
|
logger.info("[NeuroLink] Context budget exceeded, triggering auto-compaction", {
|
|
5545
5553
|
usageRatio,
|
|
5546
5554
|
estimatedTokens: estimatedInputTokens,
|
|
@@ -5552,7 +5560,21 @@ Current user's request: ${currentInput}`;
|
|
|
5552
5560
|
?.summarizationProvider,
|
|
5553
5561
|
summarizationModel: this.conversationMemoryConfig?.conversationMemory?.summarizationModel,
|
|
5554
5562
|
});
|
|
5555
|
-
|
|
5563
|
+
// Fixed overhead (system + prompt + tools + files) already exceeds the
|
|
5564
|
+
// window — no amount of history compaction can fit this request, and
|
|
5565
|
+
// compacting to an empty history would only hide the real cause.
|
|
5566
|
+
if (historyBudget <= 0) {
|
|
5567
|
+
throw new ContextBudgetExceededError(`Context exceeds model budget before any history is included. ` +
|
|
5568
|
+
`System prompt, current prompt and tool definitions alone require ` +
|
|
5569
|
+
`more than the ${availableInputTokens}-token input budget. ` +
|
|
5570
|
+
`Reduce the tool set or the prompt size.`, {
|
|
5571
|
+
estimatedTokens: estimatedInputTokens,
|
|
5572
|
+
availableTokens: availableInputTokens,
|
|
5573
|
+
stagesUsed: [],
|
|
5574
|
+
breakdown: {},
|
|
5575
|
+
});
|
|
5576
|
+
}
|
|
5577
|
+
const compactionResult = await compactor.compact(conversationMessages, historyBudget, this.conversationMemoryConfig?.conversationMemory, requestId);
|
|
5556
5578
|
let compactedMessages = conversationMessages;
|
|
5557
5579
|
if (compactionResult.compacted) {
|
|
5558
5580
|
const repairedResult = repairToolPairs(compactionResult.messages);
|
|
@@ -5966,7 +5988,7 @@ Current user's request: ${currentInput}`;
|
|
|
5966
5988
|
summarizationModel: this.conversationMemoryConfig?.conversationMemory
|
|
5967
5989
|
?.summarizationModel,
|
|
5968
5990
|
});
|
|
5969
|
-
const compactionResult = await compactor.compact(conversationMessages, budgetCheck
|
|
5991
|
+
const compactionResult = await compactor.compact(conversationMessages, resolveHistoryBudget(budgetCheck), this.conversationMemoryConfig?.conversationMemory, options.context?.requestId);
|
|
5970
5992
|
if (compactionResult.compacted) {
|
|
5971
5993
|
const repairedResult = repairToolPairs(compactionResult.messages);
|
|
5972
5994
|
conversationMessages = repairedResult.messages;
|
|
@@ -8201,7 +8223,7 @@ Current user's request: ${currentInput}`;
|
|
|
8201
8223
|
?.summarizationProvider,
|
|
8202
8224
|
summarizationModel: this.conversationMemoryConfig?.conversationMemory?.summarizationModel,
|
|
8203
8225
|
});
|
|
8204
|
-
const compactionResult = await compactor.compact(conversationMessages, streamBudget
|
|
8226
|
+
const compactionResult = await compactor.compact(conversationMessages, resolveHistoryBudget(streamBudget), this.conversationMemoryConfig?.conversationMemory, options.context?.requestId);
|
|
8205
8227
|
if (compactionResult.compacted) {
|
|
8206
8228
|
const repairedResult = repairToolPairs(compactionResult.messages);
|
|
8207
8229
|
conversationMessages = repairedResult.messages;
|
|
@@ -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
|
|
168
|
-
|
|
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
|
|
@@ -50,6 +50,18 @@ export declare function estimateTokens(text: string, provider?: string, isCode?:
|
|
|
50
50
|
/**
|
|
51
51
|
* Estimate token count for a single ChatMessage.
|
|
52
52
|
* Includes message framing overhead.
|
|
53
|
+
*
|
|
54
|
+
* Counts `content` AND `args`. A `tool_call` is persisted with an EMPTY
|
|
55
|
+
* `content` and its entire payload in `args` (see flushPendingToolExecutions),
|
|
56
|
+
* so a content-only estimate scored a 39 KB Write call at ~28 tokens against a
|
|
57
|
+
* real cost near 9,750 — the budget checker, the compaction trigger and the
|
|
58
|
+
* summarization threshold were all blind to the single largest source of
|
|
59
|
+
* context growth in an agentic session.
|
|
60
|
+
*
|
|
61
|
+
* `result` is deliberately NOT counted: `result.result` is re-hydrated FROM
|
|
62
|
+
* `content` at read time (redisConversationMemoryManager), so counting both
|
|
63
|
+
* double-bills the same bytes. Only `result.error`, which has no counterpart in
|
|
64
|
+
* `content`, is included.
|
|
53
65
|
*/
|
|
54
66
|
export declare function estimateMessageTokens(message: ChatMessage | {
|
|
55
67
|
role: string;
|
|
@@ -33,6 +33,12 @@ export const TOKENS_PER_MESSAGE = 4;
|
|
|
33
33
|
export const TOKENS_PER_CONVERSATION = 24;
|
|
34
34
|
/** Image token estimate (flat) */
|
|
35
35
|
export const IMAGE_TOKEN_ESTIMATE = 1_024;
|
|
36
|
+
/**
|
|
37
|
+
* Chars charged for a value that cannot be serialized for estimation (V8 max
|
|
38
|
+
* string length). Deliberately large: such a value is enormous by definition,
|
|
39
|
+
* and under-charging it would defeat the budget check it feeds.
|
|
40
|
+
*/
|
|
41
|
+
const OVERSIZED_VALUE_FALLBACK_CHARS = 200_000;
|
|
36
42
|
/**
|
|
37
43
|
* Per-provider token multipliers.
|
|
38
44
|
* Applied on top of the base GPT-style character estimate.
|
|
@@ -80,9 +86,39 @@ export function estimateTokens(text, provider, isCode) {
|
|
|
80
86
|
const safetyBuffer = baseTokens * TOKEN_SAFETY_MARGIN_ADDITIVE;
|
|
81
87
|
return Math.ceil(providerAdjusted + safetyBuffer);
|
|
82
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Serialize an arbitrary value for estimation. Never throws: `JSON.stringify`
|
|
91
|
+
* raises RangeError once a value exceeds V8's max string length, and a tool
|
|
92
|
+
* argument blob is exactly the shape that gets there. A payload that large is
|
|
93
|
+
* charged at the fallback size rather than aborting the estimate (and with it
|
|
94
|
+
* the whole turn).
|
|
95
|
+
*/
|
|
96
|
+
function serializeForEstimate(value) {
|
|
97
|
+
if (typeof value === "string") {
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
return JSON.stringify(value) ?? "";
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return "x".repeat(OVERSIZED_VALUE_FALLBACK_CHARS);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
83
107
|
/**
|
|
84
108
|
* Estimate token count for a single ChatMessage.
|
|
85
109
|
* Includes message framing overhead.
|
|
110
|
+
*
|
|
111
|
+
* Counts `content` AND `args`. A `tool_call` is persisted with an EMPTY
|
|
112
|
+
* `content` and its entire payload in `args` (see flushPendingToolExecutions),
|
|
113
|
+
* so a content-only estimate scored a 39 KB Write call at ~28 tokens against a
|
|
114
|
+
* real cost near 9,750 — the budget checker, the compaction trigger and the
|
|
115
|
+
* summarization threshold were all blind to the single largest source of
|
|
116
|
+
* context growth in an agentic session.
|
|
117
|
+
*
|
|
118
|
+
* `result` is deliberately NOT counted: `result.result` is re-hydrated FROM
|
|
119
|
+
* `content` at read time (redisConversationMemoryManager), so counting both
|
|
120
|
+
* double-bills the same bytes. Only `result.error`, which has no counterpart in
|
|
121
|
+
* `content`, is included.
|
|
86
122
|
*/
|
|
87
123
|
export function estimateMessageTokens(message, provider) {
|
|
88
124
|
let contentStr = "";
|
|
@@ -100,8 +136,16 @@ export function estimateMessageTokens(message, provider) {
|
|
|
100
136
|
}
|
|
101
137
|
}
|
|
102
138
|
}
|
|
103
|
-
|
|
104
|
-
|
|
139
|
+
let total = estimateTokens(contentStr, provider) + TOKENS_PER_MESSAGE;
|
|
140
|
+
const args = message.args;
|
|
141
|
+
if (args) {
|
|
142
|
+
total += estimateTokens(serializeForEstimate(args), provider);
|
|
143
|
+
}
|
|
144
|
+
const resultError = message.result?.error;
|
|
145
|
+
if (resultError) {
|
|
146
|
+
total += estimateTokens(serializeForEstimate(resultError), provider);
|
|
147
|
+
}
|
|
148
|
+
return total;
|
|
105
149
|
}
|
|
106
150
|
/**
|
|
107
151
|
* Estimate total token count for an array of messages.
|