Skip to content

Add missing Vercel AI SDK v6 tool approval part types#4388

Merged
DouweM merged 25 commits intopydantic:mainfrom
bendrucker:fix-ai-sdk-v6-approval-types
Mar 6, 2026
Merged

Add missing Vercel AI SDK v6 tool approval part types#4388
DouweM merged 25 commits intopydantic:mainfrom
bendrucker:fix-ai-sdk-v6-approval-types

Conversation

@bendrucker
Copy link
Contributor

@bendrucker bendrucker commented Feb 20, 2026

Changes

  • Adds ToolApprovalRequestedPart, ToolApprovalRespondedPart, ToolOutputDeniedPart, and their dynamic counterparts to request_types.py so the adapter can load and dump all AI SDK v6 tool approval states (approval-requested, approval-responded, output-denied)
  • Preserves tool denial state through message round-trips by storing {'is_denied': True} in BaseToolReturnPart.metadata, exposed via an is_denied property. This is UI display state (not semantic content sent to the model), ensuring denied tools render correctly after dump/load cycles
  • Uses is_denied in the streaming path (_event_stream.py) to emit ToolOutputDeniedChunk directly from the part, replacing the previous _denied_tool_ids side-channel that scanned message history

Testing

  • Round-trip tests verify that denied tool state survives dump/load cycles for both dynamic and builtin tools, including intermediate assertions on the UI part types and states
  • Load path tests cover output-denied parts with and without an approval reason
  • Streaming tests verify ToolOutputDeniedChunk emission for denied dynamic tools via the full agent flow

References

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.

@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 Feb 20, 2026
devin-ai-integration[bot]

This comment was marked as resolved.

@bendrucker bendrucker mentioned this pull request Feb 20, 2026
6 tasks
@bendrucker
Copy link
Contributor Author

I used #4390 to help reproduce the error using the AI SDK client from Node.js and then test the fix. I'll get that covering the full integration and clean it up shortly. I recognize that it's somewhat odd to be calling a Node.js test suite from Pytest.

We understand if you're hesitant to merge this. But regardless, it'll be helpful to me, versus clicking through an AI Elements frontend.

@dmmihov
Copy link
Contributor

dmmihov commented Feb 22, 2026

Hi Ben,

Have been testing this out today.
This now looks good on an initial approval/denial, but think it breaks on repeated usage.

For example, if we deny a tool use and the agent re-tries the tool I am seeing the following stack trace:
https://logfire-us.pydantic.dev/public-trace/667ea865-7767-461d-9d7b-b0b7786f963d?spanId=15382dd132f6f007

What seems to be happening is that on a tool denial, the agent seems to want to re-try the same tool (at least sometimes) and this so far makes sense.

When I put a breakpoint In _adapter when we call VercelAIAdapter.deferred_tool_results on line 172 where we do

for tool_call_id, approval in iter_tool_approval_responses(self.run_input.messages):

in self.run_input.messages[1] I see is the following:

UIMessage(id='tAtClhLObTutaP9v', role='assistant', metadata=None, parts=[
StepStartUIPart(type='step-start'),
ToolOutputDeniedPart(type='tool-create_journal_entry', tool_call_id='call_KdCaFjbCbFlBG4CRCqHJct9h', state='output-denied', input={'content': 'Agentic work complete after major uplift for deepagents and agent harness'}, provider_executed=None, call_provider_metadata=None, approval=ToolApprovalResponded(id='ea30f48d-88c8-46d7-b0f2-860372f6790d', approved=False, reason=None)), 
StepStartUIPart(type='step-start'), 
ToolOutputAvailablePart(type='tool-get_current_date', tool_call_id='call_8MgEpRPqVpLRmDp0xDWk8WV4', state='output-available', input={}, output="Today's date is Sunday, February 22, 2026", provider_executed=None, call_provider_metadata=None, preliminary=None, approval=None), 
StepStartUIPart(type='step-start'), 
ToolApprovalRespondedPart(type='tool-create_journal_entry', tool_call_id='call_WDh7cUdcl8WhCFXoMOyhQDMG', state='approval-responded', input={'content': 'Agentic work complete after major uplift for deepagents and agent harness on Sunday, February 22, 2026.'}, provider_executed=None, call_provider_metadata=None, approval=ToolApprovalResponded(id='796f069c-54b5-4421-9ab0-e1df9ae3a913', approved=False, reason=None))
])

(please ignore the get_current_date tool call, don't know why the agent decided to do that)

So we see a StepStartUIPart, a ToolOutputDeniedPart (first denial), StepStartUIPart, ToolApprovalRespondedPart(second denial we just sent) as parts and both denials match
if isinstance(part, _TOOL_PART_TYPES) and isinstance(part.approval, ToolApprovalResponded):

So we have deferred_tool_results containing both denials by their id.
When we then go back to the agent graph in
return await self._handle_deferred_tool_results(self.deferred_tool_results, messages, ctx)
our self.deferred_tool_results contains both denials.

This seems to then be an issue for further validations.

The immediate simplest fix to me seems to be in iter_tool_approval_responses to specifically filter for only ToolApprovalRespondedPart and DynamicToolApprovalRespondedPart respectively (this seems to work locally), but I am not 100% convinced this will not cause further issues.

The other option is probably to ensure that a UI treats a denial as a end of the message and a subsequent request for the same tool as a completely new message, so there two are processed separately. I am not sure if this is the intended behaviour though.

@bendrucker
Copy link
Contributor Author

Thanks, I will look into this either tomorrow or Tuesday.

The other option is probably to ensure that a UI treats a denial as a end of the message

In theory the denial reason could contain a user message and so this would be true but I need to spend more time looking into how AI SDK is expecting these on the frontend.

@bendrucker
Copy link
Contributor Author

@dmmihov just pushed a change

The immediate simplest fix to me seems to be in iter_tool_approval_responses to specifically filter for only ToolApprovalRespondedPart and DynamicToolApprovalRespondedPart respectively

It does exactly this and adjusts the unit tests to match.

I repro'd this in #4390 as well.

I should have time tomorrow to test this end to end. But in the mean time please do try it locally if you have time!

@dmmihov
Copy link
Contributor

dmmihov commented Feb 24, 2026

Thank you, Ben.

I will test this out tonight.

@dmmihov
Copy link
Contributor

dmmihov commented Feb 24, 2026

Confirm this seems to be working for repeat deferrals.

image

Not familiar enough with the rest of the adapter usage to confirm this will not break anything else though.

@bendrucker
Copy link
Contributor Author

Awesome, thank you! I am reasonably confident in this specific fix. I also have plans later this week to properly integrate these new features into a production application. So to the extent there are any other edge cases or subtleties I'll be looking closely. Definitely planning to address the issue the display issue for approval state as well:

#4388 (comment)

devin-ai-integration[bot]

This comment was marked as resolved.

@bendrucker bendrucker force-pushed the fix-ai-sdk-v6-approval-types branch 4 times, most recently from 2f7df41 to e67bde2 Compare February 25, 2026 15:19
@bendrucker
Copy link
Contributor Author

Fixed the uncovered built-in tool branch, should be ready to go now

@DouweM DouweM changed the title Add missing AI SDK v6 tool approval part types Add missing Vercel AI SDK v6 tool approval part types Feb 27, 2026
github-actions[bot]

This comment was marked as resolved.

Copy link
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 12 additional findings in Devin Review.

Open in Devin Review

devin-ai-integration[bot]

This comment was marked as resolved.

@DouweM
Copy link
Collaborator

DouweM commented Mar 2, 2026

@bendrucker Please have a look at the merge conflicts; I just merged #4196

devin-ai-integration[bot]

This comment was marked as resolved.

timestamp: datetime = field(default_factory=_now_utc)
"""The timestamp, when the tool returned."""

status: Literal['success', 'error', 'denied'] = 'success'
Copy link
Collaborator

Choose a reason for hiding this comment

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

I like the outcome name from the comment better than status!

type=tool_name,
tool_call_id=part.tool_call_id,
input=_safe_args_as_dict(part),
state='output-denied',
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is implied by the class right? We shouldn't need to set it explicitly

)
else:
ui_parts.append(
ToolOutputAvailablePart(
Copy link
Collaborator

Choose a reason for hiding this comment

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

We can also handle ToolOutputErrorPart here now right?

should be extracted.
"""
from pydantic_ai.ui.vercel_ai._utils import iter_tool_approval_responses
from pydantic_ai.ui.vercel_ai.request_types import DynamicToolOutputDeniedPart
Copy link
Collaborator

Choose a reason for hiding this comment

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

Imports at the top of the file please

elif isinstance(part, ToolOutputErrorPart): # pragma: no branch
output = {'error_text': part.error_text, 'is_error': True}
if isinstance(part, ToolOutputErrorPart):
output: Any = {'error_text': part.error_text, 'is_error': True}
Copy link
Collaborator

Choose a reason for hiding this comment

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

We can just store error_text as output now, right?

@dmmihov
Copy link
Contributor

dmmihov commented Mar 3, 2026

Not a contributer yet, so can't push to this branch.
Hopefully this helps though, started addressing some of the comments:
bendrucker#2

@bendrucker
Copy link
Contributor Author

Thanks @dmmihov, merged, will have another look at this tonight if I have time

devin-ai-integration[bot]

This comment was marked as resolved.

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
devin-ai-integration[bot]

This comment was marked as resolved.

… errors

- Use ToolReturnPart(outcome='error') instead of RetryPromptPart in
  load_messages for tool errors from Vercel AI format
- Store builtin tool error_text directly as content instead of
  {'error_text': ..., 'is_error': True} wrapper
- Handle outcome='error' in dump path, emitting ToolOutputErrorPart
- Add backward-compat is_error content check for old serialized data
- Handle ToolReturnPart(outcome='error') in event stream
- Extract _dump_tool_call_part helper to reduce C901 complexity
- Remove redundant state= assignments implied by class defaults
- Move inline test imports to top of file
@github-actions github-actions bot added size: L Large PR (501-1500 weighted lines) and removed size: M Medium PR (101-500 weighted lines) labels Mar 4, 2026
devin-ai-integration[bot]

This comment was marked as resolved.

@adtyavrdhn
Copy link
Contributor

/gemini Summary

Copy link
Collaborator

@DouweM DouweM left a comment

Choose a reason for hiding this comment

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

@bendrucker Thanks Ben, just a small final change and we're good to go!

…al default

- Rename 'error' → 'failed' in BaseToolReturnPart.outcome to align
  with the upcoming ToolFailed exception (pydantic#2586)
- Use ToolDenied().message instead of hardcoded string in
  _denial_reason() for consistency
devin-ai-integration[bot]

This comment was marked as resolved.

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@DouweM DouweM merged commit 54125a3 into pydantic:main Mar 6, 2026
38 checks passed
AlanPonnachan pushed a commit to AlanPonnachan/pydantic-ai that referenced this pull request Mar 7, 2026
Co-authored-by: Douwe Maan <douwe@pydantic.dev>
Co-authored-by: Darin Mihov <dmmihov@gmail.com>
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
wenming-ma pushed a commit to wenming-ma/pydantic-ai that referenced this pull request Mar 12, 2026
Co-authored-by: Douwe Maan <douwe@pydantic.dev>
Co-authored-by: Darin Mihov <dmmihov@gmail.com>
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting author revision bug Report that something isn't working, or PR implementing a fix size: L Large PR (501-1500 weighted lines)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Vercel AI Elements build_run_input failing on deferred tool approval/denial.

5 participants