Skip to content

fix(python): tolerate server response fields the SDK does not declare (#161) - #177

Merged
salishforge merged 1 commit into
mainfrom
fix/161-python-sdk-fields
Jul 27, 2026
Merged

fix(python): tolerate server response fields the SDK does not declare (#161)#177
salishforge merged 1 commit into
mainfrom
fix/161-python-sdk-fields

Conversation

@salishforge

Copy link
Copy Markdown
Owner

Fixes #161.

Far worse than filed

The issue framed this as an explain-only edge case. In fact client.query() has been broken on every non-empty response since v3.8 — all four warm-tier SELECT paths emit context_signals, epistemic_status and evidence_count unconditionally (all three are NOT NULL columns), and app.ts passes rows through unprojected. Since the SDK builds results with Cls(**raw), every one of those raises TypeError.

Four more types are affected:

Type Missing fields Breaks on
QueryResult context_signals, epistemic_status, evidence_count, explanation every non-empty query
SleepCycleResult 13 fields every /sleep (4 are unconditional)
MemoryHealth stale_memory_count, avg_staleness, knowledge_gap_count_7d every /health
ConsolidateResult batchesProcessed every /consolidate (both return paths)
AgentStats stale_embedding_count when embeddings are enabled

Verified 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.
  • The missing fields are declared with defaults, so the new data is actually readable and an SDK newer than its server still parses.

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.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 — meaning summary cannot remain a required positional argument. This changes positional construction. Judged acceptable: the package is unpublished at 0.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:

  • 65 tests — realistic current payloads per type, older-server payloads with the new fields absent (proving the defaults work), and an unknown-future-field guard per type: the test that would have caught this bug.
  • A CI job on Python 3.10 and 3.13, the bounds pyproject.toml claims to support. Parsing-layer only — no server, no Postgres, runs in seconds.

Left alone deliberately

resilient.py converts 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

…#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
@salishforge
salishforge merged commit 42a7b83 into main Jul 27, 2026
14 checks passed
@salishforge
salishforge deleted the fix/161-python-sdk-fields branch July 27, 2026 23:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python SDK: QueryResult dataclass rejects unknown response keys (breaks on v3.8+ fields)

1 participant