Skip to content

DurableAgent crashes on malformed streamed tool-call input before experimental_repairToolCall can run #1706

@craze3

Description

@craze3

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:

  1. experimental_repairToolCall runs and repairs the malformed tool call, or
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions