Skip to content
21 changes: 3 additions & 18 deletions docs/output.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,26 +237,11 @@ RouterFailure(explanation='I am not equipped to provide travel information, such

#### Text output

If you provide an output function that takes a string, Pydantic AI will by default create an output tool like for any other output function. If instead you'd like the model to provide the string using plain text output, you can wrap the function in the [`TextOutput`][pydantic_ai.output.TextOutput] marker class. If desired, this marker class can be used alongside one or more [`ToolOutput`](#tool-output) marker classes (or unmarked types or functions) in a list provided to `output_type`.
If you provide an output function that takes a string, Pydantic AI will by default create an output tool like for any other output function. If instead you'd like the model to provide the string using plain text output, you can wrap the function in the [`TextOutput`][pydantic_ai.output.TextOutput] marker class.

```python {title="text_output_function.py"}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I didn't mean to remove the example entirely; just the extra RunContext-based on you'd added 😄

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lmao I was like "okay..."

from pydantic_ai import Agent, TextOutput
If desired, this marker class can be used alongside one or more [`ToolOutput`](#tool-output) marker classes (or unmarked types or functions) in a list provided to `output_type`.


def split_into_words(text: str) -> list[str]:
return text.split()


agent = Agent(
'openai:gpt-5',
output_type=TextOutput(split_into_words),
)
result = agent.run_sync('Who was Albert Einstein?')
print(result.output)
#> ['Albert', 'Einstein', 'was', 'a', 'German-born', 'theoretical', 'physicist.']
```

_(This example is complete, it can be run "as is")_
Like other output functions, text output functions can optionally take [`RunContext`][pydantic_ai.tools.RunContext] as the first argument, and can raise [`ModelRetry`][pydantic_ai.exceptions.ModelRetry] to ask the model to try again with modified arguments (or with a different output type).
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Above the example please :) I prefer to explain, then show


### Output modes

Expand Down
14 changes: 8 additions & 6 deletions pydantic_ai_slim/pydantic_ai/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@

from . import _utils, exceptions
from ._json_schema import InlineDefsJsonSchemaTransformer
from ._run_context import RunContext
from .messages import ToolCallPart
from .tools import DeferredToolRequests, ObjectJsonSchema, RunContext, ToolDefinition
from .tools import DeferredToolRequests, ObjectJsonSchema, ToolDefinition

