Skip to content

Add Message dict conversion and multi-part tool messages - #79

Merged
frostming merged 4 commits into
mainfrom
message-dict
Oct 10, 2026
Merged

frostming merged 4 commits into
mainfrom
message-dict

Conversation

@frostming

Copy link
Copy Markdown
Collaborator

Summary

  • Add Message.to_dict() and Message.from_dict(). A message with a single text part stores it as a plain content string; any other parts use a typed list (text, reasoning, image, video, provider_data). Media data is base64-encoded, so the dict round-trips through JSON.

  • Breaking: tool results are now tool messages built with republic.tool(call, *content, is_error=False). The output may mix text, images, and videos. This replaces ToolResult, republic.tool_result(), and assistant(tool_results=...).

  • Message gains tool_call and is_error. tool_call is required on tool messages and rejected on other roles.

  • normalize() still announces calls that do not appear earlier in the conversation, adding them to the assistant turn before the run of tool messages.

  • Multi-part tool output per format:

    • Chat: role: "tool" content becomes a content list.
    • Responses: function_call_output.output becomes an input_text/input_image list.
    • Messages: tool_result.content becomes a block list.
    • Gemini: text goes in response, and media goes in functionResponse.parts.

    Text-only output produces the same request bodies as before.

Migration

# Before
results = [republic.tool_result(call, output) for call in response.tool_calls]
await model.chat([task, response.message, republic.assistant(tool_results=results)])

# After
results = [republic.tool(call, output) for call in response.tool_calls]
await model.chat([task, response.message, *results])

Tests

  • uv run prek run --all-files
  • uv run ty check
  • uv run pytest (449 passed)

🤖 Generated with Claude Code

frostming and others added 2 commits October 10, 2026 10:41
Message.to_dict()/from_dict() convert messages to JSON-compatible dicts. A
single text part is stored as a plain content string; other parts use a
typed list.

Tool results are now `tool` messages built with republic.tool(call, ...),
whose output may mix text, images, and videos. This replaces ToolResult,
tool_result(), and assistant(tool_results=...). Unannounced calls are still
added to the assistant turn before their results.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolve conflicts with native audio input: chat tool messages reuse the
format's audio encoder, Gemini tool responses carry audio parts, and
Message dict conversion handles audio parts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking finding: a Gemini tool result that carries media as a URL is serialized outside the API schema.

republic.tool(call, "Captured", republic.image("https://…/shot.png")) reaches the google provider as functionResponse.parts[].fileData, which is not part of Gemini's FunctionResponsePart, so that round trip never delivers the image. Media carried as bytes is unaffected. The inline comment has the emitted body, the schema evidence and a repair direction.

Non-blocking question on the chat format: OpenAI's Chat Completions schema for a tool message (ChatCompletionRequestToolMessage.content) says "For tool messages, only type text is supported", so the image_url part this PR emits for a media tool result is rejected by OpenAI and by strict OpenAI-compatible servers. If that is intentional for gateways such as OpenRouter, the "Media support in tool results depends on the provider and API format" line added in docs/guides/tools.md may be enough; otherwise the chat format needs the same guard the other formats have.

Comment thread src/republic/formats/gemini.py Outdated
Gemini's FunctionResponsePart accepts only inlineData, so a tool result
with URL media would be serialized as fileData outside the schema. Raise
UnsupportedFeatureError before sending instead, and document the media
limits of tool results.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frostming

Copy link
Copy Markdown
Collaborator Author

On the Chat Completions question: emitting image_url in tool messages is intentional. Gateways such as OpenRouter accept it, and Republic does not move media into a synthetic user message. 6f96b3f makes the limits explicit in docs/guides/tools.md: the Gemini format accepts only inline media in tool results, and OpenAI's own Chat Completions service accepts only text in tool messages.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking finding: normalize() no longer drops assistant turns that carry no parts and no tool calls, so the Messages and Gemini formats now send a request body their APIs reject.

The rewrite replaced the old guard (if message.parts or calls:) with an unconditional normalized.append(message). A refusal or an empty completion that republic wrote to history is therefore echoed back as an empty assistant turn, and the next request in that conversation fails instead of continuing. The inline comment has the emitted bodies, a reproduction on this revision and a one-line repair verified against the test suite, lint and type checks.

Comment thread src/republic/formats/_base.py Outdated
An assistant turn with no parts and no tool calls, such as a refusal written
to history, was sent back after the normalize() rewrite. Anthropic and Gemini
reject empty content, so the next request failed. Filter those turns out
after announcing calls, restoring the previous request bodies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No blocking findings.

The findings from my earlier reviews of this branch are addressed on this revision, including the two commits pushed afterwards; the normalize() one is verified in its thread.

@frostming
frostming merged commit 64b1b67 into main Oct 10, 2026
9 checks passed
@frostming
frostming deleted the message-dict branch October 10, 2026 03:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant