Skip to content

Commit 1c6f994

Browse files
authored
Merge pull request #158 from vstorm-co/fix/patch-tool-calls-drops-trailing-request
fix: keep trailing ModelRequest when patching strips all orphaned results
2 parents 73a753f + 4857869 commit 1c6f994

8 files changed

Lines changed: 71 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.3.32] - 2026-06-26
11+
12+
### Changed
13+
14+
- **Migrated to pydantic-ai 2.0.** pydantic-ai 2.0 removed the deprecated `Agent(history_processors=...)` parameter and the `pydantic_ai.usage.Usage` class. User-supplied `history_processors` are now wrapped in `ProcessHistory` capabilities and registered via the capabilities API (`pydantic_deep/agent.py`); the `history_processors=` argument to `create_deep_agent()` is unchanged. The CLI headless runner now uses `RunUsage` instead of `Usage` (`apps/cli/run.py`); its JSON output keys (`request_tokens` / `response_tokens`) are unchanged, sourced from `RunUsage.input_tokens` / `output_tokens`.
15+
- **`WebSearch` / `WebFetch` keep their local fallback under pydantic-ai 2.0** (`pydantic_deep/agent.py`). 2.0 changed the capability default to `local=None` (native-only), so a model whose provider lacks a native `WebFetchTool` now errored (`Native tool(s) ['WebFetchTool'] not supported by this model`) instead of falling back. We now pass `WebSearch(local="duckduckgo")` and `WebFetch(local=True)` to restore the pre-2.0 behaviour: the native tool is used when the provider supports it, and the local fallback kicks in otherwise.
16+
17+
### Fixed
18+
19+
- **`PatchToolCallsCapability` no longer drops a trailing `ModelRequest` left empty after stripping orphaned tool results** (`pydantic_deep/processors/patch.py`). Phase 2 removes `ToolReturnPart`s that have no matching `ToolCallPart`, then discarded any `ModelRequest` left with no parts. When that request was the **last** message — a resumed or interrupted history whose tail holds only orphaned results — dropping it left the history ending on a `ModelResponse`, which trips pydantic-ai's `Processed history must end with a `ModelRequest`` validation (`UserError`). This is the same class of bug as the upstream `LimitWarnerProcessor` fix. The stripped request is now kept as an empty `ModelRequest` structural placeholder (the shape pydantic-ai uses when resuming without a prompt) when it is the final message; interior empty requests are still dropped.
20+
21+
### Dependencies
22+
23+
- **Bumped `pydantic-ai-slim` to `>=2.0.0`** (`pyproject.toml`). pydantic-deep now targets the pydantic-ai 2.0 line (see *Changed* above). Consumers still pinned to pydantic-ai 1.x must stay on pydantic-deep 0.3.31.
24+
- **Bumped `summarization-pydantic-ai` to `>=0.1.10`** (`pyproject.toml`). Picks up two history-rewriting fixes of the same trailing-`ModelRequest` class: `LimitWarnerProcessor` no longer drops the already-empty trailing `ModelRequest` that pydantic-ai appends when resuming without a prompt, and `SlidingWindowProcessor` no longer trims history down to empty on a zero `keep`.
25+
1026
## [0.3.31] - 2026-06-22
1127

1228
### Changed

apps/cli/modals/diff_picker.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -260,8 +260,6 @@ def _render_action_hint(self) -> str:
260260
"[dim]· Esc cancel[/dim]"
261261
)
262262

263-
# ── Actions ───────────────────────────────────────────────────
264-
265263
def action_move_path_up(self) -> None:
266264
if not self._paths:
267265
return
@@ -329,8 +327,6 @@ def action_browse_merge_view(self) -> None:
329327
def action_cancel(self) -> None:
330328
self.dismiss(None)
331329

332-
# ── Internal helpers ──────────────────────────────────────────
333-
334330
def _refresh_paths(self) -> None:
335331
for i in range(len(self._paths)):
336332
with contextlib.suppress(Exception): # pragma: no cover - defensive

apps/cli/run.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
TextPartDelta,
2222
ThinkingPartDelta,
2323
)
24-
from pydantic_ai.usage import Usage
24+
from pydantic_ai.usage import RunUsage
2525