__all__ = (
# classes
Expand All @@ -33,6 +34,7 @@

T = TypeVar('T')
T_co = TypeVar('T_co', covariant=True)
TextOutputAgentDepsT = TypeVar('TextOutputAgentDepsT', default=None, contravariant=True)

OutputDataT = TypeVar('OutputDataT', default=str, covariant=True)
"""Covariant type variable for the output data type of a run."""
Expand Down Expand Up @@ -60,8 +62,8 @@

TextOutputFunc = TypeAliasType(
'TextOutputFunc',
Callable[[RunContext, str], Awaitable[T_co] | T_co] | Callable[[str], Awaitable[T_co] | T_co],
type_params=(T_co,),
Callable[[RunContext[TextOutputAgentDepsT], str], Awaitable[T_co] | T_co] | Callable[[str], Awaitable[T_co] | T_co],
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah looking at this now, since this will only work for TextOutput and not output functions in general, I don't think it's worth the extra (out of order) type var and would prefer to just have RunContext[Any] here and leave it at that

type_params=(T_co, TextOutputAgentDepsT),
)
"""Definition of a function that will be called to process the model's plain text output. The function must take a single string argument.

Expand Down Expand Up @@ -259,7 +261,7 @@ class OutputObjectDefinition:


@dataclass
class TextOutput(Generic[OutputDataT]):
class TextOutput(Generic[OutputDataT, TextOutputAgentDepsT]):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's drop TextOutputAgentDepsT entirely and use RunContext[Any] in TextOutputFunc, as we don't actually use the generic param and it bothers me that it's out of order compared to everything else that takes Deps followed by Output 😄

"""Marker class to use text output for an output function taking a string argument.

Example:
Expand All @@ -281,7 +283,7 @@ def split_into_words(text: str) -> list[str]:
```
"""

output_function: TextOutputFunc[OutputDataT]
output_function: TextOutputFunc[OutputDataT, TextOutputAgentDepsT]
"""The function that will be called to process the model's plain text output. The function must take a single string argument."""


Expand Down Expand Up @@ -354,7 +356,7 @@ def __get_pydantic_json_schema__(

_OutputSpecItem = TypeAliasType(
'_OutputSpecItem',
OutputTypeOrFunction[T_co] | ToolOutput[T_co] | NativeOutput[T_co] | PromptedOutput[T_co] | TextOutput[T_co],
OutputTypeOrFunction[T_co] | ToolOutput[T_co] | NativeOutput[T_co] | PromptedOutput[T_co] | TextOutput[T_co, Any],
type_params=(T_co,),
)

Expand Down
18 changes: 18 additions & 0 deletions tests/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -1167,6 +1167,24 @@ def call_tool(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
)


def test_output_type_text_output_function_with_deps():
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm pretty sure we've already tested somewhere that it works, this PR is just for fixing the type checking, so let's remove the new tests and add some cases to typed_agent.py instead.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can remove this test

"""Test that TextOutput functions can use RunContext with custom deps type."""

@dataclass
class Deps:
prefix: str

def add_prefix_and_split(ctx: RunContext[Deps], text: str) -> list[str]:
return f'{ctx.deps.prefix}: {text}'.split()

def return_text(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='Hello world')])

agent = Agent(FunctionModel(return_text), deps_type=Deps, output_type=TextOutput(add_prefix_and_split))
result = agent.run_sync('test prompt', deps=Deps(prefix='Response'))
assert result.output == snapshot(['Response:', 'Hello', 'world'])


@pytest.mark.parametrize(
'output_type',
[[str, str], [str, TextOutput(upcase)], [TextOutput(upcase), TextOutput(upcase)]],
Expand Down
1 change: 1 addition & 0 deletions tests/test_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ async def call_tool(
'What is the capital of the UK?': 'The capital of the UK is London.',
'What is the capital of Mexico?': 'The capital of Mexico is Mexico City.',
'Who was Albert Einstein?': 'Albert Einstein was a German-born theoretical physicist.',
'Hello world': 'Hello world',
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not needed anymore

'What was his most famous equation?': "Albert Einstein's most famous equation is (E = mc^2).",
'What is the date?': 'Hello Frank, the date today is 2032-01-02.',
'What is this? https://ai.pydantic.dev': 'A Python agent framework for building Generative AI applications.',
Expand Down
9 changes: 9 additions & 0 deletions tests/typed_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,10 @@ def str_to_regex(text: str) -> re.Pattern[str]:
return re.compile(text)


def str_to_regex_with_ctx(ctx: RunContext[int], text: str) -> re.Pattern[str]:
return re.compile(text)


class MyClass:
def my_method(self) -> bool:
return True
Expand Down Expand Up @@ -281,6 +285,11 @@ def my_method(self) -> bool:
# since deps are not set, they default to `None`, so can't be `int`
Agent('test', tools=[Tool(foobar_plain)], deps_type=int) # pyright: ignore[reportArgumentType,reportCallIssue]

# TextOutput with RunContext
Agent('test', output_type=TextOutput(str_to_regex_with_ctx), deps_type=int)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To verify that contravariant=True is the right option, we should also have an example where the deps_type is a subclass of the type on the function's ctx, e.g. bool. That should work, because a function that takes an int is also able to handle a bool (which is essentially 1 or 0.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we move TextOutput(str_to_regex_with_ctx) to a variable and add an assert_type so we can verify its type is inferred correctly?

Agent('test', output_type=TextOutput(str_to_regex_with_ctx), deps_type=str) # pyright: ignore[reportArgumentType,reportCallIssue]
Agent('test', output_type=TextOutput(str_to_regex_with_ctx)) # pyright: ignore[reportArgumentType,reportCallIssue]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can then reuse the var here

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In _OutputSpecItem, we fill in the generic param as Any, so I'm curious if that could cause any issues here because maybe it'll accept any type instead of just same one as deps_type? It'd be interesting to see the typing errors you got here before you add the pyright: ignores. (just pasting them into a GH comment here is fine)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no type errors, I added a NOTE for it


# prepare example from docs:


Expand Down