Description
@workflow/ai's DurableAgent crashes when a model emits a streamed tool-call chunk whose input is malformed JSON.
The important part is that this happens before experimental_repairToolCall gets a chance to run.
That means a malformed tool call currently kills the stream during Workflow's internal prompt/UI reconstruction, instead of degrading gracefully or letting the repair hook fix it.
Why this seems like a bug
DurableAgent already exposes experimental_repairToolCall, so malformed tool-call input appears to be an expected recovery scenario.
However, the current implementation eagerly does JSON.parse(...) on raw streamed tool-call input in internal agent code before the repair path is reached.
Affected versions
I checked all of the following as of April 12, 2026:
@workflow/ai@4.1.1 (latest stable on npm)
@workflow/ai@5.0.0-beta.1 (latest beta on npm)
- current
main
They all still appear to contain the same blind parse behavior.
Minimal repro
This repro uses a mock model, so it does not depend on any specific provider or application architecture.
import { tool, simulateReadableStream } from 'ai';
import { MockLanguageModelV3 } from 'ai/test';
import { DurableAgent } from '@workflow/ai/agent';
import z from 'zod';
function makeModel() {
return new MockLanguageModelV3({
doStream: async () => ({
stream: simulateReadableStream({
chunks: [
{ type: 'stream-start', warnings: [] },
{
type: 'tool-call',
toolCallId: 'call_1',
toolName: 'getWeather',
// malformed / truncated JSON
input: '{"city":"San Francisco"',
},
{
type: 'finish',
finishReason: { unified: 'tool-calls', raw: 'tool_use' },
usage: {
inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 5, text: 0, reasoning: 5 },
},
},
],
}),
}),
});
}
const agent = new DurableAgent({
model: async () => makeModel(),
tools: {
getWeather: tool({
inputSchema: z.object({ city: z.string() }),
execute: async ({ city }) => ({ city, ok: true }),
}),
},
});
await agent.stream({
messages: [
{
role: 'user',
content: [{ type: 'text', text: 'What is the weather?' }],
},
],
writable: new WritableStream({ write() {} }),
experimental_repairToolCall: async ({ toolCall }) => ({
...toolCall,
input: JSON.stringify({ city: 'San Francisco' }),
}),
});
Expected
One of these should happen:
experimental_repairToolCall runs and repairs the malformed tool call, or
- the stream continues without crashing and the malformed input is preserved in a recoverable form
Either way, the workflow should not fail during internal stream reconstruction.
Actual
The stream throws before repair can happen because internal code eagerly parses the malformed toolCall.input.
Current upstream crash path
These internal sites appear to parse tool-call input too early:
packages/ai/src/agent/do-stream-step.ts
input: JSON.parse(part.input || '{}')
packages/ai/src/agent/do-stream-step.ts
- multiple
JSON.parse(toolCall.input) calls while constructing step/tool metadata
packages/ai/src/agent/stream-text-iterator.ts
input: JSON.parse(toolCall.input) when reconstructing assistant tool-call parts for the next turn
The repair path appears later in:
packages/ai/src/agent/durable-agent.ts
executeTool(...)
experimental_repairToolCall
So the stream can die before the repair hook is ever consulted.
Suggested fix
Use a safe parse helper when reconstructing streamed tool-call input in do-stream-step.ts and stream-text-iterator.ts.
For example:
function safeParseToolCallInput(input: string | undefined) {
if (input == null || input === '') return {};
try {
return JSON.parse(input);
} catch {
return input;
}
}
Then replace the blind JSON.parse(...) calls in these reconstruction paths with safeParseToolCallInput(...).
That would allow:
- malformed input to survive long enough to reach repair/error handling
- UI stream generation to fail closed instead of crashing the whole workflow step
Why preserving the raw string seems preferable
Returning the raw string on parse failure avoids silently discarding the provider output.
It also keeps the malformed payload available for:
experimental_repairToolCall
- debugging / observability
- future provider-specific recovery logic
Related context
There are already several DurableAgent issues/PRs in nearby code paths. This one appears distinct: the crash happens specifically because malformed streamed tool-call input is parsed before the repair path can run.
Description
@workflow/ai'sDurableAgentcrashes when a model emits a streamedtool-callchunk whoseinputis malformed JSON.The important part is that this happens before
experimental_repairToolCallgets a chance to run.That means a malformed tool call currently kills the stream during Workflow's internal prompt/UI reconstruction, instead of degrading gracefully or letting the repair hook fix it.
Why this seems like a bug
DurableAgentalready exposesexperimental_repairToolCall, so malformed tool-call input appears to be an expected recovery scenario.However, the current implementation eagerly does
JSON.parse(...)on raw streamed tool-call input in internal agent code before the repair path is reached.Affected versions
I checked all of the following as of April 12, 2026:
@workflow/ai@4.1.1(latest stable on npm)@workflow/ai@5.0.0-beta.1(latest beta on npm)mainThey all still appear to contain the same blind parse behavior.
Minimal repro
This repro uses a mock model, so it does not depend on any specific provider or application architecture.
Expected
One of these should happen:
experimental_repairToolCallruns and repairs the malformed tool call, orEither way, the workflow should not fail during internal stream reconstruction.
Actual
The stream throws before repair can happen because internal code eagerly parses the malformed
toolCall.input.Current upstream crash path
These internal sites appear to parse tool-call input too early:
packages/ai/src/agent/do-stream-step.tsinput: JSON.parse(part.input || '{}')packages/ai/src/agent/do-stream-step.tsJSON.parse(toolCall.input)calls while constructing step/tool metadatapackages/ai/src/agent/stream-text-iterator.tsinput: JSON.parse(toolCall.input)when reconstructing assistant tool-call parts for the next turnThe repair path appears later in:
packages/ai/src/agent/durable-agent.tsexecuteTool(...)experimental_repairToolCallSo the stream can die before the repair hook is ever consulted.
Suggested fix
Use a safe parse helper when reconstructing streamed tool-call input in
do-stream-step.tsandstream-text-iterator.ts.For example:
Then replace the blind
JSON.parse(...)calls in these reconstruction paths withsafeParseToolCallInput(...).That would allow:
Why preserving the raw string seems preferable
Returning the raw string on parse failure avoids silently discarding the provider output.
It also keeps the malformed payload available for:
experimental_repairToolCallRelated context
There are already several DurableAgent issues/PRs in nearby code paths. This one appears distinct: the crash happens specifically because malformed streamed tool-call input is parsed before the repair path can run.