Skip to content

Commit 806d41b

Browse files
JSv4claude
andauthored
Compaction follow-up: JSON-serialise shrunk tool returns, strengthen 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>
1 parent dd2b1fa commit 806d41b

6 files changed

Lines changed: 233 additions & 27 deletions

File tree

CHANGELOG.md

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

112112
### Fixed
113113

114+
- **In-run history compaction follow-up (issue #1824).** Code-review fixes for
115+
the pydantic-ai `shrink_old_artifacts_processor` shipped in #1817.
116+
- **Structured tool returns now shrink to valid JSON**
117+
(`opencontractserver/llms/history_processors.py`). `ToolReturnPart.content`
118+
can be a dict/list; the shrink path stringified it with `str()` (Python
119+
repr — single-quoted keys, `True`/`None`), which the model can misparse. A
120+
new `_stringify_tool_content` helper serialises non-strings with
121+
`json.dumps(content, default=str)` (falling back to `str()` only if JSON
122+
serialisation fails) and is shared by both the token estimator and the
123+
shrink, so the pre-shrink estimate matches the post-shrink payload.
124+
- **Strengthened `test_thinking_only_modelresponse_is_not_emptied`**
125+
(`opencontractserver/tests/test_history_processors.py`). The old fixture had
126+
no shrinkable content in the older prefix, so the test passed via the no-op
127+
early-return rather than the empty-parts guard it names. It now places a
128+
large `ToolReturnPart` alongside the thinking-only response so the shrink
129+
actually runs, and asserts (via telemetry) that the guard — not an early
130+
return — is why the `ThinkingPart` survives. Added
131+
`test_non_string_tool_return_serialized_as_json` to pin the JSON fix.
114132
- **MCP `WWW-Authenticate` base-URL hardening** (`opencontractserver/mcp/server.py`)
115133
— the 401 challenge now prefers the trusted `MCP_PUBLIC_BASE_URL` over the
116134
request `Host` header (MCP bypasses `ALLOWED_HOSTS`), falling back to the
@@ -234,6 +252,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
234252

235253
### Changed
236254

255+
- **DRY: single definition of the compaction kick-in point** (issue #1824)
256+
(`opencontractserver/llms/context_guardrails.py`). New
257+
`context_window_and_threshold(model_name, threshold_ratio)` replaces the
258+
`int(get_context_window_for_model(...) * ratio)` triplication across
259+
`should_compact`, `compact_message_history`, and the in-run
260+
`shrink_old_artifacts_processor`, so turn-level and in-run compaction derive
261+
the threshold identically from one place. Documented (finding #3) that
262+
`IN_RUN_TOOL_RETURN_TARGET_CHARS` bounds the preserved prefix, not the final
263+
string — the appended trim notice adds a small fixed overhead — and added a
264+
comment confirming the `_on_in_run_shrink` closure's captured message IDs are
265+
stable for the turn (finding #5).
237266
- **`BaseService.filter_visible_qs` now fails closed** (`opencontractserver/shared/services/base.py`)
238267
— an input lacking a `visible_to_user` method previously passed through
239268
**unfiltered** (fail-open, a latent row-leak); it now raises `TypeError`.

opencontractserver/constants/context_guardrails.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,12 @@
168168
# trim notice is appended. Chosen so a shrunk return still carries enough
169169
# signal for the model to recall what the tool produced without dominating
170170
# the budget.
171+
#
172+
# This bounds the preserved *prefix*, not the final string: the appended
173+
# trim notice (see ``history_processors._TRIM_NOTICE_TEMPLATE``, ~40 chars
174+
# plus the digits of the elided count) makes the post-shrink content
175+
# slightly longer than this value. Negligible at the default 4K, but
176+
# callers tuning this down to a tight ceiling should budget for the notice.
171177
IN_RUN_TOOL_RETURN_TARGET_CHARS: int = 4_000
172178

173179
# Whether the in-run processor strips ThinkingPart instances from messages

opencontractserver/llms/agents/pydantic_ai_agents.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -991,6 +991,12 @@ async def _stream_core(
991991
# compaction fires. Non-streaming (_chat_raw) never sets this
992992
# callback, so those chats get log-only telemetry from the
993993
# processor itself.
994+
#
995+
# The closure captures ``user_msg_id`` / ``llm_msg_id`` by Python
996+
# late-binding, but both are resolved earlier in this function and
997+
# never reassigned afterwards: ``_stream_core`` handles a single
998+
# user turn, so the IDs are stable for the closure's lifetime and
999+
# every in-loop shrink in this turn tags the same message pair.
9941000
def _on_in_run_shrink(event: Any) -> None:
9951001
try:
9961002
thought_evt = ThoughtEvent(

opencontractserver/llms/context_guardrails.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
__all__ = [
4545
"estimate_token_count",
4646
"get_context_window_for_model",
47+
"context_window_and_threshold",
4748
"truncate_tool_output",
4849
"cap_summary_length",
4950
"strip_compaction_prefix",
@@ -122,6 +123,24 @@ def get_context_window_for_model(model_name: str) -> int:
122123
return DEFAULT_CONTEXT_WINDOW
123124

124125

126+
def context_window_and_threshold(
127+
model_name: str,
128+
threshold_ratio: float = COMPACTION_THRESHOLD_RATIO,
129+
) -> tuple[int, int]:
130+
"""Return ``(context_window, threshold)`` for *model_name*.
131+
132+
``threshold`` is ``int(context_window * threshold_ratio)`` — the single
133+
definition of "the estimated-token count at which compaction kicks in".
134+
Shared verbatim by turn-level compaction (:func:`should_compact`,
135+
:func:`compact_message_history`) and the in-run history processor
136+
(``opencontractserver.llms.history_processors.shrink_old_artifacts_processor``)
137+
so both layers derive the kick-in point identically from the same
138+
``threshold_ratio``.
139+
"""
140+
window = get_context_window_for_model(model_name)
141+
return window, int(window * threshold_ratio)
142+
143+
125144
# ---------------------------------------------------------------------------
126145
# Tool output truncation
127146
# ---------------------------------------------------------------------------
@@ -220,13 +239,12 @@ def should_compact(
220239
prompt + stored summary + all messages) exceeds *threshold_ratio*
221240
of the model's context window.
222241
"""
223-
context_window = get_context_window_for_model(model_name)
224242
total_tokens = (
225243
system_prompt_tokens
226244
+ stored_summary_tokens
227245
+ sum(m.token_estimate for m in messages)
228246
)
229-
threshold = int(context_window * threshold_ratio)
247+
_, threshold = context_window_and_threshold(model_name, threshold_ratio)
230248
return total_tokens > threshold
231249

232250

@@ -276,8 +294,7 @@ def compact_message_history(
276294
"""
277295
message_tokens = sum(m.token_estimate for m in messages)
278296
total_before = system_prompt_tokens + stored_summary_tokens + message_tokens
279-
context_window = get_context_window_for_model(model_name)
280-
threshold = int(context_window * threshold_ratio)
297+
_, threshold = context_window_and_threshold(model_name, threshold_ratio)
281298

282299
if total_before <= threshold:
283300
return CompactionResult(

opencontractserver/llms/history_processors.py

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
from __future__ import annotations
2424

25+
import json
2526
import logging
2627
from dataclasses import dataclass, replace
2728
from typing import Any
@@ -40,8 +41,8 @@
4041
)
4142
from opencontractserver.llms.context_guardrails import (
4243
CompactionConfig,
44+
context_window_and_threshold,
4345
estimate_token_count,
44-
get_context_window_for_model,
4546
)
4647

4748
logger = logging.getLogger(__name__)
@@ -84,18 +85,44 @@ class InRunShrinkEvent:
8485
)
8586

8687

88+
def _stringify_tool_content(content: Any) -> str:
89+
"""Serialise (possibly non-string) tool-return content to a string.
90+
91+
``ToolReturnPart.content`` is typed ``Any`` — tools may return dicts or
92+
lists. We serialise those with ``json.dumps`` rather than ``str`` so the
93+
model receives valid JSON (double-quoted keys, ``true``/``null`` literals)
94+
instead of Python ``repr`` output it can misparse. ``default=str`` absorbs
95+
stray non-JSON-serialisable values (datetimes, Decimals); if ``json.dumps``
96+
still fails we fall back to ``str`` so the shrink/estimate never crashes on
97+
exotic content.
98+
99+
Used by both the token estimator and the actual shrink, so the
100+
pre-shrink estimate and the post-shrink payload are serialised
101+
identically.
102+
"""
103+
if isinstance(content, str):
104+
return content
105+
try:
106+
return json.dumps(content, default=str)
107+
except (TypeError, ValueError, RecursionError):
108+
# All three are genuinely reachable, not defensive padding:
109+
# - TypeError: ``default=str`` only rescues non-serialisable *values*;
110+
# a dict with a non-string/number key (e.g. a tuple) still raises.
111+
# - ValueError: out-of-range floats (NaN/Infinity) with a strict
112+
# encoder, etc.
113+
# - RecursionError: a self-referential (circular) tool return.
114+
# The fallback intent is "never crash on exotic content", so str() it.
115+
return str(content)
116+
117+
87118
def _estimate_total_tokens(messages: list[ModelMessage], system_prompt: str) -> int:
88119
"""Cheap upper-bound token estimate for the messages + system prompt."""
89120
total = estimate_token_count(system_prompt or "")
90121
for msg in messages:
91122
for part in getattr(msg, "parts", []):
92123
content = getattr(part, "content", None)
93-
if isinstance(content, str):
94-
total += estimate_token_count(content)
95-
elif content is not None:
96-
# Tool returns can be non-string (lists/dicts); stringify
97-
# cheaply for estimation.
98-
total += estimate_token_count(str(content))
124+
if content is not None:
125+
total += estimate_token_count(_stringify_tool_content(content))
99126
return total
100127

101128

@@ -157,13 +184,18 @@ def _shrink_tool_return_part(
157184
) -> tuple[ToolReturnPart, bool]:
158185
"""Return ``(maybe_shrunk_part, was_shrunk)``.
159186
160-
For non-string contents (dict/list), stringifies first so the
161-
truncation arithmetic is uniform; the shrunk copy carries the
162-
stringified, truncated form so the model sees a string.
187+
For non-string contents (dict/list), serialises first (see
188+
:func:`_stringify_tool_content`) so the truncation arithmetic is
189+
uniform; the shrunk copy carries the serialised, truncated form so the
190+
model sees a string.
191+
192+
``target_chars`` bounds the preserved *prefix*, not the final string:
193+
the returned content is up to ``target_chars`` plus the length of the
194+
appended trim notice (``_TRIM_NOTICE_TEMPLATE`` — ~40 chars plus the
195+
digits of the elided count). Callers setting a tight ceiling should
196+
budget for that overhead.
163197
"""
164-
content = part.content
165-
if not isinstance(content, str):
166-
content = str(content)
198+
content = _stringify_tool_content(part.content)
167199

168200
if len(content) <= target_chars:
169201
return part, False
@@ -240,15 +272,18 @@ async def shrink_old_artifacts_processor(
240272
return messages
241273

242274
tokens_before = _estimate_total_tokens(messages, system_prompt)
243-
context_window = get_context_window_for_model(model_name)
244275
# ``threshold_ratio`` is shared by design with the outer
245276
# turn-level compaction (in ``MessageHistoryService``). A single
246277
# ratio keeps the kick-in point coherent across both layers — if
247278
# the outer pass already wants to compact persisted history,
248279
# the in-loop pass should also be willing to shrink older
249280
# tool returns. Do not split into two ratios without rethinking
250-
# both call sites together.
251-
threshold = int(context_window * cfg.threshold_ratio)
281+
# both call sites together. ``context_window_and_threshold`` is
282+
# the single definition of that kick-in point, shared verbatim
283+
# with the turn-level compaction functions.
284+
context_window, threshold = context_window_and_threshold(
285+
model_name, cfg.threshold_ratio
286+
)
252287

253288
if tokens_before <= threshold:
254289
return messages

opencontractserver/tests/test_history_processors.py

Lines changed: 120 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -491,30 +491,143 @@ def _raises(_event: InRunShrinkEvent) -> None:
491491

492492

493493
def test_thinking_only_modelresponse_is_not_emptied():
494-
"""A ModelResponse with only ThinkingPart is left intact (no empty parts)."""
494+
"""A ModelResponse whose only part is a ThinkingPart survives the
495+
drop-thinking pass because of the empty-parts guard.
496+
497+
The fixture deliberately places a *second*, shrinkable artifact in the
498+
older prefix (a large ``ToolReturnPart``). Without it the processor
499+
would hit the ``tool_returns_shrunk == 0 and thinking_parts_dropped ==
500+
0`` early-return and leave the whole list untouched — so the
501+
thinking-only response would survive simply because *nothing* ran, not
502+
because the guard fired. The big tool return forces the real shrink
503+
path: the processor does modify the older prefix this pass, and the
504+
guard is the reason the thinking-only ModelResponse alone is spared.
505+
"""
495506
# An old ModelResponse with only a giant ThinkingPart.
496507
only_thinking = ModelResponse(parts=[ThinkingPart(content="t" * 600_000)])
497-
# Pair it with a ModelRequest so the structure is valid.
498-
old_req = ModelRequest(
499-
parts=[UserPromptPart(content="paired with thinking-only response")]
508+
# A large, shrinkable tool return alongside it in the older prefix so
509+
# the processor has something to actually shrink this pass.
510+
big_return_req = ModelRequest(
511+
parts=[
512+
ToolReturnPart(
513+
tool_name="load_document_text",
514+
content="r" * 600_000,
515+
tool_call_id="BIG",
516+
)
517+
]
500518
)
501519
recent_pairs: list[ModelMessage] = []
502520
for i in range(5):
503521
r1, r2 = _make_pair(tool_call_id=f"R{i}", tool_name="ping", return_chars=50)
504522
recent_pairs.extend([r1, r2])
505523
messages: list[ModelMessage] = [
506524
only_thinking,
507-
old_req,
525+
big_return_req,
508526
*recent_pairs,
509527
ModelRequest(parts=[UserPromptPart(content="continue")]),
510528
]
511529

512530
deps = _FakeDeps()
513531
result = _run(messages, deps)
514532

515-
# The thinking-only ModelResponse is left intact (we didn't strip the
516-
# only thing it contained).
533+
# (a) The thinking-only ModelResponse is left intact — the guard refused
534+
# to strip its only part even though in_run_drop_thinking is on.
517535
first = result[0]
518536
assert isinstance(first, ModelResponse)
519537
assert len(first.parts) == 1
520538
assert isinstance(first.parts[0], ThinkingPart)
539+
540+
# (b) The shrink genuinely ran this pass (not the no-op early return):
541+
# the large sibling tool return WAS truncated.
542+
big_return = result[1]
543+
assert isinstance(big_return, ModelRequest)
544+
big_part = big_return.parts[0]
545+
assert isinstance(big_part, ToolReturnPart)
546+
assert isinstance(big_part.content, str)
547+
assert len(big_part.content) < 5_000
548+
assert IN_RUN_TRIM_NOTICE_MARKER in big_part.content
549+
assert big_part.tool_call_id == "BIG"
550+
551+
# (c) Telemetry confirms the guard — not an early return — is why the
552+
# ThinkingPart survived: a shrink event fired with the tool return
553+
# counted but zero thinking parts dropped (the only droppable
554+
# ThinkingPart was the one the guard protected).
555+
assert len(deps.events) == 1
556+
evt = deps.events[0]
557+
assert evt.tool_returns_shrunk == 1
558+
assert evt.thinking_parts_dropped == 0
559+
560+
561+
def test_non_string_tool_return_serialized_as_json():
562+
"""A structured (dict) ToolReturnPart is serialised with ``json.dumps``,
563+
not ``str()``, when shrunk.
564+
565+
Tools may return dicts/lists. ``str()`` on those yields Python repr
566+
(single-quoted keys, ``True``/``None`` literals) the model can
567+
misparse; ``json.dumps`` yields valid JSON. This pins that the shrunk
568+
content is JSON-shaped.
569+
"""
570+
# A structured return whose JSON serialisation alone exceeds the
571+
# threshold, so it is both the over-threshold trigger and the thing
572+
# that gets shrunk. ``True``/``None`` round-trip differently under
573+
# json.dumps ("true"/"null") vs str() ("True"/"None"), giving the
574+
# assertion a clean discriminator.
575+
big_payload = {
576+
"flag": True,
577+
"missing": None,
578+
"blob": "v" * 700_000,
579+
}
580+
old_resp = ModelResponse(
581+
parts=[
582+
TextPart(content="step"),
583+
ToolCallPart(tool_name="search", args="{}", tool_call_id="JSON"),
584+
]
585+
)
586+
old_req = ModelRequest(
587+
parts=[
588+
ToolReturnPart(
589+
tool_name="search",
590+
content=big_payload,
591+
tool_call_id="JSON",
592+
)
593+
]
594+
)
595+
recent_pairs: list[ModelMessage] = []
596+
for i in range(5):
597+
r1, r2 = _make_pair(tool_call_id=f"R{i}", tool_name="ping", return_chars=50)
598+
recent_pairs.extend([r1, r2])
599+
messages: list[ModelMessage] = [
600+
ModelRequest(parts=[UserPromptPart(content="start")]),
601+
old_resp,
602+
old_req,
603+
*recent_pairs,
604+
ModelRequest(parts=[UserPromptPart(content="continue")]),
605+
]
606+
607+
deps = _FakeDeps()
608+
result = _run(messages, deps)
609+
610+
shrunk = result[2]
611+
assert isinstance(shrunk, ModelRequest)
612+
part = shrunk.parts[0]
613+
assert isinstance(part, ToolReturnPart)
614+
# Serialised to a string and truncated with the trim notice.
615+
assert isinstance(part.content, str)
616+
assert IN_RUN_TRIM_NOTICE_MARKER in part.content
617+
# JSON serialisation, not Python repr: object opens with ``{``, keys are
618+
# double-quoted, and there are no single-quoted Python-repr keys.
619+
assert part.content.startswith("{")
620+
assert '"flag"' in part.content
621+
assert "'flag'" not in part.content
622+
# The booleans/null are JSON tokens, never Python ``True``/``None``.
623+
assert "true" in part.content
624+
assert "True" not in part.content
625+
assert "null" in part.content # JSON null, not Python None
626+
# Safe because the only non-JSON text spliced into the payload is the trim
627+
# notice (IN_RUN_TRIM_NOTICE_MARKER), whose template is fixed numeric/text
628+
# and contains no literal "None"; if that template ever changes to include
629+
# the word, tighten this to assert against the JSON prefix only.
630+
assert "None" not in part.content
631+
# tool_call_id correlation preserved across the serialise+shrink.
632+
assert part.tool_call_id == "JSON"
633+
assert deps.events and deps.events[0].tool_returns_shrunk == 1

0 commit comments

Comments
 (0)