Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 6 additions & 1 deletion pydantic_ai_harness/compaction/_shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,8 +387,13 @@ def rebuild_with_cleared(
request_parts: list[ModelRequestPart] = []
changed = False
for part in msg.parts:
# Exact type, not `isinstance`: typed `ToolReturnPart` subclasses such as
# `ToolSearchReturnPart` carry structured `TypedDict` content that core re-parses
# (and trusts to be valid) on every request -- see `parse_discovered_tools`. Blanking
# it to a string both breaks that invariant (crash next request) and discards
# discovery state, so only untyped tool results are cleared.
if (
isinstance(part, ToolReturnPart)
type(part) is ToolReturnPart
and part.tool_call_id in clear_return_ids
and str(part.content) != placeholder
):
Expand Down
33 changes: 33 additions & 0 deletions tests/compaction/test_compaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@
TextPart,
ToolCallPart,
ToolReturnPart,
ToolSearchReturnContent,
ToolSearchReturnPart,
UserPromptPart,
)
from pydantic_ai.models import Model, ModelRequestContext, ModelRequestParameters
from pydantic_ai.models.test import TestModel
from pydantic_ai.toolsets._tool_search import parse_discovered_tools
from pydantic_ai.usage import RunUsage

from pydantic_ai_harness.compaction import (
Expand Down Expand Up @@ -1581,6 +1584,36 @@ async def test_idempotent(self):
assert _return_contents(twice) == ['[tool result cleared]']
assert _call_args(twice) == ['{}']

@pytest.mark.anyio
async def test_preserves_typed_tool_search_return(self):
# `ToolSearchReturnPart` subclasses `ToolReturnPart` but carries structured content that
# core's `parse_discovered_tools` re-reads on the next request. Blanking it to a string
# crashed that reader; clearing must skip typed subclasses and touch only plain results.
cap = ClearToolResults(max_messages=1, keep_pairs=0)
search_content: ToolSearchReturnContent = {'discovered_tools': [{'name': 'alpha'}, {'name': 'beta'}]}
messages: list[ModelMessage] = [
_tool_call('fn', 'u1'),
_tool_return('fn', 'u1', 'plain result'),
ModelResponse(parts=[ToolCallPart(tool_name='search_tools', args='{}', tool_call_id='ts1')]),
ModelRequest(parts=[ToolSearchReturnPart(content=search_content, tool_call_id='ts1')]),
]
result = await cap.before_model_request(_make_ctx(), _make_request_context(messages))

returns = {
p.tool_call_id: p
for m in result.messages
if isinstance(m, ModelRequest)
for p in m.parts
if isinstance(p, ToolReturnPart)
}
# Plain result blanked, typed search return kept intact (concrete type + structured content).
assert type(returns['u1']) is ToolReturnPart
assert returns['u1'].content == cap.placeholder
assert type(returns['ts1']) is ToolSearchReturnPart
assert returns['ts1'].content == search_content
# The real next-request regression: core still recovers the discovered names.
assert parse_discovered_tools(result.messages) == {'alpha', 'beta'}


# ---------------------------------------------------------------------------
# DeduplicateFileReads
Expand Down
Loading