In-run history compaction via pydantic-ai history_processors#1817
Conversation
Adds four new fields to CompactionConfig (in_run_enabled, in_run_keep_recent_pairs, in_run_tool_return_target_chars, in_run_drop_thinking) wired to the constants added in the previous commit. __post_init__ validates the two integer fields are >= 1. Covers the new surface with four SimpleTestCase tests.
…rts guard
Fix 1: Add `compaction: CompactionConfig` field to PydanticAIDependencies so
the in-run HistoryProcessor reads its knobs from the correct production path
(`deps.compaction`) instead of the non-existent `deps.config.compaction`.
Update `_resolve_config` to try `deps.compaction` first (production), then
`deps.config_compaction` (test-stub legacy), then fall back to defaults.
Fix 2: Collapse the duplicated string/non-string branches in
`_shrink_tool_return_part` into a single unified path — stringify once, then
apply the same truncation arithmetic regardless of the original type.
Fix 3: Guard `_shrink_message`'s ModelResponse branch so that dropping
ThinkingParts from a response that contains only ThinkingParts leaves the
message intact rather than producing an empty parts list.
Fix 4: Correct docstring typo in `_split_protected_suffix` ("any trailing
leading ModelRequest" → "any trailing ModelRequest").
Fix 5: Add missing `tool_returns_shrunk == 1` assertion to
`test_drops_older_thinking_parts`.
Two new regression tests added: `test_resolves_compaction_from_deps_compaction_field`
and `test_thinking_only_modelresponse_is_not_emptied`. Total: 12 tests, all passing.
Wire shrink_old_artifacts_processor as the first history_processors entry on every Agent constructed through the factory chokepoint. Caller-supplied processors are preserved after ours. Also tightens the ctx parameter annotation on the processor from Any to RunContext so pydantic-ai's takes_run_context() introspection invokes the two-argument form. The _FakeRunContext call sites in test_history_processors.py get type: ignore[arg-type] (runtime-correct; mypy suppression only). The Agent(history_processors=...) constructor parameter is deprecated in pydantic-ai 1.62 in favour of a v2 API (capabilities=[ProcessHistory(fn)] or Hooks(before_model_request=fn)) — but that replacement API does NOT ship in 1.62 yet (neither ProcessHistory nor Hooks is exported), so the deprecated form is the only workable path. We silence the forward-looking PydanticAIDeprecationWarning with a narrowly-scoped warnings filter that targets only that custom warning class.
Wire the in-run HistoryProcessor's on_in_run_shrink callback in _stream_core so that when in-loop compaction fires during a streamed chat, a ThoughtEvent is appended to the TimelineBuilder and becomes visible in the UI timeline. Non-streaming (_chat_raw) callers leave the callback unset and continue to receive log-only telemetry from the processor.
Signed-off-by: JSIV <5049984+JSv4@users.noreply.github.com>
Code Review: In-run history compaction via pydantic-ai history_processorsOverviewThis PR wires a threshold-gated Bug:
|
Main fix (Medium severity from Claude review): - ``PydanticAICoreAgent._apply_context_budget`` now copies the full ``config.compaction`` onto ``agent_deps.compaction``. Previously only ``threshold_ratio`` was forwarded, so per-conversation overrides to ``in_run_enabled`` / ``in_run_keep_recent_pairs`` / ``in_run_tool_return_target_chars`` / ``in_run_drop_thinking`` silently defaulted inside the in-run history processor. Pinned by a new regression test in ``TestRefreshContextBudgetFallback``. Cleanup: - ``_resolve_config`` drops the ``config_compaction`` test-stub fallback branch. ``_FakeDeps`` in ``test_history_processors.py`` now mirrors the production attribute name (``compaction``), and the previously-separate ``_ProdDeps`` test stub collapses into ``_FakeDeps``. - ``test_drops_older_thinking_parts`` replaces the positional ``result[7]`` lookup with a predicate-based find so the test stays self-documenting if the fixture grows. - ``test_pydantic_ai_factory.py`` routes the three new ``root_capability.capabilities`` accesses through a single ``_agent_capabilities`` helper that documents the brittle internal contract and raises a precise tripwire if pydantic-ai renames it. - ``history_processors.py`` consolidates the dual ``import dataclasses`` + ``from dataclasses import dataclass`` into a single ``from dataclasses import dataclass, replace``. CHANGELOG updated under ### Fixed.
Code Review: In-run history compaction via pydantic-ai history_processorsOverviewThis PR closes a genuine gap: the existing compaction pipeline ran once per turn (before What Works Well
Issues to Address1.
|
Items addressed from the second review pass (the first review's items were already in 7501a95 / c8916ea): - Import-time guard: assert ToolReturnPart, ModelRequest, ModelResponse remain stdlib @DataClass. dataclasses.replace silently fails on BaseModel instances; the outer try/except in shrink_old_artifacts_processor would have swallowed the resulting TypeError into a no-op. The guard makes the next pydantic-ai bump that breaks the assumption visible in CI. - New regression test test_callback_exception_does_not_propagate pinning the contract that a raising on_in_run_shrink callback is caught locally inside the callback block (logger.exception fires), not by the outer try/except — so the actual shrink work survives telemetry-sink failures. The previous # pragma: no cover - defensive marker is dropped now that the path is covered. - Docstring clarity on _split_protected_suffix explaining the "pairs" terminology vs. the response-boundary anchor (the split point is the Nth most-recent ModelResponse; trailing ModelRequests stay with the recent suffix). - Inline comment near the threshold computation noting that threshold_ratio is shared by design between the in-run processor and the outer MessageHistoryService compaction — future tuning should treat both layers together. CHANGELOG updated with two new Fixed entries.
|
Review posted below |
|
UPDATE: The review has been posted above but the content needs to be replaced. Please see the detailed review analysis below. |
|
Code Review — PR 1817: In-run history compaction via pydantic-ai history_processors Overview This PR wires shrink_old_artifacts_processor into every pydantic-ai agent via make_pydantic_ai_agent, addressing the gap from PR 1814 where in-loop tool call/return messages accumulated unbounded. The architecture is clean: a single injection point, threshold-gated, with a defensively-wrapped outer try/except so a processor bug can never break an agent run. Strengths
Issues Issue 1: Legacy processor wrapping may fail at runtime (medium) In pydantic_ai_factory.py the factory wraps caller-supplied history_processors with ProcessHistory(fn). ProcessHistory expects (RunContext, list[ModelMessage]) -> list[ModelMessage]. The factory tests pass functions that only accept messages (one argument), which passes inspection but raises TypeError at runtime when pydantic-ai invokes fn(ctx, messages). If any existing call site passes single-argument processors, they are silently wrapped into something that crashes on the first tool call. Recommendation: document the required (ctx, messages) signature in the factory docstring, or validate at wrap time with inspect.signature and raise a clear error early. Issue 2: Import-time assertion introduces a startup failure path (low) The module-level loop in history_processors.py checking dataclass_fields on pydantic-ai message types is intentional (fail loudly at import time), but is not in the Common Pitfalls section of CLAUDE.md. A developer doing a routine pip install --upgrade pydantic-ai will get a RuntimeError on manage.py runserver with a non-obvious traceback. Worth adding a pitfall entry. Observations
Uses agent.root_capability.capabilities. Well-documented with a nice AttributeError tripwire. Consider adding a link to the pydantic-ai source line so a future version bump is easier to diagnose.
The Any presumably avoids a circular import from history_processors.py. A TYPE_CHECKING guard would keep the specific InRunShrinkEvent type in annotations without a runtime import cycle. Minor, but helps IDEs and mypy.
When over threshold but the only large content is in UserPromptPart (no ToolReturnPart or ThinkingPart in the older prefix), the processor logs WARNING. This is correct but slightly misleading since the content is structurally uncompressible by this processor. An INFO with a note that UserPromptPart is not compacted might be less alarming in production log monitors.
The test checks result[1] for the old ModelResponse. If the fixture changes the index breaks silently. Prefer the next(...) pattern used a few lines down for the recent ThinkingPart check.
Tests assert "in-run trim" in old_return_part.content. Consider promoting the stable substring to a public constant in constants/context_guardrails.py so tests can import it rather than string-matching a private template. Summary Solid feature. Architecture, defensive error handling, and test coverage are all strong. Two items worth addressing before merge:
Everything else is non-blocking polish. |
- Document legacy history-processor signature contract in make_pydantic_ai_agent: ProcessHistory dispatches on the first parameter's type annotation, so an untyped 2-arg processor crashes at runtime — callers wanting (ctx, messages) MUST annotate as RunContext. - Add CLAUDE.md pitfall #17: history_processors.py import-time RuntimeError on pydantic-ai dataclass-shape drift. - Type on_in_run_shrink as Callable[[InRunShrinkEvent], None] (direct import — there is no cycle to defer behind TYPE_CHECKING). - Soften "no shrinkable content" / "no older messages" logs from WARNING to INFO with rationale; pin the level in the unit test. - Replace fragile result[1] index in test_drops_older_thinking_parts with a next(...) lookup keyed on the ToolCallPart's tool_call_id. - Promote the trim-notice marker to a public constant (IN_RUN_TRIM_NOTICE_MARKER in constants/context_guardrails.py); the private template composes from it and tests import the public form. - Add upstream pydantic-ai source pointers to _agent_capabilities() so the AttributeError tripwire is faster to diagnose on a bump.
Code Review: In-run history compaction via pydantic-ai history_processorsOverviewThis PR wires a threshold-gated Findings1.
|
…guard test, DRY threshold (#1824) (#1853) * Compaction follow-up: JSON-serialise shrunk tool returns, strengthen 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. * Address review: guard RecursionError in tool-content stringify, drop 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. * Address PR #1853 review: clarify _stringify_tool_content except + 'None' 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). --------- Co-authored-by: Claude <noreply@anthropic.com>
Summary
shrink_old_artifacts_processorinto the single agent-construction chokepoint (make_pydantic_ai_agent) as aProcessHistorycapability so every chat path (corpus, document, structured extraction, deep-research) gets in-run history compaction with zero per-call-site changes.threshold_ratio * context_window. When over threshold, truncates olderToolReturnPart.contenttoin_run_tool_return_target_charsand drops olderThinkingPartinstances, preservingtool_call_idcorrelation throughout.ThoughtEventwithmetadata["in_run_shrink"]; non-streamed chats get log-only INFO telemetry.Closes the gap surfaced by PR #1814 deep-research review: the existing once-per-turn compaction only handled persisted ChatMessage rows; in-loop tool-call/tool-result messages were accumulating unbounded.
Architecture
opencontractserver/llms/history_processors.py(new) — pure-functionshrink_old_artifacts_processor+InRunShrinkEventdataclass + helpers (_estimate_total_tokens,_resolve_config,_split_protected_suffix,_shrink_tool_return_part,_shrink_message). Defensive top-level try/except so processor bugs can never break an agent run.CompactionConfiggains four fields (in_run_enabled,in_run_keep_recent_pairs,in_run_tool_return_target_chars,in_run_drop_thinking) with sensible defaults.in_run_enabled=Falseis the kill switch.PydanticAIDependenciesgains acompaction: CompactionConfigfield and anon_in_run_shrinktelemetry callback.make_pydantic_ai_agentunconditionally prependsProcessHistory(shrink_old_artifacts_processor)to the agent'scapabilities=list. Both legacyhistory_processors=[...](auto-wrapped) and moderncapabilities=[...]caller kwargs are accepted and run after ours.PydanticAICoreAgent._stream_corewires the telemetry callback to append aThoughtEventto the streamingTimelineBuilder. Non-streaming chats leave the callback unset → log-only.API choice
Uses the modern pydantic-ai capabilities API (
pydantic_ai.capabilities.ProcessHistory), available since pydantic-ai 1.71+. The codebase is pinned to>=1.102.0,<2, so the new API is always available — no warning suppression or deprecation-form fallback needed.Test plan
test_history_processors.pycovering threshold gate, shrink, ThinkingPart drop, tool_call_id preservation, last-message invariant, short histories, disabled config, deps=None, callback contract, production-path config resolution, empty-parts guard.test_pydantic_ai_factory.py— existingsystem_promptguards + 3 new tests pinningProcessHistoryinjection order, legacy-kwarg auto-wrapping, and modern-kwarg passthrough.[history_processors] In-run shrinklog line fires and the report still finalises with citations intact.