Skip to content

Commit 77b5660

Browse files
authored
Tools can now return AG-UI events separate from result sent to model (#2922)
1 parent e9b94f6 commit 77b5660

File tree

3 files changed

+55
-48
lines changed

3 files changed

+55
-48
lines changed

docs/ag-ui.md

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -204,18 +204,18 @@ user experiences with frontend user interfaces.
204204

205205
### Events
206206

207-
Pydantic AI tools can send
208-
[AG-UI events](https://docs.ag-ui.com/concepts/events) simply by defining a tool
209-
which returns a (subclass of)
210-
[`BaseEvent`](https://docs.ag-ui.com/sdk/python/core/events#baseevent), which allows
211-
for custom events and state updates.
207+
Pydantic AI tools can send [AG-UI events](https://docs.ag-ui.com/concepts/events) simply by returning a
208+
[`ToolReturn`](tools-advanced.md#advanced-tool-returns) object with a
209+
[`BaseEvent`](https://docs.ag-ui.com/sdk/python/core/events#baseevent) (or a list of events) as `metadata`,
210+
which allows for custom events and state updates.
212211

213212
```python {title="ag_ui_tool_events.py"}
214213
from ag_ui.core import CustomEvent, EventType, StateSnapshotEvent
215214
from pydantic import BaseModel
216215

217216
from pydantic_ai import Agent, RunContext
218217
from pydantic_ai.ag_ui import StateDeps
218+
from pydantic_ai.messages import ToolReturn
219219

220220

221221
class DocumentState(BaseModel):
@@ -233,27 +233,35 @@ app = agent.to_ag_ui(deps=StateDeps(DocumentState()))
233233

234234

235235
@agent.tool
236-
async def update_state(ctx: RunContext[StateDeps[DocumentState]]) -> StateSnapshotEvent:
237-
return StateSnapshotEvent(
238-
type=EventType.STATE_SNAPSHOT,
239-
snapshot=ctx.deps.state,
236+
async def update_state(ctx: RunContext[StateDeps[DocumentState]]) -> ToolReturn:
237+
return ToolReturn(
238+
return_value='State updated',
239+
metadata=[
240+
StateSnapshotEvent(
241+
type=EventType.STATE_SNAPSHOT,
242+
snapshot=ctx.deps.state,
243+
),
244+
],
240245
)
241246

242247

243248
@agent.tool_plain
244-
async def custom_events() -> list[CustomEvent]:
245-
return [
246-
CustomEvent(
247-
type=EventType.CUSTOM,
248-
name='count',
249-
value=1,
250-
),
251-
CustomEvent(
252-
type=EventType.CUSTOM,
253-
name='count',
254-
value=2,
255-
),
256-
]
249+
async def custom_events() -> ToolReturn:
250+
return ToolReturn(
251+
return_value='Count events sent',
252+
metadata=[
253+
CustomEvent(
254+
type=EventType.CUSTOM,
255+
name='count',
256+
value=1,
257+
),
258+
CustomEvent(
259+
type=EventType.CUSTOM,
260+
name='count',
261+
value=2,
262+
),
263+
]
264+
)
257265
```
258266

259267
Since `app` is an ASGI application, it can be used with any ASGI server:

pydantic_ai_slim/pydantic_ai/ag_ui.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -559,15 +559,15 @@ async def _handle_tool_result_event(
559559
content=result.model_response_str(),
560560
)
561561

562-
# Now check for AG-UI events returned by the tool calls.
563-
content = result.content
564-
if isinstance(content, BaseEvent):
565-
yield content
566-
elif isinstance(content, str | bytes): # pragma: no branch
562+
# Now check for AG-UI events returned by the tool calls.
563+
possible_event = result.metadata or result.content
564+
if isinstance(possible_event, BaseEvent):
565+
yield possible_event
566+
elif isinstance(possible_event, str | bytes): # pragma: no branch
567567
# Avoid iterable check for strings and bytes.
568568
pass
569-
elif isinstance(content, Iterable): # pragma: no branch
570-
for item in content: # type: ignore[reportUnknownMemberType]
569+
elif isinstance(possible_event, Iterable): # pragma: no branch
570+
for item in possible_event: # type: ignore[reportUnknownMemberType]
571571
if isinstance(item, BaseEvent): # pragma: no branch
572572
yield item
573573

tests/test_ag_ui.py

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
SystemPromptPart,
2929
TextPart,
3030
ToolCallPart,
31+
ToolReturn,
3132
ToolReturnPart,
3233
UserPromptPart,
3334
)
@@ -163,24 +164,22 @@ async def send_snapshot() -> StateSnapshotEvent:
163164
)
164165

165166

166-
async def send_custom() -> list[CustomEvent]:
167-
"""Display the recipe to the user.
168-
169-
Returns:
170-
StateSnapshotEvent.
171-
"""
172-
return [
173-
CustomEvent(
174-
type=EventType.CUSTOM,
175-
name='custom_event1',
176-
value={'key1': 'value1'},
177-
),
178-
CustomEvent(
179-
type=EventType.CUSTOM,
180-
name='custom_event2',
181-
value={'key2': 'value2'},
182-
),
183-
]
167+
async def send_custom() -> ToolReturn:
168+
return ToolReturn(
169+
return_value='Done',
170+
metadata=[
171+
CustomEvent(
172+
type=EventType.CUSTOM,
173+
name='custom_event1',
174+
value={'key1': 'value1'},
175+
),
176+
CustomEvent(
177+
type=EventType.CUSTOM,
178+
name='custom_event2',
179+
value={'key2': 'value2'},
180+
),
181+
],
182+
)
184183

185184

186185
def uuid_str() -> str:
@@ -785,7 +784,7 @@ async def stream_function(
785784
'type': 'TOOL_CALL_RESULT',
786785
'messageId': IsStr(),
787786
'toolCallId': tool_call_id,
788-
'content': '[{"type":"CUSTOM","timestamp":null,"raw_event":null,"name":"custom_event1","value":{"key1":"value1"}},{"type":"CUSTOM","timestamp":null,"raw_event":null,"name":"custom_event2","value":{"key2":"value2"}}]',
787+
'content': 'Done',
789788
'role': 'tool',
790789
},
791790
{'type': 'CUSTOM', 'name': 'custom_event1', 'value': {'key1': 'value1'}},

0 commit comments

Comments
 (0)