Skip to content

fix(vercel-ai): auto-detect deferred tool approval state in dump_messages()#4831

Merged
DouweM merged 9 commits intopydantic:mainfrom
tijmenhammer:fix/dump-messages-deferred-approval
Apr 21, 2026
Merged

fix(vercel-ai): auto-detect deferred tool approval state in dump_messages()#4831
DouweM merged 9 commits intopydantic:mainfrom
tijmenhammer:fix/dump-messages-deferred-approval

Conversation

@tijmenhammer
Copy link
Copy Markdown
Contributor

@tijmenhammer tijmenhammer commented Mar 24, 2026

Pre-Review Checklist

  • Any AI generated code has been reviewed line-by-line by the human PR author, who stands by it.
  • No breaking changes in accordance with the version policy.
  • Linting and type checking pass per make format and make typecheck.
  • PR title is fit for the release changelog.

Pre-Merge Checklist

  • New tests for any fix or new behavior, maintaining 100% coverage.
  • Updated documentation for new features and behaviors, including docstrings for API docs.

Summary

Fixes #4830

dump_messages() now automatically detects deferred tool calls by checking which ToolCallParts have no corresponding result in the message history. These are emitted with state='approval-requested' instead of state='input-available', enabling the frontend to render approve/reject buttons on page reload.

No new API surface — deferred status is inferred from the messages, not passed in by the caller.

Also uses tool_call_id as approval_id in the streaming path for consistency with the dump path (was uuid4() before).

Changes

  • _adapter.py: Tool calls without results automatically emit approval-requested with approval={id: tool_call_id} (both ToolCallPart and BuiltinToolCallPart)
  • _event_stream.py: Use tool_call_id for approval_id (consistent with dump path)
  • test_vercel_ai.py: Updated snapshot tests, added deferred tool coverage

Test plan

  • All 124 test_vercel_ai.py tests pass
  • All 15 test_ui.py tests pass
  • ruff check + ruff format clean
  • pyright clean on changed files (0 errors)
  • Backward compatible — no new parameters, existing behavior unchanged for tool calls with results

…ext in dump_messages()

1. Add `deferred_tool_call_ids` parameter to `dump_messages()` so callers
   can specify which tool calls are deferred. These are emitted with
   `state='approval-requested'` and `approval={id: tool_call_id}` instead
   of `state='input-available'` with `approval=null`, enabling the frontend
   to render approve/reject buttons on reload.

2. Use raw `RetryPromptPart.content` (when it's a string) instead of
   `model_response()` for the UI error_text. `model_response()` appends
   "Fix the errors and try again." which is intended for the model, not
   for UI display, and mangles custom error markers like "Cancelled".

Fixes pydantic#4830
@github-actions github-actions Bot added size: M Medium PR (101-500 weighted lines) bug Report that something isn't working, or PR implementing a fix labels Mar 24, 2026
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 4 additional findings.

Open in Devin Review

devin-ai-integration[bot]

This comment was marked as resolved.

…t error text

The streaming handler in _event_stream.py used part.model_response() which
appends "Fix the errors and try again." to string content. Now uses raw
content for strings (matching _adapter.py dump path), so the same error
shows identical text whether streamed in real-time or reconstructed from
persisted messages.
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 6 additional findings in Devin Review.

Open in Devin Review

input=part.args_as_dict(),
provider_executed=True,
call_provider_metadata=call_provider_metadata,
approval=ToolApprovalRequested(id=part.tool_call_id),
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot Mar 25, 2026

Choose a reason for hiding this comment

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

📝 Info: approval_id and tool_call_id are intentionally identical

In _event_stream.py:130-131, both approval_id and tool_call_id are set to tool_call.tool_call_id. Similarly in the dump path, ToolApprovalRequested(id=part.tool_call_id) reuses the tool call ID. I verified that iter_tool_approval_responses in _utils.py:147-151 uses part.tool_call_id (not part.approval.id) for matching, confirming that approval.id is not used as a matching key. This makes the output fully deterministic, which is desirable for snapshot testing and idempotent renders. The only potential concern would be if the Vercel AI SDK frontend uses approval_id for deduplication across multiple approval requests for the same tool call, but the comments explicitly note this is not the case.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The inconsistency is harmless approval.id is never used for matching anywhere. All approval pairing goes through tool_call_id (see iter_tool_approval_responses in _utils.py). Using tool_call_id in the dump path is intentional for deterministic output. Added a clarifying comment.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@tijmenhammer If we use tool_call_id in the dump case, let's do it in the streaming case as well.

@DouweM
Copy link
Copy Markdown
Collaborator

DouweM commented Mar 25, 2026

@tijmenhammer Bedankt Tijmen :) Can you please look at that Devin comment? I'm not sure if the approval ID actually has to match the tool call ID, or if any UUID is fine.

@tijmenhammer
Copy link
Copy Markdown
Contributor Author

Hey @DouweM geen probleem ;)

Looked into it approval ID can be anything, it's not used for matching. All pairing goes through toolCallId on the message part (iter_tool_approval_responses in _utils.py). Went with tool_call_id in the dump path for deterministic output (better for snapshots), the streaming path uses uuid4() which is fine there since it's ephemeral. Added a comment explaining why.

Comment thread pydantic_ai_slim/pydantic_ai/ui/vercel_ai/_adapter.py Outdated
input=part.args_as_dict(),
provider_executed=True,
call_provider_metadata=call_provider_metadata,
approval=ToolApprovalRequested(id=part.tool_call_id),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@tijmenhammer If we use tool_call_id in the dump case, let's do it in the streaming case as well.

elif isinstance(tool_result, RetryPromptPart):
# Use the raw content string to avoid model_response() appending
# "Fix the errors and try again." — that suffix is intended for the
# model, not for UI display. For structured validation errors (list
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This means that these RetryPromptParts are now lossy in a round-trip right? Because on the next turn (after dump and load), the LLM would not see the "Fix the errors..." bit anymore, breaking the cache, and potentially being less clear to it than the message with the prompt.

Either way I think this is a more controversial change than the one about approval state so would like to see this in a separate PR, if you insist we need it :)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fair point about the lossy round-trip. Reverted will open a separate PR if I find the time.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Opened #4869 for this

…_id in streaming, revert RetryPromptPart change

- Remove deferred_tool_call_ids param; dump_messages() now auto-detects deferred
  tool calls (no result in history) and emits approval-requested
- Use tool_call_id for approval_id in streaming path (consistent with dump path)
- Revert RetryPromptPart raw content change (separate PR per reviewer request)
@github-actions github-actions Bot added size: S Small PR (≤100 weighted lines) and removed size: M Medium PR (101-500 weighted lines) labels Mar 26, 2026
devin-ai-integration[bot]

This comment was marked as resolved.

@tijmenhammer tijmenhammer changed the title fix(vercel-ai): preserve deferred tool approval state and raw error text in dump_messages() fix(vercel-ai): auto-detect deferred tool approval state in dump_messages() Mar 26, 2026
@tijmenhammer tijmenhammer requested a review from DouweM March 26, 2026 22:28
"""Transform Pydantic AI messages into Vercel AI messages.

Tool calls that have no corresponding result in the message history are automatically
detected as deferred and emitted with ``state='approval-requested'``, so the frontend
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
detected as deferred and emitted with ``state='approval-requested'``, so the frontend
detected as deferred and emitted with `state='approval-requested'`, so the frontend

# so we use tool_call_id for a stable, deterministic value in dump output.
ui_parts.append(
ToolInputAvailablePart(
ToolApprovalRequestedPart(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I believe this (and the one below) should be gated by self.sdk_version >= 6!

@DouweM
Copy link
Copy Markdown
Collaborator

DouweM commented Apr 13, 2026

@tijmenhammer Hey Tijmen, heb je nog interesse om dit af te maken? Or let me know and I can take it over!

DouweM review: ToolApprovalRequestedPart is a v6-only concept; on v5
fall back to the previous ToolInputAvailablePart behavior. Adds a
keyword-only sdk_version parameter to dump_messages (default 5 for
backwards compatibility) and threads it through the dump helpers.

Also fixes the docstring backtick style per review suggestion.
@github-actions github-actions Bot added size: M Medium PR (101-500 weighted lines) and removed size: S Small PR (≤100 weighted lines) labels Apr 18, 2026
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 5 additional findings in Devin Review.

Open in Devin Review

Comment on lines 702 to 728
else:
ui_parts.append(
ToolInputAvailablePart(
type=tool_type,
tool_call_id=part.tool_call_id,
input=part.args_as_dict(),
provider_executed=False,
call_provider_metadata=call_provider_metadata,
# No result found → the tool call is deferred (awaiting approval or external result).
# On v6, emit `approval-requested` so the frontend can render approve/reject buttons on reload.
# On v5, fall back to `input-available` since approval states are v6-only.
# `approval.id` is not used for matching (tool_call_id is the match key),
# so we use tool_call_id for a stable, deterministic value in dump output.
if sdk_version >= 6:
ui_parts.append(
ToolApprovalRequestedPart(
type=tool_type,
tool_call_id=part.tool_call_id,
input=part.args_as_dict(),
provider_executed=False,
call_provider_metadata=call_provider_metadata,
approval=ToolApprovalRequested(id=part.tool_call_id),
)
)
else:
ui_parts.append(
ToolInputAvailablePart(
type=tool_type,
tool_call_id=part.tool_call_id,
input=part.args_as_dict(),
provider_executed=False,
call_provider_metadata=call_provider_metadata,
)
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚩 All tool calls without results are treated as deferred on v6, not just approval-requiring ones

The new logic in both _dump_response_message and _dump_tool_call_part emits ToolApprovalRequestedPart for ANY tool call without a corresponding result when sdk_version >= 6. This includes tool calls that may not require approval (e.g., a tool call at the end of a message history where execution was interrupted, or tools configured without requires_approval=True). The assumption is that in the dump path, a tool call without a result is always "deferred" and should show approval UI on reload. This is a reasonable heuristic since the dump path only processes persisted message history, and in practice tool calls without results in a deferred workflow are the primary use case. However, it means that if a user dumps a partial message history (e.g., from a crashed run) with v6, those incomplete tool calls will show approval buttons rather than a neutral "input-available" state.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Intentional, matches the PR's goal. Without a result part, the dump path can't distinguish a deferred approval from an interrupted run (there's no "deferred" marker on ToolCallPart; that state lives in runtime DeferredToolRequests).

@tijmenhammer
Copy link
Copy Markdown
Contributor Author

hey @DouweM, ja was even niet beschikbaar maar maak hem graag af! Heb je feedback verwerkt in mijn laatste commit

@DouweM DouweM merged commit 89f60ee into pydantic:main Apr 21, 2026
56 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Report that something isn't working, or PR implementing a fix size: M Medium PR (101-500 weighted lines)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dump_messages() doesn't preserve deferred tool approval state for Vercel AI SDK v6

3 participants