@juspay/neurolink 12.16.0 → 12.17.0
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 +7 -2
- package/dist/browser/neurolink.min.js +377 -373
- package/dist/context/nativeGenerateGuard.d.ts +6 -0
- package/dist/context/nativeGenerateGuard.js +192 -0
- package/dist/context/toolOutputLimits.js +76 -5
- package/dist/core/modules/ToolsManager.d.ts +45 -3
- package/dist/core/modules/ToolsManager.js +193 -8
- package/dist/core/nativeGenerateLoop.js +25 -5
- package/dist/providers/openaiChatCompletionsBase.js +15 -0
- package/dist/types/config.d.ts +10 -0
- package/dist/types/context.d.ts +15 -0
- package/dist/types/generate.d.ts +9 -0
- package/dist/utils/tokenEstimation.d.ts +13 -0
- package/dist/utils/tokenEstimation.js +6 -1
- package/package.json +3 -1
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { NativeGenerateGuardConfig } from "../types/index.js";
|
|
2
|
+
/** V3-shape adapter for the reclaim policy shared with streaming guards. */
|
|
3
|
+
export declare function createNativeGenerateGuard(config: NativeGenerateGuardConfig): {
|
|
4
|
+
observeUsage(usage: unknown): void;
|
|
5
|
+
guardConversation(conversation: Array<Record<string, unknown>>): Array<Record<string, unknown>> | undefined;
|
|
6
|
+
};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { estimateTokens, TOKENS_PER_MESSAGE, } from "../utils/tokenEstimation.js";
|
|
2
|
+
import { logger } from "../utils/logger.js";
|
|
3
|
+
import { planLoopGuardReclaim } from "./loopGuardCore.js";
|
|
4
|
+
import { DEFAULT_CONTEXT_GUARD_RATIO } from "../core/constants.js";
|
|
5
|
+
import { generateToolOutputPreview } from "./toolOutputLimits.js";
|
|
6
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7
|
+
const partsOf = (value) => Array.isArray(value) ? value.filter(isRecord) : [];
|
|
8
|
+
const textOf = (value) => {
|
|
9
|
+
if (typeof value === "string") {
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
return JSON.stringify(value) ?? "";
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return "x".repeat(200_000);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
const previewMessage = (message) => ({
|
|
20
|
+
...message,
|
|
21
|
+
// Do not stringify the whole message: call IDs, result types and metadata
|
|
22
|
+
// must survive the wire conversion. Only the result payload is shortened.
|
|
23
|
+
content: partsOf(message.content).map((part) => {
|
|
24
|
+
if (part.type !== "tool-result") {
|
|
25
|
+
return part;
|
|
26
|
+
}
|
|
27
|
+
const output = part.output;
|
|
28
|
+
const wrapped = isRecord(output) &&
|
|
29
|
+
["text", "json", "error-text", "error-json"].includes(String(output.type)) &&
|
|
30
|
+
"value" in output;
|
|
31
|
+
const { preview, truncated } = generateToolOutputPreview(textOf(wrapped ? output.value : output), { maxBytes: 2048, maxLines: 60 });
|
|
32
|
+
if (!truncated) {
|
|
33
|
+
return part;
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
...part,
|
|
37
|
+
output: wrapped
|
|
38
|
+
? {
|
|
39
|
+
...output,
|
|
40
|
+
type: String(output.type).startsWith("error")
|
|
41
|
+
? "error-text"
|
|
42
|
+
: "text",
|
|
43
|
+
value: preview,
|
|
44
|
+
}
|
|
45
|
+
: preview,
|
|
46
|
+
};
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
/** V3-shape adapter for the reclaim policy shared with streaming guards. */
|
|
50
|
+
export function createNativeGenerateGuard(config) {
|
|
51
|
+
let previousSentEstimate;
|
|
52
|
+
let observedPromptTokens;
|
|
53
|
+
const cost = (message) => estimateTokens(textOf(message), config.provider) + TOKENS_PER_MESSAGE;
|
|
54
|
+
return {
|
|
55
|
+
observeUsage(usage) {
|
|
56
|
+
const input = isRecord(usage) ? usage.inputTokens : undefined;
|
|
57
|
+
const total = isRecord(input) ? input.total : input;
|
|
58
|
+
observedPromptTokens =
|
|
59
|
+
typeof total === "number" && Number.isFinite(total) && total > 0
|
|
60
|
+
? total
|
|
61
|
+
: undefined;
|
|
62
|
+
},
|
|
63
|
+
guardConversation(conversation) {
|
|
64
|
+
// Policy entry zero is protected. Fold the entire initial task prefix
|
|
65
|
+
// into it so leading system messages cannot displace task protection.
|
|
66
|
+
const taskIndex = conversation.findIndex((message) => message.role === "user");
|
|
67
|
+
const prefixLength = Math.max(1, taskIndex + 1);
|
|
68
|
+
const tail = conversation.slice(prefixLength);
|
|
69
|
+
const previews = tail.map(previewMessage);
|
|
70
|
+
const entries = [
|
|
71
|
+
{
|
|
72
|
+
kind: "other",
|
|
73
|
+
tokens: conversation
|
|
74
|
+
.slice(0, prefixLength)
|
|
75
|
+
.reduce((sum, message) => sum + cost(message), 0),
|
|
76
|
+
},
|
|
77
|
+
...tail.map((message, index) => {
|
|
78
|
+
const parts = partsOf(message.content);
|
|
79
|
+
if (message.role === "assistant" &&
|
|
80
|
+
parts.some((part) => part.type === "tool-call")) {
|
|
81
|
+
return { kind: "toolCall", tokens: cost(message) };
|
|
82
|
+
}
|
|
83
|
+
if (message.role === "tool" &&
|
|
84
|
+
parts.some((part) => part.type === "tool-result")) {
|
|
85
|
+
return {
|
|
86
|
+
kind: "toolResult",
|
|
87
|
+
tokens: cost(message),
|
|
88
|
+
previewTokens: cost(previews[index]),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return { kind: "other", tokens: cost(message) };
|
|
92
|
+
}),
|
|
93
|
+
];
|
|
94
|
+
const overhead = config.getFixedOverheadTokens();
|
|
95
|
+
const calibration = observedPromptTokens && previousSentEstimate
|
|
96
|
+
? Math.min(3, Math.max(1, observedPromptTokens / previousSentEstimate))
|
|
97
|
+
: 1;
|
|
98
|
+
const plan = planLoopGuardReclaim(entries, {
|
|
99
|
+
availableInputTokens: config.availableInputTokens,
|
|
100
|
+
fixedOverheadTokens: overhead,
|
|
101
|
+
calibration,
|
|
102
|
+
});
|
|
103
|
+
// The policy fires at a RATIO of the window, not at the window itself, so
|
|
104
|
+
// every number below is measured against that same threshold. Comparing
|
|
105
|
+
// against the raw budget leaves a ~15% band in which the guard has
|
|
106
|
+
// already crossed its reclaim point while the log still reads "under
|
|
107
|
+
// budget" with positive headroom — defeating the purpose of making the
|
|
108
|
+
// threshold measurable, since a probe sizing a payload from that headroom
|
|
109
|
+
// would aim past the point it meant to cross.
|
|
110
|
+
const reclaimThresholdTokens = Math.floor((config.availableInputTokens * DEFAULT_CONTEXT_GUARD_RATIO) /
|
|
111
|
+
(calibration > 0 ? calibration : 1));
|
|
112
|
+
const estimatedBeforeTokens = overhead + entries.reduce((sum, entry) => sum + entry.tokens, 0);
|
|
113
|
+
if (!plan.fire) {
|
|
114
|
+
previousSentEstimate = estimatedBeforeTokens;
|
|
115
|
+
// Staying under budget is the common case and must not be noisy, but
|
|
116
|
+
// silence here is unfalsifiable: no line at all reads the same whether
|
|
117
|
+
// the conversation fit, the guard was never installed, or this hook was
|
|
118
|
+
// never called. Two live attempts to demonstrate the reclaim against a
|
|
119
|
+
// real vendor produced nothing and could not distinguish those, because
|
|
120
|
+
// the budget is not observable from outside the process. Emitting the
|
|
121
|
+
// budget and the headroom at debug makes the threshold measurable, so a
|
|
122
|
+
// probe can size a payload to cross it instead of guessing.
|
|
123
|
+
// Two very different situations reach this branch and must not share a
|
|
124
|
+
// message. Fitting is routine and belongs at debug. Being over budget
|
|
125
|
+
// with nothing reclaimable is neither: the planner only touches entries
|
|
126
|
+
// strictly between the task and the newest
|
|
127
|
+
// DEFAULT_LOOP_GUARD_PROTECTED_TAIL, so a conversation whose oversize
|
|
128
|
+
// content sits inside that protected tail — one large tool result, the
|
|
129
|
+
// common case — yields an empty plan, and the request is sent over the
|
|
130
|
+
// window anyway to be rejected or silently truncated by the vendor.
|
|
131
|
+
// Observed live against deepseek: 186,847 estimated against a 62,500
|
|
132
|
+
// budget, empty plan, no signal of any kind before this warning.
|
|
133
|
+
if (estimatedBeforeTokens > reclaimThresholdTokens) {
|
|
134
|
+
logger.warn("[NativeGenerateGuard] Over threshold, nothing reclaimable", {
|
|
135
|
+
provider: config.provider,
|
|
136
|
+
availableInputTokens: config.availableInputTokens,
|
|
137
|
+
reclaimThresholdTokens,
|
|
138
|
+
estimatedPromptTokens: estimatedBeforeTokens,
|
|
139
|
+
overThresholdByTokens: estimatedBeforeTokens - reclaimThresholdTokens,
|
|
140
|
+
calibration,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
logger.debug("[NativeGenerateGuard] Under threshold", {
|
|
145
|
+
provider: config.provider,
|
|
146
|
+
availableInputTokens: config.availableInputTokens,
|
|
147
|
+
reclaimThresholdTokens,
|
|
148
|
+
estimatedPromptTokens: estimatedBeforeTokens,
|
|
149
|
+
headroomToThresholdTokens: reclaimThresholdTokens - estimatedBeforeTokens,
|
|
150
|
+
calibration,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
const drop = new Set(plan.drop);
|
|
156
|
+
const truncate = new Set(plan.truncate);
|
|
157
|
+
const result = [...conversation.slice(0, prefixLength)];
|
|
158
|
+
let noted = false;
|
|
159
|
+
tail.forEach((message, index) => {
|
|
160
|
+
const entry = index + 1;
|
|
161
|
+
if (drop.has(entry)) {
|
|
162
|
+
if (!noted) {
|
|
163
|
+
result.push({
|
|
164
|
+
role: "user",
|
|
165
|
+
content: "[Earlier tool exchanges were removed to fit the context window.]",
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
noted = true;
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
result.push(truncate.has(entry) ? previews[index] : message);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
previousSentEstimate =
|
|
175
|
+
overhead + result.reduce((sum, message) => sum + cost(message), 0);
|
|
176
|
+
logger.info("[NativeGenerateGuard] Reclaimed context", {
|
|
177
|
+
provider: config.provider,
|
|
178
|
+
// The budget and both estimates travel with the event so a reader can
|
|
179
|
+
// see WHY it fired and by how much it overshot, rather than only that
|
|
180
|
+
// it did.
|
|
181
|
+
availableInputTokens: config.availableInputTokens,
|
|
182
|
+
reclaimThresholdTokens,
|
|
183
|
+
estimatedBeforeTokens,
|
|
184
|
+
estimatedAfterTokens: previousSentEstimate,
|
|
185
|
+
truncated: plan.truncate.length,
|
|
186
|
+
dropped: plan.drop.length,
|
|
187
|
+
calibration,
|
|
188
|
+
});
|
|
189
|
+
return result;
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
@@ -29,6 +29,27 @@ export function exceedsToolOutputPreviewBudget(output, options) {
|
|
|
29
29
|
return (Buffer.byteLength(output, "utf-8") > maxBytes ||
|
|
30
30
|
output.split("\n").length > maxLines);
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* First `limit` bytes of `value`, cut on a code-point boundary.
|
|
34
|
+
*
|
|
35
|
+
* `Buffer.subarray().toString()` mid-sequence emits U+FFFD, which is itself
|
|
36
|
+
* three bytes and can push the result back over the very budget the cut was
|
|
37
|
+
* made to satisfy — so back off to the start of the split character instead.
|
|
38
|
+
*/
|
|
39
|
+
function takeBytes(value, limit) {
|
|
40
|
+
if (limit <= 0) {
|
|
41
|
+
return "";
|
|
42
|
+
}
|
|
43
|
+
const buf = Buffer.from(value, "utf-8");
|
|
44
|
+
if (buf.length <= limit) {
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
let end = limit;
|
|
48
|
+
while (end > 0 && (buf[end] & 0xc0) === 0x80) {
|
|
49
|
+
end--;
|
|
50
|
+
}
|
|
51
|
+
return buf.subarray(0, end).toString("utf-8");
|
|
52
|
+
}
|
|
32
53
|
/**
|
|
33
54
|
* Generate a head/tail preview of a tool output string.
|
|
34
55
|
* If the output is within limits, returns it unchanged with truncated: false.
|
|
@@ -81,11 +102,61 @@ export function generateToolOutputPreview(output, options) {
|
|
|
81
102
|
const tailBuf = Buffer.from(tail, "utf-8");
|
|
82
103
|
tail = tailBuf.subarray(tailBuf.length - tailMaxBytes).toString("utf-8");
|
|
83
104
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
105
|
+
// Default notice names retrieve_context; callers whose instance never
|
|
106
|
+
// registers that tool (see RETRIEVE_CONTEXT_TOOL_NAME usages in
|
|
107
|
+
// ToolsManager) pass their own `notice` so the model isn't pointed at a
|
|
108
|
+
// tool that doesn't exist.
|
|
109
|
+
const renderNotice = (omitted) => options?.notice === undefined
|
|
110
|
+
? `\n\n[... ${omitted} bytes omitted. ` +
|
|
111
|
+
`Use ${RETRIEVE_CONTEXT_TOOL_NAME} tool to access full output ...]\n\n`
|
|
112
|
+
: typeof options.notice === "function"
|
|
113
|
+
? options.notice(omitted)
|
|
114
|
+
: options.notice;
|
|
115
|
+
const omittedFor = (h, t) => Math.max(0, originalSize -
|
|
116
|
+
Buffer.byteLength(h, "utf-8") -
|
|
117
|
+
Buffer.byteLength(t, "utf-8"));
|
|
118
|
+
// `headMaxBytes + tailMaxBytes` is exactly `maxBytes`, leaving no room for
|
|
119
|
+
// the notice — so appending it unmeasured pushed every truncated preview
|
|
120
|
+
// over the caller's budget (82 bytes over, for the default notice). Give the
|
|
121
|
+
// notice its space back out of the tail, then the head. Two passes, because
|
|
122
|
+
// shrinking them raises `omittedBytes` and can lengthen the notice by a
|
|
123
|
+
// digit; the second pass settles that.
|
|
124
|
+
let notice = renderNotice(omittedFor(head, tail));
|
|
125
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
126
|
+
const excess = Buffer.byteLength(head, "utf-8") +
|
|
127
|
+
Buffer.byteLength(notice, "utf-8") +
|
|
128
|
+
Buffer.byteLength(tail, "utf-8") -
|
|
129
|
+
maxBytes;
|
|
130
|
+
if (excess <= 0) {
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
const tailBuf = Buffer.from(tail, "utf-8");
|
|
134
|
+
const takeFromTail = Math.min(excess, tailBuf.length);
|
|
135
|
+
tail = tailBuf.subarray(takeFromTail).toString("utf-8");
|
|
136
|
+
const stillOver = excess - takeFromTail;
|
|
137
|
+
if (stillOver > 0) {
|
|
138
|
+
const headBuf = Buffer.from(head, "utf-8");
|
|
139
|
+
head = headBuf
|
|
140
|
+
.subarray(0, Math.max(0, headBuf.length - stillOver))
|
|
141
|
+
.toString("utf-8");
|
|
142
|
+
}
|
|
143
|
+
notice = renderNotice(omittedFor(head, tail));
|
|
144
|
+
}
|
|
145
|
+
// A notice at least as long as the whole budget cannot be paid for out of
|
|
146
|
+
// `head` and `tail`: the loop above empties both and the notice alone is
|
|
147
|
+
// still over `maxBytes`. `maxBytes` is a hard cap the caller sizes its
|
|
148
|
+
// context window against, so bound the notice itself rather than handing
|
|
149
|
+
// back more than was asked for.
|
|
150
|
+
if (Buffer.byteLength(head, "utf-8") +
|
|
151
|
+
Buffer.byteLength(notice, "utf-8") +
|
|
152
|
+
Buffer.byteLength(tail, "utf-8") >
|
|
153
|
+
maxBytes) {
|
|
154
|
+
return {
|
|
155
|
+
preview: takeBytes(notice, maxBytes),
|
|
156
|
+
truncated: true,
|
|
157
|
+
originalSize,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
89
160
|
return {
|
|
90
161
|
preview: head + notice + tail,
|
|
91
162
|
truncated: true,
|
|
@@ -18,9 +18,6 @@ export declare function buildMCPSchemaValidator(toolName: string, schema: Record
|
|
|
18
18
|
success: false;
|
|
19
19
|
error: Error;
|
|
20
20
|
}) | undefined>;
|
|
21
|
-
/**
|
|
22
|
-
* ToolsManager class - Handles all tool management operations
|
|
23
|
-
*/
|
|
24
21
|
export declare class ToolsManager {
|
|
25
22
|
private readonly providerName;
|
|
26
23
|
private readonly directTools;
|
|
@@ -42,6 +39,51 @@ export declare class ToolsManager {
|
|
|
42
39
|
* every tool uncancellable (deadline overshoot / ghost executions).
|
|
43
40
|
*/
|
|
44
41
|
private wrapExecuteWithTruncation;
|
|
42
|
+
/**
|
|
43
|
+
* Byte budget for BZ-666 truncation. Reads the instance-level
|
|
44
|
+
* `tools.outputTruncationMaxBytes` config (see `ToolConfig` /
|
|
45
|
+
* `NeuroLink.getToolsConfig()`) so hosts can raise or lower the ceiling
|
|
46
|
+
* without a new env var or constructor param; falls back to the original
|
|
47
|
+
* hard-coded 51,200 bytes (== DEFAULT_MAX_PREVIEW_BYTES) so existing
|
|
48
|
+
* consumers see no behavior change.
|
|
49
|
+
*/
|
|
50
|
+
private resolveTruncationMaxBytes;
|
|
51
|
+
/**
|
|
52
|
+
* The default omission notice names `retrieve_context` as the on-demand
|
|
53
|
+
* read-back tool. That is only true when this NeuroLink instance actually
|
|
54
|
+
* registered it (Redis or an artifact store must exist — see
|
|
55
|
+
* `retrieveContextRegistered` in neurolink.ts). Otherwise hand
|
|
56
|
+
* `generateToolOutputPreview` a notice that doesn't mention it.
|
|
57
|
+
*/
|
|
58
|
+
private resolveTruncationNotice;
|
|
59
|
+
/**
|
|
60
|
+
* Truncate an MCP `CallToolResult`-shaped envelope (`{content:[...],
|
|
61
|
+
* isError?}`) — at the top level, or nested under `data` the way
|
|
62
|
+
* NeuroLink's own `executeExternalMCPTool` / `{success, data}` wrapper
|
|
63
|
+
* shapes it — WITHOUT collapsing it to the generic `_truncated` sentinel,
|
|
64
|
+
* while still honouring `maxBytes` for the envelope AS A WHOLE.
|
|
65
|
+
*
|
|
66
|
+
* Only TEXT content items are rewritten (byte-accurate head/tail preview,
|
|
67
|
+
* same mechanism as a plain string result). `isError` and any non-text
|
|
68
|
+
* items (images, resources, embedded resources, …) pass through
|
|
69
|
+
* untouched, and every other top-level field on the result is preserved.
|
|
70
|
+
*
|
|
71
|
+
* The budget is enforced on the serialized envelope, not per item: the
|
|
72
|
+
* bytes the envelope occupies with every text item emptied (structure,
|
|
73
|
+
* non-text items, other fields) come off the top, and what remains is
|
|
74
|
+
* water-filled across the text items — items that already fit keep their
|
|
75
|
+
* text and donate the rest of their share to the larger ones. JSON
|
|
76
|
+
* escaping and the per-item notice add bytes the raw allocation cannot
|
|
77
|
+
* see, so the result is re-measured and the text budget tightened until
|
|
78
|
+
* the whole envelope fits.
|
|
79
|
+
*
|
|
80
|
+
* Returns `undefined` when `result` doesn't have this shape at all, AND
|
|
81
|
+
* when the shape cannot be kept within `maxBytes` — no text items, the
|
|
82
|
+
* non-text payload alone exceeds the budget, or tightening did not
|
|
83
|
+
* converge — so the caller's generic sentinel path bounds it instead.
|
|
84
|
+
* Preserving the shape never buys the right to exceed the ceiling.
|
|
85
|
+
*/
|
|
86
|
+
private truncateMcpContentEnvelope;
|
|
45
87
|
/**
|
|
46
88
|
* BZ-666: Apply generateToolOutputPreview to tool results to prevent
|
|
47
89
|
* context overflow when large results flow into the AI SDK accumulator.
|
|
@@ -5,7 +5,7 @@ import { SpanStatusCode } from "@opentelemetry/api";
|
|
|
5
5
|
import { logger } from "../../utils/logger.js";
|
|
6
6
|
import { getKeyCount } from "../../utils/transformationUtils.js";
|
|
7
7
|
import { convertJsonSchemaToZod } from "../../utils/schemaConversion.js";
|
|
8
|
-
import { generateToolOutputPreview } from "../../context/toolOutputLimits.js";
|
|
8
|
+
import { generateToolOutputPreview, DEFAULT_MAX_PREVIEW_BYTES, RETRIEVE_CONTEXT_TOOL_NAME, } from "../../context/toolOutputLimits.js";
|
|
9
9
|
import { tool as createAISDKTool, jsonSchema } from "../../utils/tool.js";
|
|
10
10
|
/** Abort-shaped error so provider loops route it to their cancellation path. */
|
|
11
11
|
function makeToolAbortError() {
|
|
@@ -133,6 +133,44 @@ function raceWithAbortSignal(promise, signal) {
|
|
|
133
133
|
/**
|
|
134
134
|
* ToolsManager class - Handles all tool management operations
|
|
135
135
|
*/
|
|
136
|
+
/** Upper bound on re-measure-and-tighten rounds in truncateMcpContentEnvelope. */
|
|
137
|
+
const MAX_ENVELOPE_TIGHTENING_PASSES = 6;
|
|
138
|
+
function serializedByteLength(value) {
|
|
139
|
+
try {
|
|
140
|
+
return Buffer.byteLength(JSON.stringify(value), "utf-8");
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function isTextContentItem(item) {
|
|
147
|
+
return (item !== null &&
|
|
148
|
+
typeof item === "object" &&
|
|
149
|
+
item.type === "text" &&
|
|
150
|
+
typeof item.text === "string");
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Split `total` bytes across items of the given sizes, water-filling:
|
|
154
|
+
* smallest first, each item takes the lesser of its own size and an equal
|
|
155
|
+
* share of what is left, so items that already fit keep everything and the
|
|
156
|
+
* remainder flows to the larger ones.
|
|
157
|
+
*/
|
|
158
|
+
function waterFillTextBudget(sizes, total) {
|
|
159
|
+
const allocations = new Array(sizes.length).fill(0);
|
|
160
|
+
const order = sizes
|
|
161
|
+
.map((_, index) => index)
|
|
162
|
+
.sort((a, b) => sizes[a] - sizes[b]);
|
|
163
|
+
let remaining = Math.max(0, total);
|
|
164
|
+
let left = order.length;
|
|
165
|
+
for (const index of order) {
|
|
166
|
+
const share = Math.floor(remaining / left);
|
|
167
|
+
const give = Math.min(sizes[index], share);
|
|
168
|
+
allocations[index] = give;
|
|
169
|
+
remaining -= give;
|
|
170
|
+
left--;
|
|
171
|
+
}
|
|
172
|
+
return allocations;
|
|
173
|
+
}
|
|
136
174
|
export class ToolsManager {
|
|
137
175
|
providerName;
|
|
138
176
|
directTools;
|
|
@@ -176,6 +214,139 @@ export class ToolsManager {
|
|
|
176
214
|
return this.truncateToolResult(toolName, result);
|
|
177
215
|
};
|
|
178
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* Byte budget for BZ-666 truncation. Reads the instance-level
|
|
219
|
+
* `tools.outputTruncationMaxBytes` config (see `ToolConfig` /
|
|
220
|
+
* `NeuroLink.getToolsConfig()`) so hosts can raise or lower the ceiling
|
|
221
|
+
* without a new env var or constructor param; falls back to the original
|
|
222
|
+
* hard-coded 51,200 bytes (== DEFAULT_MAX_PREVIEW_BYTES) so existing
|
|
223
|
+
* consumers see no behavior change.
|
|
224
|
+
*/
|
|
225
|
+
resolveTruncationMaxBytes() {
|
|
226
|
+
const configured = this.neurolink?.getToolsConfig?.()?.outputTruncationMaxBytes;
|
|
227
|
+
return typeof configured === "number" &&
|
|
228
|
+
Number.isFinite(configured) &&
|
|
229
|
+
configured > 0
|
|
230
|
+
? configured
|
|
231
|
+
: DEFAULT_MAX_PREVIEW_BYTES;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* The default omission notice names `retrieve_context` as the on-demand
|
|
235
|
+
* read-back tool. That is only true when this NeuroLink instance actually
|
|
236
|
+
* registered it (Redis or an artifact store must exist — see
|
|
237
|
+
* `retrieveContextRegistered` in neurolink.ts). Otherwise hand
|
|
238
|
+
* `generateToolOutputPreview` a notice that doesn't mention it.
|
|
239
|
+
*/
|
|
240
|
+
resolveTruncationNotice() {
|
|
241
|
+
const hasRetrieveContext = this.neurolink?.getCustomTools?.()?.has(RETRIEVE_CONTEXT_TOOL_NAME) ??
|
|
242
|
+
false;
|
|
243
|
+
if (hasRetrieveContext) {
|
|
244
|
+
return undefined; // defer to generateToolOutputPreview's own default
|
|
245
|
+
}
|
|
246
|
+
return (omittedBytes) => `\n\n[... ${omittedBytes} bytes omitted ...]\n\n`;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Truncate an MCP `CallToolResult`-shaped envelope (`{content:[...],
|
|
250
|
+
* isError?}`) — at the top level, or nested under `data` the way
|
|
251
|
+
* NeuroLink's own `executeExternalMCPTool` / `{success, data}` wrapper
|
|
252
|
+
* shapes it — WITHOUT collapsing it to the generic `_truncated` sentinel,
|
|
253
|
+
* while still honouring `maxBytes` for the envelope AS A WHOLE.
|
|
254
|
+
*
|
|
255
|
+
* Only TEXT content items are rewritten (byte-accurate head/tail preview,
|
|
256
|
+
* same mechanism as a plain string result). `isError` and any non-text
|
|
257
|
+
* items (images, resources, embedded resources, …) pass through
|
|
258
|
+
* untouched, and every other top-level field on the result is preserved.
|
|
259
|
+
*
|
|
260
|
+
* The budget is enforced on the serialized envelope, not per item: the
|
|
261
|
+
* bytes the envelope occupies with every text item emptied (structure,
|
|
262
|
+
* non-text items, other fields) come off the top, and what remains is
|
|
263
|
+
* water-filled across the text items — items that already fit keep their
|
|
264
|
+
* text and donate the rest of their share to the larger ones. JSON
|
|
265
|
+
* escaping and the per-item notice add bytes the raw allocation cannot
|
|
266
|
+
* see, so the result is re-measured and the text budget tightened until
|
|
267
|
+
* the whole envelope fits.
|
|
268
|
+
*
|
|
269
|
+
* Returns `undefined` when `result` doesn't have this shape at all, AND
|
|
270
|
+
* when the shape cannot be kept within `maxBytes` — no text items, the
|
|
271
|
+
* non-text payload alone exceeds the budget, or tightening did not
|
|
272
|
+
* converge — so the caller's generic sentinel path bounds it instead.
|
|
273
|
+
* Preserving the shape never buys the right to exceed the ceiling.
|
|
274
|
+
*/
|
|
275
|
+
truncateMcpContentEnvelope(toolName, obj, maxBytes) {
|
|
276
|
+
const atTopLevel = Array.isArray(obj.content);
|
|
277
|
+
const dataObj = !atTopLevel && obj.data && typeof obj.data === "object"
|
|
278
|
+
? obj.data
|
|
279
|
+
: undefined;
|
|
280
|
+
const nestedUnderData = !atTopLevel && Array.isArray(dataObj?.content);
|
|
281
|
+
if (!atTopLevel && !nestedUnderData) {
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
const envelope = atTopLevel ? obj : dataObj;
|
|
285
|
+
const serializedSize = serializedByteLength(obj);
|
|
286
|
+
if (serializedSize === undefined) {
|
|
287
|
+
return obj; // unserializable — leave the envelope exactly as-is
|
|
288
|
+
}
|
|
289
|
+
if (serializedSize <= maxBytes) {
|
|
290
|
+
return obj;
|
|
291
|
+
}
|
|
292
|
+
const content = envelope.content;
|
|
293
|
+
const textIndexes = [];
|
|
294
|
+
content.forEach((item, index) => {
|
|
295
|
+
if (isTextContentItem(item)) {
|
|
296
|
+
textIndexes.push(index);
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
if (textIndexes.length === 0) {
|
|
300
|
+
return undefined; // nothing trimmable — let the sentinel bound it
|
|
301
|
+
}
|
|
302
|
+
const rebuild = (replacements) => {
|
|
303
|
+
const newContent = content.map((item, index) => {
|
|
304
|
+
const text = replacements.get(index);
|
|
305
|
+
return text === undefined
|
|
306
|
+
? item
|
|
307
|
+
: { ...item, text };
|
|
308
|
+
});
|
|
309
|
+
const newEnvelope = { ...envelope, content: newContent };
|
|
310
|
+
return atTopLevel ? newEnvelope : { ...obj, data: newEnvelope };
|
|
311
|
+
};
|
|
312
|
+
// Bytes the envelope costs with every text item emptied. If that alone
|
|
313
|
+
// is over budget, no amount of text trimming honours the ceiling while
|
|
314
|
+
// keeping the shape.
|
|
315
|
+
const fixedBytes = serializedByteLength(rebuild(new Map(textIndexes.map((index) => [index, ""]))));
|
|
316
|
+
if (fixedBytes === undefined || fixedBytes >= maxBytes) {
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
const notice = this.resolveTruncationNotice();
|
|
320
|
+
const sizes = textIndexes.map((index) => Buffer.byteLength(content[index].text, "utf-8"));
|
|
321
|
+
let textBudget = maxBytes - fixedBytes;
|
|
322
|
+
for (let pass = 0; pass < MAX_ENVELOPE_TIGHTENING_PASSES; pass++) {
|
|
323
|
+
const allocations = waterFillTextBudget(sizes, textBudget);
|
|
324
|
+
const replacements = new Map();
|
|
325
|
+
textIndexes.forEach((index, k) => {
|
|
326
|
+
const item = content[index];
|
|
327
|
+
const { preview, truncated, originalSize } = generateToolOutputPreview(item.text, { maxBytes: allocations[k], notice });
|
|
328
|
+
if (truncated) {
|
|
329
|
+
logger.debug(`[ToolsManager] Truncated '${toolName}' MCP content text item ${index}: ${originalSize} bytes → ${Buffer.byteLength(preview, "utf-8")} bytes (envelope budget ${maxBytes})`);
|
|
330
|
+
replacements.set(index, preview);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
const candidate = rebuild(replacements);
|
|
334
|
+
const candidateSize = serializedByteLength(candidate);
|
|
335
|
+
if (candidateSize !== undefined && candidateSize <= maxBytes) {
|
|
336
|
+
return candidate;
|
|
337
|
+
}
|
|
338
|
+
if (candidateSize === undefined || replacements.size === 0) {
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
// Over by the escaping/notice overhead the allocation could not see:
|
|
342
|
+
// take that overage (plus a margin) back out of the text budget.
|
|
343
|
+
textBudget -= candidateSize - maxBytes + Math.ceil(maxBytes * 0.02) + 1;
|
|
344
|
+
if (textBudget <= 0) {
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return undefined; // could not fit while preserving the shape — sentinel
|
|
349
|
+
}
|
|
179
350
|
/**
|
|
180
351
|
* BZ-666: Apply generateToolOutputPreview to tool results to prevent
|
|
181
352
|
* context overflow when large results flow into the AI SDK accumulator.
|
|
@@ -184,9 +355,10 @@ export class ToolsManager {
|
|
|
184
355
|
if (result === null || result === undefined) {
|
|
185
356
|
return result;
|
|
186
357
|
}
|
|
358
|
+
const maxBytes = this.resolveTruncationMaxBytes();
|
|
187
359
|
// Handle string results directly
|
|
188
360
|
if (typeof result === "string") {
|
|
189
|
-
const { preview, truncated, originalSize } = generateToolOutputPreview(result);
|
|
361
|
+
const { preview, truncated, originalSize } = generateToolOutputPreview(result, { maxBytes, notice: this.resolveTruncationNotice() });
|
|
190
362
|
if (truncated) {
|
|
191
363
|
logger.debug(`[ToolsManager] Truncated '${toolName}' string output: ${originalSize} bytes → ${Buffer.byteLength(preview, "utf-8")} bytes`);
|
|
192
364
|
}
|
|
@@ -196,9 +368,10 @@ export class ToolsManager {
|
|
|
196
368
|
if (typeof result === "object") {
|
|
197
369
|
const obj = result;
|
|
198
370
|
let nextObj = null;
|
|
199
|
-
// Truncate "content" if present and oversized
|
|
371
|
+
// Truncate "content" if present and oversized (string form only —
|
|
372
|
+
// an ARRAY "content" is an MCP envelope, handled below)
|
|
200
373
|
if (typeof obj.content === "string") {
|
|
201
|
-
const { preview, truncated, originalSize } = generateToolOutputPreview(obj.content);
|
|
374
|
+
const { preview, truncated, originalSize } = generateToolOutputPreview(obj.content, { maxBytes, notice: this.resolveTruncationNotice() });
|
|
202
375
|
if (truncated) {
|
|
203
376
|
logger.debug(`[ToolsManager] Truncated '${toolName}' content field: ${originalSize} bytes → ${Buffer.byteLength(preview, "utf-8")} bytes`);
|
|
204
377
|
nextObj = { ...(nextObj ?? obj), content: preview };
|
|
@@ -206,7 +379,7 @@ export class ToolsManager {
|
|
|
206
379
|
}
|
|
207
380
|
// Truncate "data" if present and oversized — both fields can coexist
|
|
208
381
|
if (typeof obj.data === "string") {
|
|
209
|
-
const { preview, truncated, originalSize } = generateToolOutputPreview(obj.data);
|
|
382
|
+
const { preview, truncated, originalSize } = generateToolOutputPreview(obj.data, { maxBytes, notice: this.resolveTruncationNotice() });
|
|
210
383
|
if (truncated) {
|
|
211
384
|
logger.debug(`[ToolsManager] Truncated '${toolName}' data field: ${originalSize} bytes → ${Buffer.byteLength(preview, "utf-8")} bytes`);
|
|
212
385
|
nextObj = { ...(nextObj ?? obj), data: preview };
|
|
@@ -215,12 +388,24 @@ export class ToolsManager {
|
|
|
215
388
|
if (nextObj) {
|
|
216
389
|
return nextObj;
|
|
217
390
|
}
|
|
391
|
+
// MCP CallToolResult envelope — { content: [{type,text}, ...], isError? }
|
|
392
|
+
// — either at the top level or nested under `data` (NeuroLink's own
|
|
393
|
+
// executeExternalMCPTool / {success, data} wrapper shape). Preserve the
|
|
394
|
+
// envelope and truncate only the oversized text items instead of
|
|
395
|
+
// collapsing the whole thing to the generic sentinel below.
|
|
396
|
+
const envelopeResult = this.truncateMcpContentEnvelope(toolName, obj, maxBytes);
|
|
397
|
+
if (envelopeResult !== undefined) {
|
|
398
|
+
return envelopeResult;
|
|
399
|
+
}
|
|
218
400
|
// For other objects, check if their JSON serialization is too large.
|
|
219
|
-
// Use UTF-8 byte length, not string length, to match the
|
|
401
|
+
// Use UTF-8 byte length, not string length, to match the byte budget.
|
|
220
402
|
try {
|
|
221
403
|
const jsonStr = JSON.stringify(result);
|
|
222
|
-
if (Buffer.byteLength(jsonStr, "utf-8") >
|
|
223
|
-
const { preview, truncated, originalSize } = generateToolOutputPreview(jsonStr
|
|
404
|
+
if (Buffer.byteLength(jsonStr, "utf-8") > maxBytes) {
|
|
405
|
+
const { preview, truncated, originalSize } = generateToolOutputPreview(jsonStr, {
|
|
406
|
+
maxBytes,
|
|
407
|
+
notice: this.resolveTruncationNotice(),
|
|
408
|
+
});
|
|
224
409
|
if (truncated) {
|
|
225
410
|
logger.debug(`[ToolsManager] Truncated '${toolName}' JSON output: ${originalSize} bytes → ${Buffer.byteLength(preview, "utf-8")} bytes`);
|
|
226
411
|
// Preserve object shape so callers reading structured fields don't
|
|
@@ -118,6 +118,20 @@ export async function runNativeGenerateLoop(args, toolExecutionSummaries) {
|
|
|
118
118
|
const hasTools = Boolean(args.tools && args.tools.length > 0);
|
|
119
119
|
for (let step = 0; step < args.maxSteps; step++) {
|
|
120
120
|
steps = step + 1;
|
|
121
|
+
// Per-step context reclaim. The pre-dispatch budget check runs ONCE and
|
|
122
|
+
// never sees the assistant turns and tool results this loop appends, which
|
|
123
|
+
// is how a long agentic run overflows the model window mid-loop and loses
|
|
124
|
+
// every completed step to a provider 400. The streaming paths have guarded
|
|
125
|
+
// this for a while; generate() did not, so the identical turn reclaimed on
|
|
126
|
+
// stream() and overflowed on generate().
|
|
127
|
+
//
|
|
128
|
+
// The provider supplies the guard because only it knows its wire shape.
|
|
129
|
+
// Returning undefined leaves the conversation byte-identical, so a turn
|
|
130
|
+
// that fits never pays a prompt-cache invalidation.
|
|
131
|
+
const guarded = args.guardConversation?.(args.conversation);
|
|
132
|
+
if (guarded) {
|
|
133
|
+
args.conversation.splice(0, args.conversation.length, ...guarded);
|
|
134
|
+
}
|
|
121
135
|
const runThisStep = () => args.runStep(() => args.doGenerate({
|
|
122
136
|
prompt: args.conversation,
|
|
123
137
|
...(args.tools && args.tools.length > 0 ? { tools: args.tools } : {}),
|
|
@@ -169,6 +183,7 @@ export async function runNativeGenerateLoop(args, toolExecutionSummaries) {
|
|
|
169
183
|
throw stepError;
|
|
170
184
|
}
|
|
171
185
|
reaskPending = false;
|
|
186
|
+
args.observeUsage?.(res.usage);
|
|
172
187
|
const parts = asParts(res.content);
|
|
173
188
|
// Each step REPLACES the text rather than appending: the final step's
|
|
174
189
|
// answer is the turn's answer, matching what generateText reported.
|
|
@@ -177,11 +192,16 @@ export async function runNativeGenerateLoop(args, toolExecutionSummaries) {
|
|
|
177
192
|
.map((p) => p.text)
|
|
178
193
|
.join("");
|
|
179
194
|
// Reasoner models (DeepSeek `reasoning_content`, gateway `reasoning`,
|
|
180
|
-
// OpenAI o-series) emit reasoning as its own V3 content part.
|
|
181
|
-
//
|
|
182
|
-
// `
|
|
183
|
-
//
|
|
184
|
-
|
|
195
|
+
// OpenAI o-series) emit reasoning as its own V3 content part.
|
|
196
|
+
//
|
|
197
|
+
// Unlike `text`, this ACCUMULATES across steps. Each step's answer
|
|
198
|
+
// supersedes the last, but each step's reasoning is its own thought:
|
|
199
|
+
// replacing meant a tool loop reported only whatever the final step
|
|
200
|
+
// happened to think, which is usually the shortest part and frequently
|
|
201
|
+
// empty once the model just answers from the tool result. Concatenating
|
|
202
|
+
// with nothing between them is what a stream() consumer accumulates from
|
|
203
|
+
// the per-step reasoning chunks, so both surfaces agree on the turn.
|
|
204
|
+
reasoning += parts
|
|
185
205
|
.filter((p) => p.type === "reasoning" && typeof p.text === "string")
|
|
186
206
|
.map((p) => p.text)
|
|
187
207
|
.join("");
|