Skip to content

Commit 6ff3619

Browse files
authored
Remove last run messages (#536)
1 parent 629495c commit 6ff3619

File tree

8 files changed

+206
-115
lines changed

8 files changed

+206
-115
lines changed

docs/agents.md

Lines changed: 71 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -408,10 +408,10 @@ user_id=123 message='Hello John, would you be free for coffee sometime next week
408408

409409
If models behave unexpectedly (e.g., the retry limit is exceeded, or their API returns `503`), agent runs will raise [`UnexpectedModelBehavior`][pydantic_ai.exceptions.UnexpectedModelBehavior].
410410

411-
In these cases, [`agent.last_run_messages`][pydantic_ai.Agent.last_run_messages] can be used to access the messages exchanged during the run to help diagnose the issue.
411+
In these cases, [`capture_run_messages`][pydantic_ai.capture_run_messages] can be used to access the messages exchanged during the run to help diagnose the issue.
412412

413413
```python
414-
from pydantic_ai import Agent, ModelRetry, UnexpectedModelBehavior
414+
from pydantic_ai import Agent, ModelRetry, UnexpectedModelBehavior, capture_run_messages
415415

416416
agent = Agent('openai:gpt-4o')
417417

@@ -424,68 +424,76 @@ def calc_volume(size: int) -> int: # (1)!
424424
raise ModelRetry('Please try again.')
425425

426426

427-
try:
428-
result = agent.run_sync('Please get me the volume of a box with size 6.')
429-
except UnexpectedModelBehavior as e:
430-
print('An error occurred:', e)
431-
#> An error occurred: Tool exceeded max retries count of 1
432-
print('cause:', repr(e.__cause__))
433-
#> cause: ModelRetry('Please try again.')
434-
print('messages:', agent.last_run_messages)
435-
"""
436-
messages:
437-
[
438-
ModelRequest(
439-
parts=[
440-
UserPromptPart(
441-
content='Please get me the volume of a box with size 6.',
442-
timestamp=datetime.datetime(...),
443-
part_kind='user-prompt',
444-
)
445-
],
446-
kind='request',
447-
),
448-
ModelResponse(
449-
parts=[
450-
ToolCallPart(
451-
tool_name='calc_volume',
452-
args=ArgsDict(args_dict={'size': 6}),
453-
tool_call_id=None,
454-
part_kind='tool-call',
455-
)
456-
],
457-
timestamp=datetime.datetime(...),
458-
kind='response',
459-
),
460-
ModelRequest(
461-
parts=[
462-
RetryPromptPart(
463-
content='Please try again.',
464-
tool_name='calc_volume',
465-
tool_call_id=None,
466-
timestamp=datetime.datetime(...),
467-
part_kind='retry-prompt',
468-
)
469-
],
470-
kind='request',
471-
),
472-
ModelResponse(
473-
parts=[
474-
ToolCallPart(
475-
tool_name='calc_volume',
476-
args=ArgsDict(args_dict={'size': 6}),
477-
tool_call_id=None,
478-
part_kind='tool-call',
479-
)
480-
],
481-
timestamp=datetime.datetime(...),
482-
kind='response',
483-
),
484-
]
485-
"""
486-
else:
487-
print(result.data)
427+
with capture_run_messages() as messages: # (2)!
428+
try:
429+
result = agent.run_sync('Please get me the volume of a box with size 6.')
430+
except UnexpectedModelBehavior as e:
431+
print('An error occurred:', e)
432+
#> An error occurred: Tool exceeded max retries count of 1
433+
print('cause:', repr(e.__cause__))
434+
#> cause: ModelRetry('Please try again.')
435+
print('messages:', messages)
436+
"""
437+
messages:
438+
[
439+
ModelRequest(
440+
parts=[
441+
UserPromptPart(
442+
content='Please get me the volume of a box with size 6.',
443+
timestamp=datetime.datetime(...),
444+
part_kind='user-prompt',
445+
)
446+
],
447+
kind='request',
448+
),
449+
ModelResponse(
450+
parts=[
451+
ToolCallPart(
452+
tool_name='calc_volume',
453+
args=ArgsDict(args_dict={'size': 6}),
454+
tool_call_id=None,
455+
part_kind='tool-call',
456+
)
457+
],
458+
timestamp=datetime.datetime(...),
459+
kind='response',
460+
),
461+
ModelRequest(
462+
parts=[
463+
RetryPromptPart(
464+
content='Please try again.',
465+
tool_name='calc_volume',
466+
tool_call_id=None,
467+
timestamp=datetime.datetime(...),
468+
part_kind='retry-prompt',
469+
)
470+
],
471+
kind='request',
472+
),
473+
ModelResponse(
474+
parts=[
475+
ToolCallPart(
476+
tool_name='calc_volume',
477+
args=ArgsDict(args_dict={'size': 6}),
478+
tool_call_id=None,
479+
part_kind='tool-call',
480+
)
481+
],
482+
timestamp=datetime.datetime(...),
483+
kind='response',
484+
),
485+
]
486+
"""
487+
else:
488+
print(result.data)
488489
```
490+
489491
1. Define a tool that will raise `ModelRetry` repeatedly in this case.
492+
2. [`capture_run_messages`][pydantic_ai.capture_run_messages] is used to capture the messages exchanged during the run.
490493

491494
_(This example is complete, it can be run "as is")_
495+
496+
!!! note
497+
You may not call [`run`][pydantic_ai.Agent.run], [`run_sync`][pydantic_ai.Agent.run_sync], or [`run_stream`][pydantic_ai.Agent.run_stream] more than once within a single `capture_run_messages` context.
498+
499+
If you try to do so, a [`UserError`][pydantic_ai.exceptions.UserError] will be raised.

docs/api/agent.md

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,3 @@
1-
# `pydantic_ai.Agent`
1+
# `pydantic_ai.agent`
22

3-
::: pydantic_ai.Agent
4-
options:
5-
members:
6-
- __init__
7-
- name
8-
- run
9-
- run_sync
10-
- run_stream
11-
- model
12-
- override
13-
- last_run_messages
14-
- system_prompt
15-
- tool
16-
- tool_plain
17-
- result_validator
18-
19-
::: pydantic_ai.agent.EndStrategy
20-
options:
21-
show_root_heading: true
3+
::: pydantic_ai.agent

docs/testing-evals.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ import pytest
9595

9696
from dirty_equals import IsNow
9797

98-
from pydantic_ai import models
98+
from pydantic_ai import models, capture_run_messages
9999
from pydantic_ai.models.test import TestModel
100100
from pydantic_ai.messages import (
101101
ArgsDict,
@@ -118,14 +118,15 @@ models.ALLOW_MODEL_REQUESTS = False # (2)!
118118
async def test_forecast():
119119
conn = DatabaseConn()
120120
user_id = 1
121-
with weather_agent.override(model=TestModel()): # (3)!
122-
prompt = 'What will the weather be like in London on 2024-11-28?'
123-
await run_weather_forecast([(prompt, user_id)], conn) # (4)!
121+
with capture_run_messages() as messages:
122+
with weather_agent.override(model=TestModel()): # (3)!
123+
prompt = 'What will the weather be like in London on 2024-11-28?'
124+
await run_weather_forecast([(prompt, user_id)], conn) # (4)!
124125

125126
forecast = await conn.get_forecast(user_id)
126127
assert forecast == '{"weather_forecast":"Sunny with a chance of rain"}' # (5)!
127128

128-
assert weather_agent.last_run_messages == [ # (6)!
129+
assert messages == [ # (6)!
129130
ModelRequest(
130131
parts=[
131132
SystemPromptPart(
@@ -178,7 +179,7 @@ async def test_forecast():
178179
3. We're using [`Agent.override`][pydantic_ai.agent.Agent.override] to replace the agent's model with [`TestModel`][pydantic_ai.models.test.TestModel], the nice thing about `override` is that we can replace the model inside agent without needing access to the agent `run*` methods call site.
179180
4. Now we call the function we want to test inside the `override` context manager.
180181
5. But default, `TestModel` will return a JSON string summarising the tools calls made, and what was returned. If you wanted to customise the response to something more closely aligned with the domain, you could add [`custom_result_text='Sunny'`][pydantic_ai.models.test.TestModel.custom_result_text] when defining `TestModel`.
181-
6. So far we don't actually know which tools were called and with which values, we can use the [`last_run_messages`][pydantic_ai.agent.Agent.last_run_messages] attribute to inspect messages from the most recent run and assert the exchange between the agent and the model occurred as expected.
182+
6. So far we don't actually know which tools were called and with which values, we can use [`capture_run_messages`][pydantic_ai.capture_run_messages] to inspect messages from the most recent run and assert the exchange between the agent and the model occurred as expected.
182183
7. The [`IsNow`][dirty_equals.IsNow] helper allows us to use declarative asserts even with data which will contain timestamps that change over time.
183184
8. `TestModel` isn't doing anything clever to extract values from the prompt, so these values are hardcoded.
184185

pydantic_ai_slim/pydantic_ai/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
from importlib.metadata import version
22

3-
from .agent import Agent
3+
from .agent import Agent, capture_run_messages
44
from .exceptions import AgentRunError, ModelRetry, UnexpectedModelBehavior, UsageLimitExceeded, UserError
55
from .tools import RunContext, Tool
66

77
__all__ = (
88
'Agent',
9+
'capture_run_messages',
910
'RunContext',
1011
'Tool',
1112
'AgentRunError',

pydantic_ai_slim/pydantic_ai/agent.py

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@
55
import inspect
66
from collections.abc import AsyncIterator, Awaitable, Iterator, Sequence
77
from contextlib import asynccontextmanager, contextmanager
8+
from contextvars import ContextVar
89
from dataclasses import dataclass, field
910
from types import FrameType
1011
from typing import Any, Callable, Generic, Literal, cast, final, overload
1112

1213
import logfire_api
13-
from typing_extensions import assert_never
14+
from typing_extensions import assert_never, deprecated
1415

1516
from . import (
1617
_result,
@@ -35,7 +36,7 @@
3536
ToolPrepareFunc,
3637
)
3738

38-
__all__ = ('Agent',)
39+
__all__ = 'Agent', 'capture_run_messages', 'EndStrategy'
3940

4041
_logfire = logfire_api.Logfire(otel_scope='pydantic-ai')
4142

@@ -89,12 +90,6 @@ class Agent(Generic[AgentDeps, ResultData]):
8990
be merged with this value, with the runtime argument taking priority.
9091
"""
9192

92-
last_run_messages: list[_messages.ModelMessage] | None
93-
"""The messages from the last run, useful when a run raised an exception.
94-
95-
Note: these are not used by the agent, e.g. in future runs, they are just stored for developers' convenience.
96-
"""
97-
9893
_result_schema: _result.ResultSchema[ResultData] | None = field(repr=False)
9994
_result_validators: list[_result.ResultValidator[AgentDeps, ResultData]] = field(repr=False)
10095
_allow_text_result: bool = field(repr=False)
@@ -161,7 +156,6 @@ def __init__(
161156
self.end_strategy = end_strategy
162157
self.name = name
163158
self.model_settings = model_settings
164-
self.last_run_messages = None
165159
self._result_schema = _result.ResultSchema[result_type].build(
166160
result_type, result_tool_name, result_tool_description
167161
)
@@ -234,7 +228,7 @@ async def run(
234228
) as run_span:
235229
run_context = RunContext(deps, 0, [], None, model_used)
236230
messages = await self._prepare_messages(user_prompt, message_history, run_context)
237-
self.last_run_messages = run_context.messages = messages
231+
run_context.messages = messages
238232

239233
for tool in self._function_tools.values():
240234
tool.current_retry = 0
@@ -393,7 +387,7 @@ async def main():
393387
) as run_span:
394388
run_context = RunContext(deps, 0, [], None, model_used)
395389
messages = await self._prepare_messages(user_prompt, message_history, run_context)
396-
self.last_run_messages = run_context.messages = messages
390+
run_context.messages = messages
397391

398392
for tool in self._function_tools.values():
399393
tool.current_retry = 0
@@ -614,7 +608,7 @@ async def result_validator_deps(ctx: RunContext[str], data: str) -> str:
614608
#> success (no tool calls)
615609
```
616610
"""
617-
self._result_validators.append(_result.ResultValidator(func))
611+
self._result_validators.append(_result.ResultValidator[AgentDeps, Any](func))
618612
return func
619613

620614
@overload
@@ -835,14 +829,25 @@ async def add_tool(tool: Tool[AgentDeps]) -> None:
835829
async def _prepare_messages(
836830
self, user_prompt: str, message_history: list[_messages.ModelMessage] | None, run_context: RunContext[AgentDeps]
837831
) -> list[_messages.ModelMessage]:
832+
try:
833+
messages = _messages_ctx_var.get()
834+
except LookupError:
835+
messages = []
836+
else:
837+
if messages:
838+
raise exceptions.UserError(
839+
'The capture_run_messages() context manager may only be used to wrap '
840+
'one call to run(), run_sync(), or run_stream().'
841+
)
842+
838843
if message_history:
839844
# shallow copy messages
840-
messages = message_history.copy()
845+
messages.extend(message_history)
841846
messages.append(_messages.ModelRequest([_messages.UserPromptPart(user_prompt)]))
842847
else:
843848
parts = await self._sys_parts(run_context)
844849
parts.append(_messages.UserPromptPart(user_prompt))
845-
messages: list[_messages.ModelMessage] = [_messages.ModelRequest(parts)]
850+
messages.append(_messages.ModelRequest(parts))
846851

847852
return messages
848853

@@ -1119,6 +1124,51 @@ def _infer_name(self, function_frame: FrameType | None) -> None:
11191124
self.name = name
11201125
return
11211126

1127+
@property
1128+
@deprecated(
1129+
'The `last_run_messages` attribute has been removed, use `capture_run_messages` instead.', category=None
1130+
)
1131+
def last_run_messages(self) -> list[_messages.ModelMessage]:
1132+
raise AttributeError('The `last_run_messages` attribute has been removed, use `capture_run_messages` instead.')
1133+
1134+
1135+
_messages_ctx_var: ContextVar[list[_messages.ModelMessage]] = ContextVar('var')
1136+
1137+
1138+
@contextmanager
1139+
def capture_run_messages() -> Iterator[list[_messages.ModelMessage]]:
1140+
"""Context manager to access the messages used in a [`run`][pydantic_ai.Agent.run], [`run_sync`][pydantic_ai.Agent.run_sync], or [`run_stream`][pydantic_ai.Agent.run_stream] call.
1141+
1142+
Useful when a run may raise an exception, see [model errors](../agents.md#model-errors) for more information.
1143+
1144+
Examples:
1145+
```python
1146+
from pydantic_ai import Agent, capture_run_messages
1147+
1148+
agent = Agent('test')
1149+
1150+
with capture_run_messages() as messages:
1151+
try:
1152+
result = agent.run_sync('foobar')
1153+
except Exception:
1154+
print(messages)
1155+
raise
1156+
```
1157+
1158+
!!! note
1159+
You may not call `run`, `run_sync`, or `run_stream` more than once within a single `capture_run_messages` context.
1160+
If you try to do so, a [`UserError`][pydantic_ai.exceptions.UserError] will be raised.
1161+
"""
1162+
try:
1163+
yield _messages_ctx_var.get()
1164+
except LookupError:
1165+
messages: list[_messages.ModelMessage] = []
1166+
token = _messages_ctx_var.set(messages)
1167+
try:
1168+
yield messages
1169+
finally:
1170+
_messages_ctx_var.reset(token)
1171+
11221172

11231173
@dataclass
11241174
class _MarkFinalResult(Generic[ResultData]):

0 commit comments

Comments
 (0)