2626
from apps.cli.agent import create_cli_agent
2727
from apps.cli.init import ensure_initialized
@@ -219,14 +219,14 @@ async def _run_verbose(agent: Any, task: str, deps: Any, run_kwargs: dict[str, A
219219
return run.result
220220

221221

222-
def _build_json_output(output: str, usage: Usage) -> dict[str, Any]:
222+
def _build_json_output(output: str, usage: RunUsage) -> dict[str, Any]:
223223
"""Build a JSON-serializable output dict."""
224224
return {
225225
"output": output,
226226
"usage": {
227227
"total_tokens": usage.total_tokens,
228-
"request_tokens": usage.request_tokens,
229-
"response_tokens": usage.response_tokens,
228+
"request_tokens": usage.input_tokens,
229+
"response_tokens": usage.output_tokens,
230230
"requests": usage.requests,
231231
},
232232
}

pydantic_deep/agent.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -989,7 +989,7 @@ def _set_toolset_retries(toolset: AbstractToolset[DeepAgentDeps], max_retries: i
989989
skills=skills,
990990
directories=directories, # type: ignore[arg-type] # pyright: ignore[reportArgumentType]
991991
)
992-
all_toolsets.append(skills_toolset) # type: ignore[arg-type]
992+
all_toolsets.append(skills_toolset)
993993

994994
# Context toolset
995995
context_toolset = None
@@ -1219,9 +1219,6 @@ def _deep_agent_factory(cfg: dict[str, Any]) -> Any: # pragma: no cover
12191219
on_cost_update=on_cost_update,
12201220
)
12211221

1222-
if all_processors:
1223-
agent_create_kwargs["history_processors"] = all_processors
1224-
12251222
# Anthropic-specific keys are silently ignored by non-Anthropic models,
12261223
# so we set them unconditionally - no provider detection needed.
12271224
effective_model_settings: dict[str, Any] = {
@@ -1333,22 +1330,36 @@ def _deep_agent_factory(cfg: dict[str, Any]) -> Any: # pragma: no cover
13331330
if cost_cap is not None:
13341331
all_capabilities.append(cost_cap)
13351332

1333+
# `local=` provides a fallback for models whose provider has no native web
1334+
# tool. pydantic-ai 2.0 changed the default to `local=None` (no fallback),
1335+
# so a model that lacks native `WebFetchTool` now errors instead of falling
1336+
# back. We opt back into the local fallback to preserve pre-2.0 behaviour.
13361337
if web_search: # pragma: no cover
13371338
from pydantic_ai.capabilities import WebSearch
13381339

1339-
all_capabilities.append(WebSearch())
1340+
all_capabilities.append(WebSearch(local="duckduckgo"))
13401341

13411342
if web_fetch: # pragma: no cover
13421343
from pydantic_ai.capabilities import WebFetch
13431344

1344-
all_capabilities.append(WebFetch())
1345+
all_capabilities.append(WebFetch(local=True))
13451346

13461347
if thinking is not False: # pragma: no cover
13471348
from pydantic_ai.capabilities import Thinking
13481349

13491350
effort: Any = thinking if isinstance(thinking, str) else True
13501351
all_capabilities.append(Thinking(effort=effort))
13511352

1353+
# User-provided history processors are wrapped as ProcessHistory
1354+
# capabilities — pydantic-ai 2.0 removed the `Agent(history_processors=...)`
1355+
# parameter in favour of the capabilities API. They run after the built-in
1356+
# history-affecting capabilities (context manager, eviction) so they operate
1357+
# on the already-managed history.
1358+
if all_processors:
1359+
from pydantic_ai.capabilities import ProcessHistory
1360+
1361+
all_capabilities.extend(ProcessHistory(processor) for processor in all_processors)
1362+
13521363
# Add user-provided capabilities
13531364
if capabilities:
13541365
all_capabilities.extend(capabilities)

pydantic_deep/processors/patch.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,9 @@ def patch_tool_calls_processor(
214214

215215
if remaining_parts:
216216
patched2.append(ModelRequest(parts=remaining_parts))
217-
# If no parts remain, skip the message entirely
217+
elif i == len(messages) - 1:
218+
patched2.append(ModelRequest(parts=[]))
219+
# Otherwise the request is interior; dropping it is safe.
218220

219221
messages = patched2
220222

pyproject.toml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "pydantic-deep"
3-
version = "0.3.31"
3+
version = "0.3.32"
44
description = "Batteries-included agent harness for Python — tool-calling, sandboxed execution, multi-agent teams, and unlimited context on Pydantic AI"
55
readme = "README.md"
66
keywords = [
@@ -40,10 +40,10 @@ classifiers = [
4040
"Typing :: Typed",
4141
]
4242
dependencies = [
43-
"pydantic-ai-slim[web-fetch]>=1.97.0",
43+
"pydantic-ai-slim[web-fetch]>=2.0.0",
4444
"pydantic-ai-todo>=0.2.6",
4545
"pydantic-ai-backend[console]>=0.2.14",
46-
"summarization-pydantic-ai>=0.1.9",
46+
"summarization-pydantic-ai>=0.1.10",
4747
"subagents-pydantic-ai>=0.2.7",
4848
"pydantic-ai-shields>=0.3.4",
4949
"pydantic>=2.0",
@@ -63,7 +63,7 @@ cli = [
6363
"prompt-toolkit>=3.0.0",
6464
"tomli>=2.0; python_version < '3.11'",
6565
"python-dotenv>=1.0.0",
66-
"pydantic-ai-slim[anthropic,openai,openrouter,web-fetch,duckduckgo]>=1.97.0",
66+
"pydantic-ai-slim[anthropic,openai,openrouter,web-fetch,duckduckgo]>=2.0.0",
6767
]
6868
# TUI (Textual-based terminal UI)
6969
tui = [
@@ -72,7 +72,7 @@ tui = [
7272
"rich>=13.0.0",
7373
"tomli>=2.0; python_version < '3.11'",
7474
"python-dotenv>=1.0.0",
75-
"pydantic-ai-slim[anthropic,openai,openrouter,web-fetch,duckduckgo]>=1.97.0",
75+
"pydantic-ai-slim[anthropic,openai,openrouter,web-fetch,duckduckgo]>=2.0.0",
7676
]
7777
# Web server support (for full_app example)
7878
web = [
@@ -87,7 +87,7 @@ browser = [
8787
]
8888
# MCP (Model Context Protocol) client support — connect to MCP servers.
8989
# py-key-value-aio[disk] persists OAuth tokens (e.g. hosted Figma) across runs.
90-
mcp = ["pydantic-ai-slim[mcp]>=1.97.0", "py-key-value-aio[disk]>=0.4.5"]
90+
mcp = ["pydantic-ai-slim[mcp]>=2.0.0", "py-key-value-aio[disk]>=0.4.5"]
9191
# Document parsing via LiteParse (requires Node.js >= 18)
9292
liteparse = ["liteparse>=0.1.0"]
9393
# ACP (Agent Client Protocol) support for editor integration

tests/test_cli_run.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,8 @@ def mock_agent(self) -> MagicMock:
115115
mock_result.output = "Task completed successfully"
116116
mock_usage = MagicMock()
117117
mock_usage.total_tokens = 1000
118-
mock_usage.request_tokens = 800
119-
mock_usage.response_tokens = 200
118+
mock_usage.input_tokens = 800
119+
mock_usage.output_tokens = 200
120120
mock_usage.requests = 3
121121
mock_result.usage.return_value = mock_usage
122122
agent.run = AsyncMock(return_value=mock_result)
@@ -299,8 +299,8 @@ class TestBuildJsonOutput:
299299
def test_builds_output(self) -> None:
300300
usage = MagicMock()
301301
usage.total_tokens = 500
302-
usage.request_tokens = 400
303-
usage.response_tokens = 100
302+
usage.input_tokens = 400
303+
usage.output_tokens = 100
304304
usage.requests = 2
305305

306306
result = _build_json_output("Done", usage)

tests/test_patch_tool_calls.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,27 @@ def test_orphaned_tool_result_all_stripped(self):
311311
assert isinstance(result[1], ModelResponse)
312312
assert isinstance(result[2], ModelResponse)
313313

314+
def test_orphaned_tool_result_all_stripped_at_end_keeps_placeholder(self):
315+
"""A trailing request of only orphaned results survives as an empty placeholder.
316+
317+
Dropping the final message would leave the history ending on a
318+
`ModelResponse`, tripping pydantic-ai's "Processed history must end with
319+
a `ModelRequest`" validation. The stripped request is kept as an empty
320+
`ModelRequest` — the structural placeholder pydantic-ai uses when
321+
resuming without a prompt.
322+
"""
323+
messages = [
324+
ModelRequest(parts=[UserPromptPart(content="hello")]),
325+
ModelResponse(parts=[TextPart(content="no tool calls here")]),
326+
# Trailing request holding only an orphaned tool result.
327+
ModelRequest(parts=[ToolReturnPart(tool_name="t1", content="r1", tool_call_id="c1")]),
328+
]
329+
result = patch_tool_calls_processor(messages)
330+
assert len(result) == 3
331+
last = result[-1]
332+
assert isinstance(last, ModelRequest)
333+
assert last.parts == []
334+
314335
def test_mixed_valid_and_orphaned_results(self):
315336
"""Some ToolReturnParts have matching calls, some don't."""
316337
messages = [

0 commit comments

Comments
 (0)