Skip to content

Commit 81bb8d2

Browse files
Pavanmanikanta98pavandsfacciniDouweM
authored
Support Agent(output_type=str | None) for optional output (#4042)
Co-authored-by: pavan <pavanmanikanta98@gmail.com> Co-authored-by: David <64162682+dsfaccini@users.noreply.github.com> Co-authored-by: Douwe Maan <douwe@pydantic.dev> Co-authored-by: Douwe Maan <hi@douwe.me>
1 parent 5cead88 commit 81bb8d2

6 files changed

Lines changed: 428 additions & 6 deletions

File tree

docs/output.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,44 @@ Once upon a time, in a hidden underwater cave, lived a curious axolotl named Pip
675675
"""
676676
```
677677

678+
## Optional output (allowing `None`) {#optional-output}
679+
680+
Some agents perform their work entirely through tool calls and don't need to produce a final output — for example, an agent that updates a record via a tool and then stops. Certain models (notably [Anthropic](models/anthropic.md)) will return an empty response in this case, which by default causes Pydantic AI to retry until the model produces content.
681+
682+
To instead treat an empty response as a successful run, include `None` in the `output_type`:
683+
684+
```python {title="optional_output.py"}
685+
from pydantic_ai import Agent
686+
687+
agent = Agent('anthropic:claude-opus-4-6', output_type=str | None)
688+
689+
690+
@agent.tool_plain
691+
def mark_task_done(task_id: int) -> str:
692+
"""Mark the task as done."""
693+
return f'Task {task_id} marked done.'
694+
695+
696+
result = agent.run_sync('Mark task 1 as done, then stop without saying anything.')
697+
print(result.output)
698+
#> None
699+
```
700+
701+
When the model returns an empty response and `None` is an allowed output type, the agent will return `None` instead of retrying. [Output validator functions](#output-validator-functions) still run with `None` as the argument, so you can raise [`ModelRetry`][pydantic_ai.exceptions.ModelRetry] to reject it if needed.
702+
703+
`output_type=str | None` is the canonical case: it's handled as regular text output, and the **only** way the model signals `None` is by returning an empty response — there's no output tool or structured schema involved. This mirrors how plain `str` is already treated specially as free-form text output rather than a structured tool call.
704+
705+
`None` is also supported in the other output modes, with an extra structured commit path in addition to (or in place of) the empty-response fallback:
706+
707+
- **Bare unions including `None` that use tool mode** — e.g. `output_type=int | None`, `output_type=[int, float, None]`, or `output_type=[ToolOutput(Foo), None]`: a dedicated `final_result_NoneType` output tool is exposed alongside the other output tools, so the model can commit to `None` through a tool call. An empty model response is still also treated as `None`, as with `str | None`.
708+
- **Explicit output mode markers** — e.g. `output_type=ToolOutput(int | None)`, `output_type=NativeOutput([int, None])`, or `output_type=PromptedOutput([int, None])`: `None` is included as a branch of the structured schema the wrapper generates. The model commits by calling the tool with `null` (for `ToolOutput`) or by selecting the `NoneType` branch of the discriminated schema (for `NativeOutput`/`PromptedOutput`). An empty response is **not** accepted — once you've opted into an explicit structured output mode, the model is expected to commit through the schema.
709+
710+
!!! note
711+
`output_type=None` on its own is not valid — at least one other output type must be provided alongside `None`.
712+
713+
!!! note
714+
When using [`agent.run_stream()`][pydantic_ai.Agent.run_stream] with an optional output type, an empty model response has no intermediate values to yield, so [`stream_output()`][pydantic_ai.result.StreamedRunResult.stream_output] produces an empty iterator in this case. Use [`get_output()`][pydantic_ai.result.StreamedRunResult.get_output] to retrieve the final `None` value instead.
715+
678716
## Streamed Results
679717

680718
There two main challenges with streamed results:

pydantic_ai_slim/pydantic_ai/_agent_graph.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1028,6 +1028,19 @@ async def _run_stream() -> AsyncIterator[_messages.HandleResponseEvent]: # noqa
10281028

10291029
raise exceptions.ContentFilterError(message, body=body)
10301030

1031+
# If the output type allows None, an empty response is a valid result.
1032+
if is_empty and output_schema.allows_none:
1033+
run_context = _build_output_run_context(ctx)
1034+
try:
1035+
result_data = await _run_output_validators(ctx, cast(NodeRunEndT, None), run_context)
1036+
self._next_node = self._handle_final_result(ctx, result.FinalResult(result_data), [])
1037+
except ToolRetryError as e:
1038+
ctx.state.increment_retries(ctx.deps.max_result_retries, error=e)
1039+
self._next_node = ModelRequestNode[DepsT, NodeRunEndT](
1040+
_messages.ModelRequest(parts=[e.tool_retry])
1041+
)
1042+
return
1043+
10311044
# Try to recover text from a previous model response.
10321045
# This handles the case where the model returned text alongside tool calls
10331046
# (so the text was discarded in favor of executing the tools) and subsequently

pydantic_ai_slim/pydantic_ai/_output.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from collections.abc import Awaitable, Callable, Sequence
77
from copy import copy
88
from dataclasses import dataclass, field, replace
9+
from types import NoneType
910
from typing import TYPE_CHECKING, Any, Generic, Literal, cast, overload
1011

1112
from pydantic import Json, TypeAdapter, ValidationError
@@ -213,6 +214,7 @@ async def validate(
213214

214215
@dataclass(kw_only=True)
215216
class OutputSchema(ABC, Generic[OutputDataT]):
217+
allows_none: bool
216218
text_processor: BaseOutputProcessor[OutputDataT] | None = None
217219
toolset: OutputToolset[Any] | None = None
218220
object_def: OutputObjectDefinition | None = None
@@ -239,6 +241,13 @@ def build( # noqa: C901
239241
"""Build an OutputSchema dataclass from an output type."""
240242
outputs = _flatten_output_spec(output_spec)
241243

244+
# `str | None` produces NoneType (the class) via get_union_args; bare `None` value produces None itself
245+
allows_none = NoneType in outputs or None in outputs
246+
if allows_none:
247+
outputs = [output for output in outputs if output is not NoneType and output is not None]
248+
if len(outputs) == 0:
249+
raise UserError('At least one output type must be provided other than `None`.')
250+
242251
allows_deferred_tools = DeferredToolRequests in outputs
243252
if allows_deferred_tools:
244253
outputs = [output for output in outputs if output is not DeferredToolRequests]
@@ -274,6 +283,7 @@ def build( # noqa: C901
274283
),
275284
allows_deferred_tools=allows_deferred_tools,
276285
allows_image=allows_image,
286+
allows_none=allows_none,
277287
)
278288
elif output := next((output for output in outputs if isinstance(output, PromptedOutput)), None): # pyright: ignore[reportUnknownVariableType,reportUnknownArgumentType]
279289
if len(outputs) > 1:
@@ -299,6 +309,7 @@ def build( # noqa: C901
299309
),
300310
allows_deferred_tools=allows_deferred_tools,
301311
allows_image=allows_image,
312+
allows_none=allows_none,
302313
)
303314

304315
text_outputs: Sequence[type[str] | TextOutput[OutputDataT]] = []
@@ -320,6 +331,12 @@ def build( # noqa: C901
320331
else:
321332
other_outputs.append(output)
322333

334+
# If `None` is allowed and we're building output tools, expose `NoneType` as its own
335+
# output tool so the model can commit to `None` through the structured schema alongside
336+
# any other output types, matching how the model would pick between them.
337+
if allows_none and (tool_outputs or other_outputs):
338+
other_outputs.append(cast(OutputTypeOrFunction[OutputDataT], NoneType))
339+
323340
toolset = OutputToolset.build(tool_outputs + other_outputs, name=name, description=description, strict=strict)
324341

325342
text_processor: BaseOutputProcessor[OutputDataT] | None = None
@@ -340,17 +357,22 @@ def build( # noqa: C901
340357
text_processor=text_processor,
341358
allows_deferred_tools=allows_deferred_tools,
342359
allows_image=allows_image,
360+
allows_none=allows_none,
343361
)
344362
else:
345363
return TextOutputSchema(
346364
text_processor=text_processor,
347365
allows_deferred_tools=allows_deferred_tools,
348366
allows_image=allows_image,
367+
allows_none=allows_none,
349368
)
350369

351370
if len(tool_outputs) > 0:
352371
return ToolOutputSchema(
353-
toolset=toolset, allows_deferred_tools=allows_deferred_tools, allows_image=allows_image
372+
toolset=toolset,
373+
allows_deferred_tools=allows_deferred_tools,
374+
allows_image=allows_image,
375+
allows_none=allows_none,
354376
)
355377

356378
if len(other_outputs) > 0:
@@ -359,10 +381,11 @@ def build( # noqa: C901
359381
toolset=toolset,
360382
allows_deferred_tools=allows_deferred_tools,
361383
allows_image=allows_image,
384+
allows_none=allows_none,
362385
)
363386

364387
if allows_image:
365-
return ImageOutputSchema(allows_deferred_tools=allows_deferred_tools)
388+
return ImageOutputSchema(allows_deferred_tools=allows_deferred_tools, allows_none=allows_none)
366389

367390
raise UserError('At least one output type must be provided.')
368391

@@ -390,6 +413,7 @@ def __init__(
390413
toolset: OutputToolset[Any] | None,
391414
allows_deferred_tools: bool,
392415
allows_image: bool,
416+
allows_none: bool,
393417
):
394418
# We set a toolset here as they're checked for name conflicts with other toolsets in the Agent constructor.
395419
# At that point we may not know yet what output mode we're going to use if no model was provided or it was deferred until agent.run time,
@@ -400,6 +424,7 @@ def __init__(
400424
text_processor=processor,
401425
allows_deferred_tools=allows_deferred_tools,
402426
allows_image=allows_image,
427+
allows_none=allows_none,
403428
)
404429
self.processor = processor
405430

@@ -416,11 +441,13 @@ def __init__(
416441
text_processor: TextOutputProcessor[OutputDataT],
417442
allows_deferred_tools: bool,
418443
allows_image: bool,
444+
allows_none: bool,
419445
):
420446
super().__init__(
421447
text_processor=text_processor,
422448
allows_deferred_tools=allows_deferred_tools,
423449
allows_image=allows_image,
450+
allows_none=allows_none,
424451
)
425452

426453
@property
@@ -429,8 +456,8 @@ def mode(self) -> OutputMode:
429456

430457

431458
class ImageOutputSchema(OutputSchema[OutputDataT]):
432-
def __init__(self, *, allows_deferred_tools: bool):
433-
super().__init__(allows_deferred_tools=allows_deferred_tools, allows_image=True)
459+
def __init__(self, *, allows_deferred_tools: bool, allows_none: bool):
460+
super().__init__(allows_deferred_tools=allows_deferred_tools, allows_image=True, allows_none=allows_none)
434461

435462
@property
436463
def mode(self) -> OutputMode:
@@ -449,12 +476,14 @@ def __init__(
449476
processor: BaseObjectOutputProcessor[OutputDataT],
450477
allows_deferred_tools: bool,
451478
allows_image: bool,
479+
allows_none: bool,
452480
):
453481
super().__init__(
454482
text_processor=processor,
455483
object_def=processor.object_def,
456484
allows_deferred_tools=allows_deferred_tools,
457485
allows_image=allows_image,
486+
allows_none=allows_none,
458487
)
459488
self.processor = processor
460489
self.template = template
@@ -496,12 +525,14 @@ def __init__(
496525
text_processor: BaseOutputProcessor[OutputDataT] | None = None,
497526
allows_deferred_tools: bool,
498527
allows_image: bool,
528+
allows_none: bool,
499529
):
500530
super().__init__(
501531
toolset=toolset,
502532
allows_deferred_tools=allows_deferred_tools,
503533
text_processor=text_processor,
504534
allows_image=allows_image,
535+
allows_none=allows_none,
505536
)
506537

507538
@property

0 commit comments

Comments
 (0)