fix(ai): avoid crashing on malformed streamed tool-call input - #1707

Merged
VaguelySerious merged 3 commits into
vercel:mainfrom
craze3:codex/fix-malformed-tool-call-input
Apr 14, 2026
Merged

fix(ai): avoid crashing on malformed streamed tool-call input#1707
VaguelySerious merged 3 commits into
vercel:mainfrom
craze3:codex/fix-malformed-tool-call-input

Conversation

@craze3

@craze3craze3 commented Apr 12, 2026

Copy link
Copy Markdown
Contributor

Description

Closes#1706

  • add a small safe parser for streamed tool-call.input
  • replace eager JSON.parse(...) calls in do-stream-step.ts and stream-text-iterator.ts that could crash the workflow step before experimental_repairToolCall was reached
  • preserve malformed input as the raw string instead of throwing so existing repair and error-handling paths remain reachable

This keeps the fix narrowly scoped to internal DurableAgent reconstruction paths. It does not change the public API or introduce provider-specific behavior.

How did you test your changes?

  • added unit coverage for the new safe parser in packages/ai/src/agent/do-stream-step.test.ts
  • added a regression test that exercises doStreamStep with a mock streamed tool call whose input is malformed JSON and verifies the step no longer throws
  • added a regression test in packages/ai/src/agent/stream-text-iterator.test.ts that verifies malformed tool-call input is preserved into the reconstructed assistant message instead of crashing during prompt rebuild
  • ran the focused @workflow/ai test suite for the touched agent paths:
pnpm --filter @workflow/ai test -- \
src/agent/do-stream-step.test.ts \
src/agent/stream-text-iterator.test.ts \
src/agent/durable-agent.test.ts \
src/agent/telemetry.test.ts
  • ran the package build:
pnpm --filter @workflow/ai build

Recommended commands to rerun in a fresh fork before marking ready:

pnpm --filter @workflow/ai test -- \
src/agent/do-stream-step.test.ts \
src/agent/stream-text-iterator.test.ts \
src/agent/durable-agent.test.ts \
src/agent/telemetry.test.ts
pnpm --filter @workflow/ai build

Notes

  • This intentionally preserves the raw malformed input string on parse failure rather than coercing to {} so repair hooks and debugging still have access to the original provider output.
  • This does not attempt to repair malformed input itself; it only prevents internal reconstruction from crashing before repair/error handling can occur.

PR Checklist - Required to merge

  • 📦 pnpm changeset was run to create a changelog for this PR
  • 🔒 DCO sign-off passes
  • 📝 Ping @vercel/workflow in a comment once the PR is ready, and the above checklist is complete

@changeset-bot

changeset-botBot commented Apr 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 88d9174

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

This PR includes changesets to release 1 package
NameType
@workflow/aiPatch

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

vercelBot commented Apr 12, 2026

Copy link
Copy Markdown
Contributor

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

A member of the Team first needs to authorize it.

@craze3

Copy link
Copy Markdown
ContributorAuthor

@vercel/workflow ready for review.

This fixes #1706 by preserving malformed streamed tool-call input during DurableAgent reconstruction so experimental_repairToolCall and normal error handling can run instead of crashing early.

It looks like the Vercel preview checks on this fork PR are blocked pending team authorization.

@craze3
craze3 marked this pull request as ready for review April 12, 2026 22:22
@craze3
craze3 requested a review from a team as a code ownerApril 12, 2026 22:22

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM generally, I was concerned about leaving the malformed string in there potentially, so I had AI do a quick assessment. Let me know what you think about it / adjust as you see fit, then feel free to re-tag me for review here or send a comment on the issue


If the repair function fixes the tool call and execution succeeds, the loop continues and the prompt is sent to the provider on the next turn. Providers serialize this input directly:

  • OpenAI (@ai-sdk/openai, internal/index.mjs:224): does arguments: JSON.stringify(part.input). If input is already a string like '{"city":"San', JSON.stringify produces a double-encoded
    string literal '"{\"city\":\"San"' — not a valid function arguments value.
  • Anthropic (@ai-sdk/anthropic, internal/index.mjs:2451): passes input: part.input directly as tool_use.input. Anthropic's API expects a JSON object here; a string will be rejected.

So the flow is:

  1. Model emits malformed tool-call input
  2. safeParseToolCallInput preserves it as a string (no crash — good)
  3. String is baked into conversationPrompt
  4. executeTool in durable-agent.ts:1581 hits JSON.parse → throws → repair runs → tool executes successfully
  5. Next doStreamStep call sends the contaminated prompt to the provider
  6. Provider API error on the next turn — a new failure that didn't exist before

Before this PR, the crash at step 2 prevented the loop from ever reaching step 5-6. After the PR, you trade an immediate crash for a deferred one on the next model call. This only
matters when all three conditions hold: malformed input + repair succeeds + there's a subsequent model turn. But that's exactly the scenario the PR is designed to enable.

Suggested fix:
After repair succeeds in executeTool, the repaired tool call input should be patched back into the conversationPrompt. Alternatively, streamTextIterator could defer writing the
assistant tool-call message until after tool execution/repair, using the (potentially repaired) input.

