Capability
Compaction
Bug Description
ClearToolResults blanks old tool results via dataclasses.replace(part, content=placeholder). replace preserves the concrete class, so a typed subclass like ToolSearchReturnPart survives as a typed part whose content is now a plain string — violating the invariant its type promises.
Core re-parses these parts from history on every request (_agent_graph._refresh_discovered_tool_names → parse_discovered_tools, which per its docstring trusts typed content: “No defensive isinstance walks needed”), and since capability-processed messages are written back into run state (_agent_graph.py: ctx.state.message_history[:] = messages right after before_model_request), the corrupted part persists.
The next request then dies:
File "pydantic_ai/_agent_graph.py", line 1996, in _refresh_discovered_tool_names
discovered_tool_names = parse_discovered_tools(ctx.state.message_history)
File "pydantic_ai/toolsets/_tool_search.py", line 203, in parse_discovered_tools
_collect_typed(part.content, discovered)
File "pydantic_ai/toolsets/_tool_search.py", line 221, in _collect_typed
discovered.update(match['name'] for match in content['discovered_tools'])
TypeError: string indices must be integers, not 'str'
Minimal Reproduction
import asyncio
from pydantic_ai import Agent
from pydantic_ai.messages import (
ModelMessage, ModelRequest, ModelResponse, TextPart,
ToolCallPart, ToolReturnPart, ToolSearchReturnPart,
)
from pydantic_ai.models.function import AgentInfo, FunctionModel
from pydantic_ai_harness.compaction import ClearToolResults
history: list[ModelMessage] = [
# A tool previously discovered via the local `search_tools` fallback path.
ModelResponse(parts=[ToolCallPart(tool_name="search_tools", args={"queries": ["weather"]}, tool_call_id="s1")]),
ModelRequest(parts=[ToolSearchReturnPart(
tool_name="search_tools", tool_call_id="s1",
content={"discovered_tools": [{"name": "get_weather", "description": "", "toolset": ""}]},
)]),
]
# Bulk tool traffic so the token trigger fires.
for i in range(10):
history.append(ModelResponse(parts=[ToolCallPart(tool_name="fetch", args={}, tool_call_id=f"c{i}")]))
history.append(ModelRequest(parts=[ToolReturnPart(tool_name="fetch", content="x" * 5000, tool_call_id=f"c{i}")]))
request_count = 0
def scripted_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
global request_count
request_count += 1
if request_count == 1: # one tool call, so the run makes a second request after clearing fires
return ModelResponse(parts=[ToolCallPart(tool_name="fetch", args={}, tool_call_id="new1")])
return ModelResponse(parts=[TextPart(content="done")])
agent = Agent(FunctionModel(scripted_model), capabilities=[ClearToolResults(max_tokens=1_000, keep_pairs=0)])
@agent.tool_plain
def fetch() -> str:
return "ok"
asyncio.run(agent.run("hi", message_history=history)) # TypeError on request 2
pydantic-ai-harness version
0.7.0
pydantic-ai version
2.10
Capability
Compaction
Bug Description
ClearToolResultsblanks old tool results viadataclasses.replace(part, content=placeholder).replacepreserves the concrete class, so a typed subclass likeToolSearchReturnPartsurvives as a typed part whosecontentis now a plain string — violating the invariant its type promises.Core re-parses these parts from history on every request (
_agent_graph._refresh_discovered_tool_names→parse_discovered_tools, which per its docstring trusts typed content: “No defensive isinstance walks needed”), and since capability-processed messages are written back into run state (_agent_graph.py:ctx.state.message_history[:] = messagesright afterbefore_model_request), the corrupted part persists.The next request then dies:
Minimal Reproduction
pydantic-ai-harness version
0.7.0
pydantic-ai version
2.10