Skip to content

Commit 9b75a06

Browse files
fix(core): sanitize LLM tokens from tool-call input before JSON.parse (#13400)
## Summary - Strip LLM-appended tokens (`<|call|>`, `<|endoftext|>`, etc.) from tool-call input strings before `JSON.parse`, recovering valid arguments that were previously silently discarded - Add comprehensive tests verifying data recovery for real-world malformed inputs reported in #13185 and #13261 ## Problem `convertFullStreamChunkToMastra()` in `packages/core/src/stream/aisdk/v5/transform.ts` parses tool-call input via `JSON.parse(value.input)`. The existing `try/catch` handles parse failures gracefully (no crash), but **silently sets `toolCallInput = undefined`** — meaning the tool call loses its arguments entirely. In practice, many of these failures are recoverable: | Source | Example Input | Root Cause | |--------|--------------|------------| | OpenAI models (#13185) | `'{"checkpointNumber": 1}\t<\|call\|>'` | LLM appends internal `<\|call\|>` token after valid JSON | | OpenAI models (#13185) | `'{}<\|call\|>'` | Empty args with trailing token | | OpenRouter/Novita (#13261) | `'{"checkpointNumber":?}'` | Provider returns truly malformed JSON (not recoverable) | The first two cases contain **valid JSON buried under LLM noise** — stripping the tokens before parsing recovers the data. ## Solution Added an exported `sanitizeToolCallInput(input: string)` function that removes `<|...|>` token patterns and surrounding whitespace via regex before the input reaches `JSON.parse`: ```typescript export function sanitizeToolCallInput(input: string): string { return input.replace(/[\s]*<\|[^|]*\|>[\s]*/g, '').trim(); } ``` The `tool-call` case now sanitizes first, then parses: ```typescript const sanitized = sanitizeToolCallInput(value.input); try { toolCallInput = JSON.parse(sanitized); } catch (error) { // ... existing error handling unchanged } ``` ## What Changed | File | Change | |------|--------| | `packages/core/src/stream/aisdk/v5/transform.ts` | Added `sanitizeToolCallInput()`, updated `tool-call` case to sanitize before parse | | `packages/core/src/stream/aisdk/v5/transform.test.ts` | Added 12 new tests: 4 integration (data recovery) + 8 unit (sanitizer) | ## Tests **New integration tests** verify actual data recovery (not just no-throw): - ✅ `'{}<|call|>'` → recovers `{}` - ✅ `'{"checkpointNumber": 1, ...}\t<|call|>'` → recovers full object - ✅ `'{"query": "hello world"}<|endoftext|>'` → recovers object - ✅ `'{"checkpointNumber":?}'` → gracefully returns `undefined` **New unit tests** for `sanitizeToolCallInput`: - ✅ Strips `<|call|>`, `<|endoftext|>`, `<|end|>` tokens - ✅ Strips multiple consecutive tokens - ✅ Strips tab + token combinations - ✅ No-op on clean JSON - ✅ Handles empty string All 21 tests pass. Build succeeds. Zero lint/type errors. Closes #13185 Closes #13261 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved resilience of streamed tool-call input handling by stripping internal formatting tokens and surrounding whitespace so valid JSON payloads are preserved and parsing failures are reduced. * **Tests** * Added comprehensive tests for malformed/partial JSON, trailing/token/whitespace variations, and preservation of token-like text inside string values. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Roamin <97863888+roaminro@users.noreply.github.com>
1 parent 332c014 commit 9b75a06

4 files changed

Lines changed: 207 additions & 11 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@mastra/core": patch
3+
---
4+
5+
**Fixed tool-call arguments being silently lost when LLMs append internal tokens to JSON**
6+
7+
LLMs (particularly via OpenRouter and OpenAI) sometimes append internal tokens like `<|call|>`, `<|endoftext|>`, or `<|end|>` to otherwise valid JSON in streamed tool-call arguments. Previously, these inputs would fail `JSON.parse` and the tool call would silently lose its arguments (set to `undefined`).
8+
9+
Now, `sanitizeToolCallInput` strips these token patterns before parsing, recovering valid data that was previously discarded. Valid JSON containing `<|...|>` inside string values is left untouched. Truly malformed JSON still gracefully returns `undefined`.
10+
11+
Fixes https://github.com/mastra-ai/mastra/issues/13185 and https://github.com/mastra-ai/mastra/issues/13261.

e2e-tests/create-mastra/create-mastra.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ describe('create mastra', () => {
126126
127127
Use the weatherTool to fetch current weather data.
128128
",
129-
"modelId": "gpt-4o",
129+
"modelId": "gpt-5-mini",
130130
"modelVersion": "v2",
131131
"name": "Weather Agent",
132132
"outputProcessors": [],

packages/core/src/stream/aisdk/v5/transform.test.ts

Lines changed: 156 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { describe, it, expect } from 'vitest';
1+
import { describe, it, expect, vi } from 'vitest';
22
import { ChunkFrom } from '../../types';
3-
import { convertFullStreamChunkToMastra } from './transform';
3+
import { convertFullStreamChunkToMastra, sanitizeToolCallInput } from './transform';
44
import type { StreamPart } from './transform';
55

66
describe('convertFullStreamChunkToMastra', () => {
@@ -180,6 +180,160 @@ describe('convertFullStreamChunkToMastra', () => {
180180
throw new Error('Result is not a tool-call');
181181
}
182182
});
183+
184+
it('should recover valid JSON with trailing <|call|> token', () => {
185+
const chunk: StreamPart = {
186+
type: 'tool-call',
187+
toolCallId: 'call-1',
188+
toolName: 'get_weather',
189+
input: '{}<|call|>',
190+
providerExecuted: false,
191+
};
192+
193+
const result = convertFullStreamChunkToMastra(chunk, { runId: 'test-run-123' });
194+
195+
expect(result).toBeDefined();
196+
expect(result?.type).toBe('tool-call');
197+
if (result?.type === 'tool-call') {
198+
expect(result.payload.args).toEqual({});
199+
}
200+
});
201+
202+
it('should recover valid JSON with tab + <|call|> token (issue #13185)', () => {
203+
const chunk: StreamPart = {
204+
type: 'tool-call',
205+
toolCallId: 'call-2',
206+
toolName: 'checkpoint',
207+
input: '{\n"checkpointNumber": 1,\n"vehicleType": "leopard"\n}\t<|call|>',
208+
providerExecuted: false,
209+
};
210+
211+
const result = convertFullStreamChunkToMastra(chunk, { runId: 'test-run-123' });
212+
213+
expect(result).toBeDefined();
214+
expect(result?.type).toBe('tool-call');
215+
if (result?.type === 'tool-call') {
216+
expect(result.payload.args).toEqual({ checkpointNumber: 1, vehicleType: 'leopard' });
217+
}
218+
});
219+
220+
it('should recover valid JSON with <|endoftext|> token', () => {
221+
const chunk: StreamPart = {
222+
type: 'tool-call',
223+
toolCallId: 'call-3',
224+
toolName: 'search',
225+
input: '{"query": "hello world"}<|endoftext|>',
226+
providerExecuted: false,
227+
};
228+
229+
const result = convertFullStreamChunkToMastra(chunk, { runId: 'test-run-123' });
230+
231+
expect(result).toBeDefined();
232+
expect(result?.type).toBe('tool-call');
233+
if (result?.type === 'tool-call') {
234+
expect(result.payload.args).toEqual({ query: 'hello world' });
235+
}
236+
});
237+
238+
it('should gracefully return undefined for truly malformed JSON (issue #13261)', () => {
239+
const chunk: StreamPart = {
240+
type: 'tool-call',
241+
toolCallId: 'call-4',
242+
toolName: 'checkpoint',
243+
input: '{"vehicleType":"leopard","checkpointNumber":?}',
244+
providerExecuted: false,
245+
};
246+
247+
const result = convertFullStreamChunkToMastra(chunk, { runId: 'test-run-123' });
248+
249+
expect(result).toBeDefined();
250+
expect(result?.type).toBe('tool-call');
251+
if (result?.type === 'tool-call') {
252+
expect(result.payload.args).toBeUndefined();
253+
}
254+
});
255+
256+
it('should preserve <|...|> patterns inside JSON string values in tool-call args', () => {
257+
const chunk: StreamPart = {
258+
type: 'tool-call',
259+
toolCallId: 'call-5',
260+
toolName: 'process_text',
261+
input: '{"text": "The <|endoftext|> token marks boundaries"}',
262+
providerExecuted: false,
263+
};
264+
265+
const result = convertFullStreamChunkToMastra(chunk, { runId: 'test-run-123' });
266+
267+
expect(result).toBeDefined();
268+
expect(result?.type).toBe('tool-call');
269+
if (result?.type === 'tool-call') {
270+
expect(result.payload.args).toEqual({ text: 'The <|endoftext|> token marks boundaries' });
271+
}
272+
});
273+
274+
it('should return undefined args without console.error noise when input is purely LLM tokens', () => {
275+
const errorSpy = vi.spyOn(console, 'error');
276+
const chunk: StreamPart = {
277+
type: 'tool-call',
278+
toolCallId: 'call-6',
279+
toolName: 'noop',
280+
input: '<|call|>',
281+
providerExecuted: false,
282+
};
283+
284+
const result = convertFullStreamChunkToMastra(chunk, { runId: 'test-run-123' });
285+
expect(result).toBeDefined();
286+
expect(result?.type).toBe('tool-call');
287+
if (result?.type === 'tool-call') {
288+
expect(result.payload.args).toBeUndefined();
289+
}
290+
expect(errorSpy).not.toHaveBeenCalled();
291+
errorSpy.mockRestore();
292+
});
293+
});
294+
295+
describe('sanitizeToolCallInput', () => {
296+
it('should strip <|call|> token from valid JSON', () => {
297+
expect(sanitizeToolCallInput('{}<|call|>')).toBe('{}');
298+
});
299+
300+
it('should strip <|endoftext|> token', () => {
301+
expect(sanitizeToolCallInput('{"a":1}<|endoftext|>')).toBe('{"a":1}');
302+
});
303+
304+
it('should strip multiple tokens', () => {
305+
expect(sanitizeToolCallInput('{}<|call|><|endoftext|>')).toBe('{}');
306+
});
307+
308+
it('should strip tab + token combinations', () => {
309+
expect(sanitizeToolCallInput('{}\t<|call|>')).toBe('{}');
310+
});
311+
312+
it('should be a no-op on clean JSON', () => {
313+
expect(sanitizeToolCallInput('{"key": "value"}')).toBe('{"key": "value"}');
314+
});
315+
316+
it('should handle empty string', () => {
317+
expect(sanitizeToolCallInput('')).toBe('');
318+
});
319+
320+
it('should strip <|end|> token', () => {
321+
expect(sanitizeToolCallInput('{"x":1}<|end|>')).toBe('{"x":1}');
322+
});
323+
324+
it('should strip tokens with surrounding whitespace', () => {
325+
expect(sanitizeToolCallInput('{"x":1} <|call|> ')).toBe('{"x":1}');
326+
});
327+
328+
it('should preserve <|...|> patterns inside JSON string values', () => {
329+
const input = '{"text": "use <|call|> token"}';
330+
expect(sanitizeToolCallInput(input)).toBe('{"text": "use <|call|> token"}');
331+
});
332+
333+
it('should preserve multiple <|...|> patterns inside JSON string values', () => {
334+
const input = '{"prompt": "tokens: <|endoftext|> and <|call|> are special"}';
335+
expect(sanitizeToolCallInput(input)).toBe('{"prompt": "tokens: <|endoftext|> and <|call|> are special"}');
336+
});
183337
});
184338

185339
describe('other chunk types', () => {

packages/core/src/stream/aisdk/v5/transform.ts

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,34 @@ import type { ChunkType, LanguageModelUsage } from '../../types';
1111
import { ChunkFrom } from '../../types';
1212
import { DefaultGeneratedFile, DefaultGeneratedFileWithType } from './file';
1313

14+
/**
15+
* Sanitizes tool-call input strings for safe JSON parsing.
16+
*
17+
* LLMs sometimes append internal tokens like `<|call|>`, `<|endoftext|>`, or `<|end|>`
18+
* to otherwise valid JSON in streamed tool-call arguments, causing JSON.parse to fail.
19+
*
20+
* This function first attempts JSON.parse on the original input. If parsing succeeds,
21+
* the original string is returned unchanged — this avoids corrupting valid JSON payloads
22+
* that legitimately contain `<|...|>` patterns inside string values.
23+
*
24+
* Only when the original input is not valid JSON does the function fall back to stripping
25+
* `<|...|>` token patterns and surrounding whitespace via regex.
26+
*
27+
* @see https://github.com/mastra-ai/mastra/issues/13261
28+
* @see https://github.com/mastra-ai/mastra/issues/13185
29+
*/
30+
export function sanitizeToolCallInput(input: string): string {
31+
// Fast path: if input is already valid JSON, return unchanged to avoid
32+
// corrupting <|...|> patterns that appear inside JSON string values.
33+
try {
34+
JSON.parse(input);
35+
return input;
36+
} catch {
37+
// Input is not valid JSON — strip LLM-specific tokens and retry
38+
return input.replace(/[\s]*<\|[^|]*\|>[\s]*/g, '').trim();
39+
}
40+
}
41+
1442
export type StreamPart =
1543
| Exclude<LanguageModelV2StreamPart, { type: 'finish' }>
1644
| {
@@ -134,14 +162,17 @@ export function convertFullStreamChunkToMastra(value: StreamPart, ctx: { runId:
134162
let toolCallInput: Record<string, any> | undefined = undefined;
135163

136164
if (value.input) {
137-
try {
138-
toolCallInput = JSON.parse(value.input);
139-
} catch (error) {
140-
console.error('Error converting tool call input to JSON', {
141-
error,
142-
input: value.input,
143-
});
144-
toolCallInput = undefined;
165+
const sanitized = sanitizeToolCallInput(value.input);
166+
if (sanitized) {
167+
try {
168+
toolCallInput = JSON.parse(sanitized);
169+
} catch (error) {
170+
console.error('Error converting tool call input to JSON', {
171+
error,
172+
input: value.input,
173+
});
174+
toolCallInput = undefined;
175+
}
145176
}
146177
}
147178

0 commit comments

Comments
 (0)