Compaction follow-up: JSON-serialise shrunk tool returns, strengthen guard test, DRY threshold (#1824)#1853
Conversation
…guard test, DRY threshold (#1824) Code-review follow-up for the in-run history compaction processor (#1817). - Serialise non-string ToolReturnPart content with json.dumps (not str()) when shrinking, via a shared _stringify_tool_content helper that is reused by the token estimator so the pre-shrink estimate and post-shrink payload are serialised identically. Matches the existing json.dumps(..., default=str) convention used for tool-result content elsewhere in pydantic_ai_agents.py. (finding #2) - Strengthen test_thinking_only_modelresponse_is_not_emptied: place a large ToolReturnPart in the older prefix so the empty-parts guard -- not the no-op early return -- is the verified reason the thinking-only ModelResponse survives. Add test_non_string_tool_return_serialized_as_json to pin the JSON-serialisation fix. (finding #1) - DRY: new context_window_and_threshold() is the single definition of the compaction kick-in point, shared verbatim by should_compact, compact_message_history, and the in-run shrink processor (replaces three copies of int(get_context_window_for_model(...) * ratio)). - Document that IN_RUN_TOOL_RETURN_TARGET_CHARS / _shrink_tool_return_part bound the preserved prefix, not the final string (the trim notice adds a small fixed overhead) (finding #3), and add a comment confirming the _on_in_run_shrink closure's captured message IDs are stable for the turn (finding #5). Findings #4 (test reaching into a pydantic-ai internal) intentionally left as documented in the issue -- no change needed.
Code ReviewThis is a well-motivated follow-up that addresses real correctness and quality issues. The core fix (finding #2) is sound, the DRY refactor is clean, and the test improvements are genuinely valuable. A few notes below, ordered by severity. Minor: Hardcoded line numbers in comment (
|
…stale line refs - _stringify_tool_content now also catches RecursionError so a (pathological) self-referential tool return falls back to str() instead of crashing the shrink/estimate — the stated 'never crash on exotic content' contract. - Drop the hardcoded 'lines ~866-889' from the closure-stability comment in pydantic_ai_agents.py (would drift); say 'earlier in this function'. - Add the symmetric positive assertion (json null present) to test_non_string_tool_return_serialized_as_json.
Code Review: Compaction Follow-up (#1824)Clean, well-scoped PR. All five findings were correctly triaged, and the two correctness fixes are real improvements. A few minor observations below. OverviewThis follow-up correctly addresses the five code-review findings from #1817:
Correctness
Tests
One minor fragility: the
Documentation and StyleAll good. The constant annotation, SummaryApprove with nits. The correctness fix is valid and the test improvements close the gap the original review identified. The only actionable suggestions are:
Neither is a blocker. |
…ll-12QEE # Conflicts: # CHANGELOG.md
…ne' assertion comments - Keep the broad except in _stringify_tool_content but correct the misleading comment: TypeError/ValueError/RecursionError are all genuinely reachable (default=str does not rescue non-string dict keys, NaN/Inf with a strict encoder, or circular refs), so narrowing to RecursionError would turn a graceful fallback into a crash (review nit #1). - Document why 'None' not in part.content is safe: the only non-JSON text spliced in is the fixed trim notice, which contains no literal 'None' (review nit #2).
|
Code Review - PR 1853. Overview: Focused, well-scoped follow-up to PR 1817 in-run history compaction. Addresses five findings: a correctness bug (str vs json.dumps), a broken test, a DRY violation, and two documentation gaps. All changes are surgical. No blocking issues. Ready to merge. Full review to follow in next comment. |
|
Correctness Fix - _stringify_tool_content (history_processors.py): The bug was real -- str on a dict produces Python repr (single-quoted keys, True/None literals) that a model could misparse. json.dumps produces valid JSON (true/null, double-quoted keys). The exception triple (TypeError, ValueError, RecursionError) is well-chosen; inline comments justify each. Consistency win: _stringify_tool_content is shared by both _estimate_total_tokens and _shrink_tool_return_part, so the pre-shrink estimate and post-shrink payload are serialised identically. Consistent with the existing json.dumps(tool_result, default=str) convention at line 2110 of pydantic_ai_agents.py. Minor note: ValueError may not always be reachable with Python default encoder (float nan encodes as NaN without raising) -- the comment overstates reachability slightly, but keeping it is defensible. |
|
DRY Refactor - context_window_and_threshold (context_guardrails.py): Clean extraction. The three callers (should_compact, compact_message_history, shrink_old_artifacts_processor) all computed int(get_context_window_for_model(model_name) * threshold_ratio) identically. The tuple return (window, threshold) with callers using _, threshold is idiomatic -- the two turn-level callers only need threshold; the in-run processor genuinely uses both. Added to all correctly. from future import annotations is already at line 19 so the tuple[int, int] return type annotation is safe on Python < 3.9. |
|
Test Fix - test_thinking_only_modelresponse_is_not_emptied: The original test was genuinely broken as described -- with no shrinkable content in the older prefix, the processor hit the early-return and the test passed trivially via no-op, not via the empty-parts guard it claimed to test. Adding the large ToolReturnPart forces the processor to enter the shrink path. The three-part assertion structure (a/b/c) is easy to follow. assert len(deps.events) == 1 is a tight assertion that verifies exactly one shrink event fired, not just at least one. |
|
New Test - test_non_string_tool_return_serialized_as_json: Well-designed discriminator test. Using True/None in the payload gives a clean true/null vs True/None distinction that str() cannot produce. One observation: the assertion checking that the string None does not appear in part.content relies on _TRIM_NOTICE_TEMPLATE not containing the literal word None. This holds today and the inline comment documents the assumption. A more robust alternative would check only the JSON prefix before the trim notice marker, but the current approach is acceptable given the documentation. tool_call_id correlation preserved across serialise+shrink is a nice extra assertion. |
|
Documentation Changes: IN_RUN_TOOL_RETURN_TARGET_CHARS docstring clarification (target bounds the preserved prefix, not the final string) is accurate and useful for anyone tuning the constant down to a tight ceiling. The _on_in_run_shrink closure comment about late-binding stability is correct: _stream_core handles a single user turn and neither user_msg_id nor llm_msg_id is reassigned after the closure is defined. Overall verdict: no blocking issues. The str-to-json.dumps fix is a genuine correctness improvement consistent with existing conventions. The DRY refactor is byte-for-byte identical arithmetic. The test fix means the test now actually exercises the guard it claims to test. Ready to merge. |
…ll-12QEE # Conflicts: # CHANGELOG.md
Code ReviewThis is a solid, targeted follow-up that addresses the five findings cleanly. The changes are small, self-contained, and well-documented. A few observations: What works well
Test fixture strengthening (
Minor observations
Missing Changelog whitespace nit Test coverage assessment
The reported 14/14 and 61/61 pass counts are consistent with the scope of changes. No new async tool calls were introduced, so no risk of SummaryAll five findings are correctly addressed. The correctness fix (Finding #2, Approved. |
Code ReviewSummary: This is a solid follow-up PR that correctly addresses all five code-review findings from #1817. The changes are well-scoped, the fixes are correct, and the test improvements genuinely test what they claim to. Below are a few minor observations worth noting before merge. Strengths
Minor issues1. Inaccurate comment on
|
Closes #1824.
Code-review follow-up for the in-run history compaction processor shipped in #1817. Each of the five findings was assessed against the code before acting.
Findings assessment & resolution
test_thinking_only_modelresponse_is_not_emptiedpasses via the no-op early-return, not the empty-parts guard it names_shrink_tool_return_partstringifies non-string content withstr()instead ofjson.dumps()_stringify_tool_contenthelper usingjson.dumps(..., default=str)target_charsby the trim-notice length; undocumentedroot_capability.capabilities)_on_in_run_shrinkclosure capturesuser_msg_id/llm_msg_idby late-binding — worth a commentChanges
ToolReturnPart.contentcan be a dict/list; the shrink path serialised it withstr()(Python repr — single-quoted keys,True/None), which the model can misparse. New_stringify_tool_contentserialises non-strings withjson.dumps(content, default=str)(falling back tostr()only if JSON serialisation fails) and is reused by both the token estimator and the shrink, so the pre-shrink estimate matches the post-shrink payload. This matches the existingjson.dumps(tool_result, default=str)convention already used to build tool-result content inpydantic_ai_agents.py.tool_returns_shrunk == 0 and thinking_parts_dropped == 0early-return and the test passed because nothing ran. It now places a largeToolReturnPartalongside the thinking-only response so the shrink actually runs, and asserts via the emittedInRunShrinkEvent(tool_returns_shrunk == 1,thinking_parts_dropped == 0) that the empty-parts guard is the reason theThinkingPartsurvives. Addedtest_non_string_tool_return_serialized_as_jsonto pin the Bump traefik from v2.8.7 to v2.9.1 in /compose/production/traefik #2 fix.context_window_and_threshold(model_name, threshold_ratio)is the single definition of the compaction kick-in point, replacing three copies ofint(get_context_window_for_model(...) * ratio)acrossshould_compact,compact_message_history, andshrink_old_artifacts_processor. This formalises the shared-threshold_ratiodesign the review praised. Also unified the twostr(content)stringification sites behind_stringify_tool_content.IN_RUN_TOOL_RETURN_TARGET_CHARSbounds the preserved prefix (the trim notice adds a small fixed overhead) on both the constant and_shrink_tool_return_part; added the closure-stability comment in_stream_core.Testing
Ran the affected pure-unit suites in an isolated venv (pydantic-ai-slim 1.104.0, matching the repo pin):
test_history_processors.py: 14/14 passed (incl. the strengthened guard test + new JSON test).test_context_guardrails.py(TestShouldCompact,TestCompactMessageHistory,TestGetContextWindowForModel,TestTruncateToolOutput, …): 61/61 passed, exercising thecontext_window_and_thresholdrefactor.black,isort, andflake8pass on all changed files. No production behaviour changes beyond thestr()→json.dumps()serialisation of non-string shrunk tool returns; the threshold refactor is byte-for-byte identical arithmetic.