@juspay/neurolink 10.10.0 → 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.
- package/CHANGELOG.md +12 -0
- package/dist/browser/neurolink.min.js +394 -392
- package/dist/context/stages/structuredSummarizer.js +15 -3
- package/dist/context/summarizationEngine.js +12 -2
- package/dist/context/toolPairRepair.d.ts +34 -5
- package/dist/context/toolPairRepair.js +218 -43
- package/dist/core/modules/GenerationHandler.js +21 -2
- package/dist/core/modules/structuredOutputPolicy.d.ts +8 -0
- package/dist/core/modules/structuredOutputPolicy.js +8 -0
- package/dist/core/redisConversationMemoryManager.js +8 -0
- package/dist/lib/context/stages/structuredSummarizer.js +15 -3
- package/dist/lib/context/summarizationEngine.js +12 -2
- package/dist/lib/context/toolPairRepair.d.ts +34 -5
- package/dist/lib/context/toolPairRepair.js +218 -43
- package/dist/lib/core/modules/GenerationHandler.js +21 -2
- package/dist/lib/core/modules/structuredOutputPolicy.d.ts +8 -0
- package/dist/lib/core/modules/structuredOutputPolicy.js +8 -0
- package/dist/lib/core/redisConversationMemoryManager.js +8 -0
- package/dist/lib/providers/anthropic/client.js +105 -3
- package/dist/lib/providers/anthropic/structuredOutput.d.ts +58 -0
- package/dist/lib/providers/anthropic/structuredOutput.js +98 -0
- package/dist/lib/types/context.d.ts +12 -0
- package/dist/lib/types/conversation.d.ts +11 -0
- package/dist/lib/types/generate.d.ts +11 -0
- package/dist/lib/utils/conversationMemory.js +18 -2
- package/dist/providers/anthropic/client.js +105 -3
- package/dist/providers/anthropic/structuredOutput.d.ts +58 -0
- package/dist/providers/anthropic/structuredOutput.js +97 -0
- package/dist/types/context.d.ts +12 -0
- package/dist/types/conversation.d.ts +11 -0
- package/dist/types/generate.d.ts +11 -0
- package/dist/utils/conversationMemory.js +18 -2
- package/package.json +4 -2
|
@@ -8,6 +8,7 @@ import { randomUUID } from "crypto";
|
|
|
8
8
|
import { generateSummary } from "../../utils/conversationMemory.js";
|
|
9
9
|
import { estimateTokens } from "../../utils/tokenEstimation.js";
|
|
10
10
|
import { logger } from "../../utils/logger.js";
|
|
11
|
+
import { snapSplitToBatchBoundary } from "../toolPairRepair.js";
|
|
11
12
|
/**
|
|
12
13
|
* Find the split index using token counting — walk backward from the end,
|
|
13
14
|
* accumulating token counts until we've reserved `targetRecentTokens` worth
|
|
@@ -38,8 +39,10 @@ function findSplitIndexByTokens(messages, targetRecentTokens, provider) {
|
|
|
38
39
|
}
|
|
39
40
|
recentTokens += msgTokens;
|
|
40
41
|
}
|
|
41
|
-
// Ensure at least one message is summarized
|
|
42
|
-
|
|
42
|
+
// Ensure at least one message is summarized, then snap off any tool batch
|
|
43
|
+
// the boundary would otherwise cut in half — a summary that starts the
|
|
44
|
+
// recent window on an orphaned tool_result is rejected by the provider.
|
|
45
|
+
return snapSplitToBatchBoundary(messages, Math.max(1, splitIndex));
|
|
43
46
|
}
|
|
44
47
|
export async function summarizeMessages(messages, config) {
|
|
45
48
|
const keepRecentRatio = config?.keepRecentRatio ?? 0.3;
|
|
@@ -65,7 +68,16 @@ export async function summarizeMessages(messages, config) {
|
|
|
65
68
|
}
|
|
66
69
|
// Clamp so at least the last message is always preserved (never summarize everything)
|
|
67
70
|
splitIndex = Math.min(splitIndex, messages.length - 1);
|
|
68
|
-
|
|
71
|
+
// Re-snap AFTER the clamp: the clamp can pull the boundary back into a tool
|
|
72
|
+
// batch that findSplitIndexByTokens had already cleared, and it is also the
|
|
73
|
+
// only guard covering the legacy message-count branch above.
|
|
74
|
+
splitIndex = snapSplitToBatchBoundary(messages, splitIndex);
|
|
75
|
+
// A boundary that still cuts a batch means no legal split exists (e.g. the
|
|
76
|
+
// whole array is one tool batch). Summarizing anyway would hand the provider
|
|
77
|
+
// an orphaned tool_result, so decline and let a later stage reclaim instead.
|
|
78
|
+
if (splitIndex <= 0 ||
|
|
79
|
+
splitIndex >= messages.length ||
|
|
80
|
+
splitIndex !== snapSplitToBatchBoundary(messages, splitIndex)) {
|
|
69
81
|
return { summarized: false, messages };
|
|
70
82
|
}
|
|
71
83
|
const messagesToSummarize = messages.slice(0, splitIndex);
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { TokenUtils } from "../constants/tokens.js";
|
|
8
8
|
import { buildContextFromPointer, generateSummary, } from "../utils/conversationMemory.js";
|
|
9
9
|
import { RECENT_MESSAGES_RATIO } from "../config/conversationMemory.js";
|
|
10
|
+
import { snapSplitToBatchBoundary } from "./toolPairRepair.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";
|
|
@@ -143,7 +144,16 @@ export class SummarizationEngine {
|
|
|
143
144
|
}
|
|
144
145
|
recentTokens += msgTokens;
|
|
145
146
|
}
|
|
146
|
-
// Ensure at least one message is summarized
|
|
147
|
-
|
|
147
|
+
// Ensure at least one message is summarized, then snap off any tool batch
|
|
148
|
+
// the boundary would otherwise cut in half (which would leave the recent
|
|
149
|
+
// window opening on an orphaned tool_result).
|
|
150
|
+
const snapped = snapSplitToBatchBoundary(messages, Math.max(1, splitIndex));
|
|
151
|
+
// A forward snap can consume the ENTIRE array when the batch starts at
|
|
152
|
+
// index 0. Summarizing everything would leave no recent window at all and
|
|
153
|
+
// move the pointer to the last message, so the next read returns a summary
|
|
154
|
+
// and nothing else. Decline instead — `summarizeSession` skips a round on
|
|
155
|
+
// an empty slice. `structuredSummarizer` already guards this case; this is
|
|
156
|
+
// the same guard for the pointer-based path.
|
|
157
|
+
return snapped >= messages.length ? 0 : snapped;
|
|
148
158
|
}
|
|
149
159
|
}
|
|
@@ -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,
|
|
@@ -26,6 +26,7 @@ import { DEFAULT_MAX_STEPS, DEFAULT_WRAPUP_TIME_LEAD_MS, } from "../constants.js
|
|
|
26
26
|
import { createStepBudgetGuard, estimateFixedOverheadTokens, } from "../../context/stepBudgetGuard.js";
|
|
27
27
|
import { isTemperatureDeprecatedError, isSchemaComplexityError, isToolsSchemaConflictError, isToolsSchemaExclusionInForce, } from "./structuredOutputPolicy.js";
|
|
28
28
|
import { coerceJsonToSchema } from "../../utils/json/coerce.js";
|
|
29
|
+
import { convertZodToJsonSchema } from "../../utils/schemaConversion.js";
|
|
29
30
|
import { NoObjectGeneratedError } from "../../utils/generationErrors.js";
|
|
30
31
|
import { Output, stepCountIs } from "../../utils/tool.js";
|
|
31
32
|
import { generateText } from "../../utils/generation.js";
|
|
@@ -105,11 +106,14 @@ export function resolveTurnBudget(options, turnStartMs) {
|
|
|
105
106
|
* `providerOptions:` spreads in the args literal would silently clobber each
|
|
106
107
|
* other (object spread does not deep-merge).
|
|
107
108
|
*/
|
|
108
|
-
function buildProviderOptions(options, isGoogleProvider, callerTimeoutMs) {
|
|
109
|
+
function buildProviderOptions(options, isGoogleProvider, callerTimeoutMs, finalResultSchema) {
|
|
109
110
|
const providerOptions = {};
|
|
110
111
|
if (callerTimeoutMs !== undefined) {
|
|
111
112
|
providerOptions.neurolink = { timeoutMs: callerTimeoutMs };
|
|
112
113
|
}
|
|
114
|
+
if (finalResultSchema) {
|
|
115
|
+
providerOptions.anthropic = { finalResultSchema };
|
|
116
|
+
}
|
|
113
117
|
if (options.thinkingConfig?.enabled && isGoogleProvider) {
|
|
114
118
|
// Gemini 3 uses thinkingLevel; Gemini 2.5 uses thinkingBudget.
|
|
115
119
|
providerOptions.google = {
|
|
@@ -236,7 +240,22 @@ export class GenerationHandler {
|
|
|
236
240
|
const prepareStep = options.prepareStep;
|
|
237
241
|
const { callerTimeoutMs, turnBudgetMs, wrapupLeadMs, turnDeadline } = resolveTurnBudget(options, turnStartMs);
|
|
238
242
|
let wrapupForced = false;
|
|
239
|
-
|
|
243
|
+
// The native Anthropic Messages surface cannot combine AI-SDK structured
|
|
244
|
+
// output with tools (see structuredOutputPolicy — experimental_output
|
|
245
|
+
// replaces the tools array), so `useStructuredOutput` is false above and
|
|
246
|
+
// the schema would simply be dropped for every agent/MCP turn. Hand the
|
|
247
|
+
// JSON Schema to the provider instead: it appends an additive
|
|
248
|
+
// `final_result` tool and returns the answer as that tool's arguments,
|
|
249
|
+
// keeping the real tools callable. Bedrock is deliberately excluded — it
|
|
250
|
+
// runs on the third-party @ai-sdk/amazon-bedrock model, which has no such
|
|
251
|
+
// handling.
|
|
252
|
+
const finalResultSchema = this.providerName === "anthropic" &&
|
|
253
|
+
!!options.schema &&
|
|
254
|
+
shouldUseTools &&
|
|
255
|
+
Object.keys(tools).length > 0
|
|
256
|
+
? convertZodToJsonSchema(options.schema)
|
|
257
|
+
: undefined;
|
|
258
|
+
const providerOptions = buildProviderOptions(options, isGoogleProvider, callerTimeoutMs, finalResultSchema);
|
|
240
259
|
// Hoist system-role messages into generateText's top-level `system` option
|
|
241
260
|
// rather than passing them inside `messages` (deprecated by the AI SDK,
|
|
242
261
|
// rejected in v7). See extractSystemMessages for the rationale. (#1024)
|
|
@@ -23,6 +23,14 @@ export declare function isGeminiProvider(providerName: string, modelName: string
|
|
|
23
23
|
* experimental_output + tools silently drops tool_use blocks on this surface, so
|
|
24
24
|
* structured output must be disabled when tools are active. Vertex+Claude is NOT
|
|
25
25
|
* matched here (different transport, no conflict).
|
|
26
|
+
*
|
|
27
|
+
* Being excluded here no longer means the schema is LOST for provider
|
|
28
|
+
* "anthropic": GenerationHandler forwards the JSON Schema to the provider via
|
|
29
|
+
* `providerOptions.anthropic.finalResultSchema`, and the provider appends an
|
|
30
|
+
* additive `final_result` tool (see providers/anthropic/structuredOutput.ts) —
|
|
31
|
+
* schema enforcement without giving up tool calling. "bedrock" has no such
|
|
32
|
+
* handling (it runs on the third-party @ai-sdk/amazon-bedrock model) and still
|
|
33
|
+
* falls back to text-mode coercion.
|
|
26
34
|
*/
|
|
27
35
|
export declare function isNativeAnthropicProvider(providerName: string): boolean;
|
|
28
36
|
/**
|
|
@@ -33,6 +33,14 @@ export function isGeminiProvider(providerName, modelName) {
|
|
|
33
33
|
* experimental_output + tools silently drops tool_use blocks on this surface, so
|
|
34
34
|
* structured output must be disabled when tools are active. Vertex+Claude is NOT
|
|
35
35
|
* matched here (different transport, no conflict).
|
|
36
|
+
*
|
|
37
|
+
* Being excluded here no longer means the schema is LOST for provider
|
|
38
|
+
* "anthropic": GenerationHandler forwards the JSON Schema to the provider via
|
|
39
|
+
* `providerOptions.anthropic.finalResultSchema`, and the provider appends an
|
|
40
|
+
* additive `final_result` tool (see providers/anthropic/structuredOutput.ts) —
|
|
41
|
+
* schema enforcement without giving up tool calling. "bedrock" has no such
|
|
42
|
+
* handling (it runs on the third-party @ai-sdk/amazon-bedrock model) and still
|
|
43
|
+
* falls back to text-mode coercion.
|
|
36
44
|
*/
|
|
37
45
|
export function isNativeAnthropicProvider(providerName) {
|
|
38
46
|
return providerName === "anthropic" || providerName === "bedrock";
|
|
@@ -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
|
};
|
|
@@ -8,6 +8,7 @@ import { randomUUID } from "crypto";
|
|
|
8
8
|
import { generateSummary } from "../../utils/conversationMemory.js";
|
|
9
9
|
import { estimateTokens } from "../../utils/tokenEstimation.js";
|
|
10
10
|
import { logger } from "../../utils/logger.js";
|
|
11
|
+
import { snapSplitToBatchBoundary } from "../toolPairRepair.js";
|
|
11
12
|
/**
|
|
12
13
|
* Find the split index using token counting — walk backward from the end,
|
|
13
14
|
* accumulating token counts until we've reserved `targetRecentTokens` worth
|
|
@@ -38,8 +39,10 @@ function findSplitIndexByTokens(messages, targetRecentTokens, provider) {
|
|
|
38
39
|
}
|
|
39
40
|
recentTokens += msgTokens;
|
|
40
41
|
}
|
|
41
|
-
// Ensure at least one message is summarized
|
|
42
|
-
|
|
42
|
+
// Ensure at least one message is summarized, then snap off any tool batch
|
|
43
|
+
// the boundary would otherwise cut in half — a summary that starts the
|
|
44
|
+
// recent window on an orphaned tool_result is rejected by the provider.
|
|
45
|
+
return snapSplitToBatchBoundary(messages, Math.max(1, splitIndex));
|
|
43
46
|
}
|
|
44
47
|
export async function summarizeMessages(messages, config) {
|
|
45
48
|
const keepRecentRatio = config?.keepRecentRatio ?? 0.3;
|
|
@@ -65,7 +68,16 @@ export async function summarizeMessages(messages, config) {
|
|
|
65
68
|
}
|
|
66
69
|
// Clamp so at least the last message is always preserved (never summarize everything)
|
|
67
70
|
splitIndex = Math.min(splitIndex, messages.length - 1);
|
|
68
|
-
|
|
71
|
+
// Re-snap AFTER the clamp: the clamp can pull the boundary back into a tool
|
|
72
|
+
// batch that findSplitIndexByTokens had already cleared, and it is also the
|
|
73
|
+
// only guard covering the legacy message-count branch above.
|
|
74
|
+
splitIndex = snapSplitToBatchBoundary(messages, splitIndex);
|
|
75
|
+
// A boundary that still cuts a batch means no legal split exists (e.g. the
|
|
76
|
+
// whole array is one tool batch). Summarizing anyway would hand the provider
|
|
77
|
+
// an orphaned tool_result, so decline and let a later stage reclaim instead.
|
|
78
|
+
if (splitIndex <= 0 ||
|
|
79
|
+
splitIndex >= messages.length ||
|
|
80
|
+
splitIndex !== snapSplitToBatchBoundary(messages, splitIndex)) {
|
|
69
81
|
return { summarized: false, messages };
|
|
70
82
|
}
|
|
71
83
|
const messagesToSummarize = messages.slice(0, splitIndex);
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { TokenUtils } from "../constants/tokens.js";
|
|
8
8
|
import { buildContextFromPointer, generateSummary, } from "../utils/conversationMemory.js";
|
|
9
9
|
import { RECENT_MESSAGES_RATIO } from "../config/conversationMemory.js";
|
|
10
|
+
import { snapSplitToBatchBoundary } from "./toolPairRepair.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";
|
|
@@ -143,8 +144,17 @@ export class SummarizationEngine {
|
|
|
143
144
|
}
|
|
144
145
|
recentTokens += msgTokens;
|
|
145
146
|
}
|
|
146
|
-
// Ensure at least one message is summarized
|
|
147
|
-
|
|
147
|
+
// Ensure at least one message is summarized, then snap off any tool batch
|
|
148
|
+
// the boundary would otherwise cut in half (which would leave the recent
|
|
149
|
+
// window opening on an orphaned tool_result).
|
|
150
|
+
const snapped = snapSplitToBatchBoundary(messages, Math.max(1, splitIndex));
|
|
151
|
+
// A forward snap can consume the ENTIRE array when the batch starts at
|
|
152
|
+
// index 0. Summarizing everything would leave no recent window at all and
|
|
153
|
+
// move the pointer to the last message, so the next read returns a summary
|
|
154
|
+
// and nothing else. Decline instead — `summarizeSession` skips a round on
|
|
155
|
+
// an empty slice. `structuredSummarizer` already guards this case; this is
|
|
156
|
+
// the same guard for the pointer-based path.
|
|
157
|
+
return snapped >= messages.length ? 0 : snapped;
|
|
148
158
|
}
|
|
149
159
|
}
|
|
150
160
|
//# 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;
|