From e748577ea1ada333079a3bc68130909db6edc4e1 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Sat, 10 Oct 2026 10:41:09 +0800 Subject: [PATCH 1/3] Add Message dict conversion and tool messages with multi-part output 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 --- docs/guides/minimal-agent.md | 10 +-- docs/guides/tools.md | 4 +- docs/reference/provider-api.md | 4 +- src/republic/__init__.py | 6 +- src/republic/_content.py | 132 +++++++++++++++++++++++------ src/republic/formats/_base.py | 37 +++++--- src/republic/formats/chat.py | 10 +-- src/republic/formats/gemini.py | 17 ++-- src/republic/formats/messages.py | 23 ++--- src/republic/formats/responses.py | 11 ++- tests/test_chat_format.py | 18 +++- tests/test_codex.py | 4 +- tests/test_compatible_providers.py | 4 +- tests/test_gemini_format.py | 23 ++++- tests/test_github_copilot.py | 4 +- tests/test_message_dict.py | 102 ++++++++++++++++++++++ tests/test_messages_format.py | 25 +++++- tests/test_models.py | 37 ++++++-- tests/test_provider_golden.py | 10 +-- tests/test_responses_format.py | 18 +++- 20 files changed, 396 insertions(+), 103 deletions(-) create mode 100644 tests/test_message_dict.py diff --git a/docs/guides/minimal-agent.md b/docs/guides/minimal-agent.md index ff72f13..d25444c 100644 --- a/docs/guides/minimal-agent.md +++ b/docs/guides/minimal-agent.md @@ -25,15 +25,15 @@ read_file = republic.Tool( ) -def execute(call: republic.ToolCall) -> republic.ToolResult: +def execute(call: republic.ToolCall) -> republic.Message: args = call.args if call.name != "read_file" or not isinstance(args, dict) or args.get("path") not in FILES: - return republic.tool_result(call, "Unknown tool or file", is_error=True) + return republic.tool(call, "Unknown tool or file", is_error=True) try: content = Path(args["path"]).read_text(encoding="utf-8") except (OSError, UnicodeError): - return republic.tool_result(call, "Cannot read that project file", is_error=True) - return republic.tool_result(call, content) + return republic.tool(call, "Cannot read that project file", is_error=True) + return republic.tool(call, content) async def agent(model: republic.ChatModel, task: str) -> str: @@ -47,7 +47,7 @@ async def agent(model: republic.ChatModel, task: str) -> str: if not response.tool_calls: return response.refusal or response.text results = [execute(call) for call in response.tool_calls] - messages.append(republic.assistant(tool_results=results)) + messages.extend(results) raise RuntimeError("The agent reached its limit of 8 model calls") diff --git a/docs/guides/tools.md b/docs/guides/tools.md index 202aca0..69d90d9 100644 --- a/docs/guides/tools.md +++ b/docs/guides/tools.md @@ -21,7 +21,7 @@ async def main(): return results = [execute(call) for call in response.tool_calls] - answer = await model.chat([task, response.message, republic.assistant(tool_results=results)]) + answer = await model.chat([task, response.message, *results]) print(answer.refusal or answer.text) @@ -32,7 +32,7 @@ Run this script from the same project directory as `agent.py`. `tool_choice=read `call.args` decodes the model's JSON arguments. The executor checks the tool name and permitted filenames before reading anything. A JSON decoding error propagates to the application; a schema alone does not validate the arguments locally. -`tool_result()` keeps the original call with its output, including the ID needed to associate the result with that call. `response.message` also preserves reasoning blocks, signed calls, and other provider data needed for the next turn. Replacing it with `response.text` loses that state. +`republic.tool(call, ...)` builds a `tool` message that keeps the original call with its output, including the ID needed to associate the result with that call. The output may mix text, images, and videos, such as `republic.tool(call, "Captured", republic.image("shot.png"))`; pass `is_error=True` to report a failed call. Image support in tool results depends on the provider and API format. `response.message` also preserves reasoning blocks, signed calls, and other provider data needed for the next turn. Replacing it with `response.text` loses that state. The second request completes this round without offering more tools. To allow another round, supply the schemas again and repeat the sequence, as the minimal agent does. With an attached history object, the first assistant message is already recorded; send only the new results instead of appending the same conversation twice. diff --git a/docs/reference/provider-api.md b/docs/reference/provider-api.md index 5530f14..0ed3568 100644 --- a/docs/reference/provider-api.md +++ b/docs/reference/provider-api.md @@ -150,13 +150,13 @@ response = await model.chat([ print(response.text) ``` -After executing tool calls from a response, return the results with the full assistant message. Here, `results` contains `republic.tool_result(call, output)` values created by the application: +After executing tool calls from a response, return the results with the full assistant message. Here, `results` contains `republic.tool(call, output)` messages created by the application: ```python answer = await model.chat([ "Hello, how are you?", response.message, - republic.assistant(tool_results=results), + *results, ]) print(answer.text) ``` diff --git a/src/republic/__init__.py b/src/republic/__init__.py index cca8639..e1942a9 100644 --- a/src/republic/__init__.py +++ b/src/republic/__init__.py @@ -12,12 +12,11 @@ Text, Tool, ToolCall, - ToolResult, Video, assistant, image, system, - tool_result, + tool, user, video, ) @@ -63,7 +62,6 @@ "Tool", "ToolCall", "ToolChoice", - "ToolResult", "Video", "all_providers", "assistant", @@ -81,7 +79,7 @@ "providers", "register_provider", "system", - "tool_result", + "tool", "tools", "user", "video", diff --git a/src/republic/_content.py b/src/republic/_content.py index 8ab9e8f..b090e6c 100644 --- a/src/republic/_content.py +++ b/src/republic/_content.py @@ -120,19 +120,19 @@ def args(self) -> Any: return json.loads(self.arguments or "{}") -@dataclass(frozen=True) -class ToolResult: - call: ToolCall - output: str - is_error: bool = False - - @dataclass(frozen=True) class Message: role: Role parts: tuple[Part, ...] = () tool_calls: tuple[ToolCall, ...] = () - tool_results: tuple[ToolResult, ...] = () + tool_call: ToolCall | None = None + """The call a ``tool`` message answers; its parts hold the output.""" + is_error: bool = False + """Whether a ``tool`` message reports a failed call.""" + + def __post_init__(self) -> None: + if (self.role == "tool") != (self.tool_call is not None): + raise ValueError("tool_call is required for tool messages and only allowed on them") @property def text(self) -> str: @@ -142,6 +142,94 @@ def text(self) -> str: def reasoning(self) -> str: return "".join(part.text for part in self.parts if isinstance(part, Reasoning)) + def to_dict(self) -> dict[str, Any]: + """Convert the message to a JSON-compatible dict. + + A message holding a single text part stores it as a plain ``content`` string; + any other parts are stored as a list of typed items. + """ + data: dict[str, Any] = {"role": self.role} + match self.parts: + case (): + pass + case (Text(text),): + data["content"] = text + case parts: + data["content"] = [_part_to_dict(part) for part in parts] + if self.tool_calls: + data["tool_calls"] = [_tool_call_to_dict(call) for call in self.tool_calls] + if self.tool_call is not None: + data["tool_call"] = _tool_call_to_dict(self.tool_call) + if self.is_error: + data["is_error"] = True + return data + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> Message: + """Build a message from a dict produced by :meth:`to_dict`.""" + content = data.get("content") + if content is None: + parts: tuple[Part, ...] = () + elif isinstance(content, str): + parts = (Text(content),) + else: + parts = tuple(_part_from_dict(item) for item in content) + return cls( + data["role"], + parts, + tool_calls=tuple(_tool_call_from_dict(call) for call in data.get("tool_calls", ())), + tool_call=None if (call := data.get("tool_call")) is None else _tool_call_from_dict(call), + is_error=data.get("is_error", False), + ) + + +def _part_to_dict(part: Part) -> dict[str, Any]: + match part: + case Text(text): + return {"type": "text", "text": text} + case Reasoning(text): + return {"type": "reasoning", "text": text} + case Image() | Video(): + data: dict[str, Any] = {"type": part.kind, "media_type": part.media_type} + if part.url is not None: + data["url"] = part.url + if part.data is not None: + data["data"] = part.base64_data + return data + case ProviderData(api_format, payload): + return {"type": "provider_data", "api_format": api_format, "payload": dict(payload)} + + +def _part_from_dict(data: Mapping[str, Any]) -> Part: + match data["type"]: + case "text": + return Text(data["text"]) + case "reasoning": + return Reasoning(data["text"]) + case "image" | "video" as kind: + media_class = Image if kind == "image" else Video + inline = data.get("data") + return media_class( + data["media_type"], + data=None if inline is None else base64.b64decode(inline, validate=True), + url=data.get("url"), + ) + case "provider_data": + return ProviderData(data["api_format"], data["payload"]) + case other: + raise ValueError(f"Unknown message part type: {other!r}") + + +def _tool_call_to_dict(call: ToolCall) -> dict[str, Any]: + data: dict[str, Any] = {"id": call.id, "name": call.name, "arguments": call.arguments} + if call.metadata: + data["metadata"] = dict(call.metadata) + return data + + +def _tool_call_from_dict(data: Mapping[str, Any]) -> ToolCall: + return ToolCall(data["id"], data["name"], data["arguments"], metadata=dict(data.get("metadata", {}))) + UserContent = str | Image | Video @@ -160,28 +248,18 @@ def user(*content: UserContent) -> Message: return Message("user", tuple(_to_part(item) for item in content)) -def assistant( - *content: UserContent, - tool_calls: Iterable[ToolCall] = (), - tool_results: Iterable[ToolResult] = (), -) -> Message: - """Build an assistant turn, optionally carrying tool calls and their results. +def assistant(*content: UserContent, tool_calls: Iterable[ToolCall] = ()) -> Message: + """Build an assistant turn, optionally carrying tool calls.""" + return Message("assistant", tuple(_to_part(item) for item in content), tool_calls=tuple(tool_calls)) - A tool result already holds its call, so ``assistant(tool_results=[...])`` is - enough to continue after executing the calls from a response. Calls that - already appear earlier in the conversation are not sent twice. - """ - return Message( - "assistant", - tuple(_to_part(item) for item in content), - tool_calls=tuple(tool_calls), - tool_results=tuple(tool_results), - ) +def tool(call: ToolCall, *content: UserContent, is_error: bool = False) -> Message: + """Build a tool message holding the output of executing ``call``. -def tool_result(call: ToolCall, output: str, *, is_error: bool = False) -> ToolResult: - """Pair a tool call with the output produced by executing it.""" - return ToolResult(call, output, is_error=is_error) + The output may mix text, images, and videos. A call that does not appear + earlier in the conversation is announced automatically before its result. + """ + return Message("tool", tuple(_to_part(item) for item in content), tool_call=call, is_error=is_error) def image(source: str | os.PathLike[str] | bytes, *, media_type: str | None = None) -> Image: diff --git a/src/republic/formats/_base.py b/src/republic/formats/_base.py index 8ecd898..d1b9fc3 100644 --- a/src/republic/formats/_base.py +++ b/src/republic/formats/_base.py @@ -365,27 +365,38 @@ def _subtract(current: TokenUsage, previous: TokenUsage) -> TokenUsage: def normalize(messages: Iterable[Message]) -> list[Message]: - """Split tool results into ``tool`` messages that follow their calls. + """Announce every answered tool call before its result. - An assistant message carrying tool results also announces their calls, - unless an earlier assistant message already did. + A tool message whose call does not appear earlier in the conversation adds + the call to the assistant message right before its run of tool messages, + creating that assistant message when needed. """ normalized: list[Message] = [] announced: set[str] = set() for message in messages: - if message.role != "assistant": - normalized.append(message) - continue - result_calls = [result.call for result in message.tool_results] - calls = [call for call in (*message.tool_calls, *result_calls) if call.id not in announced] - announced.update(call.id for call in calls) - if message.parts or calls: - normalized.append(Message("assistant", message.parts, tool_calls=tuple(dict.fromkeys(calls)))) - if message.tool_results: - normalized.append(Message("tool", tool_results=message.tool_results)) + if message.role == "assistant": + announced.update(call.id for call in message.tool_calls) + elif message.tool_call is not None and message.tool_call.id not in announced: + announced.add(message.tool_call.id) + start = len(normalized) + while start and normalized[start - 1].role == "tool": + start -= 1 + if start and normalized[start - 1].role == "assistant": + previous = normalized[start - 1] + normalized[start - 1] = replace(previous, tool_calls=(*previous.tool_calls, message.tool_call)) + else: + normalized.insert(start, Message("assistant", tool_calls=(message.tool_call,))) + normalized.append(message) return normalized +def answered_call(message: Message) -> ToolCall: + """The tool call that a ``tool`` message answers.""" + if message.tool_call is None: + raise ValueError(f"A {message.role} message does not answer a tool call") + return message.tool_call + + def merge_same_role(entries: list[dict[str, Any]], *, content_key: str) -> list[dict[str, Any]]: """Merge consecutive entries of the same role by concatenating their content lists.""" merged: list[dict[str, Any]] = [] diff --git a/src/republic/formats/chat.py b/src/republic/formats/chat.py index a5d7e17..0d277b9 100644 --- a/src/republic/formats/chat.py +++ b/src/republic/formats/chat.py @@ -22,6 +22,7 @@ StreamParser, ToolCallFragment, UsageReport, + answered_call, approximate_location, deep_merge, strict_schema, @@ -180,7 +181,7 @@ def _message_entries(api_format: ChatFormat, message: Message) -> list[dict[str, case "system": return [{"role": "system", "content": message.text}] case "user": - return [{"role": "user", "content": _user_content(message)}] + return [{"role": "user", "content": _content(message)}] case "assistant": entry: dict[str, Any] = {"role": "assistant", "content": message.text or None} if message.tool_calls: @@ -191,13 +192,10 @@ def _message_entries(api_format: ChatFormat, message: Message) -> list[dict[str, entry.update(api_format.assistant_fields(message)) return [entry] case "tool": - return [ - {"role": "tool", "tool_call_id": result.call.id, "content": result.output} - for result in message.tool_results - ] + return [{"role": "tool", "tool_call_id": answered_call(message).id, "content": _content(message)}] -def _user_content(message: Message) -> str | list[dict[str, Any]]: +def _content(message: Message) -> str | list[dict[str, Any]]: if all(isinstance(part, Text) for part in message.parts): return message.text content: list[dict[str, Any]] = [] diff --git a/src/republic/formats/gemini.py b/src/republic/formats/gemini.py index 7525f93..e3b766d 100644 --- a/src/republic/formats/gemini.py +++ b/src/republic/formats/gemini.py @@ -9,7 +9,7 @@ from collections.abc import Iterable, Mapping from typing import Any -from republic._content import Image, Message, ProviderData, Text, Tool, ToolResult, Video, _Media +from republic._content import Image, Message, ProviderData, Text, Tool, Video, _Media from republic._options import ReasoningEffort, ToolChoice from republic._response import BuiltinToolCall, Citation, FinishReason from republic.errors import APIResponseError, UnsupportedFeatureError @@ -25,6 +25,7 @@ StreamParser, ToolCallFragment, UsageReport, + answered_call, deep_merge, merge_same_role, provider_payloads, @@ -252,7 +253,7 @@ def _function_calling_config(tool_choice: ToolChoice) -> dict[str, Any]: def _content(message: Message) -> dict[str, Any]: if message.role == "tool": - return {"role": "user", "parts": [_function_response(result) for result in message.tool_results]} + return {"role": "user", "parts": [_function_response(message)]} parts: list[Mapping[str, Any]] = provider_payloads(message, GeminiFormat.name) parts.extend(_part(part) for part in message.parts if isinstance(part, Text | Image | Video)) for call in message.tool_calls: @@ -266,13 +267,17 @@ def _content(message: Message) -> dict[str, Any]: return {"role": "model" if message.role == "assistant" else "user", "parts": parts} -def _function_response(result: ToolResult) -> dict[str, Any]: +def _function_response(message: Message) -> dict[str, Any]: + """Text goes into the response object; images and videos become multimodal response parts.""" + call = answered_call(message) response: dict[str, Any] = { - "name": result.call.name, - "response": {"error" if result.is_error else "output": result.output}, + "name": call.name, + "response": {"error" if message.is_error else "output": message.text}, } - if call_id := result.call.metadata.get(_CALL_ID): + if call_id := call.metadata.get(_CALL_ID): response["id"] = call_id + if media := [_part(part) for part in message.parts if isinstance(part, Image | Video)]: + response["parts"] = media return {"functionResponse": response} diff --git a/src/republic/formats/messages.py b/src/republic/formats/messages.py index 593a12a..c506140 100644 --- a/src/republic/formats/messages.py +++ b/src/republic/formats/messages.py @@ -22,6 +22,7 @@ StreamParser, ToolCallFragment, UsageReport, + answered_call, approximate_location, deep_merge, merge_same_role, @@ -120,18 +121,18 @@ def reasoning_fields(self, effort: ReasoningEffort | None, *, include_reasoning: def _entry(self, message: Message) -> dict[str, Any]: if message.role == "tool": - return { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": result.call.id, - "content": result.output, - "is_error": result.is_error, - } - for result in message.tool_results - ], + content = ( + message.text + if all(isinstance(part, Text) for part in message.parts) + else [_user_block(part) for part in message.parts] + ) + result = { + "type": "tool_result", + "tool_use_id": answered_call(message).id, + "content": content, + "is_error": message.is_error, } + return {"role": "user", "content": [result]} if message.role == "user": return {"role": "user", "content": [_user_block(part) for part in message.parts]} content: list[Mapping[str, Any]] = provider_payloads(message, self.name) diff --git a/src/republic/formats/responses.py b/src/republic/formats/responses.py index a381cd5..41b8399 100644 --- a/src/republic/formats/responses.py +++ b/src/republic/formats/responses.py @@ -23,6 +23,7 @@ StreamParser, ToolCallFragment, UsageReport, + answered_call, approximate_location, deep_merge, provider_payloads, @@ -121,10 +122,12 @@ def _input_items(self, message: Message) -> list[Mapping[str, Any]]: ) return items case "tool": - return [ - {"type": "function_call_output", "call_id": result.call.id, "output": result.output} - for result in message.tool_results - ] + output = ( + message.text + if all(isinstance(part, Text) for part in message.parts) + else [_user_part(part) for part in message.parts] + ) + return [{"type": "function_call_output", "call_id": answered_call(message).id, "output": output}] class _ResponsesStreamParser(StreamParser): diff --git a/tests/test_chat_format.py b/tests/test_chat_format.py index ddc1c95..efa4dff 100644 --- a/tests/test_chat_format.py +++ b/tests/test_chat_format.py @@ -86,7 +86,7 @@ async def test_tool_calls_round_trip_through_tool_results(service: FakeService) first = await model.chat("Weather in Paris?", tools=[WEATHER]) call = first.tool_calls[0] - await model.chat(["Weather in Paris?", republic.assistant(tool_results=[republic.tool_result(call, "sunny")])]) + await model.chat(["Weather in Paris?", republic.tool(call, "sunny")]) assert service.body(0)["tools"] == [ { @@ -111,6 +111,22 @@ async def test_tool_calls_round_trip_through_tool_results(service: FakeService) ] +async def test_tool_results_can_hold_multiple_parts(service: FakeService) -> None: + service.reply_json({"choices": [{"message": {"content": "A cat."}}]}) + call = republic.ToolCall("call_1", "screenshot", "{}") + + await make_model(service).chat(["Look", republic.tool(call, "Captured", republic.Image("image/png", data=b"png"))]) + + assert service.body()["messages"][-1] == { + "role": "tool", + "tool_call_id": "call_1", + "content": [ + {"type": "text", "text": "Captured"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,cG5n"}}, + ], + } + + async def test_structured_output_is_requested_and_parsed(service: FakeService) -> None: service.reply_json({"choices": [{"message": {"content": '{"city": "Paris", "sunny": true}'}}]}) diff --git a/tests/test_codex.py b/tests/test_codex.py index 8c7bb26..d8ce434 100644 --- a/tests/test_codex.py +++ b/tests/test_codex.py @@ -92,8 +92,8 @@ async def test_codex_keeps_reasoning_and_tool_round_trip(service: FakeService) - model = republic.get_model("codex:test", api_key="test-key", http_client=service.client()) first = await model.chat("weather?") - result = republic.tool_result(first.tool_calls[0], "sunny") - second = await model.chat(["weather?", first.message, republic.assistant(tool_results=[result])]) + result = republic.tool(first.tool_calls[0], "sunny") + second = await model.chat(["weather?", first.message, result]) assert first.tool_calls[0].args == {"city": "Paris"} assert second.text == "sunny" diff --git a/tests/test_compatible_providers.py b/tests/test_compatible_providers.py index a092769..b997f9a 100644 --- a/tests/test_compatible_providers.py +++ b/tests/test_compatible_providers.py @@ -154,8 +154,8 @@ async def test_reasoning_content_is_sent_back(service: FakeService, provider: st model = republic.get_model(f"{provider}:model", api_key="key", api_format="chat", http_client=service.client()) first = await model.chat("Weather?", tools=[WEATHER], reasoning_effort="high") - results = [republic.tool_result(call, "sunny") for call in first.tool_calls] - await model.chat(["Weather?", first.message, republic.assistant(tool_results=results)], tools=[WEATHER]) + results = [republic.tool(call, "sunny") for call in first.tool_calls] + await model.chat(["Weather?", first.message, *results], tools=[WEATHER]) assert service.body(0)["reasoning_effort"] == "high" assert first.reasoning == "Need the weather." diff --git a/tests/test_gemini_format.py b/tests/test_gemini_format.py index 03af04c..ccd9e0b 100644 --- a/tests/test_gemini_format.py +++ b/tests/test_gemini_format.py @@ -70,7 +70,7 @@ async def test_function_calls_keep_signatures_and_omit_generated_ids(service: Fa first = await model.chat("Weather?", tools=[republic.Tool("get_weather")]) call = first.tool_calls[0] - await model.chat(["Weather?", republic.assistant(tool_results=[republic.tool_result(call, "sunny")])]) + await model.chat(["Weather?", republic.tool(call, "sunny")]) assert service.body(0)["tools"] == [ { @@ -89,6 +89,27 @@ async def test_function_calls_keep_signatures_and_omit_generated_ids(service: Fa ] +async def test_tool_results_can_hold_multiple_parts(service: FakeService) -> None: + service.reply_json({"candidates": [{"content": {"parts": [{"text": "A cat."}]}}]}) + call = republic.ToolCall("call_1", "screenshot", "{}") + result = republic.tool(call, "Captured", republic.Image("image/png", data=b"png"), is_error=True) + + await make_model(service).chat(["Look", result]) + + assert service.body()["contents"][-1] == { + "role": "user", + "parts": [ + { + "functionResponse": { + "name": "screenshot", + "response": {"error": "Captured"}, + "parts": [{"inlineData": {"mimeType": "image/png", "data": PNG}}], + } + } + ], + } + + async def test_blocked_prompt_raises(service: FakeService) -> None: service.reply_json({"promptFeedback": {"blockReason": "SAFETY"}}) diff --git a/tests/test_github_copilot.py b/tests/test_github_copilot.py index ac79bc6..12f20d4 100644 --- a/tests/test_github_copilot.py +++ b/tests/test_github_copilot.py @@ -112,8 +112,8 @@ async def test_responses_keeps_parallel_calls_with_changing_item_ids(service: Fa assert ready == [calls[1], calls[0]] assert stream.response.finish_reason == "tool_calls" - results = [republic.tool_result(call, output) for call, output in zip(calls, ["5", "6"], strict=True)] - response = await model.chat(["Calculate", stream.response.message, republic.assistant(tool_results=results)]) + results = [republic.tool(call, output) for call, output in zip(calls, ["5", "6"], strict=True)] + response = await model.chat(["Calculate", stream.response.message, *results]) assert response.text == "done" assert service.body()["input"][1:] == [ diff --git a/tests/test_message_dict.py b/tests/test_message_dict.py new file mode 100644 index 0000000..91b1bbd --- /dev/null +++ b/tests/test_message_dict.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import json + +import pytest + +import republic +from republic import Image, Message, ProviderData, Reasoning, Text, ToolCall, Video + + +def test_single_text_message_uses_plain_content() -> None: + message = Message("user", (Text("hello"),)) + + data = message.to_dict() + + assert data == {"role": "user", "content": "hello"} + assert Message.from_dict(data) == message + + +def test_multiple_parts_use_typed_content_list() -> None: + message = Message( + "assistant", + ( + Reasoning("thinking"), + Text("look"), + Text("again"), + Image("image/png", data=b"\x89PNG"), + Video("video/mp4", url="https://example.com/a.mp4"), + ProviderData("responses", {"type": "reasoning", "id": "rs_1"}), + ), + ) + + data = message.to_dict() + + assert data["content"] == [ + {"type": "reasoning", "text": "thinking"}, + {"type": "text", "text": "look"}, + {"type": "text", "text": "again"}, + {"type": "image", "media_type": "image/png", "data": "iVBORw=="}, + {"type": "video", "media_type": "video/mp4", "url": "https://example.com/a.mp4"}, + {"type": "provider_data", "api_format": "responses", "payload": {"type": "reasoning", "id": "rs_1"}}, + ] + assert Message.from_dict(json.loads(json.dumps(data))) == message + + +def test_single_non_text_part_uses_content_list() -> None: + message = Message("user", (Image("image/png", url="https://example.com/a.png"),)) + + data = message.to_dict() + + assert data["content"] == [{"type": "image", "media_type": "image/png", "url": "https://example.com/a.png"}] + assert Message.from_dict(data) == message + + +def test_tool_calls_round_trip() -> None: + call = ToolCall("call_1", "lookup", '{"q": "x"}', metadata={"thought_signature": "sig"}) + message = republic.assistant(tool_calls=[call]) + + data = message.to_dict() + + assert data == { + "role": "assistant", + "tool_calls": [ + {"id": "call_1", "name": "lookup", "arguments": '{"q": "x"}', "metadata": {"thought_signature": "sig"}} + ], + } + restored = Message.from_dict(json.loads(json.dumps(data))) + assert restored == message + assert restored.tool_calls[0].metadata == {"thought_signature": "sig"} + + +def test_tool_message_with_text_output() -> None: + message = republic.tool(ToolCall("call_1", "lookup", "{}"), "done", is_error=True) + + data = message.to_dict() + + assert data == { + "role": "tool", + "content": "done", + "tool_call": {"id": "call_1", "name": "lookup", "arguments": "{}"}, + "is_error": True, + } + assert Message.from_dict(data) == message + + +def test_tool_message_with_multiple_parts() -> None: + message = republic.tool(ToolCall("call_1", "screenshot", "{}"), "captured", Image("image/png", data=b"png")) + + data = message.to_dict() + + assert data["role"] == "tool" + assert data["content"] == [ + {"type": "text", "text": "captured"}, + {"type": "image", "media_type": "image/png", "data": "cG5n"}, + ] + assert "is_error" not in data + assert Message.from_dict(json.loads(json.dumps(data))) == message + + +def test_from_dict_rejects_unknown_part_type() -> None: + with pytest.raises(ValueError, match="Unknown message part type"): + Message.from_dict({"role": "user", "content": [{"type": "audio"}]}) diff --git a/tests/test_messages_format.py b/tests/test_messages_format.py index 34ed926..b7c3594 100644 --- a/tests/test_messages_format.py +++ b/tests/test_messages_format.py @@ -69,7 +69,7 @@ async def test_tool_results_follow_calls_with_thinking_preserved(service: FakeSe await model.chat([ "Weather?", first.message, - republic.assistant(tool_results=[republic.tool_result(first.tool_calls[0], "sunny")]), + republic.tool(first.tool_calls[0], "sunny"), "Thanks!", ]) @@ -92,6 +92,29 @@ async def test_tool_results_follow_calls_with_thinking_preserved(service: FakeSe ] +async def test_tool_results_can_hold_multiple_parts(service: FakeService) -> None: + service.reply_json({"content": [{"type": "text", "text": "A cat."}], "usage": {"input_tokens": 1}}) + call = republic.ToolCall("toolu_1", "screenshot", "{}") + result = republic.tool(call, "Captured", republic.Image("image/png", data=b"png"), is_error=True) + + await make_model(service).chat(["Look", result]) + + assert service.body()["messages"][-1] == { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_1", + "content": [ + {"type": "text", "text": "Captured"}, + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "cG5n"}}, + ], + "is_error": True, + } + ], + } + + async def test_structured_output_closes_object_schemas(service: FakeService) -> None: service.reply_json({"content": [{"type": "text", "text": '{"value": 42}'}], "usage": {"input_tokens": 1}}) diff --git a/tests/test_models.py b/tests/test_models.py index 968cb33..74d73bc 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -79,7 +79,7 @@ async def test_trimming_restarts_at_a_user_message(self) -> None: await history.write([ republic.user("first"), republic.assistant(tool_calls=[call]), - republic.assistant(tool_results=[republic.tool_result(call, "done")]), + republic.tool(call, "done"), republic.user("second"), ]) @@ -104,15 +104,38 @@ def test_raw_bytes_need_a_media_type(self) -> None: with pytest.raises(ValueError, match="media_type"): republic.video(b"mp4") - def test_assistant_tool_results_announce_calls_once(self) -> None: + def test_tool_results_announce_calls_once(self) -> None: from republic.formats._base import normalize call = republic.ToolCall("call_1", "lookup", "{}") - result = republic.tool_result(call, "done") + result = republic.tool(call, "done") - messages = normalize([republic.assistant(tool_calls=[call]), republic.assistant(tool_results=[result])]) + assert normalize([republic.assistant(tool_calls=[call]), result]) == [ + republic.assistant(tool_calls=[call]), + result, + ] + + def test_unannounced_calls_join_the_assistant_turn_before_their_results(self) -> None: + from republic.formats._base import normalize + + first = republic.ToolCall("call_1", "lookup", "{}") + second = republic.ToolCall("call_2", "lookup", "{}") + results = [republic.tool(first, "one"), republic.tool(second, "two")] - assert messages == [ - republic.Message("assistant", tool_calls=(call,)), - republic.Message("tool", tool_results=(result,)), + assert normalize([republic.user("hi"), *results]) == [ + republic.user("hi"), + republic.assistant(tool_calls=[first, second]), + *results, ] + assert normalize([republic.assistant("Checking."), *results]) == [ + republic.assistant("Checking.", tool_calls=[first, second]), + *results, + ] + + def test_tool_call_belongs_only_to_tool_messages(self) -> None: + call = republic.ToolCall("call_1", "lookup", "{}") + + with pytest.raises(ValueError, match="tool_call"): + republic.Message("tool") + with pytest.raises(ValueError, match="tool_call"): + republic.Message("assistant", tool_call=call) diff --git a/tests/test_provider_golden.py b/tests/test_provider_golden.py index d6ed916..877b2f0 100644 --- a/tests/test_provider_golden.py +++ b/tests/test_provider_golden.py @@ -282,7 +282,7 @@ async def test_fantasy_weather_tool(service: FakeService, api_format: str, strea second, _ = await respond( model, - [*prompt, first.message, republic.assistant(tool_results=[republic.tool_result(call, "40 C")])], + [*prompt, first.message, republic.tool(call, "40 C")], streaming=streaming, max_tokens=4000, tools=[WEATHER], @@ -327,9 +327,7 @@ async def test_fantasy_parallel_tools(service: FakeService, api_format: str, str [ *prompt, first.message, - republic.assistant( - tool_results=[republic.tool_result(call, outputs[call.name]) for call in first.tool_calls] - ), + *(republic.tool(call, outputs[call.name]) for call in first.tool_calls), ], streaming=streaming, max_tokens=4000, @@ -494,7 +492,7 @@ async def test_goose_distinct_gemini_signatures_round_trip(service: FakeService) assert shell.metadata["gemini_thought_signature"] == "thought_sig_abc" prompt.extend([ first.message, - republic.assistant(tool_results=[republic.tool_result(call, "output") for call in first.tool_calls]), + *(republic.tool(call, "output") for call in first.tool_calls), ]) second, _ = await respond(model, prompt) @@ -505,7 +503,7 @@ async def test_goose_distinct_gemini_signatures_round_trip(service: FakeService) assert echo.args == {} assert echo.id not in {call.id for call in first.tool_calls} assert echo.metadata["gemini_thought_signature"] == "sig_456" - prompt.extend([second.message, republic.assistant(tool_results=[republic.tool_result(echo, "output")])]) + prompt.extend([second.message, republic.tool(echo, "output")]) final, _ = await respond(model, prompt) diff --git a/tests/test_responses_format.py b/tests/test_responses_format.py index cf967be..ab72bb7 100644 --- a/tests/test_responses_format.py +++ b/tests/test_responses_format.py @@ -53,7 +53,7 @@ async def test_reasoning_items_are_sent_back_with_tool_results(service: FakeServ model = make_model(service, history=republic.history.InMemoryHistory()) first = await model.chat("Weather in Paris?") - await model.chat(republic.assistant(tool_results=[republic.tool_result(first.tool_calls[0], "sunny")])) + await model.chat(republic.tool(first.tool_calls[0], "sunny")) assert service.body()["input"] == [ {"role": "user", "content": [{"type": "input_text", "text": "Weather in Paris?"}]}, @@ -63,6 +63,22 @@ async def test_reasoning_items_are_sent_back_with_tool_results(service: FakeServ ] +async def test_tool_results_can_hold_multiple_parts(service: FakeService) -> None: + service.reply_json({"output": []}) + call = republic.ToolCall("call_1", "screenshot", "{}") + + await make_model(service).chat(["Look", republic.tool(call, "Captured", republic.Image("image/png", data=b"png"))]) + + assert service.body()["input"][-1] == { + "type": "function_call_output", + "call_id": "call_1", + "output": [ + {"type": "input_text", "text": "Captured"}, + {"type": "input_image", "image_url": "data:image/png;base64,cG5n"}, + ], + } + + async def test_reasoning_summaries_are_exposed(service: FakeService) -> None: reasoning = {"type": "reasoning", "id": "rs_1", "summary": [{"type": "summary_text", "text": "Checked the map."}]} service.reply_json({"output": [reasoning]}) From 6f96b3f0a8f604a34dfbbbd5e347bc8f9feada71 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Sat, 10 Oct 2026 10:56:32 +0800 Subject: [PATCH 2/3] Reject remote media in Gemini tool results 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 --- docs/guides/tools.md | 2 +- src/republic/formats/gemini.py | 10 ++++++++-- tests/test_gemini_format.py | 10 ++++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/guides/tools.md b/docs/guides/tools.md index 980f2ea..eeca4b9 100644 --- a/docs/guides/tools.md +++ b/docs/guides/tools.md @@ -32,7 +32,7 @@ Run this script from the same project directory as `agent.py`. `tool_choice=read `call.args` decodes the model's JSON arguments. The executor checks the tool name and permitted filenames before reading anything. A JSON decoding error propagates to the application; a schema alone does not validate the arguments locally. -`republic.tool(call, ...)` builds a `tool` message that keeps the original call with its output, including the ID needed to associate the result with that call. The output may mix text, images, audio, and videos, such as `republic.tool(call, "Captured", republic.image("shot.png"))`; pass `is_error=True` to report a failed call. Media support in tool results depends on the provider and API format. `response.message` also preserves reasoning blocks, signed calls, and other provider data needed for the next turn. Replacing it with `response.text` loses that state. +`republic.tool(call, ...)` builds a `tool` message that keeps the original call with its output, including the ID needed to associate the result with that call. The output may mix text, images, audio, and videos, such as `republic.tool(call, "Captured", republic.image("shot.png"))`; pass `is_error=True` to report a failed call. Media support in tool results depends on the provider and API format: the Gemini format accepts only inline media there, and OpenAI's own Chat Completions service accepts only text in tool messages, although some compatible gateways accept images. `response.message` also preserves reasoning blocks, signed calls, and other provider data needed for the next turn. Replacing it with `response.text` loses that state. The second request completes this round without offering more tools. To allow another round, supply the schemas again and repeat the sequence, as the minimal agent does. With an attached history object, the first assistant message is already recorded; send only the new results instead of appending the same conversation twice. diff --git a/src/republic/formats/gemini.py b/src/republic/formats/gemini.py index a68d613..e98b9c6 100644 --- a/src/republic/formats/gemini.py +++ b/src/republic/formats/gemini.py @@ -280,8 +280,14 @@ def _function_response(message: Message) -> dict[str, Any]: } if call_id := call.metadata.get(_CALL_ID): response["id"] = call_id - if media := [_part(part) for part in message.parts if isinstance(part, Image | Audio | Video)]: - response["parts"] = media + media = [part for part in message.parts if isinstance(part, Image | Audio | Video)] + if any(part.url is not None for part in media): + # FunctionResponsePart accepts inlineData only, not fileData. + raise UnsupportedFeatureError( + f"The {GeminiFormat.name!r} API format accepts only inline media in a tool result" + ) + if media: + response["parts"] = [_part(part) for part in media] return {"functionResponse": response} diff --git a/tests/test_gemini_format.py b/tests/test_gemini_format.py index ccd9e0b..1df2b2c 100644 --- a/tests/test_gemini_format.py +++ b/tests/test_gemini_format.py @@ -110,6 +110,16 @@ async def test_tool_results_can_hold_multiple_parts(service: FakeService) -> Non } +async def test_tool_results_reject_remote_media(service: FakeService) -> None: + call = republic.ToolCall("call_1", "screenshot", "{}") + result = republic.tool(call, "Captured", republic.Image("image/png", url="https://example.com/shot.png")) + + with pytest.raises(republic.errors.UnsupportedFeatureError, match="inline media"): + await make_model(service).chat(["Look", result]) + + assert service.requests == [] + + async def test_blocked_prompt_raises(service: FakeService) -> None: service.reply_json({"promptFeedback": {"blockReason": "SAFETY"}}) From 643e540ce50e84f0929c6bfeb792140313edb505 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Sat, 10 Oct 2026 11:11:18 +0800 Subject: [PATCH 3/3] Drop empty assistant turns when normalizing messages 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 --- src/republic/formats/_base.py | 3 ++- tests/test_messages_format.py | 24 ++++++++++++++++++++++++ tests/test_models.py | 12 ++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/republic/formats/_base.py b/src/republic/formats/_base.py index d1b9fc3..74f6126 100644 --- a/src/republic/formats/_base.py +++ b/src/republic/formats/_base.py @@ -387,7 +387,8 @@ def normalize(messages: Iterable[Message]) -> list[Message]: else: normalized.insert(start, Message("assistant", tool_calls=(message.tool_call,))) normalized.append(message) - return normalized + # An assistant turn with no parts and no calls has nothing to send, and providers reject it. + return [message for message in normalized if message.role != "assistant" or message.parts or message.tool_calls] def answered_call(message: Message) -> ToolCall: diff --git a/tests/test_messages_format.py b/tests/test_messages_format.py index b7c3594..dddfbe3 100644 --- a/tests/test_messages_format.py +++ b/tests/test_messages_format.py @@ -246,6 +246,30 @@ async def test_refusal_stop_reason(service: FakeService) -> None: assert response.output is None +async def test_empty_refusal_turn_is_not_sent_back(service: FakeService) -> None: + refusal = { + "content": [], + "stop_reason": "refusal", + "stop_details": {"type": "refusal", "explanation": "Declined."}, + "usage": {"input_tokens": 1}, + } + service.reply_json(refusal) + service.reply_json(refusal) + model = republic.get_model( + "anthropic:claude-opus-5-5", + api_key="key", + http_client=service.client(), + history=republic.history.InMemoryHistory(), + ) + + await model.chat("hello") + await model.chat("and now?") + + assert service.body()["messages"] == [ + {"role": "user", "content": [{"type": "text", "text": "hello"}, {"type": "text", "text": "and now?"}]} + ] + + async def test_strict_tool_is_flagged(service: FakeService) -> None: service.reply_json({"content": [], "usage": {"input_tokens": 1}}) diff --git a/tests/test_models.py b/tests/test_models.py index 74d73bc..5efc000 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -132,6 +132,18 @@ def test_unannounced_calls_join_the_assistant_turn_before_their_results(self) -> *results, ] + def test_empty_assistant_turns_are_dropped(self) -> None: + from republic.formats._base import normalize + + call = republic.ToolCall("call_1", "lookup", "{}") + result = republic.tool(call, "done") + + assert normalize([republic.user("hi"), republic.Message("assistant"), republic.user("again")]) == [ + republic.user("hi"), + republic.user("again"), + ] + assert normalize([republic.Message("assistant"), result]) == [republic.assistant(tool_calls=[call]), result] + def test_tool_call_belongs_only_to_tool_messages(self) -> None: call = republic.ToolCall("call_1", "lookup", "{}")