Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
87 commits
Select commit Hold shift + click to select a range
543d414
initial commit
adtyavrdhn Dec 3, 2025
c5686de
Removing from here for the moment
adtyavrdhn Dec 3, 2025
8d9d9b9
Adding prompt_templates to public APIs in Agent.run family
adtyavrdhn Dec 4, 2025
4968c1f
lint
adtyavrdhn Dec 4, 2025
5d04126
docstring
adtyavrdhn Dec 4, 2025
b901be7
fix
adtyavrdhn Dec 4, 2025
ea4a9b8
remove test
adtyavrdhn Dec 4, 2025
933022f
removing unused part
adtyavrdhn Dec 4, 2025
6cc9b1d
fixing test
adtyavrdhn Dec 4, 2025
c8ebcea
format
adtyavrdhn Dec 4, 2025
7eaa90b
fix
adtyavrdhn Dec 5, 2025
16c4d92
Adding return kind to ToolReturnPart
adtyavrdhn Dec 6, 2025
edb115a
adding docstring
adtyavrdhn Dec 6, 2025
c1d77cf
lint
adtyavrdhn Dec 6, 2025
e8de0b3
Fix tests + dbos and temporal implementation of runs with prompt_temp…
adtyavrdhn Dec 6, 2025
086e035
Fix tests + dbos and temporal implementation of runs with prompt_temp…
adtyavrdhn Dec 6, 2025
f23b841
Fix tests
adtyavrdhn Dec 6, 2025
1ef50f3
merge
adtyavrdhn Dec 6, 2025
acc5420
useless diff
adtyavrdhn Dec 6, 2025
a34c391
useless diff
adtyavrdhn Dec 6, 2025
5920092
lint
adtyavrdhn Dec 6, 2025
4ac181f
fix prefect
adtyavrdhn Dec 6, 2025
ef8cc54
lint
adtyavrdhn Dec 6, 2025
874d70e
fix
adtyavrdhn Dec 6, 2025
c4ef9ba
rolling back vercel adapter return kind
adtyavrdhn Dec 6, 2025
71608af
fix test
adtyavrdhn Dec 6, 2025
e368175
RunContext type
adtyavrdhn Dec 6, 2025
e7fc0c9
RunContext type
adtyavrdhn Dec 6, 2025
eefe430
fix test
adtyavrdhn Dec 6, 2025
d2d0498
fix test
adtyavrdhn Dec 6, 2025
d4a0c2d
fix test + coverage
adtyavrdhn Dec 6, 2025
2e8a1f2
fix lint
adtyavrdhn Dec 6, 2025
f8b5026
fix test
adtyavrdhn Dec 6, 2025
b3632b7
fix test
adtyavrdhn Dec 6, 2025
9bebf4f
lint
adtyavrdhn Dec 6, 2025
59981c1
renaming variable
adtyavrdhn Dec 6, 2025
987293e
removing useless comment
adtyavrdhn Dec 9, 2025
400b34e
Merge branch 'main' of https://github.com/pydantic/pydantic-ai into c…
adtyavrdhn Dec 9, 2025
3c6ea8e
Merge branch 'main' of https://github.com/pydantic/pydantic-ai into c…
adtyavrdhn Dec 12, 2025
def1747
rolling back from __repr__
adtyavrdhn Dec 12, 2025
74c6e23
removing mutating of message history without copy(ruining history)
adtyavrdhn Dec 12, 2025
9aadb71
moving prompt_templates to a diff file
adtyavrdhn Dec 12, 2025
41f4f2b
Using class default values for init of content
adtyavrdhn Dec 12, 2025
8253d8f
Moving tool call denied
adtyavrdhn Dec 12, 2025
09b2597
removing prompt_templates from messages.py
adtyavrdhn Dec 12, 2025
0477465
lint
adtyavrdhn Dec 12, 2025
f5fb994
keep prompt_templates non-able, read default values off of the class …
adtyavrdhn Dec 12, 2025
b6415aa
fixing ToolDenied
adtyavrdhn Dec 12, 2025
339ea74
Moving to a default instance instead of reading class variables
adtyavrdhn Dec 12, 2025
8141c3a
fixing tooldenied overwritten by prompt_template
adtyavrdhn Dec 12, 2025
fee446d
fixing string in tool denied message
adtyavrdhn Dec 12, 2025
45dff51
tool return kind in google
adtyavrdhn Dec 12, 2025
0f729f0
Adding handling for retry prompt templates
adtyavrdhn Dec 12, 2025
3570d40
Removing retry_prompt for more granular controls
adtyavrdhn Dec 12, 2025
946a20b
fixing test snapshots
adtyavrdhn Dec 12, 2025
454bda1
better test string
adtyavrdhn Dec 12, 2025
da87aa5
lint fix
adtyavrdhn Dec 12, 2025
539be42
Merge branch 'main' of https://github.com/pydantic/pydantic-ai into c…
adtyavrdhn Dec 12, 2025
65e1321
fix test
adtyavrdhn Dec 12, 2025
1ef3ddc
lint fix
adtyavrdhn Dec 12, 2025
05d031e
fixing test for retry prompt part, adding default value
adtyavrdhn Dec 12, 2025
6723457
fixing test for retry prompt part, adding default value
adtyavrdhn Dec 12, 2025
e28a4ff
fixing test for retry prompt part, adding default value
adtyavrdhn Dec 12, 2025
a31598b
adding PromptOutput
adtyavrdhn Dec 12, 2025
04b6f14
fixing docs
adtyavrdhn Dec 12, 2025
c979029
coverage for tool-denied message
adtyavrdhn Dec 12, 2025
201d7f6
coverage for prompted output
adtyavrdhn Dec 12, 2025
cdc477d
cleanup
adtyavrdhn Dec 12, 2025
81755bb
lint cleanup, skeptical about cov after refactor
adtyavrdhn Dec 12, 2025
7df0a25
coverage
adtyavrdhn Dec 12, 2025
165e795
adding comment
adtyavrdhn Dec 13, 2025
f39cc66
Adding PromptConfig, composition templates inside PromptConfig, can a…
adtyavrdhn Dec 13, 2025
90bfab2
Revamping of PreparedToolSet to allow using tool_config as well
adtyavrdhn Dec 13, 2025
c91b2ab
merge
adtyavrdhn Dec 13, 2025
12c3ad6
merge conflicts ughh
adtyavrdhn Dec 13, 2025
4069ad8
fixes
adtyavrdhn Dec 13, 2025
378d0e6
test fixes
adtyavrdhn Dec 13, 2025
2c1fe89
docs
adtyavrdhn Dec 13, 2025
2c74c5a
fixing not passing prompt_config via iter
adtyavrdhn Dec 13, 2025
c36ee12
better test for tool config overriding check
adtyavrdhn Dec 13, 2025
2bde52a
lint cleanup
adtyavrdhn Dec 13, 2025
18b2fa8
fixing order
adtyavrdhn Dec 13, 2025
ca0c29c
fixing doc
adtyavrdhn Dec 13, 2025
e5285f0
fixing doc
adtyavrdhn Dec 13, 2025
ecf13ce
changes for coverage
adtyavrdhn Dec 13, 2025
60c1f89
changes for coverage
adtyavrdhn Dec 13, 2025
b0aa837
toolconfig could be none
adtyavrdhn Dec 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ The [`Agent`][pydantic_ai.Agent] class has full API documentation, but conceptua
| [Dependency type constraint](dependencies.md) | Dynamic instructions functions, tools, and output functions may all use dependencies when they're run. |
| [LLM model](api/models/base.md) | Optional default LLM model associated with the agent. Can also be specified when running the agent. |
| [Model Settings](#additional-configuration) | Optional default model settings to help fine tune requests. Can also be specified when running the agent. |
| [Prompt Configuration](#prompt-configuration) | Optional configuration for customizing system-generated messages, tool descriptions, and retry prompts. |

In typing terms, agents are generic in their dependency and output types, e.g., an agent which required dependencies of type `#!python Foobar` and produced outputs of type `#!python list[str]` would have type `Agent[Foobar, list[str]]`. In practice, you shouldn't need to care about this, it should just mean your IDE can tell you when you have the right type, and if you choose to use [static type checking](#static-type-checking) it should work well with Pydantic AI.

Expand Down Expand Up @@ -751,6 +752,125 @@ except UnexpectedModelBehavior as e:

1. This error is raised because the safety thresholds were exceeded.

### Prompt Configuration

Pydantic AI provides [`PromptConfig`][pydantic_ai.PromptConfig] to customize the system-generated messages
that are sent to models during agent runs. This includes retry prompts, tool return confirmations,
validation error messages, and tool descriptions.

#### Customizing System Messages with PromptTemplates

[`PromptTemplates`][pydantic_ai.PromptTemplates] allows you to override the default messages that Pydantic AI
sends to the model for retries, tool results, and other system-generated content.

```python {title="prompt_templates_example.py"}
from pydantic_ai import Agent, PromptConfig, PromptTemplates

# Using static strings
agent = Agent(
'openai:gpt-5',
prompt_config=PromptConfig(
templates=PromptTemplates(
validation_errors_retry='Please correct the validation errors and try again.',
final_result_processed='Result received successfully.',
),
),
)
```

You can also use callable functions for dynamic messages that have access to the message part
and the [`RunContext`][pydantic_ai.RunContext]:

```python {title="prompt_templates_dynamic.py"}
from pydantic_ai import Agent, PromptConfig, PromptTemplates
from pydantic_ai.messages import RetryPromptPart
from pydantic_ai.tools import RunContext


def custom_retry_message(part: RetryPromptPart, ctx: RunContext) -> str:
return f'Attempt #{ctx.retries + 1}: Please fix the errors and try again.'

agent = Agent(
'openai:gpt-5',
prompt_config=PromptConfig(
templates=PromptTemplates(
validation_errors_retry=custom_retry_message,
),
),
)
```

The available template fields in [`PromptTemplates`][pydantic_ai.PromptTemplates] include:

| Template Field | Description |
|----------------|-------------|
| `final_result_processed` | Confirmation message when a final result is successfully processed |
| `output_tool_not_executed` | Message when an output tool call is skipped because a result was already found |
| `function_tool_not_executed` | Message when a function tool call is skipped because a result was already found |
| `tool_call_denied` | Message when a tool call is denied by an approval handler |
| `validation_errors_retry` | Message appended to validation errors when asking the model to retry |
| `model_retry_string_tool` | Message when a `ModelRetry` exception is raised from a tool |
| `model_retry_string_no_tool` | Message when a `ModelRetry` exception is raised outside of a tool context |

#### Customizing Tool Descriptions with ToolConfig

[`ToolConfig`][pydantic_ai.ToolConfig] allows you to override tool descriptions at runtime without modifying
the original tool definitions. This is useful when you want to provide different descriptions for the same
tool in different contexts or agent runs.

```python {title="tool_config_example.py"}
from pydantic_ai import Agent, PromptConfig, ToolConfig

agent = Agent(
'openai:gpt-5',
prompt_config=PromptConfig(
tool_config=ToolConfig(
tool_descriptions={
'search_database': 'Search the customer database for user records by name or email.',
'send_notification': 'Send an urgent notification to the user via their preferred channel.',
}
),
),
)


@agent.tool_plain
def search_database(query: str) -> list[str]:
"""Original description that will be overridden."""
return ['result1', 'result2']


@agent.tool_plain
def send_notification(user_id: str, message: str) -> bool:
"""Original description that will be overridden."""
return True
```

You can also override `prompt_config` at runtime using the `prompt_config` parameter in the run methods,
or temporarily using [`agent.override()`][pydantic_ai.Agent.override]:

```python {title="prompt_config_override.py"}
from pydantic_ai import Agent, PromptConfig, PromptTemplates

agent = Agent('openai:gpt-5')

# Override at runtime
result = agent.run_sync(
'Hello',
prompt_config=PromptConfig(
templates=PromptTemplates(validation_errors_retry='Custom retry message for this run.')
),
)

# Or use agent.override() context manager
with agent.override(
prompt_config=PromptConfig(
templates=PromptTemplates(validation_errors_retry='Another custom message.')
)
):
result = agent.run_sync('Hello')
```

## Runs vs. Conversations

An agent **run** might represent an entire conversation — there's no limit to how many messages can be exchanged in a single run. However, a **conversation** might also be composed of multiple runs, especially if you need to maintain state between separate interactions or API calls.
Expand Down Expand Up @@ -1072,6 +1192,7 @@ with capture_run_messages() as messages: # (2)!
tool_name='calc_volume',
tool_call_id='pyd_ai_tool_call_id',
timestamp=datetime.datetime(...),
retry_message='Fix the errors and try again.',
)
],
run_id='...',
Expand Down
9 changes: 9 additions & 0 deletions docs/api/prompt_config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# `pydantic_ai.prompt_config`

::: pydantic_ai.prompt_config
options:
inherited_members: true
members:
- PromptConfig
- PromptTemplates
- ToolConfig
5 changes: 5 additions & 0 deletions docs/deferred-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ print(result.all_messages())
content="File 'README.md' updated: 'Hello, world!'",
tool_call_id='update_file_readme',
timestamp=datetime.datetime(...),
return_kind='tool-executed',
)
],
run_id='...',
Expand All @@ -161,12 +162,14 @@ print(result.all_messages())
content="File '.env' updated: ''",
tool_call_id='update_file_dotenv',
timestamp=datetime.datetime(...),
return_kind='tool-executed',
),
ToolReturnPart(
tool_name='delete_file',
content='Deleting files is not allowed',
tool_call_id='delete_file',
timestamp=datetime.datetime(...),
return_kind='tool-denied',
),
UserPromptPart(
content='Now create a backup of README.md',
Expand Down Expand Up @@ -195,6 +198,7 @@ print(result.all_messages())
content="File 'README.md.bak' updated: 'Hello, world!'",
tool_call_id='update_file_backup',
timestamp=datetime.datetime(...),
return_kind='tool-executed',
)
],
run_id='...',
Expand Down Expand Up @@ -348,6 +352,7 @@ async def main():
content=42,
tool_call_id='pyd_ai_tool_call_id',
timestamp=datetime.datetime(...),
return_kind='tool-executed',
)
],
run_id='...',
Expand Down
1 change: 1 addition & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ async def test_forecast():
content='Sunny with a chance of rain',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
return_kind='tool-executed',
),
],
run_id=IsStr(),
Expand Down
2 changes: 2 additions & 0 deletions docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ print(dice_result.all_messages())
content='4',
tool_call_id='pyd_ai_tool_call_id',
timestamp=datetime.datetime(...),
return_kind='tool-executed',
)
],
run_id='...',
Expand All @@ -130,6 +131,7 @@ print(dice_result.all_messages())
content='Anne',
tool_call_id='pyd_ai_tool_call_id',
timestamp=datetime.datetime(...),
return_kind='tool-executed',
)
],
run_id='...',
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ nav:
- api/models/test.md
- api/models/wrapper.md
- api/profiles.md
- api/prompt_config.md
- api/providers.md
- api/retries.md
- api/run.md
Expand Down
5 changes: 5 additions & 0 deletions pydantic_ai_slim/pydantic_ai/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
ModelProfile,
ModelProfileSpec,
)
from .prompt_config import PromptConfig, PromptTemplates, ToolConfig
from .run import AgentRun, AgentRunResult, AgentRunResultEvent
from .settings import ModelSettings
from .tools import DeferredToolRequests, DeferredToolResults, RunContext, Tool, ToolApproved, ToolDefinition, ToolDenied
Expand Down Expand Up @@ -229,6 +230,10 @@
'PromptedOutput',
'TextOutput',
'StructuredDict',
# prompt_config
'PromptConfig',
'PromptTemplates',
'ToolConfig',
# format_prompt
'format_as_xml',
# settings
Expand Down
61 changes: 52 additions & 9 deletions pydantic_ai_slim/pydantic_ai/_agent_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,16 @@
from pydantic_graph.beta import Graph, GraphBuilder
from pydantic_graph.nodes import End, NodeRunEndT

