Files
pydantic--pydantic-ai/tests/test_history_processor.py
wehub-resource-sync 9201ef759e
Harness Compat / harness compat (push) Failing after 0s
CI / test on 3.12 (standard) (push) Has been cancelled
CI / test on 3.13 (standard) (push) Has been cancelled
CI / test on 3.14 (standard) (push) Has been cancelled
CI / test on 3.10 (all-extras) (push) Has been cancelled
CI / test on 3.11 (all-extras) (push) Has been cancelled
CI / test on 3.12 (all-extras) (push) Has been cancelled
CI / test on 3.14 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.10 (pydantic-evals) (push) Has been cancelled
CI / test on 3.11 (pydantic-evals) (push) Has been cancelled
CI / test on 3.12 (pydantic-evals) (push) Has been cancelled
CI / deploy-docs-preview (push) Has been cancelled
CI / build release artifacts (push) Has been cancelled
CI / publish to PyPI (push) Has been cancelled
CI / Send tweet (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / mypy (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / test on 3.10 (standard) (push) Has been cancelled
CI / test on 3.11 (standard) (push) Has been cancelled
CI / test on 3.13 (all-extras) (push) Has been cancelled
CI / test on 3.14 (all-extras) (push) Has been cancelled
CI / test on 3.10 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.11 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.12 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.13 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.13 (pydantic-evals) (push) Has been cancelled
CI / test on 3.14 (pydantic-evals) (push) Has been cancelled
CI / test on 3.10 (lowest-versions) (push) Has been cancelled
CI / test on 3.11 (lowest-versions) (push) Has been cancelled
CI / test on 3.12 (lowest-versions) (push) Has been cancelled
CI / test on 3.13 (lowest-versions) (push) Has been cancelled
CI / test on 3.14 (lowest-versions) (push) Has been cancelled
CI / test examples on 3.11 (push) Has been cancelled
CI / test examples on 3.12 (push) Has been cancelled
CI / test examples on 3.13 (push) Has been cancelled
CI / test examples on 3.14 (push) Has been cancelled
CI / coverage (push) Has been cancelled
CI / check (push) Has been cancelled
CI / deploy-docs (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:27:52 +08:00

2017 lines
76 KiB
Python

import re
import uuid
from collections.abc import AsyncIterator
from copy import deepcopy
from dataclasses import replace
from typing import Any
import pytest
from pydantic_ai import (
Agent,
ModelMessage,
ModelRequest,
ModelRequestPart,
ModelResponse,
SystemPromptPart,
TextPart,
ToolCallPart,
ToolReturnPart,
UserPromptPart,
capture_run_messages,
)
from pydantic_ai.capabilities import ProcessHistory, ReinjectSystemPrompt
from pydantic_ai.exceptions import UserError
from pydantic_ai.models.function import AgentInfo, FunctionModel
from pydantic_ai.tools import RunContext
from pydantic_ai.usage import RequestUsage
from ._inline_snapshot import snapshot
from .conftest import IsDatetime, IsStr
pytestmark = [pytest.mark.anyio]
@pytest.fixture
def received_messages() -> list[ModelMessage]:
return []
@pytest.fixture
def function_model(received_messages: list[ModelMessage]) -> FunctionModel:
def capture_model_function(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
# Capture the messages that the provider actually receives
received_messages.clear()
received_messages.extend(messages)
return ModelResponse(parts=[TextPart(content='Provider response')])
async def capture_model_stream_function(messages: list[ModelMessage], _info: AgentInfo) -> AsyncIterator[str]:
received_messages.clear()
received_messages.extend(messages)
yield 'hello'
return FunctionModel(capture_model_function, stream_function=capture_model_stream_function)
async def test_history_processor_no_op(function_model: FunctionModel, received_messages: list[ModelMessage]):
def no_op_history_processor(messages: list[ModelMessage]) -> list[ModelMessage]:
return messages
agent = Agent(function_model, capabilities=[ProcessHistory(no_op_history_processor)])
message_history = [
ModelRequest(parts=[UserPromptPart(content='Previous question')]),
ModelResponse(parts=[TextPart(content='Previous answer')]),
]
with capture_run_messages() as captured_messages:
result = await agent.run('New question', message_history=message_history)
assert received_messages == snapshot(
[
ModelRequest(parts=[UserPromptPart(content='Previous question', timestamp=IsDatetime())]),
ModelResponse(parts=[TextPart(content='Previous answer')], timestamp=IsDatetime()),
ModelRequest(
parts=[UserPromptPart(content='New question', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert captured_messages == result.all_messages()
assert result.all_messages() == snapshot(
[
ModelRequest(parts=[UserPromptPart(content='Previous question', timestamp=IsDatetime())]),
ModelResponse(parts=[TextPart(content='Previous answer')], timestamp=IsDatetime()),
ModelRequest(
parts=[UserPromptPart(content='New question', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Provider response')],
usage=RequestUsage(input_tokens=54, output_tokens=4),
model_name='function:capture_model_function:capture_model_stream_function',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.new_messages() == result.all_messages()[-2:]
async def test_history_processor_run_replaces_message_history(
function_model: FunctionModel, received_messages: list[ModelMessage]
):
"""Test that the history processor replaces the message history in the state."""
def process_previous_answers(messages: list[ModelMessage]) -> list[ModelMessage]:
# Keep the last message (last question) and add a new system prompt
return messages[-1:] + [ModelRequest(parts=[SystemPromptPart(content='Processed answer')])]
agent = Agent(function_model, capabilities=[ProcessHistory(process_previous_answers)])
message_history = [
ModelRequest(parts=[UserPromptPart(content='Question 1')]),
ModelResponse(parts=[TextPart(content='Answer 1')]),
ModelRequest(parts=[UserPromptPart(content='Question 2')]),
ModelResponse(parts=[TextPart(content='Answer 2')]),
]
with capture_run_messages() as captured_messages:
result = await agent.run('Question 3', message_history=message_history)
assert received_messages == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Question 3',
timestamp=IsDatetime(),
),
SystemPromptPart(
content='Processed answer',
timestamp=IsDatetime(),
),
],
timestamp=IsDatetime(),
)
]
)
assert captured_messages == result.all_messages()
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Question 3', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[SystemPromptPart(content='Processed answer', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Provider response')],
usage=RequestUsage(input_tokens=54, output_tokens=2),
model_name='function:capture_model_function:capture_model_stream_function',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.new_messages() == result.all_messages()
async def test_history_processor_streaming_replaces_message_history(
function_model: FunctionModel, received_messages: list[ModelMessage]
):
"""Test that the history processor replaces the message history in the state."""
def process_previous_answers(messages: list[ModelMessage]) -> list[ModelMessage]:
# Keep the last message (last question) and add a new system prompt
return messages[-1:] + [ModelRequest(parts=[SystemPromptPart(content='Processed answer')])]
agent = Agent(function_model, capabilities=[ProcessHistory(process_previous_answers)])
message_history = [
ModelRequest(parts=[UserPromptPart(content='Question 1')]),
ModelResponse(parts=[TextPart(content='Answer 1')]),
ModelRequest(parts=[UserPromptPart(content='Question 2')]),
ModelResponse(parts=[TextPart(content='Answer 2')]),
]
with capture_run_messages() as captured_messages:
async with agent.run_stream('Question 3', message_history=message_history) as result:
async for _ in result.stream_text():
pass
assert received_messages == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Question 3',
timestamp=IsDatetime(),
),
SystemPromptPart(
content='Processed answer',
timestamp=IsDatetime(),
),
],
timestamp=IsDatetime(),
)
]
)
assert captured_messages == result.all_messages()
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Question 3', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[SystemPromptPart(content='Processed answer', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='hello')],
usage=RequestUsage(input_tokens=50, output_tokens=1),
model_name='function:capture_model_function:capture_model_stream_function',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.new_messages() == result.all_messages()
async def test_history_processor_messages_sent_to_provider(
function_model: FunctionModel, received_messages: list[ModelMessage]
):
"""Test what messages are actually sent to the provider after processing."""
def capture_messages_processor(messages: list[ModelMessage]) -> list[ModelMessage]:
# Filter out ModelResponse messages
return [msg for msg in messages if isinstance(msg, ModelRequest)]
agent = Agent(function_model, capabilities=[ProcessHistory(capture_messages_processor)])
message_history = [
ModelRequest(parts=[UserPromptPart(content='Previous question')]),
ModelResponse(parts=[TextPart(content='Previous answer')]), # This should be filtered out
]
with capture_run_messages() as captured_messages:
result = await agent.run('New question', message_history=message_history)
assert received_messages == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Previous question',
timestamp=IsDatetime(),
),
UserPromptPart(
content='New question',
timestamp=IsDatetime(),
),
],
timestamp=IsDatetime(),
)
]
)
assert captured_messages == result.all_messages()
assert result.all_messages() == snapshot(
[
ModelRequest(parts=[UserPromptPart(content='Previous question', timestamp=IsDatetime())]),
ModelRequest(
parts=[UserPromptPart(content='New question', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Provider response')],
usage=RequestUsage(input_tokens=54, output_tokens=2),
model_name='function:capture_model_function:capture_model_stream_function',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.new_messages() == result.all_messages()[-2:]
async def test_multiple_history_processors(function_model: FunctionModel, received_messages: list[ModelMessage]):
"""Test that multiple processors are applied in sequence."""
def first_processor(messages: list[ModelMessage]) -> list[ModelMessage]:
# Add a prefix to user prompts
processed: list[ModelMessage] = []
for msg in messages:
if isinstance(msg, ModelRequest):
new_parts: list[ModelRequestPart] = []
for part in msg.parts:
if isinstance(part, UserPromptPart): # pragma: no branch
new_parts.append(UserPromptPart(content=f'[FIRST] {part.content}'))
processed.append(ModelRequest(parts=new_parts))
else:
processed.append(msg)
return processed
def second_processor(messages: list[ModelMessage]) -> list[ModelMessage]:
# Add another prefix to user prompts
processed: list[ModelMessage] = []
for msg in messages:
if isinstance(msg, ModelRequest):
new_parts: list[ModelRequestPart] = []
for part in msg.parts:
if isinstance(part, UserPromptPart): # pragma: no branch
new_parts.append(UserPromptPart(content=f'[SECOND] {part.content}'))
processed.append(ModelRequest(parts=new_parts))
else:
processed.append(msg)
return processed
agent = Agent(function_model, capabilities=[ProcessHistory(first_processor), ProcessHistory(second_processor)])
message_history: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content='Question')]),
ModelResponse(parts=[TextPart(content='Answer')]),
]
with capture_run_messages() as captured_messages:
result = await agent.run('New question', message_history=message_history)
assert received_messages == snapshot(
[
ModelRequest(parts=[UserPromptPart(content='[SECOND] [FIRST] Question', timestamp=IsDatetime())]),
ModelResponse(parts=[TextPart(content='Answer')], timestamp=IsDatetime()),
ModelRequest(
parts=[UserPromptPart(content='[SECOND] [FIRST] New question', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert captured_messages == result.all_messages()
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='[SECOND] [FIRST] Question',
timestamp=IsDatetime(),
)
]
),
ModelResponse(
parts=[TextPart(content='Answer')],
timestamp=IsDatetime(),
),
ModelRequest(
parts=[
UserPromptPart(
content='[SECOND] [FIRST] New question',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Provider response')],
usage=RequestUsage(input_tokens=57, output_tokens=3),
model_name='function:capture_model_function:capture_model_stream_function',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.new_messages() == result.all_messages()[-2:]
async def test_async_history_processor(function_model: FunctionModel, received_messages: list[ModelMessage]):
"""Test that async processors work."""
async def async_processor(messages: list[ModelMessage]) -> list[ModelMessage]:
return [msg for msg in messages if isinstance(msg, ModelRequest)]
agent = Agent(function_model, capabilities=[ProcessHistory(async_processor)])
message_history = [
ModelRequest(parts=[UserPromptPart(content='Question 1')]),
ModelResponse(parts=[TextPart(content='Answer 1')]), # Should be filtered out
]
with capture_run_messages() as captured_messages:
result = await agent.run('Question 2', message_history=message_history)
assert received_messages == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Question 1',
timestamp=IsDatetime(),
),
UserPromptPart(
content='Question 2',
timestamp=IsDatetime(),
),
],
timestamp=IsDatetime(),
)
]
)
assert captured_messages == result.all_messages()
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Question 1',
timestamp=IsDatetime(),
)
]
),
ModelRequest(
parts=[
UserPromptPart(
content='Question 2',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Provider response')],
usage=RequestUsage(input_tokens=54, output_tokens=2),
model_name='function:capture_model_function:capture_model_stream_function',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.new_messages() == result.all_messages()[-2:]
async def test_history_processor_on_streamed_run(function_model: FunctionModel, received_messages: list[ModelMessage]):
"""Test that history processors work on streamed runs."""
async def async_processor(messages: list[ModelMessage]) -> list[ModelMessage]:
return [msg for msg in messages if isinstance(msg, ModelRequest)]
message_history = [
ModelRequest(parts=[UserPromptPart(content='Question 1')]),
ModelResponse(parts=[TextPart(content='Answer 1')]),
]
agent = Agent(function_model, capabilities=[ProcessHistory(async_processor)])
with capture_run_messages() as captured_messages:
async with agent.iter('Question 2', message_history=message_history) as run:
async for node in run:
if agent.is_model_request_node(node):
async with node.stream(run.ctx) as stream:
async for _ in stream.stream_response(debounce_by=None):
...
result = run.result
assert result is not None
assert received_messages == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Question 1',
timestamp=IsDatetime(),
),
UserPromptPart(
content='Question 2',
timestamp=IsDatetime(),
),
],
timestamp=IsDatetime(),
)
]
)
assert captured_messages == result.all_messages()
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Question 1',
timestamp=IsDatetime(),
)
]
),
ModelRequest(
parts=[
UserPromptPart(
content='Question 2',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='hello')],
usage=RequestUsage(input_tokens=50, output_tokens=1),
model_name='function:capture_model_function:capture_model_stream_function',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.new_messages() == result.all_messages()[-2:]
async def test_history_processor_with_context(function_model: FunctionModel, received_messages: list[ModelMessage]):
"""Test history processor that takes RunContext."""
def context_processor(ctx: RunContext[str], messages: list[ModelMessage]) -> list[ModelMessage]:
# Access deps from context
prefix = ctx.deps
processed: list[ModelMessage] = []
for msg in messages:
if isinstance(msg, ModelRequest):
new_parts: list[ModelRequestPart] = []
for part in msg.parts:
if isinstance(part, UserPromptPart):
new_parts.append(UserPromptPart(content=f'{prefix}: {part.content}'))
else:
new_parts.append(part) # pragma: no cover
processed.append(ModelRequest(parts=new_parts))
else:
processed.append(msg) # pragma: no cover
return processed
agent = Agent(function_model, capabilities=[ProcessHistory(context_processor)], deps_type=str)
with capture_run_messages() as captured_messages:
result = await agent.run('test', deps='PREFIX')
assert received_messages == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='PREFIX: test',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
)
]
)
assert captured_messages == result.all_messages()
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='PREFIX: test',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Provider response')],
usage=RequestUsage(input_tokens=52, output_tokens=2),
model_name='function:capture_model_function:capture_model_stream_function',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.new_messages() == result.all_messages()[-2:]
async def test_history_processor_with_context_async(
function_model: FunctionModel, received_messages: list[ModelMessage]
):
"""Test async history processor that takes RunContext."""
async def async_context_processor(ctx: RunContext[Any], messages: list[ModelMessage]) -> list[ModelMessage]:
return messages[-1:] # Keep only the last message
message_history = [
ModelRequest(parts=[UserPromptPart(content='Question 1')]),
ModelResponse(parts=[TextPart(content='Answer 1')]),
ModelRequest(parts=[UserPromptPart(content='Question 2')]),
ModelResponse(parts=[TextPart(content='Answer 2')]),
]
agent = Agent(function_model, capabilities=[ProcessHistory(async_context_processor)])
with capture_run_messages() as captured_messages:
result = await agent.run('Question 3', message_history=message_history)
assert received_messages == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Question 3',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
)
]
)
assert captured_messages == result.all_messages()
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Question 3',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Provider response')],
usage=RequestUsage(input_tokens=52, output_tokens=2),
model_name='function:capture_model_function:capture_model_stream_function',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.new_messages() == result.all_messages()[-2:]
async def test_history_processor_mixed_signatures(function_model: FunctionModel, received_messages: list[ModelMessage]):
"""Test mixing processors with and without context."""
def simple_processor(messages: list[ModelMessage]) -> list[ModelMessage]:
# Filter out responses
return [msg for msg in messages if isinstance(msg, ModelRequest)]
def context_processor(ctx: RunContext[Any], messages: list[ModelMessage]) -> list[ModelMessage]:
# Add prefix based on deps
prefix = getattr(ctx.deps, 'prefix', 'DEFAULT')
processed: list[ModelMessage] = []
for msg in messages:
if isinstance(msg, ModelRequest):
new_parts: list[ModelRequestPart] = []
for part in msg.parts:
if isinstance(part, UserPromptPart):
new_parts.append(UserPromptPart(content=f'{prefix}: {part.content}'))
else:
new_parts.append(part) # pragma: no cover
processed.append(ModelRequest(parts=new_parts))
else:
processed.append(msg) # pragma: no cover
return processed
message_history = [
ModelRequest(parts=[UserPromptPart(content='Question 1')]),
ModelResponse(parts=[TextPart(content='Answer 1')]),
]
# Create deps with prefix attribute
class Deps:
prefix = 'TEST'
agent = Agent(
function_model,
capabilities=[ProcessHistory(simple_processor), ProcessHistory(context_processor)],
deps_type=Deps,
)
with capture_run_messages() as captured_messages:
result = await agent.run('Question 2', message_history=message_history, deps=Deps())
# Should have filtered responses and added prefix
assert received_messages == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='TEST: Question 1',
timestamp=IsDatetime(),
),
UserPromptPart(
content='TEST: Question 2',
timestamp=IsDatetime(),
),
],
timestamp=IsDatetime(),
)
]
)
assert captured_messages == result.all_messages()
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='TEST: Question 1',
timestamp=IsDatetime(),
)
]
),
ModelRequest(
parts=[
UserPromptPart(
content='TEST: Question 2',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Provider response')],
usage=RequestUsage(input_tokens=56, output_tokens=2),
model_name='function:capture_model_function:capture_model_stream_function',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.new_messages() == result.all_messages()[-2:]
async def test_history_processor_replace_messages(function_model: FunctionModel, received_messages: list[ModelMessage]):
history: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content='Original message')]),
ModelResponse(parts=[TextPart(content='Original response')]),
ModelRequest(parts=[UserPromptPart(content='Original followup')]),
]
def return_new_history(messages: list[ModelMessage]) -> list[ModelMessage]:
return [
ModelRequest(parts=[UserPromptPart(content='Modified message')]),
]
agent = Agent(function_model, capabilities=[ProcessHistory(return_new_history)])
with capture_run_messages() as captured_messages:
result = await agent.run('foobar', message_history=history)
assert received_messages == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Modified message',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
)
]
)
assert captured_messages == result.all_messages()
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Modified message',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Provider response')],
usage=RequestUsage(input_tokens=52, output_tokens=2),
model_name='function:capture_model_function:capture_model_stream_function',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.new_messages() == result.all_messages()[-2:]
async def test_history_processor_empty_history(function_model: FunctionModel, received_messages: list[ModelMessage]):
def return_new_history(messages: list[ModelMessage]) -> list[ModelMessage]:
return []
agent = Agent(function_model, capabilities=[ProcessHistory(return_new_history)])
with pytest.raises(UserError, match=re.escape('Processed history cannot be empty.')):
await agent.run('foobar')
async def test_history_processor_history_ending_in_response(
function_model: FunctionModel, received_messages: list[ModelMessage]
):
def return_new_history(messages: list[ModelMessage]) -> list[ModelMessage]:
return [ModelResponse(parts=[TextPart(content='Provider response')])]
agent = Agent(function_model, capabilities=[ProcessHistory(return_new_history)])
with pytest.raises(UserError, match=re.escape('Processed history must end with a `ModelRequest`.')):
await agent.run('foobar')
async def test_callable_class_history_processor_no_op(
function_model: FunctionModel, received_messages: list[ModelMessage]
):
class NoOpHistoryProcessor:
def __call__(self, messages: list[ModelMessage]) -> list[ModelMessage]:
return messages
agent = Agent(function_model, capabilities=[ProcessHistory(NoOpHistoryProcessor())])
message_history = [
ModelRequest(parts=[UserPromptPart(content='Previous question')]),
ModelResponse(parts=[TextPart(content='Previous answer')]),
]
with capture_run_messages() as captured_messages:
result = await agent.run('New question', message_history=message_history)
assert received_messages == snapshot(
[
ModelRequest(parts=[UserPromptPart(content='Previous question', timestamp=IsDatetime())]),
ModelResponse(parts=[TextPart(content='Previous answer')], timestamp=IsDatetime()),
ModelRequest(
parts=[UserPromptPart(content='New question', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert captured_messages == result.all_messages()
assert result.all_messages() == snapshot(
[
ModelRequest(parts=[UserPromptPart(content='Previous question', timestamp=IsDatetime())]),
ModelResponse(parts=[TextPart(content='Previous answer')], timestamp=IsDatetime()),
ModelRequest(
parts=[UserPromptPart(content='New question', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Provider response')],
usage=RequestUsage(input_tokens=54, output_tokens=4),
model_name='function:capture_model_function:capture_model_stream_function',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.new_messages() == result.all_messages()[-2:]
async def test_callable_class_history_processor_with_ctx_no_op(
function_model: FunctionModel, received_messages: list[ModelMessage]
):
class NoOpHistoryProcessorWithCtx:
def __call__(self, _: RunContext, messages: list[ModelMessage]) -> list[ModelMessage]:
return messages
agent = Agent(function_model, capabilities=[ProcessHistory(NoOpHistoryProcessorWithCtx())])
message_history = [
ModelRequest(parts=[UserPromptPart(content='Previous question')]),
ModelResponse(parts=[TextPart(content='Previous answer')]),
]
with capture_run_messages() as captured_messages:
result = await agent.run('New question', message_history=message_history)
assert received_messages == snapshot(
[
ModelRequest(parts=[UserPromptPart(content='Previous question', timestamp=IsDatetime())]),
ModelResponse(parts=[TextPart(content='Previous answer')], timestamp=IsDatetime()),
ModelRequest(
parts=[UserPromptPart(content='New question', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert captured_messages == result.all_messages()
assert result.all_messages() == snapshot(
[
ModelRequest(parts=[UserPromptPart(content='Previous question', timestamp=IsDatetime())]),
ModelResponse(parts=[TextPart(content='Previous answer')], timestamp=IsDatetime()),
ModelRequest(
parts=[UserPromptPart(content='New question', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Provider response')],
usage=RequestUsage(input_tokens=54, output_tokens=4),
model_name='function:capture_model_function:capture_model_stream_function',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.new_messages() == result.all_messages()[-2:]
async def test_new_messages_index_during_iter_with_pruning():
"""
When a pruning history processor removes the initial user prompt during
a multi-step tool calling run, new_messages() should still return all
messages generated in this run.
"""
def keep_last_2(messages: list[ModelMessage]) -> list[ModelMessage]:
return messages[-2:] if len(messages) > 2 else messages
call_count = 0
def model_function(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(
parts=[ToolCallPart(tool_name='my_tool', args={}, tool_call_id='tool_call_1')],
)
return ModelResponse(parts=[TextPart(content='done')])
agent = Agent(model=FunctionModel(model_function, model_name='test'), capabilities=[ProcessHistory(keep_last_2)])
@agent.tool
async def my_tool(ctx: RunContext) -> str:
return 'tool executed'
with capture_run_messages() as captured_messages:
async with agent.iter('start') as run:
async for _ in run:
pass
result = run.result
assert result is not None
assert captured_messages == result.all_messages()
assert result.all_messages() == snapshot(
[
ModelResponse(
parts=[ToolCallPart(tool_name='my_tool', args={}, tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=51, output_tokens=2),
model_name='test',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='my_tool',
content='tool executed',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='done')],
usage=RequestUsage(input_tokens=52, output_tokens=3),
model_name='test',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.new_messages() == result.all_messages()
async def test_new_messages_index_during_iter_with_pruning_and_history():
"""
When running with prior message_history and a pruning history processor
that progressively removes older messages during a multi-step tool calling
run, new_messages() should return only the messages from the current run,
excluding the pruned history.
"""
def keep_last_2(messages: list[ModelMessage]) -> list[ModelMessage]:
return messages[-2:] if len(messages) > 2 else messages
call_count = 0
def model_function(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(
parts=[ToolCallPart(tool_name='my_tool', args={}, tool_call_id='tool_call_1')],
)
return ModelResponse(parts=[TextPart(content='done')])
agent = Agent(model=FunctionModel(model_function, model_name='test'), capabilities=[ProcessHistory(keep_last_2)])
@agent.tool
async def my_tool(ctx: RunContext) -> str:
return 'tool executed'
history = [
ModelRequest(parts=[UserPromptPart(content='Old message 1')]),
ModelResponse(parts=[TextPart(content='Old response 1')]),
]
with capture_run_messages() as captured_messages:
async with agent.iter('start', message_history=history) as run:
async for _ in run:
pass
result = run.result
assert result is not None
assert captured_messages == result.all_messages()
assert result.all_messages() == snapshot(
[
ModelResponse(
parts=[ToolCallPart(tool_name='my_tool', args={}, tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=51, output_tokens=5),
model_name='test',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='my_tool',
content='tool executed',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='done')],
usage=RequestUsage(input_tokens=52, output_tokens=3),
model_name='test',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.new_messages() == result.all_messages()
async def test_history_processor_reorder_old_new(function_model: FunctionModel, received_messages: list[ModelMessage]):
"""
When a history processor reorders old and new messages, the old history
message receives the current run_id, so new_messages() treats it as
part of the current run and includes it in the result.
"""
def swap_last_two(messages: list[ModelMessage]) -> list[ModelMessage]:
return messages[:-2] + messages[-2:][::-1]
agent = Agent(function_model, capabilities=[ProcessHistory(swap_last_two)])
message_history = [
ModelRequest(parts=[UserPromptPart(content='Old question')]),
]
with capture_run_messages() as captured_messages:
result = await agent.run('New question', message_history=message_history)
assert received_messages == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(content='New question', timestamp=IsDatetime()),
UserPromptPart(content='Old question', timestamp=IsDatetime()),
],
timestamp=IsDatetime(),
)
]
)
assert captured_messages == result.all_messages()
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(content='New question', timestamp=IsDatetime()),
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
UserPromptPart(content='Old question', timestamp=IsDatetime()),
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Provider response')],
usage=RequestUsage(input_tokens=54, output_tokens=2),
model_name='function:capture_model_function:capture_model_stream_function',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.new_messages() == result.all_messages()
async def test_history_processor_injects_into_new_stream(
function_model: FunctionModel, received_messages: list[ModelMessage]
):
"""
When a history processor injects a new message tagged with the current
run_id into the message list, new_messages() should include the injected
message alongside the other messages from this run.
"""
def inject_middle(ctx: RunContext[Any], messages: list[ModelMessage]) -> list[ModelMessage]:
return (
messages[:-1]
+ [ModelRequest(parts=[UserPromptPart(content='Inserted')], run_id=ctx.run_id)]
+ messages[-1:]
)
agent = Agent(function_model, capabilities=[ProcessHistory(inject_middle)])
message_history = [ModelRequest(parts=[UserPromptPart(content='Old')])]
with capture_run_messages() as captured_messages:
result = await agent.run('New question', message_history=message_history)
assert received_messages == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(content='Old', timestamp=IsDatetime()),
UserPromptPart(content='Inserted', timestamp=IsDatetime()),
UserPromptPart(content='New question', timestamp=IsDatetime()),
],
timestamp=IsDatetime(),
)
]
)
assert captured_messages == result.all_messages()
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(content='Old', timestamp=IsDatetime()),
]
),
ModelRequest(
parts=[
UserPromptPart(content='Inserted', timestamp=IsDatetime()),
],
run_id=IsStr(),
),
ModelRequest(
parts=[
UserPromptPart(content='New question', timestamp=IsDatetime()),
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Provider response')],
usage=RequestUsage(input_tokens=54, output_tokens=2),
model_name='function:capture_model_function:capture_model_stream_function',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
new_msgs = result.new_messages()
assert new_msgs == result.all_messages()[1:]
async def test_history_processor_injects_without_run_id_before_current_run(
function_model: FunctionModel, received_messages: list[ModelMessage]
):
"""
When a history processor injects a message without a run_id before the
current run, new_messages() should exclude the injected message and only
return messages that belong to the current run.
"""
def inject_middle_without_run_id(messages: list[ModelMessage]) -> list[ModelMessage]:
return messages[:-1] + [ModelRequest(parts=[UserPromptPart(content='Inserted')])] + messages[-1:]
agent = Agent(function_model, capabilities=[ProcessHistory(inject_middle_without_run_id)])
message_history = [ModelRequest(parts=[UserPromptPart(content='Old')])]
with capture_run_messages() as captured_messages:
result = await agent.run('New question', message_history=message_history)
assert received_messages == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(content='Old', timestamp=IsDatetime()),
UserPromptPart(content='Inserted', timestamp=IsDatetime()),
UserPromptPart(content='New question', timestamp=IsDatetime()),
],
timestamp=IsDatetime(),
)
]
)
assert captured_messages == result.all_messages()
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(content='Old', timestamp=IsDatetime()),
]
),
ModelRequest(
parts=[
UserPromptPart(content='Inserted', timestamp=IsDatetime()),
]
),
ModelRequest(
parts=[
UserPromptPart(content='New question', timestamp=IsDatetime()),
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Provider response')],
usage=RequestUsage(input_tokens=54, output_tokens=2),
model_name='function:capture_model_function:capture_model_stream_function',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.new_messages() == result.all_messages()[2:]
async def test_history_processor_overrides_run_id_uses_response_as_new_messages(function_model: FunctionModel):
"""
When a history processor overwrites the run_id on all messages,
new_messages() should fall back to returning only the model response
appended after processing.
"""
def override_run_id(ctx: RunContext[Any], messages: list[ModelMessage]) -> list[ModelMessage]:
override = f'{ctx.run_id}-override'
for message in messages:
message.run_id = override
return messages
agent = Agent(function_model, capabilities=[ProcessHistory(override_run_id)])
message_history = [ModelRequest(parts=[UserPromptPart(content='Old')])]
with capture_run_messages() as captured_messages:
result = await agent.run('New question', message_history=message_history)
assert captured_messages == result.all_messages()
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(content='Old', timestamp=IsDatetime()),
],
run_id=IsStr(regex='.+-override'),
),
ModelRequest(
parts=[
UserPromptPart(content='New question', timestamp=IsDatetime()),
],
timestamp=IsDatetime(),
run_id=IsStr(regex='.+-override'),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Provider response')],
usage=RequestUsage(input_tokens=53, output_tokens=2),
model_name='function:capture_model_function:capture_model_stream_function',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.new_messages() == result.all_messages()[-1:]
async def test_history_processor_resuming_without_prompt(
function_model: FunctionModel, received_messages: list[ModelMessage]
):
"""
When running without a user prompt (resuming from history), new_messages()
should exclude the request supplied via message_history even when that
request gets the current run_id.
"""
def prepend_summary(messages: list[ModelMessage]) -> list[ModelMessage]:
return [ModelRequest(parts=[SystemPromptPart(content='History summary')]), *messages]
agent = Agent(function_model, capabilities=[ProcessHistory(prepend_summary)])
message_history = [
ModelRequest(parts=[UserPromptPart(content='Original prompt')]),
]
with capture_run_messages() as captured_messages:
result = await agent.run(message_history=message_history)
assert received_messages == snapshot(
[
ModelRequest(
parts=[
SystemPromptPart(
content='History summary',
timestamp=IsDatetime(),
),
UserPromptPart(
content='Original prompt',
timestamp=IsDatetime(),
),
],
timestamp=IsDatetime(),
)
]
)
assert captured_messages == result.all_messages()
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
SystemPromptPart(
content='History summary',
timestamp=IsDatetime(),
)
]
),
ModelRequest(
parts=[
UserPromptPart(
content='Original prompt',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Provider response')],
usage=RequestUsage(input_tokens=54, output_tokens=2),
model_name='function:capture_model_function:capture_model_stream_function',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.new_messages() == result.all_messages()[-1:]
async def test_resuming_without_prompt_with_tool_calls_excludes_resumed_request():
"""
When resuming without a user prompt and the model enters a tool-call loop,
new_messages() should exclude the resumed history request even though it
gets the current run_id.
"""
call_count = 0
def model_function(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(
parts=[ToolCallPart(tool_name='my_tool', args={}, tool_call_id='tool_call_1')],
)
return ModelResponse(parts=[TextPart(content='done')])
agent = Agent(model=FunctionModel(model_function, model_name='test'))
@agent.tool
async def my_tool(_ctx: RunContext) -> str:
return 'tool executed'
with capture_run_messages() as captured_messages:
result = await agent.run(message_history=[ModelRequest(parts=[UserPromptPart(content='Original prompt')])])
assert captured_messages == result.all_messages()
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Original prompt', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='my_tool', args={}, tool_call_id='tool_call_1')],
usage=RequestUsage(input_tokens=52, output_tokens=2),
model_name='test',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='my_tool',
content='tool executed',
tool_call_id='tool_call_1',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='done')],
usage=RequestUsage(input_tokens=54, output_tokens=3),
model_name='test',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.new_messages() == result.all_messages()[1:]
async def test_resuming_without_prompt_excludes_request_with_different_run_id(
function_model: FunctionModel, received_messages: list[ModelMessage]
):
"""
When running without a user prompt and the resumed request already has a
run_id from a *previous* run, new_messages() should exclude it — only
messages stamped with the current run_id should be returned.
"""
previous_run_id = str(uuid.uuid4())
agent = Agent(function_model)
message_history = [
ModelRequest(parts=[UserPromptPart(content='Earlier question')]),
ModelResponse(parts=[TextPart(content='Earlier answer')]),
ModelRequest(
parts=[UserPromptPart(content='Previous run prompt')],
run_id=previous_run_id,
),
]
with capture_run_messages() as captured_messages:
result = await agent.run(message_history=message_history)
assert received_messages == snapshot(
[
ModelRequest(parts=[UserPromptPart(content='Earlier question', timestamp=IsDatetime())]),
ModelResponse(parts=[TextPart(content='Earlier answer')], timestamp=IsDatetime()),
ModelRequest(
parts=[UserPromptPart(content='Previous run prompt', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=previous_run_id,
conversation_id=IsStr(),
),
]
)
assert captured_messages == result.all_messages()
assert result.all_messages() == snapshot(
[
ModelRequest(parts=[UserPromptPart(content='Earlier question', timestamp=IsDatetime())]),
ModelResponse(parts=[TextPart(content='Earlier answer')], timestamp=IsDatetime()),
ModelRequest(
parts=[UserPromptPart(content='Previous run prompt', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=previous_run_id,
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Provider response')],
usage=RequestUsage(input_tokens=55, output_tokens=4),
model_name='function:capture_model_function:capture_model_stream_function',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
# The resumed request has a run_id from a different run: excluded from new_messages().
assert result.new_messages() == result.all_messages()[-1:]
assert result.new_messages()[0].run_id != previous_run_id
async def test_history_processor_deepcopy_resuming_without_prompt(
function_model: FunctionModel, received_messages: list[ModelMessage]
):
"""
When a history processor deep-copies messages (breaking object identity),
new_messages() should still exclude the resumed request supplied via
message_history.
"""
def deepcopy_processor(messages: list[ModelMessage]) -> list[ModelMessage]:
return deepcopy(messages)
agent = Agent(function_model, capabilities=[ProcessHistory(deepcopy_processor)])
message_history = [
ModelRequest(parts=[UserPromptPart(content='Original prompt')]),
]
with capture_run_messages() as captured_messages:
result = await agent.run(message_history=message_history)
assert captured_messages == result.all_messages()
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Original prompt',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Provider response')],
usage=RequestUsage(input_tokens=52, output_tokens=2),
model_name='function:capture_model_function:capture_model_stream_function',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.new_messages() == result.all_messages()[-1:]
async def test_history_processor_rebuild_resuming_without_prompt(
function_model: FunctionModel, received_messages: list[ModelMessage]
):
"""
When a history processor rebuilds `ModelRequest` instances with equivalent
values, new_messages() should still exclude the resumed request supplied
via message_history.
"""
def rebuild_processor(messages: list[ModelMessage]) -> list[ModelMessage]:
rebuilt_messages: list[ModelMessage] = []
for message in messages:
if isinstance(message, ModelRequest):
rebuilt_messages.append(
ModelRequest(
parts=list(message.parts),
timestamp=message.timestamp,
instructions=message.instructions,
run_id=message.run_id,
metadata=message.metadata.copy() if message.metadata is not None else None,
)
)
else:
rebuilt_messages.append(message)
return rebuilt_messages
agent = Agent(function_model, capabilities=[ProcessHistory(rebuild_processor)])
message_history = [
ModelRequest(parts=[UserPromptPart(content='Old question')]),
ModelResponse(parts=[TextPart(content='Old answer')]),
ModelRequest(parts=[UserPromptPart(content='Original prompt')]),
]
with capture_run_messages() as captured_messages:
result = await agent.run(message_history=message_history)
assert captured_messages == result.all_messages()
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Old question',
timestamp=IsDatetime(),
)
],
),
ModelResponse(
parts=[TextPart(content='Old answer')],
timestamp=IsDatetime(),
),
ModelRequest(
parts=[
UserPromptPart(
content='Original prompt',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Provider response')],
usage=RequestUsage(input_tokens=54, output_tokens=4),
model_name='function:capture_model_function:capture_model_stream_function',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.new_messages() == result.all_messages()[-1:]
async def test_history_processor_replace_resumed_request_excludes_resumed_request(
function_model: FunctionModel, received_messages: list[ModelMessage]
):
"""
When a history processor replaces the resumed request with completely different content,
new_messages() still excludes it. The full rewrite defeats both the object identity and value
matches, so the pinned-position fallback is what keeps it excluded. This is consistent with the
other (non-resumed) request the same processor replaces, which is likewise prior context: a
processor's transient reshaping of prior context is not a new message to persist.
"""
def replace_all_requests(messages: list[ModelMessage]) -> list[ModelMessage]:
rebuilt: list[ModelMessage] = []
for msg in messages:
if isinstance(msg, ModelRequest):
rebuilt.append(
ModelRequest(
parts=[UserPromptPart(content='Replaced content')],
timestamp=msg.timestamp,
run_id=msg.run_id,
)
)
else:
rebuilt.append(msg)
return rebuilt
agent = Agent(function_model, capabilities=[ProcessHistory(replace_all_requests)])
message_history = [
ModelRequest(parts=[UserPromptPart(content='Old question')]),
ModelResponse(parts=[TextPart(content='Old answer')]),
ModelRequest(parts=[UserPromptPart(content='Original prompt')]),
]
with capture_run_messages() as captured_messages:
result = await agent.run(message_history=message_history)
assert captured_messages == result.all_messages()
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Replaced content',
timestamp=IsDatetime(),
)
],
),
ModelResponse(
parts=[TextPart(content='Old answer')],
timestamp=IsDatetime(),
),
ModelRequest(
parts=[
UserPromptPart(
content='Replaced content',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Provider response')],
usage=RequestUsage(input_tokens=54, output_tokens=4),
model_name='function:capture_model_function:capture_model_stream_function',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
# The resumed request is excluded via the position fallback even though the processor rebuilt
# it with the current run_id and different content; only the model response is new.
assert result.new_messages() == result.all_messages()[-1:]
async def test_reinject_system_prompt_resuming_without_prompt_excludes_resumed_request(
function_model: FunctionModel, received_messages: list[ModelMessage]
):
"""
System-prompt reinjection (the UI adapters' default with `manage_system_prompt='server'`)
rebuilds the first request via `replace(...)`, prepending a `SystemPromptPart`. When
resuming without a new user prompt on the first turn, that first request *is* the resumed
request, so the rewrite changes both its identity and its `parts`. It must still be treated
as prior context and excluded from new_messages(). Regression test for the turn-1 leak in
https://github.com/pydantic/pydantic-ai/issues/6025.
"""
agent = Agent(
function_model,
system_prompt='Server prompt',
capabilities=[ReinjectSystemPrompt(replace_existing=True)],
)
message_history = [
ModelRequest(parts=[UserPromptPart(content='Original prompt')]),
]
with capture_run_messages() as captured_messages:
result = await agent.run(message_history=message_history)
# The model received the resumed request with the server prompt reinjected into it.
assert received_messages == snapshot(
[
ModelRequest(
parts=[
SystemPromptPart(content='Server prompt', timestamp=IsDatetime()),
UserPromptPart(content='Original prompt', timestamp=IsDatetime()),
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
)
]
)
assert captured_messages == result.all_messages()
# The reinjected resumed request is excluded; only the model response is new.
assert result.new_messages() == result.all_messages()[-1:]
async def test_history_processor_mutates_resumed_request_excludes_resumed_request(
function_model: FunctionModel, received_messages: list[ModelMessage]
):
"""
A history processor that rebuilds the trailing resumed request with any changed field
(here `metadata`) must not leak it into new_messages(). Re-matching the request by value
would fail on the changed field and fall back to run_id detection — which the framework
stamps on the resumed request — leaking it. Tracking the boundary by position excludes it
regardless of the rewrite. Covers the generalized leak described in
https://github.com/pydantic/pydantic-ai/issues/6025.
"""
def touch_request_metadata(messages: list[ModelMessage]) -> list[ModelMessage]:
rebuilt: list[ModelMessage] = []
for message in messages:
if isinstance(message, ModelRequest):
rebuilt.append(replace(message, metadata={'touched': True}))
else:
rebuilt.append(message)
return rebuilt
agent = Agent(function_model, capabilities=[ProcessHistory(touch_request_metadata)])
message_history = [
ModelRequest(parts=[UserPromptPart(content='Earlier question')]),
ModelResponse(parts=[TextPart(content='Earlier answer')]),
ModelRequest(parts=[UserPromptPart(content='Original prompt')]),
]
with capture_run_messages() as captured_messages:
result = await agent.run(message_history=message_history)
assert captured_messages == result.all_messages()
# The resumed request reached the model carrying the mutated metadata...
assert result.all_messages()[-2].metadata == snapshot({'touched': True})
# ...but it is still excluded from new_messages(): only the model response is new.
assert result.new_messages() == result.all_messages()[-1:]
def _user_request_present(messages: list[ModelMessage]) -> bool:
return any(isinstance(m, ModelRequest) and any(isinstance(p, UserPromptPart) for p in m.parts) for m in messages)
async def test_reinject_system_prompt_resumed_tool_loop_excludes_resumed_request():
"""
System-prompt reinjection rebuilds the resumed request in place on *every* step of a
multi-step resumed run (a tool-call loop). The resumed request must stay excluded from
new_messages() across all steps, while every message produced this run is included.
The pinned boundary is translated per step so the in-place rebuild never leaks it.
"""
call_count = 0
def model_function(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(parts=[ToolCallPart(tool_name='my_tool', args={}, tool_call_id='tool_call_1')])
return ModelResponse(parts=[TextPart(content='done')])
agent = Agent(
model=FunctionModel(model_function, model_name='test'),
system_prompt='Server prompt',
capabilities=[ReinjectSystemPrompt(replace_existing=True)],
)
@agent.tool
async def my_tool(_ctx: RunContext) -> str:
return 'tool executed'
with capture_run_messages() as captured_messages:
result = await agent.run(message_history=[ModelRequest(parts=[UserPromptPart(content='Original prompt')])])
assert captured_messages == result.all_messages()
# The reinjected resumed request stays excluded; everything produced this run is new.
assert result.new_messages() == result.all_messages()[1:]
assert not _user_request_present(result.new_messages())
async def test_history_processor_truncation_during_resumed_tool_loop_keeps_run_messages():
"""
A "keep last N" history processor can drop the resumed request partway through a
multi-step resumed run (here a tool-call loop). Once the resumed request is gone the
pinned boundary must not exclude messages produced earlier in the same run: new_messages()
should return every surviving message. Regression for the stale-pinned-index case raised
in review of the position-based boundary fix.
"""
call_count = 0
def model_function(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(parts=[ToolCallPart(tool_name='my_tool', args={}, tool_call_id='tool_call_1')])
return ModelResponse(parts=[TextPart(content='done')])
def keep_last_two(messages: list[ModelMessage]) -> list[ModelMessage]:
return messages[-2:]
agent = Agent(
model=FunctionModel(model_function, model_name='test'),
capabilities=[ProcessHistory(keep_last_two)],
)
@agent.tool
async def my_tool(_ctx: RunContext) -> str:
return 'tool executed'
with capture_run_messages() as captured_messages:
result = await agent.run(message_history=[ModelRequest(parts=[UserPromptPart(content='Original prompt')])])
assert captured_messages == result.all_messages()
# The resumed request was truncated away, so every surviving message is from this run —
# in particular the first model response must not be dropped by a stale boundary.
assert result.new_messages() == result.all_messages()
assert not _user_request_present(result.new_messages())
async def test_history_processor_removes_message_after_resumed_request_excludes_resumed_request():
"""
A history processor can remove a message positioned *after* the resumed request on a later step
(here dropping the model's tool-call response once the tool result is in history). The pinned
position can't follow a removal after the resumed request, but the resumed request object is
left untouched, so identity matching still excludes it from new_messages(). Guards against the
regression a purely position-based boundary would introduce — object matching and position
matching each cover mutations the other misses.
"""
call_count = 0
def model_function(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(parts=[ToolCallPart(tool_name='my_tool', args={}, tool_call_id='tool_call_1')])
return ModelResponse(parts=[TextPart(content='done')])
def drop_tool_call_response(messages: list[ModelMessage]) -> list[ModelMessage]:
# Only once the tool result is back: drop the model's tool-call response, which sits after
# the resumed request — a count change after it that the pinned index cannot track.
has_tool_return = any(
isinstance(m, ModelRequest) and any(isinstance(p, ToolReturnPart) for p in m.parts) for m in messages
)
if not has_tool_return:
return messages
first_response = next((m for m in messages if isinstance(m, ModelResponse)), None)
return [m for m in messages if m is not first_response]
agent = Agent(
model=FunctionModel(model_function, model_name='test'),
capabilities=[ProcessHistory(drop_tool_call_response)],
)
@agent.tool
async def my_tool(_ctx: RunContext) -> str:
return 'tool result'
with capture_run_messages() as captured_messages:
result = await agent.run(message_history=[ModelRequest(parts=[UserPromptPart(content='Original prompt')])])
assert captured_messages == result.all_messages()
# The tool-call response was dropped, but the resumed request stays excluded via identity
# matching; only the messages after it are new.
assert result.new_messages() == result.all_messages()[1:]
assert not _user_request_present(result.new_messages())
async def test_history_processor_insert_and_replace_resumed_request_excludes_resumed_request(
function_model: FunctionModel, received_messages: list[ModelMessage]
):
"""
A processor can both insert a message ahead of the resumed request AND rebuild the
resumed request itself in the same pass. The boundary is pinned by position *after*
processing runs (when the resumed request is the trailing message), so neither the
inserted message nor the rebuilt resumed request leaks into new_messages() — only the
model response is new. Covers the combined insert+replace case raised in review of the
position-based fix for https://github.com/pydantic/pydantic-ai/issues/6025.
"""
def insert_and_replace(messages: list[ModelMessage]) -> list[ModelMessage]:
# Rebuild every request (changed `metadata` defeats value re-matching) and prepend a
# fresh request at the front, shifting the resumed request off its original position.
rebuilt: list[ModelMessage] = [
replace(message, metadata={'touched': True}) if isinstance(message, ModelRequest) else message
for message in messages
]
rebuilt.insert(0, ModelRequest(parts=[SystemPromptPart(content='Injected context')]))
return rebuilt
agent = Agent(function_model, capabilities=[ProcessHistory(insert_and_replace)])
message_history = [
ModelRequest(parts=[UserPromptPart(content='Earlier question')]),
ModelResponse(parts=[TextPart(content='Earlier answer')]),
ModelRequest(parts=[UserPromptPart(content='Original prompt')]),
]
with capture_run_messages() as captured_messages:
result = await agent.run(message_history=message_history)
assert captured_messages == result.all_messages()
# Both the inserted request and the rebuilt resumed request are prior context; only the
# model response is new, and no user request leaks in.
assert result.new_messages() == result.all_messages()[-1:]
assert not _user_request_present(result.new_messages())
def test_takes_ctx_returns_false_for_untyped_processor():
"""takes_run_context returns False when the processor's first param has no type annotation."""
from pydantic_ai._utils import takes_run_context
def untyped_processor(messages) -> list[ModelMessage]: # pyright: ignore[reportUnknownParameterType,reportMissingParameterType]
return messages # pyright: ignore[reportUnknownVariableType] # pragma: no cover
# When first param has no type annotation, takes_run_context returns False
assert takes_run_context(untyped_processor) is False # pyright: ignore[reportUnknownArgumentType]