Skip to content

Commit 4ab236e

Browse files
committed
Validate tool results with return types
We have to do a bunch of annoying type annotation magic to extract the result type of aggregators from from type annotations. A follow-up question here: should we use the aggregator to populate model_input *here*, instead of in Agent? I will leave that to a second PR. The invocation for parsing tools with pydantic validation is: ``` ai.messages.Message.model_validate( data, context=ai.tool_validate_context([weather]), ) ``` It is also now an argument to `ai.agents.ui.ai_sdk.to_messages`. Happy to bikeshed about names.
1 parent f07e50a commit 4ab236e

9 files changed

Lines changed: 685 additions & 20 deletions

File tree

src/ai/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@
102102
tool_result_part,
103103
user_message,
104104
)
105+
from .types.messages import tool_context
105106

106107
__all__ = [
107108
"DEFAULT",
@@ -198,6 +199,7 @@
198199
"text_part",
199200
"thinking",
200201
"tool",
202+
"tool_context",
201203
"tool_message",
202204
"tool_result",
203205
"tool_result_part",

src/ai/agents/agent.py

Lines changed: 84 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
# Use the typing_extensions backport so this works on 3.12 too.
3636
from typing_extensions import TypeVar
3737

38-
from .. import models, types, util
38+
from .. import models, type_utils, types, util
3939
from ..types import builders
4040
from ..types import events as events_
4141
from ..types.messages import MessageBundle
@@ -351,6 +351,67 @@ async def render(prompt: str) -> StreamingTextTool:
351351
"""
352352

353353

354+
def _return_type_from_callable(fn: Callable[..., Any]) -> Any:
355+
try:
356+
return get_type_hints(fn, include_extras=True).get("return")
357+
except Exception:
358+
return None
359+
360+
361+
def _stream_item_type(return_type: Any) -> Any:
362+
resolved = type_utils.resolve_type_alias(return_type)
363+
if typing.get_origin(resolved) is typing.Annotated:
364+
resolved = typing.get_args(resolved)[0]
365+
366+
if typing.get_origin(resolved) is not AsyncGenerator:
367+
return None
368+
369+
args = typing.get_args(resolved)
370+
return args[0] if args else Any
371+
372+
373+
def _aggregator_result_type(
374+
aggregator: Callable[[], events_.Aggregator[Any, Any, Any]] | None,
375+
stream_item_type: Any,
376+
) -> Any:
377+
agg_cls = _aggregator_cls(aggregator)
378+
if agg_cls is None:
379+
return None
380+
381+
args = type_utils.generic_base_args(agg_cls, events_.Aggregator)
382+
if args is None:
383+
return None
384+
385+
item_type, result_type, _model_input_type = args
386+
bindings = type_utils.bind_typevars(item_type, stream_item_type)
387+
return type_utils.replace_typevars(result_type, bindings)
388+
389+
390+
def _tool_result_type_from_return_annotation(
391+
return_type: Any,
392+
aggregator: Callable[[], events_.Aggregator[Any, Any, Any]] | None,
393+
) -> Any:
394+
"""Return the type used to validate ``ToolResultPart.result``.
395+
396+
Normal tools store the function's return value directly, so the function's
397+
return annotation is the result type.
398+
399+
Async-generator tools store ``aggregator.snapshot()`` instead. For those,
400+
read the ``Result`` parameter from ``Aggregator[Item, Result, ModelInput]``
401+
on the configured aggregator class, binding generic aggregators from the
402+
stream item type when possible (for example ``StreamingStatusTool[str]`` +
403+
``LastAggregator[T]`` becomes ``str | None``).
404+
"""
405+
if return_type is None:
406+
return None
407+
408+
item_type = _stream_item_type(return_type)
409+
if item_type is not None:
410+
return _aggregator_result_type(aggregator, item_type)
411+
412+
return return_type
413+
414+
354415
def _aggregate_from_return_type(fn: Callable[..., Any]) -> Aggregate | None:
355416
"""Find an ``Aggregate`` marker in *fn*'s return-type metadata, if any.
356417
@@ -360,11 +421,7 @@ def _aggregate_from_return_type(fn: Callable[..., Any]) -> Aggregate | None:
360421
* a PEP 695 alias ``type Foo = Annotated[X, Aggregate(...)]``,
361422
* a parameterized alias ``type Foo[T] = Annotated[X[T], Aggregate(...)]``.
362423
"""
363-
try:
364-
hints = get_type_hints(fn, include_extras=True)
365-
except Exception:
366-
return None
367-
ret = hints.get("return")
424+
ret = _return_type_from_callable(fn)
368425
if ret is None:
369426
return None
370427

@@ -398,6 +455,7 @@ class AgentTool:
398455
validator: type[pydantic.BaseModel] | None = None
399456
is_gen: bool = False
400457
aggregator: Callable[[], events_.Aggregator[Any, Any, Any]] | None = None
458+
return_type: Any = None
401459

402460
@property
403461
def name(self) -> str:
@@ -435,7 +493,17 @@ def tool[**P, T](fn: Callable[P, AsyncGenerator[T]], /) -> AgentTool: ...
435493

436494
@overload
437495
def tool[**P](
438-
*, require_approval: bool
496+
*,
497+
require_approval: bool,
498+
return_type: Any = None,
499+
) -> Callable[[Callable[P, Any]], AgentTool]: ...
500+
501+
502+
@overload
503+
def tool[**P](
504+
*,
505+
return_type: Any,
506+
require_approval: bool = False,
439507
) -> Callable[[Callable[P, Any]], AgentTool]: ...
440508

441509

@@ -444,6 +512,7 @@ def tool[**P](
444512
*,
445513
aggregator: Callable[[], events_.Aggregator[Any, Any, Any]],
446514
require_approval: bool = False,
515+
return_type: Any = None,
447516
) -> Callable[[Callable[P, AsyncGenerator[Any]]], AgentTool]: ...
448517

449518

@@ -455,6 +524,7 @@ def tool[**P, T, R](
455524
*,
456525
aggregator: Callable[[], events_.Aggregator[Any, Any, Any]] | None = None,
457526
require_approval: bool = False,
527+
return_type: Any = None,
458528
) -> (
459529
Callable[[Callable[P, AsyncGenerator[Any]]], AgentTool]
460530
| Callable[[Callable[P, Awaitable[R]]], AgentTool]
@@ -466,7 +536,8 @@ def tool[**P, T, R](
466536
``aggregator=`` keyword argument or by annotating the return type
467537
with an :class:`Aggregate` marker (e.g. via the :data:`SubAgentTool`
468538
or :data:`StreamingStatusTool` aliases). Specifying both raises
469-
``TypeError``.
539+
``TypeError``. Pass ``return_type=`` to override the type used when
540+
validating round-tripped tool results.
470541
"""
471542

472543
def wrap(fn: Any) -> AgentTool:
@@ -483,6 +554,7 @@ def wrap(fn: Any) -> AgentTool:
483554

484555
validator = pydantic.create_model(f"{fn.__name__}_Args", **fields)
485556

557+
annotated_return_type = _return_type_from_callable(fn)
486558
annotated_aggregate = _aggregate_from_return_type(fn)
487559
if annotated_aggregate is not None and aggregator is not None:
488560
raise TypeError(
@@ -508,6 +580,10 @@ def wrap(fn: Any) -> AgentTool:
508580
validator=validator,
509581
is_gen=inspect.isasyncgenfunction(fn),
510582
aggregator=effective_aggregator,
583+
return_type=return_type
584+
or _tool_result_type_from_return_annotation(
585+
annotated_return_type, effective_aggregator
586+
),
511587
)
512588

513589
if fn is None:

src/ai/agents/ui/ai_sdk/inbound_messages.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import json
1010
import logging
11-
from typing import Any
11+
from typing import TYPE_CHECKING, Any
1212

1313
from ....types import messages as messages_
1414
from ....types.messages import MessageBundle
@@ -17,6 +17,9 @@
1717
from .approvals import ApprovalResponse, extract_approvals
1818
from .tool_utils import normalize_tool_args
1919

20+
if TYPE_CHECKING:
21+
from ...agent import AgentTool
22+
2023
logger = logging.getLogger(__name__)
2124

2225

@@ -64,6 +67,7 @@ def _build_result_part(
6467
output: Any,
6568
is_error: bool,
6669
kind_hint: str | None = None,
70+
validate_context: dict[str, Any] | None = None,
6771
) -> messages_.ToolResultPart:
6872
"""Reconstruct a tool result from its wire form.
6973
@@ -93,16 +97,21 @@ def _build_result_part(
9397
else ui_messages_.UIMessage.model_validate(m)
9498
for m in raw
9599
]
96-
result = MessageBundle(messages=tuple(_parse(ui_msgs)))
100+
result = MessageBundle(
101+
messages=tuple(_parse(ui_msgs, validate_context=validate_context))
102+
)
97103
result_kind = "special"
98104
else:
99105
result = _normalize_tool_result(output)
100106
result_kind = "json"
101-
return messages_.ToolResultPart(
102-
tool_call_id=tool_call_id,
103-
tool_name=tool_name,
104-
result=result,
105-
result_kind=result_kind,
107+
return messages_.ToolResultPart.model_validate(
108+
dict( # noqa: C408
109+
tool_call_id=tool_call_id,
110+
tool_name=tool_name,
111+
result=result,
112+
result_kind=result_kind,
113+
),
114+
context=validate_context,
106115
)
107116

108117

@@ -205,6 +214,8 @@ def _patch_pending_hook_aborts(
205214

206215
def _parse(
207216
ui_messages: list[ui_messages_.UIMessage],
217+
*,
218+
validate_context: dict[str, Any] | None = None,
208219
) -> list[messages_.Message]:
209220
result: list[messages_.Message] = []
210221

@@ -276,6 +287,7 @@ def _parse(
276287
kind_hint=result_kinds.get(
277288
inv.tool_invocation_id
278289
),
290+
validate_context=validate_context,
279291
)
280292
)
281293

@@ -340,6 +352,7 @@ def _parse(
340352
output=_tool_result_output(tp),
341353
is_error=is_error,
342354
kind_hint=result_kinds.get(tp.tool_call_id),
355+
validate_context=validate_context,
343356
)
344357
)
345358
if tp.result_provider_metadata is not None:
@@ -507,6 +520,8 @@ def _split_assistant_parts(
507520

508521
def to_messages(
509522
ui_messages: list[ui_messages_.UIMessage],
523+
*,
524+
tools: list[AgentTool] | None = None,
510525
) -> tuple[list[messages_.Message], list[ApprovalResponse]]:
511526
"""Parse a UI request into runtime messages + extracted approvals.
512527
@@ -526,11 +541,14 @@ def to_messages(
526541
resolutions via :func:`apply_approvals` before calling
527542
:meth:`Agent.run` if the run should resume from a hook.
528543
"""
544+
validate_context = (
545+
messages_.tool_context(tools) if tools is not None else None
546+
)
529547
normalized = _normalize_ui_messages(ui_messages)
530548
approval_responses = extract_approvals(normalized)
531549
messages = [
532550
m
533-
for m in _parse(normalized)
551+
for m in _parse(normalized, validate_context=validate_context)
534552
if not approvals.is_resolved_approval_message(m)
535553
]
536554
_patch_pending_hook_aborts(messages, approval_responses)

0 commit comments

Comments
 (0)