Skip to content

fix(ai): recover from invalid tool-call input instead of aborting the agent stream#2192

Open
boomyao wants to merge 1 commit into
vercel:mainfrom
boomyao:fix/durable-agent-recover-invalid-tool-input
Open

fix(ai): recover from invalid tool-call input instead of aborting the agent stream#2192
boomyao wants to merge 1 commit into
vercel:mainfrom
boomyao:fix/durable-agent-recover-invalid-tool-input

Conversation

@boomyao
Copy link
Copy Markdown
Contributor

@boomyao boomyao commented Jun 1, 2026

Problem

In DurableAgent, the two kinds of tool error are handled in opposite ways inside executeTool:

  • execute() throws → caught and converted to an error-text tool result fed back to the model, so the agent recovers and the stream continues. The code comment even says this "aligns with AI SDK's streamText behavior for individual tool failures."
  • Tool-call arguments fail inputSchema validation (and no experimental_repairToolCall fixes them) → throw, which propagates out of executeTool, aborts agent.stream(), and fails the entire durable workflow run.

A model occasionally emitting a slightly-malformed tool call (an empty array where .min(1) is required, a missing required field, a wrong type, truncated-then-JSON-repaired args) is a recoverable event — the model will usually fix it if told. But today it is fatal: one bad tool call kills a long-running task, with no chance for the agent to self-correct. The only hook on this path, experimental_repairToolCall, can't help here because its returned tool call must itself pass the schema — so it can fix malformed JSON syntax but cannot express "tell the model its arguments were invalid and let it regenerate."

This is inconsistent (the framework already recovers from the harder case — execute() throwing) and looks like an oversight rather than intent.

Reproduction

import { z } from 'zod';
import { DurableAgent } from '@workflow/ai/agent';
import { MockLanguageModelV3, convertArrayToReadableStream } from 'ai/test';

const toolCall = (toolName: string, input: string) =>
  convertArrayToReadableStream<any>([
    { type: 'stream-start', warnings: [] },
    { type: 'tool-call', toolCallId: 'c1', toolName, input },
    { type: 'finish', finishReason: 'tool-calls', usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } },
  ]);
const stop = () =>
  convertArrayToReadableStream<any>([
    { type: 'stream-start', warnings: [] },
    { type: 'text-start', id: 't' }, { type: 'text-delta', id: 't', delta: 'ok' }, { type: 'text-end', id: 't' },
    { type: 'finish', finishReason: 'stop', usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } },
  ]);

async function run(toolName: string, inputSchema: any, execute: any, input: string) {
  let n = 0;
  const model = new MockLanguageModelV3({ doStream: async () => (++n === 1 ? { stream: toolCall(toolName, input) } : { stream: stop() }) });
  const agent = new DurableAgent({ model: () => model, instructions: 'x', tools: { [toolName]: { description: 'd', inputSchema, execute } } });
  try {
    await agent.stream({ messages: [{ role: 'user', content: 'go' }], activeTools: [toolName], maxSteps: 5, writable: new WritableStream({ write() {} }), preventClose: true, sendFinish: false });
    return 'STREAM SURVIVED';
  } catch (e) { return `STREAM ABORTED: ${(e as Error).message}`; }
}

// A: strict schema, model sends invalid args (empty string violates .min(1))
console.log(await run('strict', z.object({ x: z.string().min(1) }), () => ({ ok: true }), '{"x":""}'));
// B: permissive schema, but execute() throws
console.log(await run('thrower', z.object({}), () => { throw new Error('boom'); }, '{}'));

Before this PR:

A (schema-invalid input) → STREAM ABORTED: Invalid input for tool "strict": [ ... too_small ... ]
B (execute() throws)     → STREAM SURVIVED

A should survive too.

Fix

executeTool already funnels both malformed-JSON and the re-thrown "Invalid input for tool ..." schema-validation error through a single throw parseError at the end of the parse/validate block. This PR changes that one escape point to return the error as an error-text tool result — identical to how execute() errors are handled a few lines below — so the agent receives the error as a tool result and can correct its arguments and retry within maxSteps. experimental_repairToolCall still runs first; only the final give-up changes from throw to recover.

After this PR, both A and B print STREAM SURVIVED.

Notes

  • Behavior change: a tool call with invalid arguments that previously rejected the stream now feeds the validation error back to the model (bounded by maxSteps), consistent with execute() errors and AI SDK streamText. Happy to gate it behind an option (e.g. onInvalidToolInput: 'feedback' | 'throw', default 'feedback') if you'd prefer to preserve the throw for some callers — let me know.
  • Added a regression test mirroring the existing "tool execution error → error-text" test.
  • Added a changeset (@workflow/ai patch).

Verified locally: packages/ai typecheck clean, vitest run (47 tests) green, Biome clean on the changed files.

… stream

DurableAgent.executeTool threw when a tool call's arguments failed inputSchema
validation (and no experimental_repairToolCall fixed it), aborting the whole
agent stream — which fails the entire durable workflow run. Tool *execution*
errors are already recovered (returned to the model as an error-text tool
result so the agent can self-correct); this makes input parse/validation
failures consistent: return the error as an error-text tool result instead of
throwing, so a single occasionally-malformed model tool-call can no longer kill
a long-running task. Aligns with AI SDK streamText behavior.

Signed-off-by: yao <zhangyaoruo@outlook.com>
@boomyao boomyao requested a review from a team as a code owner June 1, 2026 05:19
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Jun 1, 2026

🦋 Changeset detected

Latest commit: 2549cd8

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@workflow/ai Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Jun 1, 2026

@boomyao is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant