Skip to content

ClearToolResults corrupts history: clearing a ToolSearchReturnPart crashes the run’s next request with TypeError #380

Description

@sevakva

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_namesparse_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

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingneeds:triageNeeds maintainer attention

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions