@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.
@@ -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
- const omittedBytes = Math.max(0, originalSize -
85
- Buffer.byteLength(head, "utf-8") -
86
- Buffer.byteLength(tail, "utf-8"));
87
- const notice = `\n\n[... ${omittedBytes} bytes omitted. ` +
88
- `Use ${RETRIEVE_CONTEXT_TOOL_NAME} tool to access full output ...]\n\n`;
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 50KB budget.
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") > 51_200) {
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. Join it on
181
- // the same replace-per-step rule as the text so the caller can surface
182
- // `result.reasoning`; without this the provider builds the part and the
183
- // loop drops it.
184
- reasoning = parts
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("");