from . import _output, _system_prompt, exceptions, messages as _messages, models, result, usage as _usage
from . import (
_output,
_system_prompt,
exceptions,
messages as _messages,
models,
prompt_config as _prompt_config,
result,
usage as _usage,
)
from .exceptions import ToolRetryError
from .output import OutputDataT, OutputSpec
from .settings import ModelSettings
Expand Down Expand Up @@ -133,6 +142,9 @@ class GraphAgentDeps(Generic[DepsT, OutputDataT]):

model: models.Model
model_settings: ModelSettings | None
prompt_config: _prompt_config.PromptConfig = dataclasses.field(
default_factory=lambda: _prompt_config.DEFAULT_PROMPT_CONFIG
)
usage_limits: _usage.UsageLimits
max_result_retries: int
end_strategy: EndStrategy
Expand Down Expand Up @@ -379,9 +391,8 @@ async def _prepare_request_parameters(
"""Build tools and create an agent model."""
output_schema = ctx.deps.output_schema

prompted_output_template = (
output_schema.template if isinstance(output_schema, _output.PromptedOutputSchema) else None
)
prompt_config = ctx.deps.prompt_config
prompted_output_template = prompt_config.templates.get_prompted_output_template(output_schema)

function_tools: list[ToolDefinition] = []
output_tools: list[ToolDefinition] = []
Expand Down Expand Up @@ -504,6 +515,14 @@ async def _prepare_request(
# Update the new message index to ensure `result.new_messages()` returns the correct messages
ctx.deps.new_message_index -= len(original_history) - len(message_history)

prompt_config = ctx.deps.prompt_config

message_history = _apply_prompt_templates_to_message_history(
message_history, prompt_config.templates, run_context
)

ctx.state.message_history[:] = message_history

# Merge possible consecutive trailing `ModelRequest`s into one, with tool call parts before user parts,
# but don't store it in the message history on state. This is just for the benefit of model classes that want clear user/assistant boundaries.
# See `tests/test_tools.py::test_parallel_tool_return_with_deferred` for an example where this is necessary
Expand Down Expand Up @@ -780,6 +799,8 @@ def _handle_final_result(

# For backwards compatibility, append a new ModelRequest using the tool returns and retries
if tool_responses:
run_ctx = build_run_context(ctx)
tool_responses = [ctx.deps.prompt_config.templates.apply_template(part, run_ctx) for part in tool_responses]
messages.append(_messages.ModelRequest(parts=tool_responses, run_id=ctx.state.run_id))

return End(final_result)
Expand Down Expand Up @@ -865,17 +886,19 @@ async def process_tool_calls( # noqa: C901
if final_result and final_result.tool_call_id == call.tool_call_id:
part = _messages.ToolReturnPart(
tool_name=call.tool_name,
content='Final result processed.',
content=_prompt_config.DEFAULT_PROMPT_CONFIG.templates.final_result_processed,
tool_call_id=call.tool_call_id,
return_kind='final-result-processed',
)
output_parts.append(part)
# Early strategy is chosen and final result is already set
elif ctx.deps.end_strategy == 'early' and final_result:
yield _messages.FunctionToolCallEvent(call)
part = _messages.ToolReturnPart(
tool_name=call.tool_name,
content='Output tool not used - a final result was already processed.',
content=_prompt_config.DEFAULT_PROMPT_CONFIG.templates.output_tool_not_executed,
tool_call_id=call.tool_call_id,
return_kind='output-tool-not-executed',
)
yield _messages.FunctionToolResultEvent(part)
output_parts.append(part)
Expand Down Expand Up @@ -916,8 +939,9 @@ async def process_tool_calls( # noqa: C901
else:
part = _messages.ToolReturnPart(
tool_name=call.tool_name,
content='Final result processed.',
content=_prompt_config.DEFAULT_PROMPT_CONFIG.templates.final_result_processed,
tool_call_id=call.tool_call_id,
return_kind='final-result-processed',
)
output_parts.append(part)

Expand All @@ -932,8 +956,9 @@ async def process_tool_calls( # noqa: C901
output_parts.append(
_messages.ToolReturnPart(
tool_name=call.tool_name,
content='Tool not executed - a final result was already processed.',
content=_prompt_config.DEFAULT_PROMPT_CONFIG.templates.function_tool_not_executed,
tool_call_id=call.tool_call_id,
return_kind='function-tool-not-executed',
)
)
else:
Expand Down Expand Up @@ -990,8 +1015,9 @@ async def process_tool_calls( # noqa: C901
output_parts.append(
_messages.ToolReturnPart(
tool_name=call.tool_name,
content='Tool not executed - a final result was already processed.',
content=_prompt_config.DEFAULT_PROMPT_CONFIG.templates.function_tool_not_executed,
tool_call_id=call.tool_call_id,
return_kind='function-tool-not-executed',
)
)
elif calls:
Expand Down Expand Up @@ -1148,6 +1174,7 @@ async def _call_tool(
tool_name=tool_call.tool_name,
content=tool_call_result.message,
tool_call_id=tool_call.tool_call_id,
return_kind='tool-denied',
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 not too opposed to having this new field, but I wonder if it's strictly necessary. Since we build the RetryPromptParts in this file, would it be an option to explicitly pass something like content=self.prompt_templates.generate(self.prompt_templates.tool_denied, ctx)?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think we could but I like the idea of this kind in the messages, I think the visibility of the ToolReturnPart's context increases.

), None
elif isinstance(tool_call_result, exceptions.ModelRetry):
m = _messages.RetryPromptPart(
Expand Down Expand Up @@ -1210,6 +1237,7 @@ async def _call_tool(
tool_call_id=tool_call.tool_call_id,
content=tool_return.return_value, # type: ignore
metadata=tool_return.metadata,
return_kind='tool-executed',
)

return return_part, tool_return.content or None
Expand Down Expand Up @@ -1380,3 +1408,18 @@ def _clean_message_history(messages: list[_messages.ModelMessage]) -> list[_mess
else:
clean_messages.append(message)
return clean_messages


def _apply_prompt_templates_to_message_history(
messages: list[_messages.ModelMessage], prompt_templates: _prompt_config.PromptTemplates, ctx: RunContext[Any]
) -> list[_messages.ModelMessage]:
messages_with_templates_applied: list[_messages.ModelMessage] = []

for msg in messages:
if isinstance(msg, _messages.ModelRequest):
parts_template_applied = [prompt_templates.apply_template(part, ctx) for part in msg.parts]
messages_with_templates_applied.append(replace(msg, parts=parts_template_applied))
else:
messages_with_templates_applied.append(msg)

return messages_with_templates_applied
Loading
Loading