Skip to content

fix(sdk/python): make memory exists() detect a missing key - #1106

Merged
ZeroPoint95 merged 3 commits into
Agent-Field:mainfrom
remote-controlled-man:codex/agentfield-memory-exists
Oct 17, 2026
Merged

ZeroPoint95 merged 3 commits into
Agent-Field:mainfrom
remote-controlled-man:codex/agentfield-memory-exists

Conversation

@remote-controlled-man

Copy link
Copy Markdown
Contributor

Summary

MemoryClient.exists() answered "is this key stored?" by calling get() and returning True unless it raised. But get() turns the control plane's deliberate 404 for a missing key into the caller's default:

if response.status_code == 404:
    return default

So nothing ever raised for an absent key, and exists() returned True for every key as long as the memory backend was reachable. ScopedMemoryClient.exists, GlobalMemoryClient.exists and MemoryInterface.exists — i.e. app.memory.exists — all delegate to that one implementation, so all four public surfaces gave the same wrong answer, and existence-gated logic (cache warm-up, "have I stored this already?", idempotent writes) silently took the wrong branch.

#966 documented exactly this — item 7, "exists() cannot detect a missing key" — rewrote the four docstrings to describe the limitation and point callers at a get(key, sentinel) workaround, and noted that "the existing unit tests miss this because they monkeypatch get() to raise on a miss, which the real get() never does". It then left the fix out on purpose: "This is a documentation change only — the implementation fix belongs in its own PR." This is that PR.

Presence is now decided against a private sentinel passed as get()'s default:

missing = object()
try:
    value = await self.get(key, default=missing, scope=scope, scope_id=scope_id)
except Exception:
    return False
return value is not missing
  • a stored value → True, including a falsy one (None, 0, ""), which an is not None shortcut would get wrong;
  • the control plane's 404 → False;
  • a transport or backend failure → still False, so the existing error-handling tests keep passing unchanged.

get()'s documented default-on-404 contract is untouched, and the docstrings state the real behavior again instead of the limitation plus workaround — the four exists() docstrings in memory.py and the exists bullet of the Agent.memory "Memory Operations" API list in agent.py, which #966 had rewritten in the same pass. This also brings Python in line with the TypeScript SDK, which decides presence from the returned value rather than from "did it throw":

async exists(key: string, options: MemoryRequestOptions = {}) {
  const value = await this.get(key, options);
  return value !== undefined;
}

The Go SDK exposes no Exists, so no third surface is affected.

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Docs only
  • Tests only
  • CI / tooling
  • Breaking change

