Skip to content

Commit b5d1c5e

Browse files
authored
Tools can now return attachments
Closes #1014 - llm.ToolOutput(output='...', attachments=[...]) for tools to return attachments - New table: `tool_results_attachments` - Table is populated when tools return attachments - llm --tools-debug shows attachments returned by tools - llm logs shows attachments returned by tools
1 parent f74e242 commit b5d1c5e

8 files changed

Lines changed: 215 additions & 22 deletions

File tree

docs/python-api.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,30 @@ for response in chain.responses():
148148
print(chunk, end="", flush=True)
149149
```
150150

151+
(python-api-tools-attachments)=
152+
153+
#### Tools can return attachments
154+
155+
Tools can return {ref}`attachments <python-api-attachments>` in addition to returning text. Attachments that are returned from a tool call will be passed to the model as attachments for the next prompt in the chain.
156+
157+
To return one or more attachments, return a `llm.ToolOutput` instance from your tool function. This can have an `output=` string and an `attachments=` list of `llm.Attachment` instances.
158+
159+
Here's an example:
160+
```python
161+
import llm
162+
163+
def generate_image(prompt: str) -> llm.ToolOutput:
164+
"""Generate an image based on the prompt."""
165+
image_content = generate_image_from_prompt(prompt)
166+
return llm.ToolOutput(
167+
output="Image generated successfully",
168+
attachments=[llm.Attachment(
169+
content=image_content,
170+
mimetype="image/png"
171+
)],
172+
)
173+
```
174+
151175
(python-api-toolbox)=
152176

153177
#### Toolbox classes

llm/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
Tool,
2323
Toolbox,
2424
ToolCall,
25+
ToolOutput,
2526
ToolResult,
2627
)
2728
from .utils import schema_dsl, Fragment
@@ -60,6 +61,7 @@
6061
"Tool",
6162
"Toolbox",
6263
"ToolCall",
64+
"ToolOutput",
6365
"ToolResult",
6466
"user_dir",
6567
"schema_dsl",

llm/cli.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1855,7 +1855,21 @@ def logs_list(
18551855
'tool_id', tr.tool_id,
18561856
'name', tr.name,
18571857
'output', tr.output,
1858-
'tool_call_id', tr.tool_call_id
1858+
'tool_call_id', tr.tool_call_id,
1859+
'attachments', COALESCE(
1860+
(SELECT json_group_array(json_object(
1861+
'id', a.id,
1862+
'type', a.type,
1863+
'path', a.path,
1864+
'url', a.url,
1865+
'content', a.content
1866+
))
1867+
FROM tool_results_attachments tra
1868+
JOIN attachments a ON tra.attachment_id = a.id
1869+
WHERE tra.tool_result_id = tr.id
1870+
),
1871+
'[]'
1872+
)
18591873
))
18601874
FROM tool_results tr
18611875
WHERE tr.response_id = responses.id
@@ -2066,11 +2080,24 @@ def _display_fragments(fragments, title):
20662080
if row["tool_results"]:
20672081
click.echo("\n### Tool results\n")
20682082
for tool_result in row["tool_results"]:
2083+
attachments = ""
2084+
for attachment in tool_result["attachments"]:
2085+
desc = ""
2086+
if attachment.get("type"):
2087+
desc += attachment["type"] + ": "
2088+
if attachment.get("path"):
2089+
desc += attachment["path"]
2090+
elif attachment.get("url"):
2091+
desc += attachment["url"]
2092+
elif attachment.get("content"):
2093+
desc += f"<{attachment['content_length']:,} bytes>"
2094+
attachments += "\n - {}".format(desc)
20692095
click.echo(
2070-
"- **{}**: `{}`<br>\n{}".format(
2096+
"- **{}**: `{}`<br>\n{}{}".format(
20712097
tool_result["name"],
20722098
tool_result["tool_call_id"],
20732099
textwrap.indent(tool_result["output"], " "),
2100+
attachments,
20742101
)
20752102
)
20762103
attachments = attachments_by_id.get(row["id"])
@@ -3885,10 +3912,17 @@ def _debug_tool_call(_, tool_call, tool_result):
38853912
err=True,
38863913
)
38873914
output = ""
3915+
attachments = ""
3916+
if tool_result.attachments:
3917+
attachments += "\nAttachments:\n"
3918+
for attachment in tool_result.attachments:
3919+
attachments += f" {repr(attachment)}\n"
3920+
38883921
try:
38893922
output = json.dumps(json.loads(tool_result.output), indent=2)
38903923
except ValueError:
38913924
output = tool_result.output
3925+
output += attachments
38923926
click.echo(
38933927
click.style(
38943928
textwrap.indent(output, " ") + "\n",

llm/default_plugins/openai_models.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,14 @@ def build_messages(self, prompt, conversation):
573573
)
574574
if prompt.system and prompt.system != current_system:
575575
messages.append({"role": "system", "content": prompt.system})
576+
for tool_result in prompt.tool_results:
577+
messages.append(
578+
{
579+
"role": "tool",
580+
"tool_call_id": tool_result.tool_call_id,
581+
"content": tool_result.output,
582+
}
583+
)
576584
if not prompt.attachments:
577585
if prompt.prompt:
578586
messages.append({"role": "user", "content": prompt.prompt or ""})
@@ -583,14 +591,6 @@ def build_messages(self, prompt, conversation):
583591
for attachment in prompt.attachments:
584592
attachment_message.append(_attachment(attachment))
585593
messages.append({"role": "user", "content": attachment_message})
586-
for tool_result in prompt.tool_results:
587-
messages.append(
588-
{
589-
"role": "tool",
590-
"tool_call_id": tool_result.tool_call_id,
591-
"content": tool_result.output,
592-
}
593-
)
594594
return messages
595595

596596
def set_usage(self, response, usage):

llm/migrations.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,3 +397,19 @@ def m019_resolved_model(db):
397397
# For models like gemini-1.5-flash-latest where we wish to record
398398
# the resolved model name in addition to the alias
399399
db["responses"].add_column("resolved_model", str)
400+
401+
402+
@migration
403+
def m020_tool_results_attachments(db):
404+
db["tool_results_attachments"].create(
405+
{
406+
"tool_result_id": int,
407+
"attachment_id": str,
408+
"order": int,
409+
},
410+
foreign_keys=(
411+
("tool_result_id", "tool_results", "id"),
412+
("attachment_id", "attachments", "id"),
413+
),
414+
pk=("tool_result_id", "attachment_id"),
415+
)

llm/models.py

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,18 @@ def content_bytes(self):
9797
def base64_content(self):
9898
return base64.b64encode(self.content_bytes()).decode("utf-8")
9999

100+
def __repr__(self):
101+
info = [f"<Attachment: {self.id()}"]
102+
if self.type:
103+
info.append(f'type="{self.type}"')
104+
if self.path:
105+
info.append(f'path="{self.path}"')
106+
if self.url:
107+
info.append(f'url="{self.url}"')
108+
if self.content:
109+
info.append(f"content={len(self.content)} bytes")
110+
return " ".join(info) + ">"
111+
100112
@classmethod
101113
def from_row(cls, row):
102114
return cls(
@@ -261,10 +273,19 @@ class ToolCall:
261273
class ToolResult:
262274
name: str
263275
output: str
276+
attachments: List[Attachment] = field(default_factory=list)
264277
tool_call_id: Optional[str] = None
265278
instance: Optional[Toolbox] = None
266279

267280

281+
@dataclass
282+
class ToolOutput:
283+
"Tool functions can return output with extra attachments"
284+
285+
output: Optional[Union[str, dict, list, bool, int, float]] = None
286+
attachments: List[Attachment] = field(default_factory=list)
287+
288+
268289
class CancelToolCall(Exception):
269290
pass
270291

@@ -887,16 +908,40 @@ def log_to_db(self, db):
887908
instance_id = tool_result.instance.instance_id
888909
except AttributeError:
889910
pass
890-
db["tool_results"].insert(
891-
{
892-
"response_id": response_id,
893-
"tool_id": tool_ids_by_name.get(tool_result.name) or None,
894-
"name": tool_result.name,
895-
"output": tool_result.output,
896-
"tool_call_id": tool_result.tool_call_id,
897-
"instance_id": instance_id,
898-
}
911+
tool_result_id = (
912+
db["tool_results"]
913+
.insert(
914+
{
915+
"response_id": response_id,
916+
"tool_id": tool_ids_by_name.get(tool_result.name) or None,
917+
"name": tool_result.name,
918+
"output": tool_result.output,
919+
"tool_call_id": tool_result.tool_call_id,
920+
"instance_id": instance_id,
921+
}
922+
)
923+
.last_pk
899924
)
925+
# Persist attachments for tool results
926+
for index, attachment in enumerate(tool_result.attachments):
927+
attachment_id = attachment.id()
928+
db["attachments"].insert(
929+
{
930+
"id": attachment_id,
931+
"type": attachment.resolve_type(),
932+
"path": attachment.path,
933+
"url": attachment.url,
934+
"content": attachment.content,
935+
},
936+
replace=True,
937+
)
938+
db["tool_results_attachments"].insert(
939+
{
940+
"tool_result_id": tool_result_id,
941+
"attachment_id": attachment_id,
942+
"order": index,
943+
},
944+
)
900945

901946

902947
class Response(_BaseResponse):
@@ -964,12 +1009,18 @@ def execute_tool_calls(
9641009
"No implementation available for tool: {}".format(tool_call.name)
9651010
)
9661011

1012+
attachments = []
1013+
9671014
try:
9681015
if asyncio.iscoroutinefunction(tool.implementation):
9691016
result = asyncio.run(tool.implementation(**tool_call.arguments))
9701017
else:
9711018
result = tool.implementation(**tool_call.arguments)
9721019

1020+
if isinstance(result, ToolOutput):
1021+
attachments = result.attachments
1022+
result = result.output
1023+
9731024
if not isinstance(result, str):
9741025
result = json.dumps(result, default=repr)
9751026
except Exception as ex:
@@ -978,6 +1029,7 @@ def execute_tool_calls(
9781029
tool_result_obj = ToolResult(
9791030
name=tool_call.name,
9801031
output=result,
1032+
attachments=attachments,
9811033
tool_call_id=tool_call.tool_call_id,
9821034
instance=_get_instance(tool.implementation),
9831035
)
@@ -1125,8 +1177,12 @@ async def run_async(tc=tc, tool=tool, idx=idx):
11251177
if inspect.isawaitable(cb):
11261178
await cb
11271179

1180+
attachments = []
11281181
try:
11291182
result = await tool.implementation(**tc.arguments)
1183+
if isinstance(result, ToolOutput):
1184+
attachments.extend(result.attachments)
1185+
result = result.output
11301186
output = (
11311187
result
11321188
if isinstance(result, str)
@@ -1138,6 +1194,7 @@ async def run_async(tc=tc, tool=tool, idx=idx):
11381194
tr = ToolResult(
11391195
name=tc.name,
11401196
output=output,
1197+
attachments=attachments,
11411198
tool_call_id=tc.tool_call_id,
11421199
instance=_get_instance(tool.implementation),
11431200
)
@@ -1159,10 +1216,14 @@ async def run_async(tc=tc, tool=tool, idx=idx):
11591216
if inspect.isawaitable(cb):
11601217
await cb
11611218

1219+
attachments = []
11621220
try:
11631221
res = tool.implementation(**tc.arguments)
11641222
if inspect.isawaitable(res):
11651223
res = await res
1224+
if isinstance(res, ToolOutput):
1225+
attachments.extend(res.attachments)
1226+
res = res.output
11661227
output = (
11671228
res if isinstance(res, str) else json.dumps(res, default=repr)
11681229
)
@@ -1172,6 +1233,7 @@ async def run_async(tc=tc, tool=tool, idx=idx):
11721233
tr = ToolResult(
11731234
name=tc.name,
11741235
output=output,
1236+
attachments=attachments,
11751237
tool_call_id=tc.tool_call_id,
11761238
instance=_get_instance(tool.implementation),
11771239
)
@@ -1427,6 +1489,9 @@ def responses(self) -> Iterator[Response]:
14271489
tool_results = current_response.execute_tool_calls(
14281490
before_call=self.before_call, after_call=self.after_call
14291491
)
1492+
attachments = []
1493+
for tool_result in tool_results:
1494+
attachments.extend(tool_result.attachments)
14301495
if tool_results:
14311496
current_response = Response(
14321497
Prompt(
@@ -1435,6 +1500,7 @@ def responses(self) -> Iterator[Response]:
14351500
tools=current_response.prompt.tools,
14361501
tool_results=tool_results,
14371502
options=self.prompt.options,
1503+
attachments=attachments,
14381504
),
14391505
self.model,
14401506
stream=self.stream,
@@ -1479,12 +1545,16 @@ async def responses(self) -> AsyncIterator[AsyncResponse]:
14791545
before_call=self.before_call, after_call=self.after_call
14801546
)
14811547
if tool_results:
1548+
attachments = []
1549+
for tool_result in tool_results:
1550+
attachments.extend(tool_result.attachments)
14821551
prompt = Prompt(
14831552
"",
14841553
self.model,
14851554
tools=current_response.prompt.tools,
14861555
tool_results=tool_results,
14871556
options=self.prompt.options,
1557+
attachments=attachments,
14881558
)
14891559
current_response = AsyncResponse(
14901560
prompt,

tests/test_plugins.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -459,20 +459,20 @@ def register_tools(self, register):
459459
('{"tool_calls": [{"name": "upper", "arguments": {"text": "one"}}]}', "[]"),
460460
(
461461
"",
462-
'[{"id": 2, "tool_id": 1, "name": "upper", "output": "ONE", "tool_call_id": null}]',
462+
'[{"id": 2, "tool_id": 1, "name": "upper", "output": "ONE", "tool_call_id": null, "attachments": []}]',
463463
),
464464
('{"tool_calls": [{"name": "upper", "arguments": {"text": "two"}}]}', "[]"),
465465
(
466466
"",
467-
'[{"id": 3, "tool_id": 1, "name": "upper", "output": "TWO", "tool_call_id": null}]',
467+
'[{"id": 3, "tool_id": 1, "name": "upper", "output": "TWO", "tool_call_id": null, "attachments": []}]',
468468
),
469469
(
470470
'{"tool_calls": [{"name": "upper", "arguments": {"text": "three"}}]}',
471471
"[]",
472472
),
473473
(
474474
"",
475-
'[{"id": 4, "tool_id": 1, "name": "upper", "output": "THREE", "tool_call_id": null}]',
475+
'[{"id": 4, "tool_id": 1, "name": "upper", "output": "THREE", "tool_call_id": null, "attachments": []}]',
476476
),
477477
)
478478
# Test the --td option

0 commit comments

Comments
 (0)