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..eeca4b9 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, 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/docs/reference/provider-api.md b/docs/reference/provider-api.md index c601925..cf0f299 100644 --- a/docs/reference/provider-api.md +++ b/docs/reference/provider-api.md @@ -181,13 +181,13 @@ Provider dialects may extend a format. For example, OpenRouter's Chat Completion Unsupported audio combinations raise `errors.UnsupportedFeatureError` before sending a request. Republic does not transcode audio or fetch remote references to make them inline. -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 f36b678..4e47d0b 100644 --- a/src/republic/__init__.py +++ b/src/republic/__init__.py @@ -13,13 +13,12 @@ Text, Tool, ToolCall, - ToolResult, Video, assistant, audio, image, system, - tool_result, + tool, user, video, ) @@ -66,7 +65,6 @@ "Tool", "ToolCall", "ToolChoice", - "ToolResult", "Video", "all_providers", "assistant", @@ -85,7 +83,7 @@ "providers", "register_provider", "system", - "tool_result", + "tool", "tools", "user", "video", diff --git a/src/republic/_content.py b/src/republic/_content.py index 6029f75..7c96360 100644 --- a/src/republic/_content.py +++ b/src/republic/_content.py @@ -128,19 +128,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: @@ -150,6 +150,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() | Audio() | 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" | "audio" | "video" as kind: + media_class = {"image": Image, "audio": Audio, "video": Video}[kind] + 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 | Audio | Video @@ -168,28 +256,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, audio, 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..74f6126 100644 --- a/src/republic/formats/_base.py +++ b/src/republic/formats/_base.py @@ -365,25 +365,37 @@ 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)) - return normalized + 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) + # 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: + """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]]: diff --git a/src/republic/formats/chat.py b/src/republic/formats/chat.py index ba7192c..8837654 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, @@ -191,7 +192,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(api_format, message)}] + return [{"role": "user", "content": _content(api_format, message)}] case "assistant": entry: dict[str, Any] = {"role": "assistant", "content": message.text or None} if message.tool_calls: @@ -203,12 +204,11 @@ def _message_entries(api_format: ChatFormat, message: Message) -> list[dict[str, return [entry] case "tool": return [ - {"role": "tool", "tool_call_id": result.call.id, "content": result.output} - for result in message.tool_results + {"role": "tool", "tool_call_id": answered_call(message).id, "content": _content(api_format, message)} ] -def _user_content(api_format: ChatFormat, message: Message) -> str | list[dict[str, Any]]: +def _content(api_format: ChatFormat, 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 8cf685b..e98b9c6 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 Audio, Image, Message, ProviderData, Text, Tool, ToolResult, Video, _Media +from republic._content import Audio, 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, @@ -256,7 +257,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 | Audio | Video)) for call in message.tool_calls: @@ -270,13 +271,23 @@ 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; other media 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 + 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/src/republic/formats/messages.py b/src/republic/formats/messages.py index 587a1ba..82b86c5 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 de7804a..d481d6c 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..1df2b2c 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,37 @@ 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_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"}}) 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..8fa74f9 --- /dev/null +++ b/tests/test_message_dict.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import json + +import pytest + +import republic +from republic import Audio, 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"), + Audio("audio/wav", url="https://example.com/a.wav"), + 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": "audio", "media_type": "audio/wav", "url": "https://example.com/a.wav"}, + {"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": "hologram"}]}) diff --git a/tests/test_messages_format.py b/tests/test_messages_format.py index 34ed926..dddfbe3 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}}) @@ -223,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 968cb33..5efc000 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,50 @@ 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 - assert messages == [ - republic.Message("assistant", tool_calls=(call,)), - republic.Message("tool", tool_results=(result,)), + first = republic.ToolCall("call_1", "lookup", "{}") + second = republic.ToolCall("call_2", "lookup", "{}") + results = [republic.tool(first, "one"), republic.tool(second, "two")] + + 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_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", "{}") + + 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]})