fix(vercel-ai): auto-detect deferred tool approval state in dump_messages()#4831
Conversation
…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
…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.
| input=part.args_as_dict(), | ||
| provider_executed=True, | ||
| call_provider_metadata=call_provider_metadata, | ||
| approval=ToolApprovalRequested(id=part.tool_call_id), |
There was a problem hiding this comment.
📝 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
@tijmenhammer If we use tool_call_id in the dump case, let's do it in the streaming case as well.
|
@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. |
|
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. |
| input=part.args_as_dict(), | ||
| provider_executed=True, | ||
| call_provider_metadata=call_provider_metadata, | ||
| approval=ToolApprovalRequested(id=part.tool_call_id), |
There was a problem hiding this comment.
@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 |
There was a problem hiding this comment.
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 :)
There was a problem hiding this comment.
Fair point about the lossy round-trip. Reverted will open a separate PR if I find the time.
…_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)
| """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 |
There was a problem hiding this comment.
| 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( |
There was a problem hiding this comment.
I believe this (and the one below) should be gated by self.sdk_version >= 6!
|
@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.
| 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, | ||
| ) | ||
| ) |
There was a problem hiding this comment.
🚩 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
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).
|
hey @DouweM, ja was even niet beschikbaar maar maak hem graag af! Heb je feedback verwerkt in mijn laatste commit |
Pre-Review Checklist
make formatandmake typecheck.Pre-Merge Checklist
Summary
Fixes #4830
dump_messages()now automatically detects deferred tool calls by checking whichToolCallParts have no corresponding result in the message history. These are emitted withstate='approval-requested'instead ofstate='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_idasapproval_idin the streaming path for consistency with the dump path (wasuuid4()before).Changes
_adapter.py: Tool calls without results automatically emitapproval-requestedwithapproval={id: tool_call_id}(bothToolCallPartandBuiltinToolCallPart)_event_stream.py: Usetool_call_idforapproval_id(consistent with dump path)test_vercel_ai.py: Updated snapshot tests, added deferred tool coverageTest plan