Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions docs/guides/minimal-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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")


Expand Down
4 changes: 2 additions & 2 deletions docs/guides/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand All @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions docs/reference/provider-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```
Expand Down
6 changes: 2 additions & 4 deletions src/republic/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,12 @@
Text,
Tool,
ToolCall,
ToolResult,
Video,
assistant,
audio,
image,
system,
tool_result,
tool,
user,
video,
)
Expand Down Expand Up @@ -66,7 +65,6 @@
"Tool",
"ToolCall",
"ToolChoice",
"ToolResult",
"Video",
"all_providers",
"assistant",
Expand All @@ -85,7 +83,7 @@
"providers",
"register_provider",
"system",
"tool_result",
"tool",
"tools",
"user",
"video",
Expand Down
132 changes: 105 additions & 27 deletions src/republic/_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand All @@ -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:
Expand Down
40 changes: 26 additions & 14 deletions src/republic/formats/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]:
Expand Down
8 changes: 4 additions & 4 deletions src/republic/formats/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
StreamParser,
ToolCallFragment,
UsageReport,
answered_call,
approximate_location,
deep_merge,
strict_schema,
Expand Down Expand Up @@ -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:
Expand All @@ -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]] = []
Expand Down
23 changes: 17 additions & 6 deletions src/republic/formats/gemini.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,6 +25,7 @@
StreamParser,
ToolCallFragment,
UsageReport,
answered_call,
deep_merge,
merge_same_role,
provider_payloads,
Expand Down Expand Up @@ -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:
Expand All @@ -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}


Expand Down
Loading
Loading