Test plan

  • RED first, on the unmodified base: cd sdk/python && python -m pytest tests/test_memory_client_core.py -k exists -rf → 2 failed, 2 passed, both failures assert True is False against a stubbed 404 (test_exists_is_false_when_the_control_plane_returns_404 and, through the scoped surface, test_scoped_exists_is_false_for_a_missing_key). The stub drives _async_request, not get() — that is the gap docs: correct memory scope docs, the app.memory API list, and the exists() promise #966 identified in the existing tests.
  • GREEN: the same two files → 24 passed; all nine tests/test_*memory*.py files → 114 passed, 2 deselected.
  • A third new test pins the falsy case (a stored JSON null → True) so this cannot later regress into is not None.
  • ruff check . → All checks passed! with the CI-pinned ruff==0.15.22; mypy --config-file mypy.ini agentfield/ → Success: no issues found in 83 source files with mypy==1.18.2. Python 3.12.7 on Windows. The new lines are ruff format-clean; the one remaining format proposal in the test file is pre-existing on main (it appears at @@ -282 there) and was left alone rather than mixed into a focused fix.
  • Full Python SDK suite, recorded native run at this exact head with the twelve proven host-baseline nodes deselected: 2507 passed, 6 skipped, 50 deselected (the 50 are the 38 marker-deselected tests plus those twelve), i.e. zero failures.
  • The same suite unfiltered on this host: 11 failed, 2508 passed, 6 skipped, 38 deselected — and every one of those 11 is inside that twelve-node host baseline. Proven by restoring the pristine target blobs (memory.py = 2dda43d47f059456640d4386bce40621fa90e686) and re-running the four affected files, which gives the identical 10 deterministic failures with identical node IDs (7 × test_log_writer CRLF/pipe-timing, 2 × test_harness_packaging GBK UnicodeDecodeError from pathlib.read_text, 1 × test_tool_calling.py::TestExecuteToolCallLoop::test_single_tool_call_then_response); the twelfth node is test_litellm_observability's known cold-cache flake and passed on this run.
  • tests/test_agent_mesh.py is excluded because it does not complete on this Windows host — a pre-existing hang, unrelated to this change. Left to CI on ubuntu-latest.
  • ./scripts/test-all.sh and ./scripts/coverage-summary.sh were not run locally: they also build the Go control plane, the web UI and the desktop app, and the coverage summary needs a Docker engine, which is unavailable here. This PR changes only sdk/python/**, which with the workflow file itself is the complete path filter of .github/workflows/sdk-python.yml.
  • No live control plane was started for validation. The 404-on-missing-key contract is read from control-plane/internal/handlers/memory.go (GetMemoryHandler, scope walk at :273, 404 at :288) and matches what docs: correct memory scope docs, the app.memory API list, and the exists() promise #966 measured against a running server.

Test coverage

  • I ran tests for the surface(s) I changed locally.
  • New code paths are covered by tests in this PR (no bare additions) — three new tests: 404 → False on the client, 404 → False through a scoped client (asserting the scope still travels), and a stored falsy value → True.
  • If I removed code, I updated coverage-baseline.json — not applicable; no code was removed.
  • The coverage gate check is green in CI before requesting review — left to CI, since ./scripts/coverage-summary.sh measures all five surfaces and needs a Docker engine.

Unlike most SDK modules, agentfield.memory is inside the nine-module --cov whitelist in sdk/python/pyproject.toml's pytest addopts, so the gate does measure this change and the new lines are exercised by the three tests above. pyproject.toml and coverage-baseline.json are untouched.

Checklist

  • I have read docs/CONTRIBUTING.md and docs/DEVELOPMENT.md.
  • Commits are signed and follow conventional-commits style. — the subjects follow conventional-commits; the commits are not signed, as I have no signing key configured. Flagging it rather than ticking the box.
  • I have linked any related issues — Refs #966, with no closing keyword: docs: correct memory scope docs, the app.memory API list, and the exists() promise #966 is a merged docs PR rather than an issue, and this implements only the fix it deferred.

Related issues / PRs

MemoryClient.exists() called get() and reported True whenever it did not
raise. get() answers the control plane's deliberate 404 for an absent key
with the caller's default instead of raising, so exists() returned True for
every key as long as the backend was reachable, and the scoped, global and
MemoryInterface surfaces inherited that answer. Agent-Field#966 documented the behavior
and left the implementation fix for its own PR.

Decide presence against a private sentinel passed as get()'s default: a
stored value -- including a falsy one such as None, 0 or "" -- means True,
a 404 means False, and a transport or backend failure still means False.
get()'s default-on-404 contract is unchanged. This matches the TypeScript
sibling, which decides presence from the returned value rather than from
"did not raise". The four docstrings Agent-Field#966 rewrote now state the real
behavior again.

Refs Agent-Field#966
…mory

Agent-Field#966 recorded the exists() limitation in five places: the four docstrings in
memory.py and the exists bullet of the Agent.memory "Memory Operations" API
list. The previous commit corrected the four; this completes the set so the
advertised API list no longer contradicts the implementation it describes,
and drops the get(key, sentinel) workaround that is no longer needed.

Refs Agent-Field#966
Copilot AI balanced review requested due to automatic review settings October 15, 2026 18:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Performance

SDK Memory Δ Latency Δ Tests Status
Python 9.1 KB - 0.17 µs -51% ✓ ✓

✓ No regressions detected

@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

📊 Coverage gate

Thresholds from .coverage-gate.toml: per-surface ≥ 84%, aggregate ≥ 85%, max per-surface regression ≤ 1.0 pp, max aggregate regression ≤ 0.50 pp.

Surface Current Baseline Δ
control-plane 88.00% 87.40% ↑ +0.60 pp 🟡
sdk-go 93.30% 92.00% ↑ +1.30 pp 🟢
sdk-python 94.72% 93.73% ↑ +0.99 pp 🟢
sdk-typescript 91.77% 90.42% ↑ +1.35 pp 🟢
web-ui 84.77% 84.79% ↓ -0.02 pp 🟡
aggregate 85.96% 85.75% ↑ +0.21 pp 🟡

✅ Gate passed

No surface regressed past the allowed threshold and the aggregate stayed above the floor.

@github-actions

Copy link
Copy Markdown
Contributor

📐 Patch coverage gate

Threshold: 80% on lines this PR touches vs origin/main (from .coverage-gate.toml:thresholds.min_patch).

Surface Touched lines Patch coverage Status
control-plane 0 — ➖ no changes
sdk-go 0 — ➖ no changes
sdk-python 3 100.00% ✅
sdk-typescript 0 — ➖ no changes
web-ui 0 — ➖ no changes

✅ Patch gate passed

Every surface whose lines were touched by this PR has patch coverage at or above the threshold.

@ZeroPoint95 ZeroPoint95 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@remote-controlled-man, this addresses the bug documented in #966 cleanly. The sentinel distinguishes missing keys from stored falsy values across the scoped and global APIs, including checks through the real HTTP client boundary. Thanks for fixing it; no changes requested from this review.

@ZeroPoint95
ZeroPoint95 merged commit 2460793 into Agent-Field:main Oct 17, 2026
24 checks passed
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.

3 participants