From ceebb21dd7cb7391a58e4b1d345951e5218860c5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:00:10 -0700 Subject: [PATCH] fix: suppress pydantic serializer warnings leaking to the terminal The Anthropic SDK's streaming accumulator builds ParsedMessage snapshots whose ParsedTextBlock content doesn't match the generic union pydantic expects, so model_dump() on stream events (message_stop) emits PydanticSerializationUnexpectedValue UserWarnings straight into the user's CLI output mid-response. Pass warnings=False at every helper that dumps arbitrary SDK models (relay_llm/_jsonable, relay_tools/_jsonable, anthropic_adapter _to_plain_data, run_agent _hook_jsonable, chat_completion_helpers extra_content/reasoning_details sites, chat_completions transport), with a TypeError fallback for duck-typed model_dump implementations. Adds regression tests including a precondition test that proves the fixture still trips the warning without suppression. --- agent/anthropic_adapter.py | 11 +- agent/chat_completion_helpers.py | 17 ++- agent/relay_llm.py | 10 +- agent/relay_tools.py | 7 +- agent/transports/chat_completions.py | 7 +- run_agent.py | 10 +- .../agent/test_pydantic_dump_warning_leak.py | 137 ++++++++++++++++++ 7 files changed, 190 insertions(+), 9 deletions(-) create mode 100644 tests/agent/test_pydantic_dump_warning_leak.py diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 70a959932318a..39edd74d006b1 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -1867,7 +1867,16 @@ def _to_plain_data(value: Any, *, _depth: int = 0, _path: Optional[set] = None) if hasattr(value, "model_dump"): _path.add(obj_id) - result = _to_plain_data(value.model_dump(), _depth=_depth + 1, _path=_path) + try: + # warnings=False: content blocks from the streaming accumulator + # (ParsedTextBlock et al.) trip pydantic's serializer-mismatch + # UserWarning against the generic Message union; the dump itself + # is correct, and the warning leaks to the user's terminal. + dumped = value.model_dump(warnings=False) + except TypeError: + # Duck-typed model_dump without pydantic's signature. + dumped = value.model_dump() + result = _to_plain_data(dumped, _depth=_depth + 1, _path=_path) _path.discard(obj_id) return result if isinstance(value, dict): diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 7e07aea731db2..364af69085a42 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -1755,7 +1755,12 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic elif hasattr(d, "__dict__"): preserved.append(d.__dict__) elif hasattr(d, "model_dump"): - preserved.append(d.model_dump()) + try: + # warnings=False: avoid pydantic serializer UserWarnings + # on generic-union SDK models leaking to the terminal. + preserved.append(d.model_dump(warnings=False)) + except TypeError: + preserved.append(d.model_dump()) if preserved: msg["reasoning_details"] = preserved @@ -1845,7 +1850,10 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic extra = getattr(tool_call, "extra_content", None) if extra is not None: if hasattr(extra, "model_dump"): - extra = extra.model_dump() + try: + extra = extra.model_dump(warnings=False) + except TypeError: + extra = extra.model_dump() tc_dict["extra_content"] = extra tool_calls.append(tc_dict) msg["tool_calls"] = tool_calls @@ -3538,7 +3546,10 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= extra = (tc_delta.model_extra if isinstance(tc_delta.model_extra, dict) else {}).get("extra_content") if extra is not None: if hasattr(extra, "model_dump"): - extra = extra.model_dump() + try: + extra = extra.model_dump(warnings=False) + except TypeError: + extra = extra.model_dump() entry["extra_content"] = extra # Fire once per tool when the full name is available name = entry["function"]["name"] diff --git a/agent/relay_llm.py b/agent/relay_llm.py index 984fcae3eb24c..8f560c1854134 100644 --- a/agent/relay_llm.py +++ b/agent/relay_llm.py @@ -1194,7 +1194,15 @@ def _jsonable(value: Any) -> Any: model_dump = getattr(type(value), "model_dump", None) if callable(model_dump): try: - return _jsonable(value.model_dump(mode="json")) + # warnings=False: SDK stream events (e.g. the Anthropic + # ParsedMessage inside message_stop) carry generic-union content + # blocks that pydantic serializes fine but warns about — and the + # warning leaks to the user's terminal mid-response (#82xxx). + try: + return _jsonable(value.model_dump(mode="json", warnings=False)) + except TypeError: + # Duck-typed model_dump without pydantic's signature. + return _jsonable(value.model_dump()) except Exception: pass try: diff --git a/agent/relay_tools.py b/agent/relay_tools.py index 5023df1bcf924..f2177f1a3b6ee 100644 --- a/agent/relay_tools.py +++ b/agent/relay_tools.py @@ -93,7 +93,12 @@ def _jsonable(value: Any) -> Any: model_dump = getattr(value, "model_dump", None) if callable(model_dump): try: - return _jsonable(model_dump(mode="json")) + # warnings=False: suppress pydantic's serializer UserWarnings on + # generic-union SDK models; they would leak to the CLI mid-turn. + try: + return _jsonable(model_dump(mode="json", warnings=False)) + except TypeError: + return _jsonable(model_dump()) except Exception: pass try: diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index 49ef5a9c5a3b8..f97364448261d 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -778,7 +778,12 @@ class ChatCompletionsTransport(ProviderTransport): if extra is not None: if hasattr(extra, "model_dump"): try: - extra = extra.model_dump() + extra = extra.model_dump(warnings=False) + except TypeError: + try: + extra = extra.model_dump() + except Exception: + pass except Exception: pass tc_provider_data["extra_content"] = extra diff --git a/run_agent.py b/run_agent.py index 47b4a0e80e549..5992d542e6144 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2768,9 +2768,15 @@ class AIAgent: try: if hasattr(value, "model_dump"): try: - dumped = value.model_dump(mode="json") + # warnings=False: pydantic's serializer UserWarnings on + # generic-union SDK models (Anthropic ParsedMessage etc.) + # would otherwise leak to the terminal mid-response. + dumped = value.model_dump(mode="json", warnings=False) except TypeError: - dumped = value.model_dump() + try: + dumped = value.model_dump(mode="json") + except TypeError: + dumped = value.model_dump() return cls._hook_jsonable( dumped, depth=depth + 1, diff --git a/tests/agent/test_pydantic_dump_warning_leak.py b/tests/agent/test_pydantic_dump_warning_leak.py new file mode 100644 index 0000000000000..48f18de71c9bc --- /dev/null +++ b/tests/agent/test_pydantic_dump_warning_leak.py @@ -0,0 +1,137 @@ +"""Serializer-warning leak regression tests (#82xxx). + +The Anthropic SDK's streaming accumulator builds ``ParsedMessage`` snapshots +whose content blocks (``ParsedTextBlock``) don't match the generic union +pydantic expects at serialization time. ``model_dump()`` on those objects +emits ``PydanticSerializationUnexpectedValue`` UserWarnings that leak +straight into the user's terminal mid-response. + +Every Hermes serialization helper that dumps arbitrary SDK models must pass +``warnings=False`` (with a TypeError fallback for duck-typed models). These +tests pin that contract for all four helpers. +""" + +import warnings + +import pytest + +anthropic = pytest.importorskip("anthropic") + +from anthropic._models import build +from anthropic.lib.streaming._messages import accumulate_event +from anthropic.lib.streaming._types import ParsedMessageStopEvent +from anthropic.types import ( + Message, + RawContentBlockDeltaEvent, + RawContentBlockStartEvent, + RawMessageStartEvent, + Usage, +) + + +def _accumulated_stop_event(): + """Build a message_stop event exactly the way the SDK stream does.""" + start = RawMessageStartEvent( + type="message_start", + message=Message( + id="msg_1", + content=[], + model="claude-x", + role="assistant", + stop_reason=None, + stop_sequence=None, + type="message", + usage=Usage(input_tokens=1, output_tokens=0), + ), + ) + cb_start = RawContentBlockStartEvent( + type="content_block_start", index=0, content_block={"type": "text", "text": ""} + ) + cb_delta = RawContentBlockDeltaEvent( + type="content_block_delta", + index=0, + delta={"type": "text_delta", "text": "hello"}, + ) + snapshot = None + for event in (start, cb_start, cb_delta): + snapshot = accumulate_event(event=event, current_snapshot=snapshot) + return build(ParsedMessageStopEvent, type="message_stop", message=snapshot), snapshot + + +def _pydantic_warnings(recorded): + return [w for w in recorded if "Pydantic serializer warnings" in str(w.message)] + + +def test_stop_event_dump_actually_warns_without_suppression(): + """Precondition: the fixture really trips the SDK/pydantic warning. + + If a future SDK/pydantic release stops warning here, the other tests + pass vacuously — this test tells us the guard can be simplified. + """ + stop_event, _ = _accumulated_stop_event() + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter("always") + stop_event.model_dump() + assert _pydantic_warnings(recorded), ( + "fixture no longer reproduces the pydantic serializer warning; " + "the warnings=False guards may be removable" + ) + + +def test_relay_llm_jsonable_no_warning_leak(): + from agent.relay_llm import _jsonable + + stop_event, _ = _accumulated_stop_event() + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter("always") + payload = _jsonable(stop_event) + assert not _pydantic_warnings(recorded) + assert payload["message"]["content"][0]["text"] == "hello" + + +def test_relay_tools_jsonable_no_warning_leak(): + from agent.relay_tools import _jsonable + + stop_event, _ = _accumulated_stop_event() + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter("always") + payload = _jsonable(stop_event) + assert not _pydantic_warnings(recorded) + assert payload["message"]["content"][0]["text"] == "hello" + + +def test_anthropic_to_plain_data_no_warning_leak(): + from agent.anthropic_adapter import _to_plain_data + + _, snapshot = _accumulated_stop_event() + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter("always") + payload = _to_plain_data(snapshot) + assert not _pydantic_warnings(recorded) + assert payload["content"][0]["text"] == "hello" + + +def test_hook_jsonable_no_warning_leak(): + from run_agent import AIAgent + + stop_event, _ = _accumulated_stop_event() + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter("always") + payload = AIAgent._hook_jsonable(stop_event) + assert not _pydantic_warnings(recorded) + assert payload["message"]["content"][0]["text"] == "hello" + + +def test_duck_typed_model_dump_fallback(): + """Non-pydantic objects with a bare model_dump() must still serialize.""" + from agent.relay_llm import _jsonable as rl_jsonable + from agent.relay_tools import _jsonable as rt_jsonable + from agent.anthropic_adapter import _to_plain_data + + class Duck: + def model_dump(self): # no mode/warnings kwargs + return {"quack": True} + + assert rl_jsonable(Duck()) == {"quack": True} + assert rt_jsonable(Duck()) == {"quack": True} + assert _to_plain_data(Duck()) == {"quack": True}