Skip to content

Conversation

jhammarstedt
Copy link

Add reverse conversion function messages_to_ag_ui()

Motivation

The AG-UI integration currently supports converting AG-UI messages to Pydantic AI's internal format via _messages_from_ag_ui(), but there's no public function to perform the reverse conversion. This creates an asymmetry in the API and makes it difficult for users to:

  1. Export Pydantic AI message history to AG-UI format for external tools or UIs
  2. Integrate Pydantic AI with AG-UI-based systems that need to consume message history
  3. Build custom AG-UI interfaces that display Pydantic AI conversation history

Changes

New Function: messages_to_ag_ui()

Added a public function that converts Pydantic AI's ModelMessage format to AG-UI's Message format:

def messages_to_ag_ui(messages: list[ModelMessage]) -> list[Message]:
    """Convert Pydantic AI messages to AG-UI message format."""

Implementation details:

  • Handles all message part types: UserPromptPart, SystemPromptPart, ToolReturnPart, RetryPromptPart, TextPart, ToolCallPart, BuiltinToolCallPart, and BuiltinToolReturnPart
  • Properly prefixes builtin tool call IDs with pyd_ai_builtin|{provider_name}|{tool_call_id} to match the format expected by _messages_from_ag_ui()
  • Combines multiple TextPart and tool calls from a single ModelResponse into one AssistantMessage
  • Generates unique UUIDs for all AG-UI message IDs
  • Skips ThinkingPart as it's not part of the conversational message history

Complexity reduction:
To maintain code quality (cyclomatic complexity < 15), the implementation is split into helper functions:

  • _convert_request_part(): Converts individual ModelRequest parts to AG-UI messages
  • _convert_response_parts(): Processes ModelResponse parts and collects builtin tool returns

Comprehensive Test Coverage

Added test_messages_to_ag_ui() that validates:

  • All message type conversions (System, User, Assistant, Tool)
  • Tool call handling (both regular and builtin)
  • Builtin tool ID prefixing
  • Content preservation
  • Proper message sequencing

The test covers the same complex scenario as test_messages_from_ag_ui() to ensure bidirectional conversion compatibility.

Checklist

  • Tests added and passing
  • No linter errors (complexity, formatting, typing)
  • Follows existing code patterns and conventions
  • Function is properly documented with docstring
  • No breaking changes

messages.append(
AssistantMessage(
id=str(uuid.uuid4()),
content=' '.join(content_parts) if content_parts else None,
Copy link
Collaborator

Choose a reason for hiding this comment

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

This isn't quite right: consecutive text parts should be concatenated without a space in between, and if there are parts in between (like built-in tool calls), the text to either side should be joined by \n\n. See the example here: https://github.com/pydantic/pydantic-ai/pull/2970/files#diff-2eb561c8eaa8a723f1017556cce8006c42e504997c187b8b394b5e8634f91283R1148

# Create separate ToolMessages for builtin tool returns
for builtin_return in builtin_returns:
prefixed_id = (
f'{_BUILTIN_TOOL_CALL_ID_PREFIX}|{builtin_return.provider_name or ""}|{builtin_return.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.

Use a helper method as I suggested above

tool_call_id=prefixed_id,
content=builtin_return.content
if isinstance(builtin_return.content, str)
else str(builtin_return.content),
Copy link
Collaborator

Choose a reason for hiding this comment

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

Use builtin_return.model_response_str()

result = messages_to_ag_ui(messages)

# Check structure and count
assert len(result) == 10
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 use assert result == snapshot() here and below so we can see the entire thing in the test. The first time you run the test it'll be filled in.

return messages, builtin_returns


def messages_to_ag_ui(messages: list[ModelMessage]) -> list[Message]:
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 make messages_from_ag_ui public as well

@jhammarstedt
Copy link
Author

@DouweM Great, thanks for input and nice that you pointed out to model_response_str. Will get to them shortly.

One thing I noticed while doing this was that Logfire won't work with AGUI loaded from history as the chat history will be loaded in as json strings when running run_ag_ui (as required by RunAgentInput). This is a separate issue and PR not related to this, but adding the option to pass history highlights this issue

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.

2 participants