Skip to content

Conversation

@dsfaccini
Copy link
Contributor

Summary

Implements a dump_messages() @classmethod for the VercelAIAdapter to convert Pydantic AI messages to Vercel AI format, enabling export of agent conversation history from Pydantic AI to Vercel AI protocol.

This is the reverse operation of the existing load_messages() method.

Implementation

dump_messages():

  • Accepts a sequence of ModelMessage objects (ModelRequest/ModelResponse)
  • Returns a list of UIMessage objects in Vercel AI format
  • Maintains message grouping and ordering
  • Quirk: accepts an _id_generator() function to generate message ids
    • this is used primarily by the test suite to keep constant UUIDs in the snapshots

Cases Covered

The implementation handles:

  1. Basic message types: System prompts, user messages, assistant responses
  2. Text content: Single and multi-part text with proper concatenation
  3. Tool calls: Regular tool calls with state tracking (in-progress, output-available, output-error)
  4. Builtin tools: Provider-executed tools with proper ID prefixing and metadata
  5. Tool returns: Automatic matching of tool calls with their returns to set proper states
  6. Thinking/reasoning: ThinkingPart conversion to ReasoningUIPart
  7. File attachments:
    • BinaryContent conversion to data URIs
    • URL-based files (ImageUrl, AudioUrl, VideoUrl, DocumentUrl)
  8. Retry prompts: Tool errors converted to output-error state
  9. Multi-modal content: Mixed text and file content in user prompts

Design Considerations

  1. Tool return handling: Tool returns in ModelRequest are used to determine the state of tool calls in ModelResponse messages, not emitted as separate user messages.

  2. Text concatenation: Consecutive TextPart instances are concatenated. When interrupted by non-text parts (tools, files, reasoning), subsequent text is separated with \n\n.

  3. Builtin tool IDs: Uses the existing BUILTIN_TOOL_CALL_ID_PREFIX pattern for consistency with other adapters.

  4. Message grouping: System and user parts within a ModelRequest are split into separate UIMessage objects when needed.

Caveats

  1. Tool call input reconstruction: When tool returns appear in ModelRequest without the original tool call in the same message history, the input field is set to an empty object {} since the original arguments are not available.

  2. No perfect roundtrip: Due to timestamp generation and UUID assignment, dump_messages(load_messages(ui_msgs)) will not produce identical objects, but will preserve semantic equivalence.

  3. Builtin tool return location: The implementation checks both the same ModelResponse (for builtin tools) and subsequent ModelRequest messages (for regular tools) to find tool returns.

Tests

Added tests for the following cases in test_vercel_ai.py

  • Basic message dumping
  • Tool calls with and without returns
  • Builtin tools with provider metadata
  • Thinking/reasoning parts
  • File attachments (data URIs and URLs)
  • Retry prompts and errors
  • Consecutive text concatenation
  • Text with interruptions
  • Roundtrip conversion (dump then load)

@dsfaccini
Copy link
Contributor Author

there's a missing for loop in the method inplementstion, will push a fix tomorrow!

…dumping and IsStr - add dump_messages to base adapter class
@dsfaccini dsfaccini requested a review from DouweM November 28, 2025 03:03
@DouweM DouweM changed the title add dump_messages method to vercel ai adapter Add VercelAIAdapter.dump_messages to convert Pydantic AI messages to Vercel AI messages Nov 28, 2025
user_ui_parts.extend(_convert_user_prompt_part(part))
elif isinstance(part, ToolReturnPart | RetryPromptPart):
# Tool returns/errors don't create separate UI parts
# They're merged into the tool call in the assistant message
Copy link
Collaborator

Choose a reason for hiding this comment

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

Only if the retry prompt part has a tool call ID, otherwise it should be a text message

local_builtin_returns: dict[str, BuiltinToolReturnPart] = {}
for part in msg.parts:
if isinstance(part, BuiltinToolReturnPart):
local_builtin_returns[part.tool_call_id] = part
Copy link
Collaborator

Choose a reason for hiding this comment

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

This can be a single dict comprehension

# Combine consecutive text parts by checking the last UI part
if ui_parts and isinstance(ui_parts[-1], TextUIPart):
last_text = ui_parts[-1]
ui_parts[-1] = last_text.model_copy(update={'text': last_text.text + part.content})
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why not just last_text.text += part.content?

elif isinstance(part, BaseToolCallPart):
if isinstance(part, BuiltinToolCallPart):
prefixed_id = (
f'{BUILTIN_TOOL_CALL_ID_PREFIX}|{part.provider_name or ""}|{part.tool_call_id}'
Copy link
Collaborator

Choose a reason for hiding this comment

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

We shouldn't need to make any special IDs, as we can store metadata on the part, right? And Vercel AI already has a native way of specifying provider_executed tool calls

tool_call_id=prefixed_id,
input=part.args_as_json_str(),
state='input-available',
provider_executed=True,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Shouldn't we still store the metadata in this case?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yeahp we should, I'm not even sure this case is reachable but I should've included the metadata

)
else:
tool_return = tool_returns.get(part.tool_call_id)
tool_error = tool_errors.get(part.tool_call_id)
Copy link
Collaborator

Choose a reason for hiding this comment

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

We could have a single dict, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

makes it more verbose though, when we start getting into second level keys accesses


@classmethod
@abstractmethod
def dump_messages(cls, messages: Sequence[ModelMessage]) -> list[MessageT]:
Copy link
Collaborator

Choose a reason for hiding this comment

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

I still wanted this method exactly as defined, just not abstractmethod :) That way subclasses are not required to implemented it, but if it's called it'll still raise NotImplementedError.

def _dump_response_message( # noqa: C901
msg: ModelResponse,
tool_returns: dict[str, ToolReturnPart],
tool_errors: dict[str, RetryPromptPart],
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we combine these 2 dicts into 1?

)
)
elif isinstance(part, BaseToolCallPart):
if isinstance(part, BuiltinToolCallPart):
Copy link
Collaborator

Choose a reason for hiding this comment

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

Instead of if isinstance(part, BaseToolCallPart): and inside it 2 branches for Builtin vs not builtin, I'd rather have top-level branches for if isinstance(part, ToolCallPart): and if isinstance(part, BuiltinToolCallPart):

…ptPart without tool_name, split BaseToolCallPart branches
)
else:
tool_return = tool_returns.get(part.tool_call_id)
tool_error = tool_errors.get(part.tool_call_id)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

makes it more verbose though, when we start getting into second level keys accesses

tool_call_id=prefixed_id,
input=part.args_as_json_str(),
state='input-available',
provider_executed=True,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

yeahp we should, I'm not even sure this case is reachable but I should've included the metadata

call_provider_metadata=call_provider_metadata,
)
)
else: # pragma: no cover
Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's cover this branch as well

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants