fix(python): tolerate server response fields the SDK does not declare (#161) - #177
Merged
Conversation
…#161) The Python SDK builds every response type with `Cls(**raw)`, so any key the server returns that the dataclass does not declare raises TypeError. The issue described this as an `explain`-only edge case. It is not: QueryResult has been broken on EVERY non-empty query response since v3.8, because all four warm-tier SELECT paths emit context_signals, epistemic_status and evidence_count unconditionally (all three are NOT NULL columns). Four more types are affected: MemoryHealth (3 fields, every /health call), SleepCycleResult (13, four of them on every /sleep call), ConsolidateResult (batchesProcessed, both return paths), and AgentStats when embeddings are enabled. Verified by running the pre-fix dataclasses against realistic current payloads — all raise. Two halves, both required. `from_response()` drops unknown keys so a future server field cannot break callers again; the missing fields are also declared with defaults so the new data is actually readable and an SDK newer than its server still parses. Filtering alone would silently discard every v3.8-v3.12 field. QueryResult.summary moves after `rank` so it can carry a default: shared-pool rows set `summary: pr.summary ?? undefined`, which JSON.stringify drops, so the key can be absent entirely. This changes positional construction, which is a real if narrow break — acceptable for an unpublished 0.1.0 deserialization type, and preferable to defaulting five fields that are always present. The Python SDK had zero tests and CI never ran Python, which is how a total breakage of client.query() survived five releases. Adds 65 tests covering realistic payloads, older-server payloads with the new fields absent, and an unknown-future-field guard per type — the test that would have caught this. Plus a CI job on the 3.10/3.13 bounds that pyproject claims to support. resilient.py is documented but unchanged: it converts these parse errors into silent empty results, which deserves its own decision. Fixes #161 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011xCqQo49d3CEbn6oEvb3Ru
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #161.
Far worse than filed
The issue framed this as an
explain-only edge case. In factclient.query()has been broken on every non-empty response since v3.8 — all four warm-tier SELECT paths emitcontext_signals,epistemic_statusandevidence_countunconditionally (all three areNOT NULLcolumns), andapp.tspasses rows through unprojected. Since the SDK builds results withCls(**raw), every one of those raisesTypeError.Four more types are affected:
QueryResultcontext_signals,epistemic_status,evidence_count,explanationSleepCycleResult/sleep(4 are unconditional)MemoryHealthstale_memory_count,avg_staleness,knowledge_gap_count_7d/healthConsolidateResultbatchesProcessed/consolidate(both return paths)AgentStatsstale_embedding_countVerified empirically by running the pre-fix dataclasses against realistic current payloads — all four raise
TypeError.Both halves, because either alone is insufficient
from_response()drops keys the dataclass doesn't declare, so the next server field can't break callers again.Filtering alone would have parsed cleanly while silently discarding every v3.8–v3.12 field — arguably worse than the crash, since it fails invisibly.
One deliberate break
QueryResult.summarymoves afterrankso it can carry a default. Shared-pool rows setsummary: pr.summary ?? undefined, whichJSON.stringifydrops, so the key can be absent entirely — meaningsummarycannot remain a required positional argument. This changes positional construction. Judged acceptable: the package is unpublished at0.1.0(no PyPI workflow), and it's a deserialization result type. The alternative was defaulting five fields that are always present, i.e. defensive code for cases that cannot occur.The real work: a test harness that did not exist
The Python SDK had zero tests and CI has never run Python — which is precisely how a total breakage of
client.query()survived five releases. This adds:pyproject.tomlclaims to support. Parsing-layer only — no server, no Postgres, runs in seconds.Left alone deliberately
resilient.pyconverts these parse errors into silently-empty results, so callers saw "no memories" rather than an error. Documented in its docstring but unchanged — whether a parse error should be treated as a transport failure deserves its own decision, not a drive-by change.🤖 Generated with Claude Code
https://claude.ai/code/session_011xCqQo49d3CEbn6oEvb3Ru