// Mock doStreamStep
vi.mock('./do-stream-step.js', () => ({
doStreamStep: vi.fn(),
safeParseToolCallInput: (input: string | undefined) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can just be original.safeParseToolCallInput instead of rewriting the function, to avoid drift

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the parser into a shared internal helper, so stream-text-iterator now imports the real implementation instead of re-declaring it in the test mock.

* Parse streamed tool-call input without crashing the workflow step when a
* provider emits malformed or truncated JSON.
*/
export function safeParseToolCallInput(input: string | undefined): unknown {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this also re-use safeParseInput from durable-agent.ts? Or is the undefined fallthrough intentional?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pulled this into a shared helper and now reuse it from do-stream-step, stream-text-iterator, and durable-agent. The undefined/empty-input -> {} behavior is still intentional for no-args tool calls.

@VaguelySeriousVaguelySerious added the backport-stable Cherry-pick this PR to the stable branch when merged label Apr 13, 2026
@craze3

Copy link
Copy Markdown
ContributorAuthor

Good catch on the deferred-failure path. I pushed a follow-up commit that patches repaired tool-call input back into the accumulated assistant tool-call message before the next model step, and also updates the original tool-call object so downstream toolCalls / toolResults reflect the repaired value too.

I also moved the parser into a shared helper to avoid drift between the touched files and tests. Re-ran the focused @workflow/ai checks after the change:

pnpm --filter @workflow/ai test -- \
src/agent/do-stream-step.test.ts \
src/agent/stream-text-iterator.test.ts \
src/agent/durable-agent.test.ts \
src/agent/telemetry.test.ts
pnpm --filter @workflow/ai build

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM now, thanks!

Comment thread.changeset/fix-malformed-tool-call-input.md Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
@VaguelySerious
VaguelySerious merged commit 86ebe9f into vercel:mainApr 14, 2026
4 of 17 checks passed
@ghostghost mentioned this pull request Apr 14, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-stableCherry-pick this PR to the stable branch when merged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@craze3@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(ai): avoid crashing on malformed streamed tool-call input - #1707

Merged
VaguelySerious merged 3 commits into
vercel:mainfrom
craze3:codex/fix-malformed-tool-call-input
Apr 14, 2026
Merged

fix(ai): avoid crashing on malformed streamed tool-call input#1707
VaguelySerious merged 3 commits into
vercel:mainfrom
craze3:codex/fix-malformed-tool-call-input

Conversation

@craze3

@craze3craze3 commented Apr 12, 2026

Copy link
Copy Markdown
Contributor

Description

Closes#1706

  • add a small safe parser for streamed tool-call.input
  • replace eager JSON.parse(...) calls in do-stream-step.ts and stream-text-iterator.ts that could crash the workflow step before experimental_repairToolCall was reached
  • preserve malformed input as the raw string instead of throwing so existing repair and error-handling paths remain reachable

This keeps the fix narrowly scoped to internal DurableAgent reconstruction paths. It does not change the public API or introduce provider-specific behavior.

How did you test your changes?

  • added unit coverage for the new safe parser in packages/ai/src/agent/do-stream-step.test.ts
  • added a regression test that exercises doStreamStep with a mock streamed tool call whose input is malformed JSON and verifies the step no longer throws
  • added a regression test in packages/ai/src/agent/stream-text-iterator.test.ts that verifies malformed tool-call input is preserved into the reconstructed assistant message instead of crashing during prompt rebuild
  • ran the focused @workflow/ai test suite for the touched agent paths:
pnpm --filter @workflow/ai test -- \
src/agent/do-stream-step.test.ts \
src/agent/stream-text-iterator.test.ts \
src/agent/durable-agent.test.ts \
src/agent/telemetry.test.ts
  • ran the package build:
pnpm --filter @workflow/ai build

Recommended commands to rerun in a fresh fork before marking ready:

pnpm --filter @workflow/ai test -- \
src/agent/do-stream-step.test.ts \
src/agent/stream-text-iterator.test.ts \
src/agent/durable-agent.test.ts \
src/agent/telemetry.test.ts
pnpm --filter @workflow/ai build

Notes

  • This intentionally preserves the raw malformed input string on parse failure rather than coercing to {} so repair hooks and debugging still have access to the original provider output.
  • This does not attempt to repair malformed input itself; it only prevents internal reconstruction from crashing before repair/error handling can occur.

PR Checklist - Required to merge

  • 📦 pnpm changeset was run to create a changelog for this PR
  • 🔒 DCO sign-off passes
  • 📝 Ping @vercel/workflow in a comment once the PR is ready, and the above checklist is complete

@changeset-bot

changeset-botBot commented Apr 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 88d9174

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

This PR includes changesets to release 1 package
NameType
@workflow/aiPatch

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

vercelBot commented Apr 12, 2026

Copy link
Copy Markdown
Contributor

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

A member of the Team first needs to authorize it.

@craze3

Copy link
Copy Markdown
ContributorAuthor

@vercel/workflow ready for review.

This fixes #1706 by preserving malformed streamed tool-call input during DurableAgent reconstruction so experimental_repairToolCall and normal error handling can run instead of crashing early.

It looks like the Vercel preview checks on this fork PR are blocked pending team authorization.

@craze3
craze3 marked this pull request as ready for review April 12, 2026 22:22
@craze3
craze3 requested a review from a team as a code ownerApril 12, 2026 22:22

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM generally, I was concerned about leaving the malformed string in there potentially, so I had AI do a quick assessment. Let me know what you think about it / adjust as you see fit, then feel free to re-tag me for review here or send a comment on the issue


If the repair function fixes the tool call and execution succeeds, the loop continues and the prompt is sent to the provider on the next turn. Providers serialize this input directly:

  • OpenAI (@ai-sdk/openai, internal/index.mjs:224): does arguments: JSON.stringify(part.input). If input is already a string like '{"city":"San', JSON.stringify produces a double-encoded
    string literal '"{\"city\":\"San"' — not a valid function arguments value.
  • Anthropic (@ai-sdk/anthropic, internal/index.mjs:2451): passes input: part.input directly as tool_use.input. Anthropic's API expects a JSON object here; a string will be rejected.

So the flow is:

  1. Model emits malformed tool-call input
  2. safeParseToolCallInput preserves it as a string (no crash — good)
  3. String is baked into conversationPrompt
  4. executeTool in durable-agent.ts:1581 hits JSON.parse → throws → repair runs → tool executes successfully
  5. Next doStreamStep call sends the contaminated prompt to the provider
  6. Provider API error on the next turn — a new failure that didn't exist before

Before this PR, the crash at step 2 prevented the loop from ever reaching step 5-6. After the PR, you trade an immediate crash for a deferred one on the next model call. This only
matters when all three conditions hold: malformed input + repair succeeds + there's a subsequent model turn. But that's exactly the scenario the PR is designed to enable.

Suggested fix:
After repair succeeds in executeTool, the repaired tool call input should be patched back into the conversationPrompt. Alternatively, streamTextIterator could defer writing the
assistant tool-call message until after tool execution/repair, using the (potentially repaired) input.

// Mock doStreamStep
vi.mock('./do-stream-step.js', () => ({
doStreamStep: vi.fn(),
safeParseToolCallInput: (input: string | undefined) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can just be original.safeParseToolCallInput instead of rewriting the function, to avoid drift

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the parser into a shared internal helper, so stream-text-iterator now imports the real implementation instead of re-declaring it in the test mock.

* Parse streamed tool-call input without crashing the workflow step when a
* provider emits malformed or truncated JSON.
*/
export function safeParseToolCallInput(input: string | undefined): unknown {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this also re-use safeParseInput from durable-agent.ts? Or is the undefined fallthrough intentional?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pulled this into a shared helper and now reuse it from do-stream-step, stream-text-iterator, and durable-agent. The undefined/empty-input -> {} behavior is still intentional for no-args tool calls.

@VaguelySeriousVaguelySerious added the backport-stable Cherry-pick this PR to the stable branch when merged label Apr 13, 2026
@craze3

Copy link
Copy Markdown
ContributorAuthor

Good catch on the deferred-failure path. I pushed a follow-up commit that patches repaired tool-call input back into the accumulated assistant tool-call message before the next model step, and also updates the original tool-call object so downstream toolCalls / toolResults reflect the repaired value too.

I also moved the parser into a shared helper to avoid drift between the touched files and tests. Re-ran the focused @workflow/ai checks after the change:

pnpm --filter @workflow/ai test -- \
src/agent/do-stream-step.test.ts \
src/agent/stream-text-iterator.test.ts \
src/agent/durable-agent.test.ts \
src/agent/telemetry.test.ts
pnpm --filter @workflow/ai build

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM now, thanks!

Comment thread.changeset/fix-malformed-tool-call-input.md Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
@VaguelySerious
VaguelySerious merged commit 86ebe9f into vercel:mainApr 14, 2026
4 of 17 checks passed
@ghostghost mentioned this pull request Apr 14, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-stableCherry-pick this PR to the stable branch when merged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@craze3@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(ai): avoid crashing on malformed streamed tool-call input - #1707

Merged
VaguelySerious merged 3 commits into
vercel:mainfrom
craze3:codex/fix-malformed-tool-call-input
Apr 14, 2026
Merged

fix(ai): avoid crashing on malformed streamed tool-call input#1707
VaguelySerious merged 3 commits into
vercel:mainfrom
craze3:codex/fix-malformed-tool-call-input

Conversation

@craze3

@craze3craze3 commented Apr 12, 2026

Copy link
Copy Markdown
Contributor

Description

Closes#1706

  • add a small safe parser for streamed tool-call.input
  • replace eager JSON.parse(...) calls in do-stream-step.ts and stream-text-iterator.ts that could crash the workflow step before experimental_repairToolCall was reached
  • preserve malformed input as the raw string instead of throwing so existing repair and error-handling paths remain reachable

This keeps the fix narrowly scoped to internal DurableAgent reconstruction paths. It does not change the public API or introduce provider-specific behavior.

How did you test your changes?

  • added unit coverage for the new safe parser in packages/ai/src/agent/do-stream-step.test.ts
  • added a regression test that exercises doStreamStep with a mock streamed tool call whose input is malformed JSON and verifies the step no longer throws
  • added a regression test in packages/ai/src/agent/stream-text-iterator.test.ts that verifies malformed tool-call input is preserved into the reconstructed assistant message instead of crashing during prompt rebuild
  • ran the focused @workflow/ai test suite for the touched agent paths:
pnpm --filter @workflow/ai test -- \
src/agent/do-stream-step.test.ts \
src/agent/stream-text-iterator.test.ts \
src/agent/durable-agent.test.ts \
src/agent/telemetry.test.ts
  • ran the package build:
pnpm --filter @workflow/ai build

Recommended commands to rerun in a fresh fork before marking ready:

pnpm --filter @workflow/ai test -- \
src/agent/do-stream-step.test.ts \
src/agent/stream-text-iterator.test.ts \
src/agent/durable-agent.test.ts \
src/agent/telemetry.test.ts
pnpm --filter @workflow/ai build

Notes

  • This intentionally preserves the raw malformed input string on parse failure rather than coercing to {} so repair hooks and debugging still have access to the original provider output.
  • This does not attempt to repair malformed input itself; it only prevents internal reconstruction from crashing before repair/error handling can occur.

PR Checklist - Required to merge

  • 📦 pnpm changeset was run to create a changelog for this PR
  • 🔒 DCO sign-off passes
  • 📝 Ping @vercel/workflow in a comment once the PR is ready, and the above checklist is complete

@changeset-bot

changeset-botBot commented Apr 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 88d9174

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

This PR includes changesets to release 1 package
NameType
@workflow/aiPatch

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

vercelBot commented Apr 12, 2026

Copy link
Copy Markdown
Contributor

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

A member of the Team first needs to authorize it.

@craze3

Copy link
Copy Markdown
ContributorAuthor

@vercel/workflow ready for review.

This fixes #1706 by preserving malformed streamed tool-call input during DurableAgent reconstruction so experimental_repairToolCall and normal error handling can run instead of crashing early.

It looks like the Vercel preview checks on this fork PR are blocked pending team authorization.

@craze3
craze3 marked this pull request as ready for review April 12, 2026 22:22
@craze3
craze3 requested a review from a team as a code ownerApril 12, 2026 22:22

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM generally, I was concerned about leaving the malformed string in there potentially, so I had AI do a quick assessment. Let me know what you think about it / adjust as you see fit, then feel free to re-tag me for review here or send a comment on the issue


If the repair function fixes the tool call and execution succeeds, the loop continues and the prompt is sent to the provider on the next turn. Providers serialize this input directly:

  • OpenAI (@ai-sdk/openai, internal/index.mjs:224): does arguments: JSON.stringify(part.input). If input is already a string like '{"city":"San', JSON.stringify produces a double-encoded
    string literal '"{\"city\":\"San"' — not a valid function arguments value.
  • Anthropic (@ai-sdk/anthropic, internal/index.mjs:2451): passes input: part.input directly as tool_use.input. Anthropic's API expects a JSON object here; a string will be rejected.

So the flow is:

  1. Model emits malformed tool-call input
  2. safeParseToolCallInput preserves it as a string (no crash — good)
  3. String is baked into conversationPrompt
  4. executeTool in durable-agent.ts:1581 hits JSON.parse → throws → repair runs → tool executes successfully
  5. Next doStreamStep call sends the contaminated prompt to the provider
  6. Provider API error on the next turn — a new failure that didn't exist before

Before this PR, the crash at step 2 prevented the loop from ever reaching step 5-6. After the PR, you trade an immediate crash for a deferred one on the next model call. This only
matters when all three conditions hold: malformed input + repair succeeds + there's a subsequent model turn. But that's exactly the scenario the PR is designed to enable.

Suggested fix:
After repair succeeds in executeTool, the repaired tool call input should be patched back into the conversationPrompt. Alternatively, streamTextIterator could defer writing the
assistant tool-call message until after tool execution/repair, using the (potentially repaired) input.

// Mock doStreamStep
vi.mock('./do-stream-step.js', () => ({
doStreamStep: vi.fn(),
safeParseToolCallInput: (input: string | undefined) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can just be original.safeParseToolCallInput instead of rewriting the function, to avoid drift

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the parser into a shared internal helper, so stream-text-iterator now imports the real implementation instead of re-declaring it in the test mock.

* Parse streamed tool-call input without crashing the workflow step when a
* provider emits malformed or truncated JSON.
*/
export function safeParseToolCallInput(input: string | undefined): unknown {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this also re-use safeParseInput from durable-agent.ts? Or is the undefined fallthrough intentional?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pulled this into a shared helper and now reuse it from do-stream-step, stream-text-iterator, and durable-agent. The undefined/empty-input -> {} behavior is still intentional for no-args tool calls.

@VaguelySeriousVaguelySerious added the backport-stable Cherry-pick this PR to the stable branch when merged label Apr 13, 2026
@craze3

Copy link
Copy Markdown
ContributorAuthor

Good catch on the deferred-failure path. I pushed a follow-up commit that patches repaired tool-call input back into the accumulated assistant tool-call message before the next model step, and also updates the original tool-call object so downstream toolCalls / toolResults reflect the repaired value too.

I also moved the parser into a shared helper to avoid drift between the touched files and tests. Re-ran the focused @workflow/ai checks after the change:

pnpm --filter @workflow/ai test -- \
src/agent/do-stream-step.test.ts \
src/agent/stream-text-iterator.test.ts \
src/agent/durable-agent.test.ts \
src/agent/telemetry.test.ts
pnpm --filter @workflow/ai build

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM now, thanks!

Comment thread.changeset/fix-malformed-tool-call-input.md Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
@VaguelySerious
VaguelySerious merged commit 86ebe9f into vercel:mainApr 14, 2026
4 of 17 checks passed
@ghostghost mentioned this pull request Apr 14, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-stableCherry-pick this PR to the stable branch when merged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@craze3@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(ai): avoid crashing on malformed streamed tool-call input - #1707

Merged
VaguelySerious merged 3 commits into
vercel:mainfrom
craze3:codex/fix-malformed-tool-call-input
Apr 14, 2026
Merged

fix(ai): avoid crashing on malformed streamed tool-call input#1707
VaguelySerious merged 3 commits into
vercel:mainfrom
craze3:codex/fix-malformed-tool-call-input

Conversation

@craze3

@craze3craze3 commented Apr 12, 2026

Copy link
Copy Markdown
Contributor

Description

Closes#1706

  • add a small safe parser for streamed tool-call.input
  • replace eager JSON.parse(...) calls in do-stream-step.ts and stream-text-iterator.ts that could crash the workflow step before experimental_repairToolCall was reached
  • preserve malformed input as the raw string instead of throwing so existing repair and error-handling paths remain reachable

This keeps the fix narrowly scoped to internal DurableAgent reconstruction paths. It does not change the public API or introduce provider-specific behavior.

How did you test your changes?

  • added unit coverage for the new safe parser in packages/ai/src/agent/do-stream-step.test.ts
  • added a regression test that exercises doStreamStep with a mock streamed tool call whose input is malformed JSON and verifies the step no longer throws
  • added a regression test in packages/ai/src/agent/stream-text-iterator.test.ts that verifies malformed tool-call input is preserved into the reconstructed assistant message instead of crashing during prompt rebuild
  • ran the focused @workflow/ai test suite for the touched agent paths:
pnpm --filter @workflow/ai test -- \
src/agent/do-stream-step.test.ts \
src/agent/stream-text-iterator.test.ts \
src/agent/durable-agent.test.ts \
src/agent/telemetry.test.ts
  • ran the package build:
pnpm --filter @workflow/ai build

Recommended commands to rerun in a fresh fork before marking ready:

pnpm --filter @workflow/ai test -- \
src/agent/do-stream-step.test.ts \
src/agent/stream-text-iterator.test.ts \
src/agent/durable-agent.test.ts \
src/agent/telemetry.test.ts
pnpm --filter @workflow/ai build

Notes

  • This intentionally preserves the raw malformed input string on parse failure rather than coercing to {} so repair hooks and debugging still have access to the original provider output.
  • This does not attempt to repair malformed input itself; it only prevents internal reconstruction from crashing before repair/error handling can occur.

PR Checklist - Required to merge

  • 📦 pnpm changeset was run to create a changelog for this PR
  • 🔒 DCO sign-off passes
  • 📝 Ping @vercel/workflow in a comment once the PR is ready, and the above checklist is complete

@changeset-bot

changeset-botBot commented Apr 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 88d9174

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

This PR includes changesets to release 1 package
NameType
@workflow/aiPatch

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

vercelBot commented Apr 12, 2026

Copy link
Copy Markdown
Contributor

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

A member of the Team first needs to authorize it.

@craze3

Copy link
Copy Markdown
ContributorAuthor

@vercel/workflow ready for review.

This fixes #1706 by preserving malformed streamed tool-call input during DurableAgent reconstruction so experimental_repairToolCall and normal error handling can run instead of crashing early.

It looks like the Vercel preview checks on this fork PR are blocked pending team authorization.

@craze3
craze3 marked this pull request as ready for review April 12, 2026 22:22
@craze3
craze3 requested a review from a team as a code ownerApril 12, 2026 22:22

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM generally, I was concerned about leaving the malformed string in there potentially, so I had AI do a quick assessment. Let me know what you think about it / adjust as you see fit, then feel free to re-tag me for review here or send a comment on the issue


If the repair function fixes the tool call and execution succeeds, the loop continues and the prompt is sent to the provider on the next turn. Providers serialize this input directly:

  • OpenAI (@ai-sdk/openai, internal/index.mjs:224): does arguments: JSON.stringify(part.input). If input is already a string like '{"city":"San', JSON.stringify produces a double-encoded
    string literal '"{\"city\":\"San"' — not a valid function arguments value.
  • Anthropic (@ai-sdk/anthropic, internal/index.mjs:2451): passes input: part.input directly as tool_use.input. Anthropic's API expects a JSON object here; a string will be rejected.

So the flow is:

  1. Model emits malformed tool-call input
  2. safeParseToolCallInput preserves it as a string (no crash — good)
  3. String is baked into conversationPrompt
  4. executeTool in durable-agent.ts:1581 hits JSON.parse → throws → repair runs → tool executes successfully
  5. Next doStreamStep call sends the contaminated prompt to the provider
  6. Provider API error on the next turn — a new failure that didn't exist before

Before this PR, the crash at step 2 prevented the loop from ever reaching step 5-6. After the PR, you trade an immediate crash for a deferred one on the next model call. This only
matters when all three conditions hold: malformed input + repair succeeds + there's a subsequent model turn. But that's exactly the scenario the PR is designed to enable.

Suggested fix:
After repair succeeds in executeTool, the repaired tool call input should be patched back into the conversationPrompt. Alternatively, streamTextIterator could defer writing the
assistant tool-call message until after tool execution/repair, using the (potentially repaired) input.

// Mock doStreamStep
vi.mock('./do-stream-step.js', () => ({
doStreamStep: vi.fn(),
safeParseToolCallInput: (input: string | undefined) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can just be original.safeParseToolCallInput instead of rewriting the function, to avoid drift

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the parser into a shared internal helper, so stream-text-iterator now imports the real implementation instead of re-declaring it in the test mock.

* Parse streamed tool-call input without crashing the workflow step when a
* provider emits malformed or truncated JSON.
*/
export function safeParseToolCallInput(input: string | undefined): unknown {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this also re-use safeParseInput from durable-agent.ts? Or is the undefined fallthrough intentional?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pulled this into a shared helper and now reuse it from do-stream-step, stream-text-iterator, and durable-agent. The undefined/empty-input -> {} behavior is still intentional for no-args tool calls.

@VaguelySeriousVaguelySerious added the backport-stable Cherry-pick this PR to the stable branch when merged label Apr 13, 2026
@craze3

Copy link
Copy Markdown
ContributorAuthor

Good catch on the deferred-failure path. I pushed a follow-up commit that patches repaired tool-call input back into the accumulated assistant tool-call message before the next model step, and also updates the original tool-call object so downstream toolCalls / toolResults reflect the repaired value too.

I also moved the parser into a shared helper to avoid drift between the touched files and tests. Re-ran the focused @workflow/ai checks after the change:

pnpm --filter @workflow/ai test -- \
src/agent/do-stream-step.test.ts \
src/agent/stream-text-iterator.test.ts \
src/agent/durable-agent.test.ts \
src/agent/telemetry.test.ts
pnpm --filter @workflow/ai build

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM now, thanks!

Comment thread.changeset/fix-malformed-tool-call-input.md Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
@VaguelySerious
VaguelySerious merged commit 86ebe9f into vercel:mainApr 14, 2026
4 of 17 checks passed
@ghostghost mentioned this pull request Apr 14, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-stableCherry-pick this PR to the stable branch when merged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@craze3@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(ai): avoid crashing on malformed streamed tool-call input - #1707

Merged
VaguelySerious merged 3 commits into
vercel:mainfrom
craze3:codex/fix-malformed-tool-call-input
Apr 14, 2026
Merged

fix(ai): avoid crashing on malformed streamed tool-call input#1707
VaguelySerious merged 3 commits into
vercel:mainfrom
craze3:codex/fix-malformed-tool-call-input

Conversation

@craze3

@craze3craze3 commented Apr 12, 2026

Copy link
Copy Markdown
Contributor

Description

Closes#1706

  • add a small safe parser for streamed tool-call.input
  • replace eager JSON.parse(...) calls in do-stream-step.ts and stream-text-iterator.ts that could crash the workflow step before experimental_repairToolCall was reached
  • preserve malformed input as the raw string instead of throwing so existing repair and error-handling paths remain reachable

This keeps the fix narrowly scoped to internal DurableAgent reconstruction paths. It does not change the public API or introduce provider-specific behavior.

How did you test your changes?

  • added unit coverage for the new safe parser in packages/ai/src/agent/do-stream-step.test.ts
  • added a regression test that exercises doStreamStep with a mock streamed tool call whose input is malformed JSON and verifies the step no longer throws
  • added a regression test in packages/ai/src/agent/stream-text-iterator.test.ts that verifies malformed tool-call input is preserved into the reconstructed assistant message instead of crashing during prompt rebuild
  • ran the focused @workflow/ai test suite for the touched agent paths:
pnpm --filter @workflow/ai test -- \
src/agent/do-stream-step.test.ts \
src/agent/stream-text-iterator.test.ts \
src/agent/durable-agent.test.ts \
src/agent/telemetry.test.ts
  • ran the package build:
pnpm --filter @workflow/ai build

Recommended commands to rerun in a fresh fork before marking ready:

pnpm --filter @workflow/ai test -- \
src/agent/do-stream-step.test.ts \
src/agent/stream-text-iterator.test.ts \
src/agent/durable-agent.test.ts \
src/agent/telemetry.test.ts
pnpm --filter @workflow/ai build

Notes

  • This intentionally preserves the raw malformed input string on parse failure rather than coercing to {} so repair hooks and debugging still have access to the original provider output.
  • This does not attempt to repair malformed input itself; it only prevents internal reconstruction from crashing before repair/error handling can occur.

PR Checklist - Required to merge

  • 📦 pnpm changeset was run to create a changelog for this PR
  • 🔒 DCO sign-off passes
  • 📝 Ping @vercel/workflow in a comment once the PR is ready, and the above checklist is complete

@changeset-bot

changeset-botBot commented Apr 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 88d9174

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

This PR includes changesets to release 1 package
NameType
@workflow/aiPatch

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

vercelBot commented Apr 12, 2026

Copy link
Copy Markdown
Contributor

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

A member of the Team first needs to authorize it.

@craze3

Copy link
Copy Markdown
ContributorAuthor

@vercel/workflow ready for review.

This fixes #1706 by preserving malformed streamed tool-call input during DurableAgent reconstruction so experimental_repairToolCall and normal error handling can run instead of crashing early.

It looks like the Vercel preview checks on this fork PR are blocked pending team authorization.

@craze3
craze3 marked this pull request as ready for review April 12, 2026 22:22
@craze3
craze3 requested a review from a team as a code ownerApril 12, 2026 22:22

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM generally, I was concerned about leaving the malformed string in there potentially, so I had AI do a quick assessment. Let me know what you think about it / adjust as you see fit, then feel free to re-tag me for review here or send a comment on the issue


If the repair function fixes the tool call and execution succeeds, the loop continues and the prompt is sent to the provider on the next turn. Providers serialize this input directly:

  • OpenAI (@ai-sdk/openai, internal/index.mjs:224): does arguments: JSON.stringify(part.input). If input is already a string like '{"city":"San', JSON.stringify produces a double-encoded
    string literal '"{\"city\":\"San"' — not a valid function arguments value.
  • Anthropic (@ai-sdk/anthropic, internal/index.mjs:2451): passes input: part.input directly as tool_use.input. Anthropic's API expects a JSON object here; a string will be rejected.

So the flow is:

  1. Model emits malformed tool-call input
  2. safeParseToolCallInput preserves it as a string (no crash — good)
  3. String is baked into conversationPrompt
  4. executeTool in durable-agent.ts:1581 hits JSON.parse → throws → repair runs → tool executes successfully
  5. Next doStreamStep call sends the contaminated prompt to the provider
  6. Provider API error on the next turn — a new failure that didn't exist before

Before this PR, the crash at step 2 prevented the loop from ever reaching step 5-6. After the PR, you trade an immediate crash for a deferred one on the next model call. This only
matters when all three conditions hold: malformed input + repair succeeds + there's a subsequent model turn. But that's exactly the scenario the PR is designed to enable.

Suggested fix:
After repair succeeds in executeTool, the repaired tool call input should be patched back into the conversationPrompt. Alternatively, streamTextIterator could defer writing the
assistant tool-call message until after tool execution/repair, using the (potentially repaired) input.

// Mock doStreamStep
vi.mock('./do-stream-step.js', () => ({
doStreamStep: vi.fn(),
safeParseToolCallInput: (input: string | undefined) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can just be original.safeParseToolCallInput instead of rewriting the function, to avoid drift

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the parser into a shared internal helper, so stream-text-iterator now imports the real implementation instead of re-declaring it in the test mock.

* Parse streamed tool-call input without crashing the workflow step when a
* provider emits malformed or truncated JSON.
*/
export function safeParseToolCallInput(input: string | undefined): unknown {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this also re-use safeParseInput from durable-agent.ts? Or is the undefined fallthrough intentional?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pulled this into a shared helper and now reuse it from do-stream-step, stream-text-iterator, and durable-agent. The undefined/empty-input -> {} behavior is still intentional for no-args tool calls.

@VaguelySeriousVaguelySerious added the backport-stable Cherry-pick this PR to the stable branch when merged label Apr 13, 2026
@craze3

Copy link
Copy Markdown
ContributorAuthor

Good catch on the deferred-failure path. I pushed a follow-up commit that patches repaired tool-call input back into the accumulated assistant tool-call message before the next model step, and also updates the original tool-call object so downstream toolCalls / toolResults reflect the repaired value too.

I also moved the parser into a shared helper to avoid drift between the touched files and tests. Re-ran the focused @workflow/ai checks after the change:

pnpm --filter @workflow/ai test -- \
src/agent/do-stream-step.test.ts \
src/agent/stream-text-iterator.test.ts \
src/agent/durable-agent.test.ts \
src/agent/telemetry.test.ts
pnpm --filter @workflow/ai build

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM now, thanks!

Comment thread.changeset/fix-malformed-tool-call-input.md Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
@VaguelySerious
VaguelySerious merged commit 86ebe9f into vercel:mainApr 14, 2026
4 of 17 checks passed
@ghostghost mentioned this pull request Apr 14, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-stableCherry-pick this PR to the stable branch when merged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@craze3@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(ai): avoid crashing on malformed streamed tool-call input - #1707

Merged
VaguelySerious merged 3 commits into
vercel:mainfrom
craze3:codex/fix-malformed-tool-call-input
Apr 14, 2026
Merged

fix(ai): avoid crashing on malformed streamed tool-call input#1707
VaguelySerious merged 3 commits into
vercel:mainfrom
craze3:codex/fix-malformed-tool-call-input

Conversation

@craze3

@craze3craze3 commented Apr 12, 2026

Copy link
Copy Markdown
Contributor

Description

Closes#1706

  • add a small safe parser for streamed tool-call.input
  • replace eager JSON.parse(...) calls in do-stream-step.ts and stream-text-iterator.ts that could crash the workflow step before experimental_repairToolCall was reached
  • preserve malformed input as the raw string instead of throwing so existing repair and error-handling paths remain reachable

This keeps the fix narrowly scoped to internal DurableAgent reconstruction paths. It does not change the public API or introduce provider-specific behavior.

How did you test your changes?

  • added unit coverage for the new safe parser in packages/ai/src/agent/do-stream-step.test.ts
  • added a regression test that exercises doStreamStep with a mock streamed tool call whose input is malformed JSON and verifies the step no longer throws
  • added a regression test in packages/ai/src/agent/stream-text-iterator.test.ts that verifies malformed tool-call input is preserved into the reconstructed assistant message instead of crashing during prompt rebuild
  • ran the focused @workflow/ai test suite for the touched agent paths:
pnpm --filter @workflow/ai test -- \
src/agent/do-stream-step.test.ts \
src/agent/stream-text-iterator.test.ts \
src/agent/durable-agent.test.ts \
src/agent/telemetry.test.ts
  • ran the package build:
pnpm --filter @workflow/ai build

Recommended commands to rerun in a fresh fork before marking ready:

pnpm --filter @workflow/ai test -- \
src/agent/do-stream-step.test.ts \
src/agent/stream-text-iterator.test.ts \
src/agent/durable-agent.test.ts \
src/agent/telemetry.test.ts
pnpm --filter @workflow/ai build

Notes

  • This intentionally preserves the raw malformed input string on parse failure rather than coercing to {} so repair hooks and debugging still have access to the original provider output.
  • This does not attempt to repair malformed input itself; it only prevents internal reconstruction from crashing before repair/error handling can occur.

PR Checklist - Required to merge

  • 📦 pnpm changeset was run to create a changelog for this PR
  • 🔒 DCO sign-off passes
  • 📝 Ping @vercel/workflow in a comment once the PR is ready, and the above checklist is complete

@changeset-bot

changeset-botBot commented Apr 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 88d9174

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

This PR includes changesets to release 1 package
NameType
@workflow/aiPatch

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

vercelBot commented Apr 12, 2026

Copy link
Copy Markdown
Contributor

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

A member of the Team first needs to authorize it.

@craze3

Copy link
Copy Markdown
ContributorAuthor

@vercel/workflow ready for review.

This fixes #1706 by preserving malformed streamed tool-call input during DurableAgent reconstruction so experimental_repairToolCall and normal error handling can run instead of crashing early.

It looks like the Vercel preview checks on this fork PR are blocked pending team authorization.

@craze3
craze3 marked this pull request as ready for review April 12, 2026 22:22
@craze3
craze3 requested a review from a team as a code ownerApril 12, 2026 22:22

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM generally, I was concerned about leaving the malformed string in there potentially, so I had AI do a quick assessment. Let me know what you think about it / adjust as you see fit, then feel free to re-tag me for review here or send a comment on the issue


If the repair function fixes the tool call and execution succeeds, the loop continues and the prompt is sent to the provider on the next turn. Providers serialize this input directly:

  • OpenAI (@ai-sdk/openai, internal/index.mjs:224): does arguments: JSON.stringify(part.input). If input is already a string like '{"city":"San', JSON.stringify produces a double-encoded
    string literal '"{\"city\":\"San"' — not a valid function arguments value.
  • Anthropic (@ai-sdk/anthropic, internal/index.mjs:2451): passes input: part.input directly as tool_use.input. Anthropic's API expects a JSON object here; a string will be rejected.

So the flow is:

  1. Model emits malformed tool-call input
  2. safeParseToolCallInput preserves it as a string (no crash — good)
  3. String is baked into conversationPrompt
  4. executeTool in durable-agent.ts:1581 hits JSON.parse → throws → repair runs → tool executes successfully
  5. Next doStreamStep call sends the contaminated prompt to the provider
  6. Provider API error on the next turn — a new failure that didn't exist before

Before this PR, the crash at step 2 prevented the loop from ever reaching step 5-6. After the PR, you trade an immediate crash for a deferred one on the next model call. This only
matters when all three conditions hold: malformed input + repair succeeds + there's a subsequent model turn. But that's exactly the scenario the PR is designed to enable.

Suggested fix:
After repair succeeds in executeTool, the repaired tool call input should be patched back into the conversationPrompt. Alternatively, streamTextIterator could defer writing the
assistant tool-call message until after tool execution/repair, using the (potentially repaired) input.

// Mock doStreamStep
vi.mock('./do-stream-step.js', () => ({
doStreamStep: vi.fn(),
safeParseToolCallInput: (input: string | undefined) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can just be original.safeParseToolCallInput instead of rewriting the function, to avoid drift

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the parser into a shared internal helper, so stream-text-iterator now imports the real implementation instead of re-declaring it in the test mock.

* Parse streamed tool-call input without crashing the workflow step when a
* provider emits malformed or truncated JSON.
*/
export function safeParseToolCallInput(input: string | undefined): unknown {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this also re-use safeParseInput from durable-agent.ts? Or is the undefined fallthrough intentional?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pulled this into a shared helper and now reuse it from do-stream-step, stream-text-iterator, and durable-agent. The undefined/empty-input -> {} behavior is still intentional for no-args tool calls.

@VaguelySeriousVaguelySerious added the backport-stable Cherry-pick this PR to the stable branch when merged label Apr 13, 2026
@craze3

Copy link
Copy Markdown
ContributorAuthor

Good catch on the deferred-failure path. I pushed a follow-up commit that patches repaired tool-call input back into the accumulated assistant tool-call message before the next model step, and also updates the original tool-call object so downstream toolCalls / toolResults reflect the repaired value too.

I also moved the parser into a shared helper to avoid drift between the touched files and tests. Re-ran the focused @workflow/ai checks after the change:

pnpm --filter @workflow/ai test -- \
src/agent/do-stream-step.test.ts \
src/agent/stream-text-iterator.test.ts \
src/agent/durable-agent.test.ts \
src/agent/telemetry.test.ts
pnpm --filter @workflow/ai build

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM now, thanks!

Comment thread.changeset/fix-malformed-tool-call-input.md Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
@VaguelySerious
VaguelySerious merged commit 86ebe9f into vercel:mainApr 14, 2026
4 of 17 checks passed
@ghostghost mentioned this pull request Apr 14, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-stableCherry-pick this PR to the stable branch when merged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@craze3@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(ai): avoid crashing on malformed streamed tool-call input - #1707

Merged
VaguelySerious merged 3 commits into
vercel:mainfrom
craze3:codex/fix-malformed-tool-call-input
Apr 14, 2026
Merged

fix(ai): avoid crashing on malformed streamed tool-call input#1707
VaguelySerious merged 3 commits into
vercel:mainfrom
craze3:codex/fix-malformed-tool-call-input

Conversation

@craze3

@craze3craze3 commented Apr 12, 2026

Copy link
Copy Markdown
Contributor

Description

Closes#1706

  • add a small safe parser for streamed tool-call.input
  • replace eager JSON.parse(...) calls in do-stream-step.ts and stream-text-iterator.ts that could crash the workflow step before experimental_repairToolCall was reached
  • preserve malformed input as the raw string instead of throwing so existing repair and error-handling paths remain reachable

This keeps the fix narrowly scoped to internal DurableAgent reconstruction paths. It does not change the public API or introduce provider-specific behavior.

How did you test your changes?

  • added unit coverage for the new safe parser in packages/ai/src/agent/do-stream-step.test.ts
  • added a regression test that exercises doStreamStep with a mock streamed tool call whose input is malformed JSON and verifies the step no longer throws
  • added a regression test in packages/ai/src/agent/stream-text-iterator.test.ts that verifies malformed tool-call input is preserved into the reconstructed assistant message instead of crashing during prompt rebuild
  • ran the focused @workflow/ai test suite for the touched agent paths:
pnpm --filter @workflow/ai test -- \
src/agent/do-stream-step.test.ts \
src/agent/stream-text-iterator.test.ts \
src/agent/durable-agent.test.ts \
src/agent/telemetry.test.ts
  • ran the package build:
pnpm --filter @workflow/ai build

Recommended commands to rerun in a fresh fork before marking ready:

pnpm --filter @workflow/ai test -- \
src/agent/do-stream-step.test.ts \
src/agent/stream-text-iterator.test.ts \
src/agent/durable-agent.test.ts \
src/agent/telemetry.test.ts
pnpm --filter @workflow/ai build

Notes

  • This intentionally preserves the raw malformed input string on parse failure rather than coercing to {} so repair hooks and debugging still have access to the original provider output.
  • This does not attempt to repair malformed input itself; it only prevents internal reconstruction from crashing before repair/error handling can occur.

PR Checklist - Required to merge

  • 📦 pnpm changeset was run to create a changelog for this PR
  • 🔒 DCO sign-off passes
  • 📝 Ping @vercel/workflow in a comment once the PR is ready, and the above checklist is complete

@changeset-bot

changeset-botBot commented Apr 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 88d9174

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

This PR includes changesets to release 1 package
NameType
@workflow/aiPatch

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

vercelBot commented Apr 12, 2026

Copy link
Copy Markdown
Contributor

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

A member of the Team first needs to authorize it.

@craze3

Copy link
Copy Markdown
ContributorAuthor

@vercel/workflow ready for review.

This fixes #1706 by preserving malformed streamed tool-call input during DurableAgent reconstruction so experimental_repairToolCall and normal error handling can run instead of crashing early.

It looks like the Vercel preview checks on this fork PR are blocked pending team authorization.

@craze3
craze3 marked this pull request as ready for review April 12, 2026 22:22
@craze3
craze3 requested a review from a team as a code ownerApril 12, 2026 22:22

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM generally, I was concerned about leaving the malformed string in there potentially, so I had AI do a quick assessment. Let me know what you think about it / adjust as you see fit, then feel free to re-tag me for review here or send a comment on the issue


If the repair function fixes the tool call and execution succeeds, the loop continues and the prompt is sent to the provider on the next turn. Providers serialize this input directly:

  • OpenAI (@ai-sdk/openai, internal/index.mjs:224): does arguments: JSON.stringify(part.input). If input is already a string like '{"city":"San', JSON.stringify produces a double-encoded
    string literal '"{\"city\":\"San"' — not a valid function arguments value.
  • Anthropic (@ai-sdk/anthropic, internal/index.mjs:2451): passes input: part.input directly as tool_use.input. Anthropic's API expects a JSON object here; a string will be rejected.

So the flow is:

  1. Model emits malformed tool-call input
  2. safeParseToolCallInput preserves it as a string (no crash — good)
  3. String is baked into conversationPrompt
  4. executeTool in durable-agent.ts:1581 hits JSON.parse → throws → repair runs → tool executes successfully
  5. Next doStreamStep call sends the contaminated prompt to the provider
  6. Provider API error on the next turn — a new failure that didn't exist before

Before this PR, the crash at step 2 prevented the loop from ever reaching step 5-6. After the PR, you trade an immediate crash for a deferred one on the next model call. This only
matters when all three conditions hold: malformed input + repair succeeds + there's a subsequent model turn. But that's exactly the scenario the PR is designed to enable.

Suggested fix:
After repair succeeds in executeTool, the repaired tool call input should be patched back into the conversationPrompt. Alternatively, streamTextIterator could defer writing the
assistant tool-call message until after tool execution/repair, using the (potentially repaired) input.

// Mock doStreamStep
vi.mock('./do-stream-step.js', () => ({
doStreamStep: vi.fn(),
safeParseToolCallInput: (input: string | undefined) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can just be original.safeParseToolCallInput instead of rewriting the function, to avoid drift

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the parser into a shared internal helper, so stream-text-iterator now imports the real implementation instead of re-declaring it in the test mock.

* Parse streamed tool-call input without crashing the workflow step when a
* provider emits malformed or truncated JSON.
*/
export function safeParseToolCallInput(input: string | undefined): unknown {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this also re-use safeParseInput from durable-agent.ts? Or is the undefined fallthrough intentional?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pulled this into a shared helper and now reuse it from do-stream-step, stream-text-iterator, and durable-agent. The undefined/empty-input -> {} behavior is still intentional for no-args tool calls.

@VaguelySeriousVaguelySerious added the backport-stable Cherry-pick this PR to the stable branch when merged label Apr 13, 2026
@craze3

Copy link
Copy Markdown
ContributorAuthor

Good catch on the deferred-failure path. I pushed a follow-up commit that patches repaired tool-call input back into the accumulated assistant tool-call message before the next model step, and also updates the original tool-call object so downstream toolCalls / toolResults reflect the repaired value too.

I also moved the parser into a shared helper to avoid drift between the touched files and tests. Re-ran the focused @workflow/ai checks after the change:

pnpm --filter @workflow/ai test -- \
src/agent/do-stream-step.test.ts \
src/agent/stream-text-iterator.test.ts \
src/agent/durable-agent.test.ts \
src/agent/telemetry.test.ts
pnpm --filter @workflow/ai build

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM now, thanks!

Comment thread.changeset/fix-malformed-tool-call-input.md Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
@VaguelySerious
VaguelySerious merged commit 86ebe9f into vercel:mainApr 14, 2026
4 of 17 checks passed
@ghostghost mentioned this pull request Apr 14, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-stableCherry-pick this PR to the stable branch when merged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@craze3@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(ai): avoid crashing on malformed streamed tool-call input - #1707

Merged
VaguelySerious merged 3 commits into
vercel:mainfrom
craze3:codex/fix-malformed-tool-call-input
Apr 14, 2026
Merged

fix(ai): avoid crashing on malformed streamed tool-call input#1707
VaguelySerious merged 3 commits into
vercel:mainfrom
craze3:codex/fix-malformed-tool-call-input

Conversation

@craze3

@craze3craze3 commented Apr 12, 2026

Copy link
Copy Markdown
Contributor

Description

Closes#1706

  • add a small safe parser for streamed tool-call.input
  • replace eager JSON.parse(...) calls in do-stream-step.ts and stream-text-iterator.ts that could crash the workflow step before experimental_repairToolCall was reached
  • preserve malformed input as the raw string instead of throwing so existing repair and error-handling paths remain reachable

This keeps the fix narrowly scoped to internal DurableAgent reconstruction paths. It does not change the public API or introduce provider-specific behavior.

How did you test your changes?

  • added unit coverage for the new safe parser in packages/ai/src/agent/do-stream-step.test.ts
  • added a regression test that exercises doStreamStep with a mock streamed tool call whose input is malformed JSON and verifies the step no longer throws
  • added a regression test in packages/ai/src/agent/stream-text-iterator.test.ts that verifies malformed tool-call input is preserved into the reconstructed assistant message instead of crashing during prompt rebuild
  • ran the focused @workflow/ai test suite for the touched agent paths:
pnpm --filter @workflow/ai test -- \
src/agent/do-stream-step.test.ts \
src/agent/stream-text-iterator.test.ts \
src/agent/durable-agent.test.ts \
src/agent/telemetry.test.ts
  • ran the package build:
pnpm --filter @workflow/ai build

Recommended commands to rerun in a fresh fork before marking ready:

pnpm --filter @workflow/ai test -- \
src/agent/do-stream-step.test.ts \
src/agent/stream-text-iterator.test.ts \
src/agent/durable-agent.test.ts \
src/agent/telemetry.test.ts
pnpm --filter @workflow/ai build

Notes

  • This intentionally preserves the raw malformed input string on parse failure rather than coercing to {} so repair hooks and debugging still have access to the original provider output.
  • This does not attempt to repair malformed input itself; it only prevents internal reconstruction from crashing before repair/error handling can occur.

PR Checklist - Required to merge

  • 📦 pnpm changeset was run to create a changelog for this PR
  • 🔒 DCO sign-off passes
  • 📝 Ping @vercel/workflow in a comment once the PR is ready, and the above checklist is complete

@changeset-bot

changeset-botBot commented Apr 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 88d9174

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

This PR includes changesets to release 1 package
NameType
@workflow/aiPatch

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

vercelBot commented Apr 12, 2026

Copy link
Copy Markdown
Contributor

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

A member of the Team first needs to authorize it.

@craze3

Copy link
Copy Markdown
ContributorAuthor

@vercel/workflow ready for review.

This fixes #1706 by preserving malformed streamed tool-call input during DurableAgent reconstruction so experimental_repairToolCall and normal error handling can run instead of crashing early.

It looks like the Vercel preview checks on this fork PR are blocked pending team authorization.

@craze3
craze3 marked this pull request as ready for review April 12, 2026 22:22
@craze3
craze3 requested a review from a team as a code ownerApril 12, 2026 22:22

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM generally, I was concerned about leaving the malformed string in there potentially, so I had AI do a quick assessment. Let me know what you think about it / adjust as you see fit, then feel free to re-tag me for review here or send a comment on the issue


If the repair function fixes the tool call and execution succeeds, the loop continues and the prompt is sent to the provider on the next turn. Providers serialize this input directly:

  • OpenAI (@ai-sdk/openai, internal/index.mjs:224): does arguments: JSON.stringify(part.input). If input is already a string like '{"city":"San', JSON.stringify produces a double-encoded
    string literal '"{\"city\":\"San"' — not a valid function arguments value.
  • Anthropic (@ai-sdk/anthropic, internal/index.mjs:2451): passes input: part.input directly as tool_use.input. Anthropic's API expects a JSON object here; a string will be rejected.

So the flow is:

  1. Model emits malformed tool-call input
  2. safeParseToolCallInput preserves it as a string (no crash — good)
  3. String is baked into conversationPrompt
  4. executeTool in durable-agent.ts:1581 hits JSON.parse → throws → repair runs → tool executes successfully
  5. Next doStreamStep call sends the contaminated prompt to the provider
  6. Provider API error on the next turn — a new failure that didn't exist before

Before this PR, the crash at step 2 prevented the loop from ever reaching step 5-6. After the PR, you trade an immediate crash for a deferred one on the next model call. This only
matters when all three conditions hold: malformed input + repair succeeds + there's a subsequent model turn. But that's exactly the scenario the PR is designed to enable.

Suggested fix:
After repair succeeds in executeTool, the repaired tool call input should be patched back into the conversationPrompt. Alternatively, streamTextIterator could defer writing the
assistant tool-call message until after tool execution/repair, using the (potentially repaired) input.

// Mock doStreamStep
vi.mock('./do-stream-step.js', () => ({
doStreamStep: vi.fn(),
safeParseToolCallInput: (input: string | undefined) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can just be original.safeParseToolCallInput instead of rewriting the function, to avoid drift

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the parser into a shared internal helper, so stream-text-iterator now imports the real implementation instead of re-declaring it in the test mock.

* Parse streamed tool-call input without crashing the workflow step when a
* provider emits malformed or truncated JSON.
*/
export function safeParseToolCallInput(input: string | undefined): unknown {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this also re-use safeParseInput from durable-agent.ts? Or is the undefined fallthrough intentional?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pulled this into a shared helper and now reuse it from do-stream-step, stream-text-iterator, and durable-agent. The undefined/empty-input -> {} behavior is still intentional for no-args tool calls.

@VaguelySeriousVaguelySerious added the backport-stable Cherry-pick this PR to the stable branch when merged label Apr 13, 2026
@craze3

Copy link
Copy Markdown
ContributorAuthor

Good catch on the deferred-failure path. I pushed a follow-up commit that patches repaired tool-call input back into the accumulated assistant tool-call message before the next model step, and also updates the original tool-call object so downstream toolCalls / toolResults reflect the repaired value too.

I also moved the parser into a shared helper to avoid drift between the touched files and tests. Re-ran the focused @workflow/ai checks after the change:

pnpm --filter @workflow/ai test -- \
src/agent/do-stream-step.test.ts \
src/agent/stream-text-iterator.test.ts \
src/agent/durable-agent.test.ts \
src/agent/telemetry.test.ts
pnpm --filter @workflow/ai build

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM now, thanks!

Comment thread.changeset/fix-malformed-tool-call-input.md Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
@VaguelySerious
VaguelySerious merged commit 86ebe9f into vercel:mainApr 14, 2026
4 of 17 checks passed
@ghostghost mentioned this pull request Apr 14, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-stableCherry-pick this PR to the stable branch when merged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@craze3@VaguelySerious