Recognize `ToolAvailabilityDeltaPart` in the compaction estimator and conversation-search index
Summary
Combining compaction with an on-demand capability crashes: pydantic-ai records newly available tools in a ToolAvailabilityDeltaPart, and that is the one ModelRequestPart carrying no content, so Harness code that walks request parts raises AttributeError rather than quietly ignoring it. Reported in #577 with a two-line reproduction.
Two call sites are affected, each with its own crash:
compaction/_shared.py— the token estimator'selsebranch doesstr(part.content)→AttributeError: ... has no attribute 'content'. This fires while checking the compaction threshold, so it breaks runs whose history is nowhere near the limit.conversation_search/_toolset.py— the indexer'sRetryPromptPartfallback readspart.tool_name→AttributeError: ... has no attribute 'tool_name'. Not in the original report; found by the same audit.
Both now recognise the part and skip it: the names it records travel in the request's tool definitions, which the estimator already leaves out, and tool-list bookkeeping is not conversation content. _format_message grew past the complexity cap, so its request branch moved to _format_request_part unchanged.
The rest of the part vocabulary was audited and needs nothing: every other type added since 2.22 subclasses one these functions already handle (ToolSearchCallPart/LoadCapabilityCallPart ⊂ ToolCallPart, the search and capability-load returns ⊂ ToolReturnPart, NativeToolSearch* ⊂ their native bases). ToolAvailabilityDeltaPart is the sole exception.
Exhaustiveness
Skipping one part type would have left the same shape of bug waiting for the next one, so both walks now name every type they handle and end in an assert_never guard: a part added upstream fails make typecheck instead of a user's run. Verified by simulating an unhandled part — pyright reports Argument of type "RetryPromptPart" cannot be assigned to parameter "arg" of type "Never".
The guard sits under if TYPE_CHECKING: rather than in the branch body, which departs from how this is usually written here (spend/_budget.py, guardrails/). Those guard unions we own, where unreachable is genuinely true. These two walk pydantic-ai's union against whatever release the user installed, and the floor has no upper bound — so the usual runtime-raising form would raise on a newer release, which is exactly the failure #577 reported. Static tripwire, no runtime cliff.
The explicit ToolAvailabilityDeltaPart branch is redundant against today's union and carries a narrow reportUnnecessaryIsInstance ignore. Without it the fallthrough is dead code, and no future part would ever reach it.
Floor and lock
The floor moves >=2.22.0 → >=2.23.0, and uv.lock moves pydantic-ai-slim/pydantic-graph 2.22.0 → 2.27.0.
An earlier revision of this PR left the floor alone on the grounds that nothing needed the newer release at runtime. That was wrong: recognising this part is a runtime requirement, because anyone on 2.23.0–2.27.0 who combines compaction with a deferred capability hits the crash regardless of what we lock. 2.23.0 is where ToolAvailabilityDeltaPart was introduced (pydantic-ai#6793, first tagged v2.23.0) — so that is the minimum honest floor, and test-floor keeps it honest. The floor is not raised to 2.27 merely because that is what we lock.
With the class guaranteed to import, both checks are isinstance rather than a part_kind string comparison, and the two new tests drop their version guards and inline imports.
What changed around this PR
#573 landed while this was open and fixed the code_mode deferred-tool assertion, so that part is dropped rather than duplicated — main asserts on revealed_tool_names, one branchless assertion holding on both sides of the visibility split. That overlap was the entire cause of the merge conflict.
#573 also silenced pydantic-settings 2.15's IncompleteFieldDefinitionWarning with a message-matched filterwarnings entry. #576 (@Neallin-917) is the better fix and is now folded in here, with bb649d3 removing the suppression: the entry silenced that warning for every model in the suite, ours included, to route around one unresolved forward reference in a dependency, whereas #576 resolves the annotation where it originates and leaves the warning armed everywhere else.
It is sufficient and load-bearing, measured at the resolution that actually reproduces the failure — lowest-direct, which pairs pydantic-settings 2.15.0 with mcp 1.26.0 (the lock carries a newer mcp, which is why this does not reproduce against the lock):
at lowest-direct | result |
|---|---|
| #576's fix, no suppression | 48 passed |
| neither | 15 errors |
Its commits are cherry-picked with authorship intact (1d098db @Neallin-917, d1b008a @adtyavrdhn), so #576 should be closed as landed here, not as an empty diff.
Verification
Run at both ends of the supported range — the floor (lowest-direct, pydantic-ai 2.23.0) and the lock (2.27.0): 4598 passed, 47 skipped, 100% branch coverage; lint and pyright clean; uv lock --check consistent.
Both new tests were mutation-checked at the floor: disabling either isinstance branch reproduces its own original AttributeError (.content in the estimator, .tool_name in the indexer).
Testing at the floor was not incidental — an earlier revision passed at the lock and failed all five floor jobs, because ToolAvailabilityDeltaPart's field was named added until #7104 renamed it to tools_added in v2.26.0. The tests now build the part with no arguments, which is valid from 2.23.0 onward and exercises the same two failures, since neither call site reads what the part carries.
Linked Issue
Checklist
- Linked issue exists and is referenced above
- Tests added/updated for new behavior
-
make lint && make typecheck && make testpasses locally (don't stress about CI -- we'll help) -
pyproject.tomlanduv.lockare unchanged, or a maintainer addeddependencies:approvedto the current head - Docstrings use single backticks (not RST double backticks)