Skip to content

Commit acee251

Browse files
committed
Add content_output builder
1 parent 4eb9ea6 commit acee251

4 files changed

Lines changed: 57 additions & 7 deletions

File tree

examples/read_file_tool.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,9 @@ async def read_file(path: str) -> str | ai.messages.ContentOutput:
4444
data = _resolve_within_allowed(path).read_bytes()
4545
image_type = media.detect_image_media_type(data)
4646
if image_type is not None:
47-
return ai.messages.ContentOutput(
48-
value=[
49-
ai.messages.TextPart(
50-
text=f"Loaded {path} ({image_type}, {len(data)} bytes)."
51-
),
52-
ai.file_part(data, media_type=image_type),
53-
]
47+
return ai.content_output(
48+
f"Loaded {path} ({image_type}, {len(data)} bytes).",
49+
ai.file_part(data, media_type=image_type),
5450
)
5551
return data.decode("utf-8", errors="replace")
5652

src/ai/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,10 @@
6363
from .types import events, messages, tools
6464
from .types.builders import (
6565
assistant_message,
66+
content_output,
6667
file_part,
6768
system_message,
69+
text_part,
6870
thinking,
6971
tool_message,
7072
tool_result_part,
@@ -119,6 +121,7 @@
119121
"agent",
120122
"assistant_message",
121123
"cancel_hook",
124+
"content_output",
122125
"errors",
123126
# Submodules
124127
"events",
@@ -137,6 +140,7 @@
137140
"resolve_hook",
138141
"stream",
139142
"system_message",
143+
"text_part",
140144
"thinking",
141145
"tool",
142146
"tool_message",

src/ai/types/builders.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
from . import events as events_
1414

1515
from .messages import (
16+
ContentOutput,
17+
ContentPart,
1618
ErrorJsonOutput,
1719
ErrorTextOutput,
1820
ExecutionDeniedOutput,
@@ -92,6 +94,35 @@ def file_part(
9294
return FilePart.from_bytes(data, media_type=media_type, filename=filename)
9395

9496

97+
def text_part(
98+
text: str,
99+
*,
100+
provider_metadata: dict[str, Any] | None = None,
101+
) -> TextPart:
102+
"""Create a :class:`TextPart`.
103+
104+
Bare strings passed to the ``*_message`` builders are coerced into
105+
text parts automatically; reach for this when you need to attach
106+
``provider_metadata`` or build a part list directly.
107+
"""
108+
return TextPart(text=text, provider_metadata=provider_metadata)
109+
110+
111+
def content_output(*content: str | TextPart | FilePart) -> ContentOutput:
112+
"""Create a multipart :class:`ContentOutput` tool result.
113+
114+
Bare strings become :class:`TextPart` objects, mirroring the
115+
``*_message`` builders, so a tool can return mixed text and files
116+
without constructing the part list by hand.
117+
118+
>>> ai.content_output("Here is the chart:", ai.file_part(png_bytes))
119+
"""
120+
parts: list[ContentPart] = []
121+
for item in content:
122+
parts.append(TextPart(text=item) if isinstance(item, str) else item)
123+
return ContentOutput(value=parts)
124+
125+
95126
def thinking(
96127
text: str,
97128
*,

tests/types/test_builders.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,25 @@ def test_user_message_mixed_content() -> None:
2020
assert isinstance(msg.parts[2], messages.TextPart)
2121

2222

23+
def test_text_part() -> None:
24+
tp = builders.text_part("hello", provider_metadata={"k": "v"})
25+
assert isinstance(tp, messages.TextPart)
26+
assert tp.text == "hello"
27+
assert tp.provider_metadata == {"k": "v"}
28+
29+
30+
def test_content_output_coerces_strings() -> None:
31+
fp = messages.FilePart(
32+
data="https://example.com/img.png", media_type="image/png"
33+
)
34+
out = builders.content_output("Here:", fp)
35+
assert isinstance(out, messages.ContentOutput)
36+
assert len(out.value) == 2
37+
assert isinstance(out.value[0], messages.TextPart)
38+
assert out.value[0].text == "Here:"
39+
assert isinstance(out.value[1], messages.FilePart)
40+
41+
2342
def test_file_part_from_url() -> None:
2443
fp = builders.file_part("https://example.com/image.png")
2544
assert isinstance(fp, messages.FilePart)

0 commit comments

Comments
 (0)