-
Notifications
You must be signed in to change notification settings - Fork 1.9k
fix(vercel-ai): auto-detect deferred tool approval state in dump_messages() #4831
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
8b05d5a
7fc28e1
960496e
d780fe6
8e4393a
6f291fb
9cdfe34
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -61,6 +61,8 @@ | |
| SourceUrlUIPart, | ||
| StepStartUIPart, | ||
| TextUIPart, | ||
| ToolApprovalRequested, | ||
| ToolApprovalRequestedPart, | ||
| ToolApprovalResponded, | ||
| ToolInputAvailablePart, | ||
| ToolOutputAvailablePart, | ||
|
|
@@ -469,7 +471,10 @@ def _dump_request_message(msg: ModelRequest) -> tuple[list[UIMessagePart], list[ | |
|
|
||
| @classmethod | ||
| def _dump_response_message( | ||
| cls, msg: ModelResponse, tool_results: dict[str, ToolReturnPart | RetryPromptPart] | ||
| cls, | ||
| msg: ModelResponse, | ||
| tool_results: dict[str, ToolReturnPart | RetryPromptPart], | ||
| deferred_tool_call_ids: frozenset[str] = frozenset(), | ||
| ) -> list[UIMessagePart]: | ||
| """Convert a ModelResponse into a UIMessage.""" | ||
| ui_parts: list[UIMessagePart] = [] | ||
|
|
@@ -580,25 +585,39 @@ def _dump_response_message( | |
| call_provider_metadata = dump_provider_metadata( | ||
| id=part.id, provider_name=part.provider_name, provider_details=part.provider_details | ||
| ) | ||
| ui_parts.append( | ||
| ToolInputAvailablePart( | ||
| type=tool_name, | ||
| tool_call_id=part.tool_call_id, | ||
| input=part.args_as_dict(), | ||
| provider_executed=True, | ||
| call_provider_metadata=call_provider_metadata, | ||
| if part.tool_call_id in deferred_tool_call_ids: | ||
| ui_parts.append( | ||
| ToolApprovalRequestedPart( | ||
| type=tool_name, | ||
| tool_call_id=part.tool_call_id, | ||
| input=part.args_as_dict(), | ||
| provider_executed=True, | ||
| call_provider_metadata=call_provider_metadata, | ||
| approval=ToolApprovalRequested(id=part.tool_call_id), | ||
| ) | ||
| ) | ||
| else: | ||
| ui_parts.append( | ||
| ToolInputAvailablePart( | ||
| type=tool_name, | ||
| tool_call_id=part.tool_call_id, | ||
| input=part.args_as_dict(), | ||
| provider_executed=True, | ||
| call_provider_metadata=call_provider_metadata, | ||
| ) | ||
| ) | ||
| ) | ||
| elif isinstance(part, ToolCallPart): | ||
| ui_parts.extend(cls._dump_tool_call_part(part, tool_results)) | ||
| ui_parts.extend(cls._dump_tool_call_part(part, tool_results, deferred_tool_call_ids)) | ||
| else: | ||
| assert_never(part) | ||
|
|
||
| return ui_parts | ||
|
|
||
| @staticmethod | ||
| def _dump_tool_call_part( | ||
| part: ToolCallPart, tool_results: dict[str, ToolReturnPart | RetryPromptPart] | ||
| part: ToolCallPart, | ||
| tool_results: dict[str, ToolReturnPart | RetryPromptPart], | ||
| deferred_tool_call_ids: frozenset[str] = frozenset(), | ||
| ) -> list[UIMessagePart]: | ||
| """Convert a ToolCallPart (with optional result) into UIMessageParts.""" | ||
| tool_result = tool_results.get(part.tool_call_id) | ||
|
|
@@ -649,14 +668,35 @@ def _dump_tool_call_part( | |
| # Check for Vercel AI chunks returned by tool calls via metadata. | ||
| ui_parts.extend(_extract_metadata_ui_parts(tool_result)) | ||
| 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 | ||
|
||
| # of ErrorDetails), fall back to the formatted model_response(). | ||
| if isinstance(tool_result.content, str): | ||
| error_text = tool_result.content | ||
| else: | ||
| error_text = tool_result.model_response() | ||
tijmenhammer marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| ui_parts.append( | ||
| ToolOutputErrorPart( | ||
| type=tool_type, | ||
| tool_call_id=part.tool_call_id, | ||
| input=part.args_as_dict(), | ||
| error_text=tool_result.model_response(), | ||
| error_text=error_text, | ||
| provider_executed=False, | ||
| call_provider_metadata=call_provider_metadata, | ||
| ) | ||
| ) | ||
| elif part.tool_call_id in deferred_tool_call_ids: | ||
| # Deferred tool awaiting approval — emit approval-requested state | ||
| # so the frontend renders approve/reject buttons on reload. | ||
| 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), | ||
| ) | ||
| ) | ||
tijmenhammer marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| else: | ||
|
|
@@ -679,6 +719,7 @@ def dump_messages( | |
| *, | ||
| generate_message_id: Callable[[ModelRequest | ModelResponse, Literal['system', 'user', 'assistant'], int], str] | ||
| | None = None, | ||
| deferred_tool_call_ids: frozenset[str] | None = None, | ||
tijmenhammer marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| ) -> list[UIMessage]: | ||
| """Transform Pydantic AI messages into Vercel AI messages. | ||
|
|
||
|
|
@@ -689,10 +730,15 @@ def dump_messages( | |
| message index (incremented per UIMessage appended), and should return a unique | ||
| string ID. If not provided, uses `provider_response_id` for responses, | ||
| run_id-based IDs for messages with run_id, or a deterministic UUID5 fallback. | ||
| deferred_tool_call_ids: Optional set of tool call IDs that are deferred (awaiting | ||
| user approval). Tool calls with these IDs that have no result will be emitted | ||
| with ``state='approval-requested'`` and an ``approval`` field, so the frontend | ||
| can render approve/reject buttons on reload. | ||
|
|
||
| Returns: | ||
| A list of UIMessage objects in Vercel AI format | ||
| """ | ||
| _deferred = deferred_tool_call_ids or frozenset() | ||
| tool_results: dict[str, ToolReturnPart | RetryPromptPart] = {} | ||
|
|
||
| for msg in messages: | ||
|
|
@@ -725,7 +771,7 @@ def dump_messages( | |
| elif isinstance( # pragma: no branch | ||
| msg, ModelResponse | ||
| ): | ||
| ui_parts: list[UIMessagePart] = cls._dump_response_message(msg, tool_results) | ||
| ui_parts: list[UIMessagePart] = cls._dump_response_message(msg, tool_results, _deferred) | ||
| if ui_parts: # pragma: no branch | ||
| result.append( | ||
| UIMessage(id=id_generator(msg, 'assistant', message_index), role='assistant', parts=ui_parts) | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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, bothapproval_idandtool_call_idare set totool_call.tool_call_id. Similarly in the dump path,ToolApprovalRequested(id=part.tool_call_id)reuses the tool call ID. I verified thatiter_tool_approval_responsesin_utils.py:147-151usespart.tool_call_id(notpart.approval.id) for matching, confirming thatapproval.idis 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 usesapproval_idfor 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.
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.
There was a problem hiding this comment.
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_idin the dump case, let's do it in the streaming case as well.