From 02a7f05af479f73d842fe25e02dafda796d4bc94 Mon Sep 17 00:00:00 2001 From: gethin Date: Sun, 12 Jul 2026 20:30:38 +0100 Subject: [PATCH 1/6] docs: agentcore onboarding fix + journey test suite designs (Hive Mind phase 1) Co-Authored-By: Claude Fable 5 --- ...26-07-12-agentcore-journey-tests-design.md | 340 ++++++++++++++++++ ...6-07-12-agentcore-onboarding-fix-design.md | 207 +++++++++++ 2 files changed, 547 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-12-agentcore-journey-tests-design.md create mode 100644 docs/superpowers/specs/2026-07-12-agentcore-onboarding-fix-design.md diff --git a/docs/superpowers/specs/2026-07-12-agentcore-journey-tests-design.md b/docs/superpowers/specs/2026-07-12-agentcore-journey-tests-design.md new file mode 100644 index 0000000..e80b277 --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-agentcore-journey-tests-design.md @@ -0,0 +1,340 @@ +# AgentCore Full-Flow Journey Test Suite — Design + +Feeds the implementer wave. Assumes fix-plan Tasks 1–3 have landed (idvar gate deleted, +MCP dispatch wired to `AgentCoreBackend`, `settings.json` backend activation written by +`agentcore init`, region single-sourced from `agentcore.json`, `write_backend_settings` +helper added to `tests/e2e/_agentcore_env.py`). + +## 1. Purpose and the one thing that makes this suite different + +The existing T2 suite (`test_agentcore_t2.py`, D1–D7) proves *individual* wire behaviors, and +almost all of it **force-activates agentcore by setting `BETTER_MEMORY_STORAGE_BACKEND=agentcore` +in the child env**. After the fix lands, that env var is no longer how a real user runs +agentcore — `agentcore init` writes `settings.json {"storage_backend":"agentcore"}` and the +backend is resolved from that file (env still wins if set). D-suite tests also carry the dead +`BETTER_MEMORY_AGENTCORE_REGION` + dummy id vars that Task 1 deletes. + +The journey suite validates the **shipped onboarding path end-to-end**: `agentcore.json` + +`settings.json` present, **no** `BETTER_MEMORY_STORAGE_BACKEND`, **no** region/id env vars — +exactly the state `agentcore init` leaves behind — and walks the whole better-memory data loop +through the *real MCP dispatch layer* and the *real hooks*, asserting per step which AWS +operation fired, which memory it routed to (EPI vs SEM), the request count, and that **zero rows +leak into local sqlite**. Its distinguishing value over the D-suite is four things the focused +tests structurally cannot show: + +1. **The tested config == the onboarding config** (settings.json activation, no env knobs). +2. **Sequential chaining / read-after-write** (observe's returned AWS id flows into + retrieve_observations; a semantic id flows into rating). +3. **No-sqlite-leakage assertions co-located with each dispatch step** (the inverse half of the + deleted D3 pin). +4. **End-to-end ordering** as one narrative: bootstrap → observe → retrieve → semantic → rate → + inject → close. + +Anything the journey does not need to re-prove (exhaustive metadata field shapes, polarity-filter +internals, env-precedence matrix, region convergence) is deferred to the owning D-suite / unit +test and cited by name, so there is no duplication. + +## 2. The onboarding-config anchor (shared harness addition) + +All hermetic scenarios build their env through one new module-level helper in +`tests/e2e/test_agentcore_journey.py`, composing the Task-1 `write_backend_settings` helper with +the existing `write_fake_agentcore_json`: + +```python +from tests.e2e._agentcore_env import ( + agentcore_env, write_fake_agentcore_json, write_backend_settings, # write_backend_settings ADDED by Task 1 +) + +def onboarding(clean_slate_home, fake, **pins): + """The exact state `agentcore init` leaves: agentcore.json + settings.json, + NO backend/region/id env vars. Backend resolves from settings.json.""" + bm_home = clean_slate_home / ".better-memory" + write_fake_agentcore_json(bm_home) # what init provisions + write_backend_settings(bm_home) # what init activates (settings.json) + return agentcore_env( + clean_slate_home, fake.port, + BETTER_MEMORY_STORAGE_BACKEND=None, # the onboarding state: env var ABSENT + **pins, + ) +``` + +`isolated_env` pins `BETTER_MEMORY_HOME=/.better-memory` (= `bm_home`), so +`resolve_storage_backend()` finds `bm_home/settings.json` and resolves `agentcore` with no env +var present. This is a **stronger** activation proof than every D-suite test. Env-precedence (env +wins over settings) is owned by Task 1's `tests/test_config.py` unit tests and is deliberately +*not* re-tested here. + +Reused fixtures/helpers, all unchanged: `clean_slate_home`, `mcp_session`, `run_hook`, `text_of` +(from `tests/e2e/conftest.py`); `FakeAgentCore` + `RecordedRequest.{operation,path,body, +sigv4_region,text()}` (from `_fake_agentcore.py`); `agentcore_env` / `write_fake_agentcore_json` +(from `_agentcore_env.py`). No new endpoint or fixture machinery. + +## 3. Scenario catalog + +### T2 hermetic — `tests/e2e/test_agentcore_journey.py` (fake endpoint, real boto3 wire) + +| ID | Scenario | Mirrors sqlite | Wired path exercised | Key wire assertion | vs D-suite | +|----|----------|----------------|----------------------|--------------------|------------| +| **J1** | Onboarding boot → wired tool surface | C1 | `create_server` backend resolve from settings.json | 0 AWS calls; wired data tools present; synthesize + 4 episode tools + `run_retention` hidden; both sqlite DBs migrated | complements D1 (activation source differs) | +| **J2** | `session_bootstrap` hook reaches AgentCore | C3 (bootstrap leg) | `session_bootstrap.py`→`build_backend`→`backend.session_bootstrap` | exactly 4 `ListMemoryRecords` (3 EPI reflections + 1 SEM); envelope carries summary; no sqlite memory-content write | NEW (zero prior coverage) | +| **J3** | `memory.observe`→`retrieve_observations` round-trip | C4 (observe+retrieve) | dispatch→`backend.observe` / `backend.list_observations` | 1 `CreateEvent` to EPI, returned id == AWS eventId (not local uuid), 0 rows in local `observations`; then 1 `ListEvents` to EPI, id-match + metadata flattened | replaces deleted D3 half; complements D4 (MCP layer vs backend-direct) | +| **J4** | `memory.retrieve` bucket fan-out | C4 (buckets) | dispatch→`backend.retrieve` | 3 `ListMemoryRecords` to EPI reflections namespace; buckets `{do:[],dont:[],neutral:[]}` | complements D2 (filters owned by D2) | +| **J5** | `semantic_observe`→`semantic_retrieve` round-trip | (no direct C) | dispatch→`backend.semantic_observe` / two `backend.semantic_list` | 1 `BatchCreateMemoryRecords` to SEM (never EPI), 0 rows in local `semantic_memories`; then **2** `ListMemoryRecords` to SEM (project + general, UD-2), merged payload with `project/created_at/updated_at` present as `None` | NEW; complements D4 | +| **J6** | Rating loop: `record_use` + short-id guard + `apply_session_ratings` + `list_session_exposures` | C4/C5 | dispatch→`backend.record_use` / `credit_one` / `list_session_exposures` | ≥40-char id → `GetMemoryRecord`+`BatchUpdateMemoryRecords` (EPI, x-amz keys stripped); <40-char id → clear error, **0 wire**; semantic-id rating routes to SEM; exposures = empty envelope, 0 wire | complements D4 (adds MCP dispatch + short-id guard + empty-exposure contract) | +| **J7** | `contextual_inject` per-prompt under activation | C5 (per-prompt) | `contextual_inject.py` resolver honours settings.json | 3 EPI + 1 SEM list calls; `` block injected | complements D6-A (activation source differs) | +| **J8** | `session_close` closure under activation (terminus) | C5 (close+marker) | `session_close.py` settings-aware gate | exactly 1 `CreateEvent` role=OTHER to EPI; spool marker written; no `memory.db` | complements D5 (env-gate matrix owned by D5) | + +### T3 live — additions to `tests/integration/test_agentcore_live_e2e.py` (real AWS, throwaway memories) + +| ID | Scenario | Proves (fix plan §4) | Read-after-write basis | vs existing | +|----|----------|----------------------|------------------------|-------------| +| **E3** | Onboarding-config MCP `observe`→`retrieve_observations` | item 2 | events are **promptly consistent** (smoke relies on it) | NEW; the non-tautological MCP write path dispatch enables | +| **E4** | MCP semantic round-trip `observe`→`retrieve`→`update`→`delete` | item 3 | semantic record readback promptly consistent | NEW | +| **E5** | `record_use`/credit on a real ≥40-char semantic record id | item 4 | uses E4's real record id | NEW (E2 only credited via backend-direct) | +| **E6** | `session_bootstrap` hook + `contextual_inject` reach AWS under onboarding config | item 7 | count/namespace validation only | NEW | +| **E7** | `session_close` Stop hook closure with settings.json only, no env | item 6 | one `CreateEvent` role=OTHER | NEW (live version of J8) | +| — | Region single-source: run E3–E7 in a **non-default** region | item 5 | indirect (cross-region factory bug 404s) | folds into E3–E7 env | + +E1 (`init→status` journey) and E2(a) (smoke CLI), E2(c) (backend-direct roundtrip) stay as-is. +E2(b) (live MCP retrieve) is already slated for plumbing update by fix-plan Task 6 (drop +idvar/region env, add settings.json) — E3 builds on that same onboarding env. + +## 4. Per-scenario detail + +### J1 — Onboarding boot advertises the wired tool surface +**Steps:** `onboarding(clean_slate_home, fake)`; `async with mcp_session(env)` → `session.list_tools()`. +**Assertions:** +- `EXPECTED_TOOL_SUBSET <= names` (observe/retrieve/semantic_observe/record_use/session_bootstrap) + **and** the rating/exposure tools (`memory.apply_session_ratings`, `memory.list_session_exposures`, + `memory.credit`, `memory.semantic_retrieve`, `memory.semantic_update`, `memory.semantic_delete`) + present. +- `SYNTHESIZE_TOOLS & names == set()` (supports_synthesis=False). +- **Episode tools + retention hidden (UD-1):** `{"memory.start_episode","memory.close_episode", + "memory.reconcile_episodes","memory.list_episodes","memory.run_retention"} & names == set()`. +- `fake.requests == []` (boot is client-construction only). +- `{"observations","episodes","hook_errors"} <= _table_names(bm_home/"memory.db")` and + `knowledge.db` exists (UD-4 doc-side: local DBs still migrated). + +**regression_caught:** revert the `supports_episodes` gate in `tools.py`/`server.py:277` → episode +tools reappear; **or** revert `create_server`'s settings.json resolution → boot stays sqlite → +synthesize tools present and episode tools present (the sqlite surface). + +### J2 — session_bootstrap hook reaches AgentCore, not sqlite +**Steps:** `onboarding(...)`; `fake.set_response("ListMemoryRecords", {"memoryRecordSummaries": []})`; +`run_hook("better_memory.hooks.session_bootstrap", {"source":"startup","session_id":"e2e-session-1","cwd":str(proj)}, env)`. +**Assertions:** +- `rc == 0`; envelope `hookSpecificOutput.hookEventName == "SessionStart"`; `additionalContext` + non-empty and contains the reflection/semantic summary (`"Reflections"` / `"Semantic memories:"` + per `backend.session_bootstrap`). +- `len(fake.requests_for("ListMemoryRecords")) == 4`; of those, exactly 3 carry `EPI-FAKE-0001` in + `path` (reflections, polarity + status filters) and exactly 1 carries `SEM-FAKE-0001`. +- No memory-content sqlite write: `bm_home/"memory.db"` either absent or contains 0 + observation/semantic rows (a `hook_errors`-only migration is tolerated; assert + `observations`/`semantic_memories` count 0 if the file exists). + +**regression_caught:** revert `session_bootstrap.py` `build_backend` routing (Task 3) → hook uses +`SessionBootstrapService` on sqlite → `fake.requests == []` and sqlite rows appear. + +### J3 — observe → retrieve_observations round-trip (dispatch anchor + read-after-write) +**Steps (one MCP session):** `onboarding(...)`; +`fake.set_response("CreateEvent", {"event":{"eventId":"evt-journey-1"}})`; +`fake.set_response("ListEvents", {"events":[{"eventId":"evt-journey-1","sessionId":"e2e-session-1","actorId":"e2e-project","payload":[{"conversational":{"content":{"text":"journey-obs-marker"}}}],"metadata":{"outcome":{"stringValue":"failure"},"theme":{"stringValue":"bug"}}}]})`. +- `observe = call_tool("memory.observe", {"content":"journey-obs-marker","outcome":"failure","theme":"bug"})` +- `obs = call_tool("memory.retrieve_observations", {"query":"journey-obs-marker"})` +**Assertions:** +- observe: `len(fake.requests_for("CreateEvent")) == 1`, path has `EPI-FAKE-0001`; returned + `id == "evt-journey-1"` (the **AWS eventId**, proving dispatch switched — a local uuid means it + hit sqlite). +- retrieve_observations: `len(fake.requests_for("ListEvents")) == 1`, path has `EPI-FAKE-0001`, + body `sessionId == "e2e-session-1"` and `includePayloads` true; result row has + `id == "evt-journey-1"`, `content == "journey-obs-marker"`, and **metadata flattened** + `outcome == "failure"`, `theme == "bug"`. +- **No sqlite leakage:** post-session, `observations` table has 0 rows (or file absent). + +*Note:* the fake does not persist writes, so hermetic read-after-write is a canned `ListEvents` +shaped to echo the observed id; **true persistence is E3's job**. Hermetic proves the two MCP tools +route to `backend.observe`/`backend.list_observations` and the response mapping is correct. +Exhaustive CreateEvent payload/metadata shape is owned by D4 `test_observe_create_event_wire_shape`. + +**regression_caught:** revert `create_server` `remote=` wiring for +`memory.observe`/`memory.retrieve_observations` → local uuid returned, sqlite `observations` row +written, `CreateEvent`/`ListEvents` counts 0. + +### J4 — memory.retrieve bucket fan-out +**Steps:** `onboarding(...)`; `fake.set_response("ListMemoryRecords", {"memoryRecordSummaries": []})`; +`call_tool("memory.retrieve", {})`. +**Assertions:** `len(fake.requests_for("ListMemoryRecords")) == 3`, all path `EPI-FAKE-0001` + +`reflections` namespace; buckets exactly `{"do":[],"dont":[],"neutral":[]}`. Polarity-filter +internals and the single-polarity restriction are owned by **D2**; J4 asserts only count + routing ++ empty-bucket shape under onboarding config. + +**regression_caught:** collapse the polarity fan-out to one call, or revert dispatch → count ≠ 3. + +### J5 — semantic_observe → semantic_retrieve round-trip (UD-2 merge) +**Steps (one session):** `onboarding(...)`; +`fake.set_response("BatchCreateMemoryRecords", {"successfulRecords":[{"memoryRecordId":"sem-journey-1"}],"failedRecords":[]})`; +`fake.set_response("ListMemoryRecords", {"memoryRecordSummaries": []})`. +- `sem = call_tool("memory.semantic_observe", {"content":"journey-sem-marker"})` +- `ret = call_tool("memory.semantic_retrieve", {})` +**Assertions:** +- observe: `len(fake.requests_for("BatchCreateMemoryRecords")) == 1`, path `SEM-FAKE-0001`, and + `"EPI-FAKE-0001" not in request.text()` (never episodic); returned `id == "sem-journey-1"`; + 0 rows in local `semantic_memories`. +- retrieve: **exactly 2** `ListMemoryRecords` to `SEM-FAKE-0001` — one project namespace, one + `general` namespace (UD-2 two-call merge). Parse the merged payload: each item carries stable + keys with `project`, `created_at`, `updated_at` present as `None`. + +**regression_caught:** drop the general-scope second call (UD-2 alternative) → 1 `ListMemoryRecords` +not 2; revert semantic dispatch → sqlite write/read, 0 SEM wire. sha256 `requestIdentifier` + +strategy-id routing owned by D4 `test_semantic_observe_sha256_request_identifier_and_routing`. + +### J6 — rating loop (record_use, short-id guard, apply_session_ratings→SEM, empty exposures) +**Steps:** `onboarding(...)`. Use a ≥40-char reflection id `_REFL = "refl-journey-" + "0"*30 + "1"` +and a ≥40-char semantic id `_SEM = "sem-journey-" + "1"*30`. +- Canned `GetMemoryRecord` (EPI) returning `_REFL` with + `{"useful_count":{"numberValue":2},"status":{"stringValue":"active"},"x-amz-agentcore-memory-recordType":{"stringValue":"EXTRACTED"}}` + and canned `BatchUpdateMemoryRecords` success; `call_tool("memory.record_use", {"id":_REFL,"outcome":"success"})`. +- Short-id guard: `call_tool("memory.record_use", {"id":"refl-1","outcome":"success"})`. +- Canned `GetMemoryRecord`/`BatchUpdateMemoryRecords` for `_SEM`; + `call_tool("memory.apply_session_ratings", {"ratings":[{"kind":"semantic","id":_SEM,"class":"cited"}]})`. +- `call_tool("memory.list_session_exposures", {})`. +**Assertions:** +- record_use: `GetMemoryRecord` + `BatchUpdateMemoryRecords` both to `EPI-FAKE-0001`; update + metadata has **no** `x-amz-agentcore-memory-*` key; `useful_count.numberValue == 3`. +- short-id: result is an MCP `isError` (or a clear error payload) naming the id-length constraint, + and **zero** additional wire requests (no `GetMemoryRecord`) — proves the Task-2 guard that avoids + the ~20s `_retry_on_transient_404` stall inside the serialized dispatch loop. +- apply_session_ratings: lookup + update both to `SEM-FAKE-0001` (semantic-kind routing); applied + count ≥ 1. +- list_session_exposures: payload `{"session_id": ...,"exposures": []}`; `fake.requests` unchanged + (agentcore has no exposure log — the rating-model difference from sqlite C5). + +**regression_caught:** remove the short-id guard → botocore 40-char client-side validation raises / +retry stall; revert record_use dispatch → sqlite `record_use` returns `{"ok":true}` with 0 wire; +break the semantic-kind branch in `credit_one` → lookup 404s against EPI. Full-snapshot strip +contract owned by D4 `test_record_use_strips_system_metadata_full_snapshot` / +`test_credit_one_semantic_kind_routes_to_sem_and_strips`. + +### J7 — contextual_inject per-prompt honours settings.json activation +**Steps:** `onboarding(clean_slate_home, fake, BETTER_MEMORY_CONTEXT_INJECT_MODE="userprompt")`; +`fake.set_response("ListMemoryRecords", {"memoryRecordSummaries": [_DOCKER_REFLECTION_SUMMARY]})` +(reuse D6's fixture constant); +`run_hook("better_memory.hooks.contextual_inject", {"hook_event_name":"UserPromptSubmit","prompt":"how do I deploy with docker compose","session_id":"e2e-inject-session","cwd":str(proj)}, env)`. +**Assertions:** `rc == 0`, `"Traceback" not in err`; envelope `additionalContext` contains +`, outcome=success)` → GetMemoryRecord + BatchUpdateMemoryRecords on EPI with `x-amz-agentcore-memory-*` keys stripped; **AND zero rows** in local `memory.db` observations/semantic_memories after the session (the inverted half of today's pin). Record ids in tests must be ≥40 chars (botocore client-side validation). +- `tests/e2e/test_agentcore_t2.py::TestServerBootToolsHidden::test_boot_hides_synthesize_tools_and_still_migrates_sqlite` → stays green (subset-based); **EXTEND**: episode tools + `memory.run_retention` absent in agentcore mode, present in the sqlite journey's tool list. Sqlite-migration assertions unchanged (UD-4 doc-side). +- `tests/e2e/test_agentcore_t2.py::TestBackendWireFidelity` (5 tests) → keep; update the class docstring ("unreachable over MCP today" no longer true). `::test_observe_without_session_id_raises_before_wire` → rewrite for lazy re-resolution: no env AND no marker (e2e homes have none) → still raises with ZERO wire requests. +- `tests/storage/test_agentcore_unit.py::test_observe_raises_value_error_when_session_id_is_none` (:207) + `::test_list_observations_raises_when_session_id_is_none` (:280) → rewrite: re-resolution from env/marker attempted first; raise only when both absent. +- `tests/e2e_meta/mutations/M1.patch` (targets mcp/server.py): rebase if hunks drift; confirm sentinel still flips red via `scripts/e2e_mutation_smoke.py`. + +--- + +### Task 3 — Hooks: session_close settings-aware gate; session_bootstrap → build_backend + +**Confidence: 91%.** Mitigations: env-first fast path preserves the zero-import sqlite exit; `tests/test_common.py::test_import_is_lightweight` is a hard constraint — the resolver import stays lazy inside the function body; new sqlite-safety sibling e2e test proves a once-provisioned sqlite user is not silently switched. + +**Owns:** `better_memory/hooks/session_close.py`, `better_memory/hooks/session_bootstrap.py`, `tests/hooks/test_session_close_agentcore.py`, `tests/hooks/test_session_bootstrap.py`, `tests/e2e/test_agentcore_t2.py` (`TestSessionCloseClosureAndEnvGate` only). + +**Regression tests to ADD FIRST:** +- `tests/hooks/test_session_bootstrap.py`: agentcore-mode test mirroring `test_contextual_inject.py::test_agentcore_mode_does_not_open_sqlite_connection` — the SessionStart hook must not open sqlite in agentcore mode (currently zero coverage anywhere). +- `tests/hooks/test_session_close_agentcore.py`: settings.json-resolved closure fires without env; corrupt settings.json → `record_hook_error` + `return False` + marker still written. + +**Code changes:** +- `session_close.py:73`: replace raw `os.environ.get` with: env check first (any explicit env value keeps today's semantics, zero file I/O) → else `resolve_storage_backend()` (Task 1's public helper), wrapped in try/except → on any resolver error `record_hook_error` + `return False` (hooks never fail; marker still written). Region code `:89-94` **unchanged** (already json-authoritative — the defect-3 convergence target). Add the `better-memory[agentcore]` hint to the lazy boto3 import at `:50` for consistency with factory. +- `session_bootstrap.py:22/:70-71`: route through `build_backend` like `contextual_inject.py:110` when resolved backend is agentcore; sqlite path (direct `SessionBootstrapService`) byte-identical. Preserves C2's known-defect pin behavior (schema-less db degradation) untouched. + +**Pin inversions:** +- `tests/e2e/test_agentcore_t2.py::TestSessionCloseClosureAndEnvGate::test_stop_hook_without_backend_env_skips_aws_but_writes_marker` → **INVERT** (the defect-4 pin, design §4 item 5): env `BETTER_MEMORY_STORAGE_BACKEND=None` but bm_home carries settings.json (via `write_backend_settings` helper from Task 1) → exactly ONE CreateEvent (role=OTHER, EPI-FAKE-0001 in path, SigV4-signed with agentcore.json's region) AND spool marker written AND no memory.db created. +- **ADD sibling** (sqlite-safety oracle): env absent AND no settings.json (agentcore.json may exist) → ZERO wire requests + marker written — existence is not consent. +- `::test_stop_hook_fires_one_closure_event_signed_with_json_region` → stays green unchanged (env-precedence + json-region regression guard). +- `tests/hooks/test_session_close_agentcore.py::test_env_guard_short_circuits_before_any_import` (:126) → rewrite: sqlite fast-exit (env=sqlite, or env unset + no settings.json) must not import boto3/`better_memory.config`; settings-file path resolves agentcore. +- M2/M3 mutation patches (session_bootstrap.py, contextual_inject.py targets): rebase check in the gate. + +--- + +### Task 4 — CLI: `agentcore init` activation + next-steps rewrite; `status` effective backend + +**Confidence: 93%.** No existing test pins next-steps text or init side effects beyond memory-ids/config (verified: `tests/cli/test_agentcore_init.py` asserts only memory-ids in stdout). + +**Owns:** `better_memory/cli/agentcore.py`, `tests/cli/test_agentcore_init.py`, `tests/cli/test_agentcore_status.py`. + +**Regression tests to ADD FIRST:** +- init writes `settings.json {"storage_backend": "agentcore"}` atomically (tmp + `os.replace`, same pattern as `save_agentcore_config`) after config save succeeds; `--no-activate` skips it; failure-path (memory creation fails) does not write it. +- Next-steps stdout pin: names settings.json + env precedence; asserts the text does **NOT** contain a bare `"Export BETTER_MEMORY_STORAGE_BACKEND"` step (the onboarding trap). +- `status` reports the EFFECTIVE backend and its source (`env` / `settings` / `default`) — makes the env-precedence surprise diagnosable. + +**Code changes:** +- `_handle_init`: settings.json write per UD-3 (+ `--no-activate` argparse flag). +- Next-steps block (:312-315) rewritten: (1) "agentcore is now the default backend for `` (persisted in settings.json; the `BETTER_MEMORY_STORAGE_BACKEND` env var still overrides — unset it or set it to agentcore). To revert: remove `storage_backend` from settings.json or set the env var to `sqlite`."; (2) restart Claude Code; (3) `better-memory agentcore status` then `smoke`, with a caveat line that smoke validates AWS credentials/wire, not MCP registration. +- `_handle_status`: effective-backend + source line. +- Custom-home caveat (installer injects `BETTER_MEMORY_HOME` only into the MCP server env; hooks fall back to `~/.better-memory`) — unchanged behavior, documented in Task 5. + +**`better_memory/cli/install_hooks.py` is UNTOUCHED** (variant A2 requires zero installer changes); `tests/cli/test_install_hooks.py` (~40), `tests/e2e/test_install_hooks.py` (8), `tests/e2e/test_setup_sh.py` passing untouched is the proof. + +--- + +### Task 5 — Docs sweep (LAST, verified against landed code) + +See section 4 below. **Confidence: 95%** (zero code risk; single risk is factual drift, mitigated by running last + token-by-token verification per the standing 0.95-confidence sync rule). + +### Task 6 — Validation phase + +Owns `tests/integration/test_agentcore_live_e2e.py` (drop idvar setenvs :327-328 and the split-brain-dodging `BETTER_MEMORY_AGENTCORE_REGION` :315-319; add settings.json to the child home). Runs the full gate (section 3) + mutation smoke + T3 live run (section 5). + +--- + +## 2. Regression gate definition + +Run after **every** task (inner loop) and in full at validation: + +| Gate | Command | Purpose | +|------|---------|---------| +| Inner loop (per fix commit) | `pytest tests/test_config.py tests/storage tests/mcp tests/hooks tests/cli -q` | config gate, factory, dispatch, hooks, CLI fast feedback | +| Full default suite | `pytest -q` (~1300 tests: unit + mcp + storage + hooks + cli + e2e T1/T2 hermetic + e2e_meta fast incl. real_home_canary) | **primary gate — 100% green with pins flipped** | +| Agentcore pin-flip proof | `pytest tests/e2e/test_agentcore_t2.py tests/e2e/test_agentcore_neg.py tests/e2e/test_tripwires_aws.py tests/e2e/test_hooks_contracts.py -q` | D1-D7 + negatives + lockdown tripwires pass in INVERTED form | +| Sqlite byte-identical proof | `pytest tests/e2e/test_sqlite_journey.py tests/e2e/test_sqlite_negative.py tests/e2e/test_install_hooks.py -q` | default path unchanged, installer untouched — **these files ship with ZERO edits** | +| Isolation proofs (MANDATORY this wave) | `pytest tests/e2e_meta/test_canary_home.py tests/e2e_meta/test_env_bleed.py -q` | settings.json adds a real-home file dependency; poisoned idvar/region vars proven inert post-removal | +| Suite-power validation (REQUIRED) | `python scripts/e2e_mutation_smoke.py` | M1/M2/M3 target files all edited this wave — rebase drifted patches, confirm each sentinel flips red | +| T3 live AWS (validation phase only) | `BETTER_MEMORY_TEST_AGENTCORE=1 pytest -m integration tests/integration/test_agentcore_live_e2e.py` | section 5; ~5-8 min, real cost, run ONCE after T3 plumbing update | + +--- + +## 3. Docs task — per-file edit list (Task 5) + +- **website/agentcore-setup.md**: reorder Initialise — `better-memory agentcore init --region ` FIRST, no export step; explain settings.json activation + env-var precedence/override + revert path; rewrite `:70` → "Memory data lives in AWS — observations, reflections, semantic memories, and reinforcement all go to Bedrock AgentCore. A local memory.db is still created for hook-error logging and knowledge.db for knowledge tools; no memory content is stored in them."; `:73` closure bullet names the settings.json mechanism; add at `:50` that agentcore.json's region is the single source of truth for all clients; custom-`BETTER_MEMORY_HOME` caveat (hooks fall back to `~/.better-memory`); keep anchor/heading text stable (inbound links from configuration.md). +- **website/troubleshooting/agentcore.md**: `:51` region section — now TRUE; drop the env-var mid-flight paragraph → "region changes require re-init (or hand-edit agentcore.json)"; extend `:39-47` closure checklist: effective backend via `agentcore status`, env-var-overrides-settings pitfall; add two new sections quoting the exact landed error strings: boto3 missing → `pip install 'better-memory[agentcore]'`; corrupt agentcore.json → delete + re-init / `--force`. +- **website/configuration.md**: `:16` `BETTER_MEMORY_STORAGE_BACKEND` row — note settings.json fallback and that env wins; **delete `:17` `BETTER_MEMORY_AGENTCORE_REGION` row** (var removed) + one-line migration note; `:72` session_close description gains the agentcore closure event; add settings.json to any file-layout section. +- **website/architecture.md**: `:3` → "backed by a pluggable storage backend — a single SQLite database by default"; `:30`/`:35` hardcoded `eu-west-2` → "region from agentcore.json"; add comparison-table row "Local files | memory.db + knowledge.db | hook-error log + knowledge index only (no memory content)". +- **website/mcp-tools.md**: episode no-op notes (`:73,:92,:99,:106`) → update per UD-1 (tools hidden in agentcore mode, like the synthesize notes `:177,:192`); one-line note that observation/semantic/rating tools dispatch to AgentCore in agentcore mode. +- **README.md**: `:5` → minimal qualifier "(with the default sqlite backend)"; `:39` → describes settings.json activation via init, env var as override; add `BETTER_MEMORY_STORAGE_BACKEND` row to the `:163` env table; re-verify the "22 tools" count at `:214` + `website/index.md:192` (this wave adds/removes no Tool registration — count-neutral, but counts drifted twice historically). +- **docs/superpowers/specs/2026-05-24-agentcore-storage-backend-design.md:433**: SUPERSEDED annotation on the idvar-env-var row (per reflection bd0dadda — only place the ID vars were ever documented). +- **docs/superpowers/specs/2026-07-12-e2e-clean-slate-smoke-design.md §4**: per-item "FIXED in " annotations so future agents don't re-pin old behavior. +- **e2e docstrings** (docstring-only edits): `tests/e2e/test_agentcore_neg.py::TestMissingAgentCoreJson` ("false 'No SQLite traffic' docs claim" clause → documented behavior), `tests/e2e/test_agentcore_t2.py:98` equivalent. + +Coordinate merge risk: `cli/agentcore.py` is owned by Task 4, not this task — next-steps text lands there; docs quote it verbatim after it lands. + +--- + +## 4. T3 live run — what it must prove (Task 6, after all fixes land) + +Update `tests/integration/test_agentcore_live_e2e.py` first (drop dead idvar/region env plumbing; child home gets settings.json), then one run must demonstrate against **real AWS**: + +1. **Onboarding path is the tested path**: child process configured exactly as the printed next-steps produce — agentcore.json + settings.json from `agentcore init`, **no** `BETTER_MEMORY_STORAGE_BACKEND` and **no** id/region env vars in the child env. +2. **observe → AWS round-trip over MCP** (new — non-tautological now that dispatch is wired): `memory.observe` via MCP stdio returns a real eventId; the content is subsequently retrievable via `memory.retrieve`/`memory.retrieve_observations` from AgentCore (allowing extraction latency), and **zero rows** land in the child home's local `memory.db` observations table. +3. **semantic round-trip over MCP**: `memory.semantic_observe` → record visible via `memory.semantic_retrieve` (project+general merge per UD-2); `semantic_update`/`semantic_delete` take effect on AWS. +4. **record_use on a real record id** succeeds (full-snapshot update, system keys stripped) — using a genuine ≥40-char AgentCore record id. +5. **Region single-source**: all requests (server plane and hook plane) hit the region written in agentcore.json — run in a non-default region (not eu-west-2) to make a factory regression impossible to miss. +6. **Hook closure without env**: Stop hook fires exactly one CreateEvent(role=OTHER) closure with only settings.json driving the backend choice; spool marker written. +7. **session_bootstrap / contextual_inject** reach AgentCore, not sqlite, in the same configuration. +8. Cleanup: created events/records deleted or the test memories torn down (existing orphan-cleanup patterns in the T3 suite). + +--- + +## Cross-cutting sequencing invariants (hard rules) + +- Task 1 (gate deletion) MUST land before or with anything that activates settings.json — a surviving gate + file-resolved agentcore = ValueError in every hook and at server boot (user worse off than before). +- Tasks 1-4 ship in **one PR/release** — settings.json without dispatch wiring makes the split-brain more visible (hooks on AWS, observe on sqlite). +- Docs task runs strictly last; every rewritten sentence verified against landed code. +- `tests/e2e/test_sqlite_journey.py`, `tests/e2e/test_sqlite_negative.py`, `tests/cli/test_install_hooks.py`, `tests/e2e/test_install_hooks.py`, `tests/e2e/test_setup_sh.py` ship with zero edits — they are the backward-compat proof. +- After merge, record the wave's non-obvious findings to better-memory per the mandatory record triggers (idvar gate vestigiality, settings.json precedence design, record_use id-domain stall). \ No newline at end of file From ce45e28279929cbc0a21e2de43dd54f996e72a46 Mon Sep 17 00:00:00 2001 From: gethin Date: Sun, 12 Jul 2026 21:43:28 +0100 Subject: [PATCH 2/6] fix: make agentcore mode usable end-to-end (Hive Mind implement wave) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements docs/superpowers/specs/2026-07-12-agentcore-onboarding-fix-design.md: - config: delete the vestigial memory-ID env-var gate and both dead Config fields; backend resolution is now env > $BETTER_MEMORY_HOME/settings.json > sqlite via public resolve_storage_backend(); region single-sourced from agentcore.json (BETTER_MEMORY_AGENTCORE_REGION deleted); boto3 ImportError gains a better-memory[agentcore] install hint; AgentCoreConfigError messages gain remediation text - mcp: dispatch wired — memory.observe/semantic_observe/record_use/ retrieve_observations/credit/semantic CRUD route to AgentCoreBackend in agentcore mode via additive keyword-only remote param (sqlite branch verbatim); episode/retention tools hidden via supports_episodes; lazy session-id re-resolution in the backend - hooks: session_close backend gate is settings-aware (env still wins, zero I/O on the sqlite fast path); session_bootstrap routes through build_backend in agentcore mode; boto3 hint mirrored - cli: agentcore init activates by default (atomic merge-write of settings.json, --no-activate opt-out); honest next-steps output; status reports effective backend + source - e2e: pin tests inverted per their FIXME contracts (dispatch-gap -> TestMcpDispatchWired with wire assertions, region split-brain -> TestRegionSingleSource convergence, idvar machinery deleted, anti- resurrection grep-pin added); new write_backend_settings helper - NEW tests/e2e/test_agentcore_journey.py: 8-step full-flow journey on the shipped onboarding config (settings.json, zero env knobs) with per-step wire assertions and no-sqlite-leakage checks; live T3 journey additions - tests/conftest.py: autouse BETTER_MEMORY_HOME isolation (unit tests must never read the developer's real settings.json) - docs: truth-first sweep of README + website (activation flow, deleted env vars, corrected sqlite-traffic claim, init next-steps quoted from source) Full suite: 1388 passed, 22 skipped. Pyright 0 errors. Ruff delta vs main: 0. Co-Authored-By: Claude Fable 5 --- README.md | 11 +- better_memory/cli/agentcore.py | 101 ++- better_memory/config.py | 100 ++- better_memory/hooks/session_bootstrap.py | 31 +- better_memory/hooks/session_close.py | 33 +- better_memory/mcp/handlers/episodes.py | 10 + better_memory/mcp/handlers/observations.py | 63 +- better_memory/mcp/handlers/reflections.py | 55 +- better_memory/mcp/handlers/semantics.py | 60 +- better_memory/mcp/handlers/sessions.py | 51 +- better_memory/mcp/server.py | 28 +- better_memory/mcp/tools.py | 28 +- better_memory/storage/agentcore.py | 62 +- .../storage/agentcore_persistence.py | 23 +- better_memory/storage/factory.py | 26 +- tests/cli/test_agentcore_init.py | 165 +++- tests/cli/test_agentcore_status.py | 108 +++ tests/conftest.py | 20 + tests/e2e/_agentcore_env.py | 78 +- tests/e2e/test_agentcore_journey.py | 750 ++++++++++++++++++ tests/e2e/test_agentcore_neg.py | 132 ++- tests/e2e/test_agentcore_t2.py | 379 ++++++--- tests/e2e/test_tripwires_aws.py | 62 +- tests/e2e_meta/mutations/M2.patch | 19 +- tests/hooks/test_contextual_inject.py | 2 - tests/hooks/test_session_bootstrap.py | 161 +++- tests/hooks/test_session_close_agentcore.py | 330 +++++++- tests/integration/test_agentcore_live_e2e.py | 466 ++++++++++- tests/mcp/test_handlers_remote.py | 555 +++++++++++++ tests/mcp/test_server_backend_dispatch.py | 223 +++++- tests/storage/test_agentcore_persistence.py | 46 +- tests/storage/test_agentcore_unit.py | 84 +- tests/storage/test_factory.py | 144 +++- tests/test_config.py | 150 +++- website/agentcore-setup.md | 33 +- website/architecture.md | 17 +- website/configuration.md | 12 +- website/mcp-tools.md | 31 +- website/troubleshooting/agentcore.md | 38 +- 39 files changed, 4080 insertions(+), 607 deletions(-) create mode 100644 tests/e2e/test_agentcore_journey.py create mode 100644 tests/mcp/test_handlers_remote.py diff --git a/README.md b/README.md index 994d1d3..62faa82 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **better-memory gives your AI coding assistant a memory that grows with your project.** It remembers what worked and what didn't, picks up the preferences and conventions you care about, and when you have to point it back to something it should have used, it records that too — so the same lesson surfaces on its own next time. Every lesson is captured the moment a decision is made and distilled into short, relevance-ranked guidance the assistant pulls up on its own. The result: an assistant that *compounds* — getting sharper on your codebase the more you work together. -It's a memory layer for Claude Code that runs entirely on your machine. Your observations, distilled lessons, and knowledge base live in a local SQLite database — nothing is sent to a cloud service. Even the work of turning raw notes into durable lessons runs inside your own Claude Code session — no separate cloud LLM, no second subscription, no third party seeing your code. +It's a memory layer for Claude Code that (with the default sqlite backend) runs entirely on your machine. Your observations, distilled lessons, and knowledge base live in a local SQLite database — nothing is sent to a cloud service. Even the work of turning raw notes into durable lessons runs inside your own Claude Code session — no separate cloud LLM, no second subscription, no third party seeing your code. (An optional [AWS-backed backend](website/agentcore-setup.md) exists for teams that want cloud-managed memory.) ## How it works @@ -34,9 +34,9 @@ better-memory has two storage backends. Pick one: | Backend | When to pick | Setup | |---|---|---| | **`sqlite`** (default) | Single-machine usage; full offline operation; no cloud cost. | None — works out of the box. | -| **`agentcore`** | Multi-machine syncing; managed extraction by AWS; team-shared memory bucket. | Requires AWS account with Bedrock AgentCore Memory enabled in `eu-west-2`. See [AgentCore setup](website/agentcore-setup.md). | +| **`agentcore`** | Multi-machine syncing; managed extraction by AWS; team-shared memory bucket. | Requires an AWS account with Bedrock AgentCore Memory available in your chosen region (`init --region` defaults to `eu-west-2`). See [AgentCore setup](website/agentcore-setup.md). | -Switch backends via `BETTER_MEMORY_STORAGE_BACKEND=agentcore` (default: `sqlite`). The MCP server reads the env var at startup and dispatches accordingly. Switching is one-way today — there is no bulk migration tool (deferred; clean start in agentcore mode is the supported path). +Switching to agentcore is done by `better-memory agentcore init`, which provisions the AWS memories and activates the backend by writing `{"storage_backend": "agentcore"}` to `$BETTER_MEMORY_HOME/settings.json`. The MCP server, hooks, and CLI all resolve the backend the same way: `BETTER_MEMORY_STORAGE_BACKEND` env var if set (always wins), else `settings.json`, else `sqlite`. To revert, remove the `storage_backend` key from `settings.json` or set the env var to `sqlite`. Switching is one-way today — there is no bulk migration tool (deferred; clean start in agentcore mode is the supported path). `agentcore` mode needs the optional dependency group: `pip install 'better-memory[agentcore]'` (or `uv pip install '.[agentcore]'`). Sqlite-only installs skip boto3 entirely. @@ -87,7 +87,7 @@ Then add to `~/.claude.json` (user-scope MCP — create the file if it doesn't e And add hooks to `~/.claude/settings.json`: -Four hooks ship. `session_bootstrap` (SessionStart) opens (or reuses) a background episode for the session and injects the project's curated context — project-scoped and general-scope semantic memories plus distilled reflections (`do` / `dont` / `neutral` buckets) — as `additionalContext` so Claude has prior memory available without needing to call any retrieval tool first. Only the top `BETTER_MEMORY_BOOTSTRAP_TOP_N` project-scoped items (default 5) render in full; the rest collapse into a one-line index plus a retrieve affordance (`BETTER_MEMORY_BOOTSTRAP_TOP_N=0` restores the old full-dump behavior). The hook does its work in-process against `memory.db`; if it fails for any reason, a fallback directive is injected instructing Claude to call `mcp__better-memory__memory_session_bootstrap` manually. `contextual_inject` (UserPromptSubmit + PreToolUse) additionally surfaces memories relevant to the current prompt/tool-input mid-session — see [Configuration](website/configuration.md) and [Architecture](website/architecture.md#injection-strategies) for how it scores, floors, and dedups candidates. +Four hooks ship. `session_bootstrap` (SessionStart) opens (or reuses) a background episode for the session and injects the project's curated context — project-scoped and general-scope semantic memories plus distilled reflections (`do` / `dont` / `neutral` buckets) — as `additionalContext` so Claude has prior memory available without needing to call any retrieval tool first. Only the top `BETTER_MEMORY_BOOTSTRAP_TOP_N` project-scoped items (default 5) render in full; the rest collapse into a one-line index plus a retrieve affordance (`BETTER_MEMORY_BOOTSTRAP_TOP_N=0` restores the old full-dump behavior). The hook does its work in-process against `memory.db` (in agentcore mode it routes through the storage backend instead and never opens the local database); if it fails for any reason, a fallback directive is injected instructing Claude to call `mcp__better-memory__memory_session_bootstrap` manually. `contextual_inject` (UserPromptSubmit + PreToolUse) additionally surfaces memories relevant to the current prompt/tool-input mid-session — see [Configuration](website/configuration.md) and [Architecture](website/architecture.md#injection-strategies) for how it scores, floors, and dedups candidates. ```json { @@ -167,6 +167,7 @@ One env var roots the runtime filesystem layout: | `EMBED_MODEL` | `nomic-embed-text` | Embedding model (must produce 768-dim vectors) | | `AUDIT_LOG_RETRIEVED` | `true` | Whether `memory.retrieve` writes per-result audit rows | | `BETTER_MEMORY_EMBEDDINGS_BACKEND` | `ollama` | `ollama` (default) — local Ollama at `OLLAMA_HOST`; `sqlite` — pure-SQL trigram-FTS5 fusion, no model downloads and no in-memory state. | +| `BETTER_MEMORY_STORAGE_BACKEND` | unset | `sqlite` or `agentcore`. Explicit override of the storage backend; when unset, `$BETTER_MEMORY_HOME/settings.json` (written by `better-memory agentcore init`) decides, falling back to `sqlite`. The env var always wins over settings.json. | | `BETTER_MEMORY_AUTO_PRUNE` | (unset = `false`) | When set to `1`, the auto-retention runner (which fires on `memory.retrieve`, throttled to once per 24h) ALSO hard-deletes archived observations older than 365 days. **Irreversible.** Default is archive-only (status flip, reversible). Opt in only if you actively want disk space reclaimed. | | `BETTER_MEMORY_PROJECT` | unset | Force the project name for all calls in this process. Highest-priority project-resolution signal — overrides both the `.better-memory` file and the git-derived name. Designed for subprocess scoping (e.g. ralph's executor sets it per-iteration so subagent observations land in the PBI's target_repo regardless of the worktree's cwd). Empty/whitespace-only values are treated as unset. | | `BETTER_MEMORY_CONTEXT_INJECT_MODE` | `both` | Contextual memory-injection hook trigger: `userprompt`, `pretool`, `both` (default), or `off`. | @@ -211,7 +212,7 @@ despite the shared name. ## MCP tools -The server registers 22 tools, grouped below. Full schemas are in [`website/mcp-tools.md`](website/mcp-tools.md) and live in `better_memory/mcp/tools.py`. +The server registers 22 tools, grouped below. Full schemas are in [`website/mcp-tools.md`](website/mcp-tools.md) and live in `better_memory/mcp/tools.py`. (In agentcore mode the two synthesis tools, the four episode tools, and `memory.run_retention` are hidden from the advertised list — 15 tools; the memory-data tools dispatch to AWS instead of SQLite.) **Episodic memory** — observations the AI writes during a session. diff --git a/better_memory/cli/agentcore.py b/better_memory/cli/agentcore.py index 497ee91..aa4221a 100644 --- a/better_memory/cli/agentcore.py +++ b/better_memory/cli/agentcore.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import json import sys import time from pathlib import Path @@ -28,9 +29,9 @@ _POLL_INTERVAL_S = 5 # Bumped to 240s vs the 180s the Plan 2 smoke uses: smoke runs solo against -# a clean account, but `init` runs after the user has just `export`ed env -# vars and may be hitting a fresh / cold region — small extra headroom is -# cheap and prevents the user thinking init hung. +# a clean account, but `init` is often the user's very first call into a +# fresh / cold region — small extra headroom is cheap and prevents the user +# thinking init hung. _POLL_TIMEOUT_S = 240 @@ -50,6 +51,15 @@ def add_subparsers(parent: argparse.ArgumentParser) -> None: action="store_true", help="Overwrite existing agentcore.json", ) + p_init.add_argument( + "--no-activate", + action="store_true", + help=( + "Provision the AWS memories and write agentcore.json without " + "activating the agentcore backend in settings.json " + "(provision-only scripting)" + ), + ) p_status = subparsers.add_parser( "status", @@ -88,6 +98,51 @@ def _resolve_home(arg_home: str | None) -> Path: return Path(os.environ.get("BETTER_MEMORY_HOME", "~/.better-memory")).expanduser() +def _write_settings_activation(home: Path) -> Path: + """Persist ``{"storage_backend": "agentcore"}`` into ``/settings.json``. + + Merges into an existing JSON object so unrelated keys survive; a missing + or corrupt file is replaced wholesale (init IS the remediation path for a + broken settings.json, so it must not crash on one). Written atomically — + tmp file + replace, the same pattern as ``save_agentcore_config``. + """ + settings_path = home / "settings.json" + data: dict[str, Any] = {} + try: + raw = json.loads(settings_path.read_text(encoding="utf-8")) + if isinstance(raw, dict): + data = raw + except (OSError, json.JSONDecodeError): + data = {} + data["storage_backend"] = "agentcore" + home.mkdir(parents=True, exist_ok=True) + tmp = settings_path.with_suffix(".json.tmp") + tmp.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8") + tmp.replace(settings_path) + return settings_path + + +def _effective_backend(home: Path) -> tuple[str, str]: + """Resolve the backend ``home`` would use, plus where the answer came from. + + Mirrors :func:`better_memory.config.resolve_storage_backend` but honours + the CLI's ``--home`` override (the config resolver only reads + ``$BETTER_MEMORY_HOME``) and reports the source: ``env`` / ``settings`` / + ``default``. May raise ``ValueError`` on a corrupt settings.json. + """ + import os + + from better_memory.config import _read_settings_storage_backend + + raw = os.environ.get("BETTER_MEMORY_STORAGE_BACKEND") + if raw is not None: + return raw, "env" + from_file = _read_settings_storage_backend(home) + if from_file is not None: + return from_file, "settings" + return "sqlite", "default" + + def _build_control_client(region: str) -> Any: """Build the bedrock-agentcore-control boto3 client. Patched out in tests.""" import boto3 @@ -304,15 +359,38 @@ def _handle_init(args: argparse.Namespace) -> int: ) save_agentcore_config(cfg, home) + settings_path = home / "settings.json" + activate = not args.no_activate + if activate: + _write_settings_activation(home) + print() print(f"agentcore.json written to {config_path}") print(f" episodic memory_id: {episodic.memory_id}") print(f" semantic memory_id: {semantic.memory_id}") print() + if activate: + print( + f"agentcore is now the default backend for {home} (persisted in " + f"{settings_path}; the BETTER_MEMORY_STORAGE_BACKEND env var " + f"still overrides — unset it or set it to agentcore). To revert: " + f"remove 'storage_backend' from settings.json or set the env var " + f"to sqlite." + ) + else: + print( + f"Activation skipped (--no-activate): agentcore.json is written " + f"but the backend for {home} is unchanged. To activate later, " + f'add {{"storage_backend": "agentcore"}} to {settings_path} or ' + f"set BETTER_MEMORY_STORAGE_BACKEND=agentcore." + ) + print() print("Next steps:") - print(" 1. Export BETTER_MEMORY_STORAGE_BACKEND=agentcore") - print(" 2. Restart your MCP server (or Claude Code session)") - print(" 3. Run `better-memory agentcore smoke` to verify the round-trip") + print(" 1. Restart Claude Code (or your MCP server) so it picks up the new backend") + print(" 2. Run `better-memory agentcore status` to confirm the effective backend") + print(" and that both memories are ACTIVE") + print(" 3. Run `better-memory agentcore smoke` to verify the AWS round-trip") + print(" (smoke validates AWS credentials and wire access, not MCP registration)") return 0 @@ -327,6 +405,17 @@ def _handle_status(args: argparse.Namespace) -> int: ) return 1 + try: + backend, source = _effective_backend(home) + print(f"effective backend: {backend} (source: {source})") + except ValueError as exc: + # A corrupt settings.json must not take `status` down — it is the + # diagnostic tool the user reaches for. Warn and keep reporting. + print( + f"WARN: could not resolve the effective backend: {exc}", + file=sys.stderr, + ) + region = args.region or cfg.region control = _build_control_client(region) diff --git a/better_memory/config.py b/better_memory/config.py index db562fe..f23d47e 100644 --- a/better_memory/config.py +++ b/better_memory/config.py @@ -8,6 +8,7 @@ knowledge.db spool/ knowledge-base/ + settings.json (optional; persists storage_backend selection) Default home is ``~/.better-memory``. External-service knobs (``OLLAMA_HOST``, ``EMBED_MODEL``, ``AUDIT_LOG_RETRIEVED``, @@ -20,6 +21,7 @@ from __future__ import annotations +import json import os from dataclasses import dataclass from pathlib import Path @@ -34,7 +36,7 @@ _VALID_EMBEDDINGS_BACKENDS = ("ollama", "sqlite") _DEFAULT_STORAGE_BACKEND = "sqlite" _VALID_STORAGE_BACKENDS = ("sqlite", "agentcore") -_DEFAULT_AGENTCORE_REGION = "eu-west-2" +_SETTINGS_FILE = "settings.json" # Maps absolute cwd string → resolved project name (or None when no git tree @@ -225,9 +227,6 @@ class Config: diag_logging: bool embeddings_backend: Literal["ollama", "sqlite"] storage_backend: Literal["sqlite", "agentcore"] - agentcore_region: str - agentcore_semantic_memory_id: str | None - agentcore_episodic_memory_id: str | None context_inject_mode: Literal["userprompt", "pretool", "both", "off"] bootstrap_top_n: int context_min_hits: int @@ -261,14 +260,70 @@ def _resolve_embeddings_backend() -> Literal["ollama", "sqlite"]: return raw # type: ignore[return-value] -def _resolve_storage_backend() -> Literal["sqlite", "agentcore"]: - raw = os.environ.get("BETTER_MEMORY_STORAGE_BACKEND", _DEFAULT_STORAGE_BACKEND) - if raw not in _VALID_STORAGE_BACKENDS: +def _read_settings_storage_backend(home: Path) -> str | None: + """Read ``storage_backend`` from ``/settings.json``. + + Returns ``None`` when the file does not exist or carries no + ``storage_backend`` key (callers fall back to the default). Raises + :class:`ValueError` — naming the file, with remediation — when the file + is malformed JSON, not a JSON object, or carries an invalid value + (symmetric to the invalid-env-var error). + """ + settings_path = home / _SETTINGS_FILE + try: + text = settings_path.read_text(encoding="utf-8") + except FileNotFoundError: + return None + remediation = ( + "Fix or delete the file, or set BETTER_MEMORY_STORAGE_BACKEND to " + "override it. It is written by `better-memory agentcore init`." + ) + try: + raw = json.loads(text) + except json.JSONDecodeError as exc: raise ValueError( - f"BETTER_MEMORY_STORAGE_BACKEND={raw!r} is not one of " - f"{_VALID_STORAGE_BACKENDS}" + f"failed to parse {settings_path}: {exc}. {remediation}" + ) from exc + if not isinstance(raw, dict): + raise ValueError(f"{settings_path} is not a JSON object. {remediation}") + value = raw.get("storage_backend") + if value is None: + return None + if value not in _VALID_STORAGE_BACKENDS: + raise ValueError( + f"storage_backend={value!r} in {settings_path} is not one of " + f"{_VALID_STORAGE_BACKENDS}. {remediation}" ) - return raw # type: ignore[return-value] + return value + + +def resolve_storage_backend() -> Literal["sqlite", "agentcore"]: + """Resolve the effective storage backend for the current environment. + + Resolution order: + + 1. ``BETTER_MEMORY_STORAGE_BACKEND`` env var, when set (validated — + an invalid value raises rather than falling through). + 2. ``$BETTER_MEMORY_HOME/settings.json`` ``storage_backend`` key + (missing file or missing key falls through; malformed file or + invalid value raises :class:`ValueError` naming the file). + 3. ``"sqlite"`` — the byte-identical default. + + Public export for hooks (and the CLI): re-resolved on every call, never + memoized, so env/file edits between calls take effect immediately. + """ + raw = os.environ.get("BETTER_MEMORY_STORAGE_BACKEND") + if raw is not None: + if raw not in _VALID_STORAGE_BACKENDS: + raise ValueError( + f"BETTER_MEMORY_STORAGE_BACKEND={raw!r} is not one of " + f"{_VALID_STORAGE_BACKENDS}" + ) + return raw # type: ignore[return-value] + from_file = _read_settings_storage_backend(resolve_home()) + if from_file is not None: + return from_file # type: ignore[return-value] + return _DEFAULT_STORAGE_BACKEND # type: ignore[return-value] def get_config() -> Config: @@ -278,27 +333,7 @@ def get_config() -> Config: """ home = resolve_home() - storage_backend = _resolve_storage_backend() - - agentcore_region = _resolve_str( - "BETTER_MEMORY_AGENTCORE_REGION", _DEFAULT_AGENTCORE_REGION - ) - agentcore_semantic_memory_id = _resolve_str( - "BETTER_MEMORY_AGENTCORE_SEMANTIC_MEMORY_ID", "" - ) or None - agentcore_episodic_memory_id = _resolve_str( - "BETTER_MEMORY_AGENTCORE_EPISODIC_MEMORY_ID", "" - ) or None - - if storage_backend == "agentcore" and ( - agentcore_semantic_memory_id is None or agentcore_episodic_memory_id is None - ): - raise ValueError( - "BETTER_MEMORY_STORAGE_BACKEND=agentcore requires both " - "BETTER_MEMORY_AGENTCORE_SEMANTIC_MEMORY_ID and " - "BETTER_MEMORY_AGENTCORE_EPISODIC_MEMORY_ID to be set. " - "Run `better-memory agentcore init` to create the memory resources." - ) + storage_backend = resolve_storage_backend() return Config( home=home, @@ -313,9 +348,6 @@ def get_config() -> Config: diag_logging=_resolve_bool("BETTER_MEMORY_DIAG_LOGGING", default=False), embeddings_backend=_resolve_embeddings_backend(), storage_backend=storage_backend, - agentcore_region=agentcore_region, - agentcore_semantic_memory_id=agentcore_semantic_memory_id, - agentcore_episodic_memory_id=agentcore_episodic_memory_id, context_inject_mode=_resolve_context_inject_mode(), bootstrap_top_n=_resolve_nonneg_int("BETTER_MEMORY_BOOTSTRAP_TOP_N", 5), context_min_hits=_resolve_nonneg_int("BETTER_MEMORY_CONTEXT_MIN_HITS", 2), diff --git a/better_memory/hooks/session_bootstrap.py b/better_memory/hooks/session_bootstrap.py index 52f75de..d862de1 100644 --- a/better_memory/hooks/session_bootstrap.py +++ b/better_memory/hooks/session_bootstrap.py @@ -15,11 +15,12 @@ from pathlib import Path from better_memory._common import get_session_id -from better_memory.config import get_config +from better_memory.config import get_config, project_name from better_memory.db.connection import connect from better_memory.hooks._error_log import record_hook_error from better_memory.runtime.session_marker import write_session_id from better_memory.services.session_bootstrap import SessionBootstrapService +from better_memory.storage import build_backend _MAX_STDIN_BYTES = 1_048_576 @@ -68,12 +69,32 @@ def main() -> None: try: cfg = get_config() - with closing(connect(cfg.memory_db)) as conn: - service = SessionBootstrapService(conn) - result = service.bootstrap( + if cfg.storage_backend == "agentcore": + # Route through the storage factory like contextual_inject.py: + # agentcore mode must never open the local sqlite database. The + # backend returns the BootstrapResult-shaped dict (see + # AgentCoreBackend.session_bootstrap); on any failure (missing + # agentcore.json, boto3 absent, wire error) the except below + # emits the manual-bootstrap fallback directive — never a + # silent fall-through to sqlite. + backend = build_backend( + config=cfg, + memory_conn=None, + embedder=None, + session_id=session_id or None, + project=project_name(Path(cwd_str)), + ) + remote_result = backend.session_bootstrap( source=source, session_id=session_id, cwd=Path(cwd_str), ) - rendered = result.additional_context + rendered = str(remote_result["additional_context"]) + else: + with closing(connect(cfg.memory_db)) as conn: + service = SessionBootstrapService(conn) + result = service.bootstrap( + source=source, session_id=session_id, cwd=Path(cwd_str), + ) + rendered = result.additional_context # Bridge session_id to the MCP server: it doesn't see CLAUDE_SESSION_ID # in its spawn env. See better_memory/runtime/session_marker.py. # Resolution MUST match read_session_id's _resolve_project_dir order: diff --git a/better_memory/hooks/session_close.py b/better_memory/hooks/session_close.py index 2e99640..df65258 100644 --- a/better_memory/hooks/session_close.py +++ b/better_memory/hooks/session_close.py @@ -47,8 +47,16 @@ def _build_agentcore_data_client(region: str): Defined as a module-level function so tests can patch it without needing boto3 installed. boto3 is imported lazily so sqlite-mode hooks never pay for the import.""" - import boto3 - from botocore.config import Config as BotoConfig + try: + import boto3 + from botocore.config import Config as BotoConfig + except ImportError as exc: + # Same install hint as better_memory/storage/factory.py — keep the + # two lazy-import surfaces consistent. + raise ModuleNotFoundError( + "boto3 is required for the agentcore storage backend. " + "Install it with: pip install 'better-memory[agentcore]'" + ) from exc return boto3.client( "bedrock-agentcore", config=BotoConfig( @@ -69,11 +77,28 @@ def _fire_agentcore_closure(*, session_id: str, project: str) -> bool: `better_memory/storage/session.py` so there's a single source of truth for the payload shape and actor-id resolution — AgentCoreBackend.observe uses the same helpers.""" - # Env-var guard BEFORE any import so sqlite-mode pays nothing. - if os.environ.get("BETTER_MEMORY_STORAGE_BACKEND", "sqlite") != "agentcore": + # Env-var check BEFORE any lazy import or file I/O: an explicit env value + # keeps today's semantics — sqlite-mode (or any non-agentcore value) pays + # nothing and skips even the settings.json resolver. + env_backend = os.environ.get("BETTER_MEMORY_STORAGE_BACKEND") + if env_backend is not None and env_backend != "agentcore": return False try: + if env_backend is None: + # No env override: resolve via the shared helper (env → + # $BETTER_MEMORY_HOME/settings.json → "sqlite"). Installed hooks + # get no env from Claude Code, so the settings file written by + # `agentcore init` is what activates the closure (defect 4). + # Import stays lazy so the explicit-env fast path above never + # touches the resolver. A resolver error (e.g. corrupt + # settings.json) falls into the except below: record_hook_error + # + return False — hooks never fail, marker still written. + from better_memory.config import resolve_storage_backend + + if resolve_storage_backend() != "agentcore": + return False + # Lazy imports — sqlite mode short-circuited above and never reaches # this block. from datetime import UTC, datetime diff --git a/better_memory/mcp/handlers/episodes.py b/better_memory/mcp/handlers/episodes.py index eb57a58..7e174e2 100644 --- a/better_memory/mcp/handlers/episodes.py +++ b/better_memory/mcp/handlers/episodes.py @@ -29,11 +29,16 @@ def __init__( observations: ObservationService, reflections: ReflectionSynthesisService, backend: StorageBackend, + remote: StorageBackend | None = None, ) -> None: self._episodes = episodes self._observations = observations self._reflections = reflections self._backend = backend + # agentcore-mode marker: episode tools are hidden from the + # advertised list (supports_episodes=False) but stay registered + # defensively; reconcile must not surface stale LOCAL episodes. + self._remote = remote def tools(self) -> dict[str, Any]: return { @@ -115,6 +120,11 @@ async def close_episode(self, args: dict[str, Any]) -> list[TextContent]: async def reconcile_episodes( self, args: dict[str, Any] ) -> list[TextContent]: + if self._remote is not None: + # AgentCore manages event grouping itself; the local episodes + # table is not the source of truth in agentcore mode, so a + # defensive caller gets an empty list rather than stale rows. + return [TextContent(type="text", text=json.dumps([]))] open_episodes = self._episodes.unclosed_episodes( exclude_session_ids={self._observations.session_id} ) diff --git a/better_memory/mcp/handlers/observations.py b/better_memory/mcp/handlers/observations.py index e6c5dea..4ff3052 100644 --- a/better_memory/mcp/handlers/observations.py +++ b/better_memory/mcp/handlers/observations.py @@ -15,19 +15,34 @@ from better_memory.config import project_name from better_memory.services.observation import ObservationService from better_memory.services.retention import RetentionService +from better_memory.storage import StorageBackend + +#: AgentCore memoryRecordIds are >= 40 chars (enforced client-side by +#: botocore). memory.observe returns EVENT ids (shorter, different id +#: domain) which are not ratable records — without this floor, a +#: record_use on an event id stalls ~20s in the backend's transient-404 +#: retry loop inside the serialized MCP dispatch loop. +_MIN_AGENTCORE_RECORD_ID_LEN = 40 class ObservationToolHandlers: - """Observation create / drill-down / reinforcement / retention.""" + """Observation create / drill-down / reinforcement / retention. + + ``remote`` (agentcore mode) routes the data tools to the + StorageBackend; ``None`` (the sqlite default) keeps the original + service path unchanged. + """ def __init__( self, *, observations: ObservationService, retention: RetentionService, + remote: StorageBackend | None = None, ) -> None: self._observations = observations self._retention = retention + self._remote = remote def tools(self) -> dict[str, Any]: return { @@ -44,6 +59,24 @@ async def observe(self, args: dict[str, Any]) -> list[TextContent]: scope=args.get("scope") or "project", component=args.get("component"), ): + if self._remote is not None: + _diag.step("mcp.memory.observe", "calling_remote_observe") + obs_id = await self._remote.observe( + content=args["content"], + component=args.get("component"), + theme=args.get("theme"), + trigger_type=args.get("trigger_type"), + outcome=args.get("outcome", "neutral"), + tech=args.get("tech"), + # Same {"scope": null} defence as the sqlite path below. + scope=args.get("scope") or "project", + ) + _diag.step( + "mcp.memory.observe", "remote_returned", obs_id=obs_id + ) + return [ + TextContent(type="text", text=json.dumps({"id": obs_id})) + ] _diag.step("mcp.memory.observe", "calling_observations_create") obs_id = await self._observations.create( content=args["content"], @@ -66,6 +99,23 @@ async def retrieve_observations( self, args: dict[str, Any] ) -> list[TextContent]: project = args.get("project") or project_name() + if self._remote is not None: + remote_results = await self._remote.list_observations( + project=project, + episode_id=args.get("episode_id"), + component=args.get("component"), + theme=args.get("theme"), + outcome=args.get("outcome"), + query=args.get("query"), + limit=args.get("limit", 50), + ) + # AgentCore events carry datetime event_timestamps + # (botocore-parsed); default=str keeps the payload JSON-safe. + return [ + TextContent( + type="text", text=json.dumps(remote_results, default=str) + ) + ] results = await self._observations.list_observations( project=project, episode_id=args.get("episode_id"), @@ -78,6 +128,17 @@ async def retrieve_observations( return [TextContent(type="text", text=json.dumps(results))] async def record_use(self, args: dict[str, Any]) -> list[TextContent]: + if self._remote is not None: + record_id = args["id"] + if len(record_id) < _MIN_AGENTCORE_RECORD_ID_LEN: + raise ValueError( + "memory.record_use in agentcore mode takes an AgentCore " + "memory RECORD id (>= 40 chars, e.g. from " + f"memory.retrieve); got {record_id!r}. Event ids " + "returned by memory.observe are not ratable records." + ) + self._remote.record_use(record_id, outcome=args.get("outcome")) + return [TextContent(type="text", text=json.dumps({"ok": True}))] self._observations.record_use( args["id"], outcome=args.get("outcome"), diff --git a/better_memory/mcp/handlers/reflections.py b/better_memory/mcp/handlers/reflections.py index 2549edd..b0237e1 100644 --- a/better_memory/mcp/handlers/reflections.py +++ b/better_memory/mcp/handlers/reflections.py @@ -45,12 +45,17 @@ def __init__( spool: SpoolService, memory_conn: sqlite3.Connection, home: Path, + remote: StorageBackend | None = None, ) -> None: self._backend = backend self._reflections = reflections self._spool = spool self._memory_conn = memory_conn self._home = home + # agentcore-mode marker: gates the sqlite-local best-effort + # pre-hooks inside ``retrieve`` (spool drain + retention). The + # retrieval itself always goes through ``backend``. + self._remote = remote def tools(self) -> dict[str, Any]: return { @@ -74,27 +79,35 @@ async def retrieve(self, args: dict[str, Any]) -> list[TextContent]: t_retrieve = time.monotonic() _diag.log(f"[bm-retrieve start cid={diag_cid}]") - # 1. Drain spool — must happen before any retrieval so fresh - # hook events (session_start, commit_close) are processed. - # SpoolService.drain is idempotent. - _diag.step("mcp.memory.retrieve", "before_spool_drain") - run_best_effort("spool.drain", self._spool.drain, diag_cid=diag_cid) - _diag.step("mcp.memory.retrieve", "after_spool_drain") - - # 2. Maybe run retention. Guard ensures at most once per 24h - # regardless of how often retrieve is called. Best-effort: - # a retention failure must NEVER block memory.retrieve. - def _retention_step() -> None: - cfg = get_config() - RetentionScheduler( - self._memory_conn, auto_prune=cfg.auto_prune - ).maybe_run(triggered_by="retrieve") - - _diag.step("mcp.memory.retrieve", "before_retention_scheduler") - run_best_effort( - "retention scheduler", _retention_step, diag_cid=diag_cid - ) - _diag.step("mcp.memory.retrieve", "after_retention_scheduler") + # Local best-effort pre-hooks run on the sqlite path only + # (remote is None). In agentcore mode both would mutate local + # episode/retention rows that no longer back retrieval; the + # spool markers written by session hooks simply accumulate + # un-drained there (files, not sqlite — no correctness impact). + if self._remote is None: + # 1. Drain spool — must happen before any retrieval so fresh + # hook events (session_start, commit_close) are processed. + # SpoolService.drain is idempotent. + _diag.step("mcp.memory.retrieve", "before_spool_drain") + run_best_effort( + "spool.drain", self._spool.drain, diag_cid=diag_cid + ) + _diag.step("mcp.memory.retrieve", "after_spool_drain") + + # 2. Maybe run retention. Guard ensures at most once per 24h + # regardless of how often retrieve is called. Best-effort: + # a retention failure must NEVER block memory.retrieve. + def _retention_step() -> None: + cfg = get_config() + RetentionScheduler( + self._memory_conn, auto_prune=cfg.auto_prune + ).maybe_run(triggered_by="retrieve") + + _diag.step("mcp.memory.retrieve", "before_retention_scheduler") + run_best_effort( + "retention scheduler", _retention_step, diag_cid=diag_cid + ) + _diag.step("mcp.memory.retrieve", "after_retention_scheduler") project = args.get("project") or project_name() limit_per_bucket = args.get("limit_per_bucket", 20) diff --git a/better_memory/mcp/handlers/semantics.py b/better_memory/mcp/handlers/semantics.py index 13157cd..4f79456 100644 --- a/better_memory/mcp/handlers/semantics.py +++ b/better_memory/mcp/handlers/semantics.py @@ -13,13 +13,25 @@ from better_memory.config import project_name from better_memory.services.semantic import SemanticMemoryService +from better_memory.storage import StorageBackend class SemanticToolHandlers: - """User-stated facts/preferences CRUD.""" + """User-stated facts/preferences CRUD. - def __init__(self, *, semantic: SemanticMemoryService) -> None: + ``remote`` (agentcore mode) routes the tools to the StorageBackend; + ``None`` (the sqlite default) keeps the original service path + unchanged. + """ + + def __init__( + self, + *, + semantic: SemanticMemoryService, + remote: StorageBackend | None = None, + ) -> None: self._semantic = semantic + self._remote = remote def tools(self) -> dict[str, Any]: return { @@ -35,6 +47,15 @@ async def semantic_observe(self, args: dict[str, Any]) -> list[TextContent]: # against MCP clients sending {"scope": null} — dict.get returns the # default only when the key is absent, not when its value is None. # Same fix as PR #25's BugBot finding on memory.observe. + if self._remote is not None: + memory_id = self._remote.semantic_observe( + content=args["content"], + project=project, + scope=args.get("scope") or "project", + ) + return [ + TextContent(type="text", text=json.dumps({"id": memory_id})) + ] memory_id = self._semantic.create( content=args["content"], project=project, @@ -44,6 +65,33 @@ async def semantic_observe(self, args: dict[str, Any]) -> list[TextContent]: async def semantic_retrieve(self, args: dict[str, Any]) -> list[TextContent]: project = args.get("project") or project_name() + if self._remote is not None: + # Sqlite parity (fix plan UD-2): merge the project namespace + # with the general namespace via two backend calls. AgentCore + # records carry no project/created_at/updated_at — keep the + # payload keys stable with None so downstream consumers + # (rate-session-memories skill, management UI) don't KeyError. + merged: list[dict[str, Any]] = [] + seen: set[str] = set() + for scope_filter in (None, "general"): + for record in self._remote.semantic_list( + project=project, scope_filter=scope_filter + ): + record_id = record["id"] + if record_id in seen: + continue + seen.add(record_id) + merged.append( + { + "id": record_id, + "content": record.get("content", ""), + "project": None, + "scope": record.get("scope", "project"), + "created_at": None, + "updated_at": None, + } + ) + return [TextContent(type="text", text=json.dumps(merged))] memories = self._semantic.list_for_project(project=project) payload = [ { @@ -59,9 +107,17 @@ async def semantic_retrieve(self, args: dict[str, Any]) -> list[TextContent]: return [TextContent(type="text", text=json.dumps(payload))] async def semantic_update(self, args: dict[str, Any]) -> list[TextContent]: + if self._remote is not None: + self._remote.semantic_update_text( + id=args["id"], content=args["content"] + ) + return [TextContent(type="text", text=json.dumps({"ok": True}))] self._semantic.update_text(id=args["id"], content=args["content"]) return [TextContent(type="text", text=json.dumps({"ok": True}))] async def semantic_delete(self, args: dict[str, Any]) -> list[TextContent]: + if self._remote is not None: + self._remote.semantic_delete(id=args["id"]) + return [TextContent(type="text", text=json.dumps({"ok": True}))] self._semantic.delete(id=args["id"]) return [TextContent(type="text", text=json.dumps({"ok": True}))] diff --git a/better_memory/mcp/handlers/sessions.py b/better_memory/mcp/handlers/sessions.py index 97e2d20..54b6d74 100644 --- a/better_memory/mcp/handlers/sessions.py +++ b/better_memory/mcp/handlers/sessions.py @@ -18,10 +18,16 @@ from better_memory.services import ui_launcher from better_memory.services.memory_rating import MemoryRatingService from better_memory.services.session_bootstrap import SessionBootstrapService +from better_memory.storage import StorageBackend class SessionToolHandlers: - """Session bootstrap + exposure rating + management-UI launch.""" + """Session bootstrap + exposure rating + management-UI launch. + + ``remote`` (agentcore mode) routes bootstrap/rating to the + StorageBackend; ``None`` (the sqlite default) keeps the original + service path unchanged. + """ def __init__( self, @@ -29,10 +35,12 @@ def __init__( session_bootstrap: SessionBootstrapService, memory_rating: MemoryRatingService, home: Path, + remote: StorageBackend | None = None, ) -> None: self._session_bootstrap = session_bootstrap self._memory_rating = memory_rating self._home = home + self._remote = remote def tools(self) -> dict[str, Any]: return { @@ -46,6 +54,31 @@ def tools(self) -> dict[str, Any]: async def session_bootstrap(self, args: dict[str, Any]) -> list[TextContent]: cwd_arg = args.get("cwd") or os.getcwd() session_id_arg = args.get("session_id") or get_session_id() + if self._remote is not None: + # AgentCoreBackend.session_bootstrap returns a DICT (not the + # sqlite BootstrapResult dataclass) — unwrap by key. The + # pending_synthesis-adjacent fields don't exist in agentcore + # mode and are intentionally absent from the payload (same as + # the sqlite payload below, which never carried them either). + remote_result = self._remote.session_bootstrap( + session_id=session_id_arg, + source=args.get("source"), + cwd=Path(cwd_arg), + ) + payload = { + "additionalContext": remote_result["additional_context"], + "project": remote_result["project"], + "source": remote_result["source"], + "episode": { + "id": remote_result["episode_id"], + "action": remote_result["episode_action"], + }, + "counts": { + "semantic": remote_result["semantic_count"], + "reflections": remote_result["reflections_counts"], + }, + } + return [TextContent(type="text", text=json.dumps(payload))] result = self._session_bootstrap.bootstrap( source=args.get("source"), session_id=session_id_arg, @@ -70,6 +103,9 @@ async def list_session_exposures( self, args: dict[str, Any] ) -> list[TextContent]: sid = resolve_session_id(self._home) or "" + if self._remote is not None: + payload = self._remote.list_session_exposures(session_id=sid) + return [TextContent(type="text", text=json.dumps(payload))] payload = self._session_bootstrap.list_session_exposures( session_id=sid, ) @@ -85,6 +121,12 @@ async def apply_session_ratings( "CLAUDE_CODE_SESSION_ID not set and no session marker " "found (SessionStart hook may not have run)" ) + if self._remote is not None: + payload = self._remote.apply_session_ratings( + session_id=sid, + ratings=args["ratings"], + ) + return [TextContent(type="text", text=json.dumps(payload))] payload = self._memory_rating.apply_session_ratings( session_id=sid, ratings=args["ratings"], @@ -95,6 +137,13 @@ async def credit(self, args: dict[str, Any]) -> list[TextContent]: sid = resolve_session_id(self._home) if not sid: payload = {"applied": None, "skipped": "no_session"} + elif self._remote is not None: + payload = self._remote.credit_one( + session_id=sid, + kind=args["kind"], + id=args["id"], + classification=args["class"], + ) else: payload = self._memory_rating.credit_one( session_id=sid, diff --git a/better_memory/mcp/server.py b/better_memory/mcp/server.py index 13dbaf7..1b0e87a 100644 --- a/better_memory/mcp/server.py +++ b/better_memory/mcp/server.py @@ -241,32 +241,49 @@ def create_server() -> tuple[ except Exception: # noqa: BLE001 — best-effort startup hook pass + # Remote dispatch (agentcore mode): the data-tool handlers receive the + # backend as ``remote`` iff the CONFIG string says agentcore. The + # predicate is deliberately the config value — never backend truthiness + # or isinstance — so the sqlite default path keeps ``remote=None`` and + # its service-stack behaviour is byte-identical (session-id / project + # resolution semantics differ between the standalone services and + # SqliteBackend; routing sqlite through the backend would change them). + remote: StorageBackend | None = ( + backend if config.storage_backend == "agentcore" else None + ) + # All tool behaviour lives in the per-domain handler classes; this # registry is the single dispatch surface for _call_tool. The # synthesize handlers stay registered even when the backend does not # support synthesis — only the *advertised* tool list is gated (see - # _list_tools), matching the pre-extraction dispatcher. + # _list_tools), matching the pre-extraction dispatcher. The same holds + # for the episode/retention handlers in agentcore mode. registry = build_registry( - ObservationToolHandlers(observations=observations, retention=retention), + ObservationToolHandlers( + observations=observations, retention=retention, remote=remote + ), ReflectionToolHandlers( backend=backend, reflections=reflections, spool=spool, memory_conn=memory_conn, home=config.home, + remote=remote, ), EpisodeToolHandlers( episodes=episodes, observations=observations, reflections=reflections, backend=backend, + remote=remote, ), - SemanticToolHandlers(semantic=semantic), + SemanticToolHandlers(semantic=semantic, remote=remote), KnowledgeToolHandlers(knowledge=knowledge), SessionToolHandlers( session_bootstrap=session_bootstrap, memory_rating=memory_rating, home=config.home, + remote=remote, ), ) @@ -274,7 +291,10 @@ def create_server() -> tuple[ @server.list_tools() async def _list_tools() -> list[Tool]: - return _tool_definitions(supports_synthesis=backend.supports_synthesis) + return _tool_definitions( + supports_synthesis=backend.supports_synthesis, + supports_episodes=backend.supports_episodes, + ) @server.call_tool() async def _call_tool( diff --git a/better_memory/mcp/tools.py b/better_memory/mcp/tools.py index b613195..1236bd5 100644 --- a/better_memory/mcp/tools.py +++ b/better_memory/mcp/tools.py @@ -9,8 +9,27 @@ from mcp.types import Tool +#: Tools gated on ``supports_episodes``: the episode lifecycle plus local +#: retention are sqlite-only concepts. In agentcore mode AgentCore manages +#: event grouping via sessionId and applies its own event expiry, so these +#: are hidden from the advertised list (handlers stay registered +#: defensively — see ``create_server``). +_EPISODE_GATED_TOOLS: frozenset[str] = frozenset( + { + "memory.start_episode", + "memory.close_episode", + "memory.reconcile_episodes", + "memory.list_episodes", + "memory.run_retention", + } +) -def tool_definitions(*, supports_synthesis: bool = True) -> list[Tool]: + +def tool_definitions( + *, + supports_synthesis: bool = True, + supports_episodes: bool = True, +) -> list[Tool]: """Return the list of tools exposed over MCP. When ``supports_synthesis`` is False, the @@ -18,6 +37,10 @@ def tool_definitions(*, supports_synthesis: bool = True) -> list[Tool]: tools are omitted. This gates the synthesis surface on the active StorageBackend's capability flag — backends without a local episode queue (e.g. AgentCoreBackend in Plan 2) do not expose these tools. + + When ``supports_episodes`` is False, the four episode-lifecycle tools + and ``memory.run_retention`` are omitted too (same capability-flag + pattern; False for AgentCoreBackend). """ tools: list[Tool] = [ Tool( @@ -557,4 +580,7 @@ def tool_definitions(*, supports_synthesis: bool = True) -> list[Tool]: ), ]) + if not supports_episodes: + tools = [t for t in tools if t.name not in _EPISODE_GATED_TOOLS] + return tools diff --git a/better_memory/storage/agentcore.py b/better_memory/storage/agentcore.py index 44abb45..d670f69 100644 --- a/better_memory/storage/agentcore.py +++ b/better_memory/storage/agentcore.py @@ -84,6 +84,39 @@ def supports_synthesis(self) -> bool: def supports_episodes(self) -> bool: return False + # ----- Session-id lazy re-resolution ----- + + def _require_session_id(self, operation: str) -> str: + """Return the session id, lazily re-resolving when frozen at None. + + The MCP server resolves the session id once at startup, but real + Claude Code does not propagate CLAUDE_SESSION_ID into the spawned + stdio server's env and the server may spawn BEFORE the SessionStart + hook writes the marker file — so construction-time resolution can + legitimately yield None. Re-resolve (env var, then the marker under + the resolved BETTER_MEMORY_HOME) at first use instead of raising + forever. When neither source resolves, raise WITHOUT any wire call + (no uuid4 fallback fabricating identities). + """ + if self._session_id is None: + # Local import: keeps the storage layer free of an mcp import + # edge at module scope (and off the hooks' lightweight-import + # path — this only loads when an event operation runs). + from better_memory._common import resolve_home + from better_memory.mcp._util import resolve_session_id + + self._session_id = resolve_session_id(resolve_home()) + if self._session_id is None: + raise ValueError( + f"AgentCoreBackend.{operation} requires session_id: none was " + "available at construction time and re-resolution found " + "neither CLAUDE_SESSION_ID / CLAUDE_CODE_SESSION_ID in the " + "environment nor a SessionStart marker file under " + "BETTER_MEMORY_HOME (the SessionStart hook writes it; if you " + "see this in production the hook has not run yet)." + ) + return self._session_id + # ----- Observations: filled in by Tasks 5-6 ----- async def observe( @@ -101,16 +134,12 @@ async def observe( ) -> str: """Write an observation as a CreateEvent against the episodic memory. - sessionId is the backend's held session id (raised if None — events - require a real session). actorId is resolved from project (or - "general" when no project is in scope). Returns the AgentCore - eventId.""" - if self._session_id is None: - raise ValueError( - "AgentCoreBackend.observe requires session_id at construction " - "time. The MCP server populates it from CLAUDE_SESSION_ID at " - "startup; if you see this in production, the env var is missing." - ) + sessionId is the backend's held session id, lazily re-resolved from + env / SessionStart marker when construction saw None (raised, with + zero wire calls, when nothing resolves — events require a real + session). actorId is resolved from project (or "general" when no + project is in scope). Returns the AgentCore eventId.""" + session_id = self._require_session_id("observe") actor_id = resolve_actor_id(project or self._project) # Event-level metadata is stringValue-only (verified API surface); @@ -140,7 +169,7 @@ async def observe( lambda: self._data.create_event( memoryId=self._cfg.episodic.memory_id, actorId=actor_id, - sessionId=self._session_id, + sessionId=session_id, eventTimestamp=datetime.now(UTC), payload=[ { @@ -317,12 +346,9 @@ async def list_observations( limit: int = 50, ) -> list[dict[str, Any]]: """List raw events from the CURRENT session as observations. Cross- - session enumeration is deferred (ListEvents requires sessionId).""" - if self._session_id is None: - raise ValueError( - "AgentCoreBackend.list_observations requires session_id at " - "construction time." - ) + session enumeration is deferred (ListEvents requires sessionId). + Same lazy session-id re-resolution contract as ``observe``.""" + session_id = self._require_session_id("list_observations") actor_id = resolve_actor_id(project or self._project) # boto3 list_events is synchronous I/O; offload to a thread so the @@ -335,7 +361,7 @@ async def list_observations( lambda: self._data.list_events( memoryId=self._cfg.episodic.memory_id, actorId=actor_id, - sessionId=self._session_id, + sessionId=session_id, maxResults=limit, includePayloads=True, ), diff --git a/better_memory/storage/agentcore_persistence.py b/better_memory/storage/agentcore_persistence.py index 51920c0..aa0f63c 100644 --- a/better_memory/storage/agentcore_persistence.py +++ b/better_memory/storage/agentcore_persistence.py @@ -15,6 +15,15 @@ _AGENTCORE_FILE = "agentcore.json" _CURRENT_SCHEMA_VERSION = 1 +# Shared remediation sentence for every AgentCoreConfigError. `init` without +# --force refuses to run while the file exists (cli/agentcore.py), so the +# delete-then-init / --force wording is deliberate. +_REMEDIATION = ( + "Delete the file and re-run `better-memory agentcore init` (or use " + "`--force`); existing AWS memories can be re-linked by hand-editing " + "the file." +) + class AgentCoreConfigError(Exception): """Raised when agentcore.json is missing required fields, corrupt, @@ -71,23 +80,26 @@ def load_agentcore_config(home: Path) -> AgentCoreConfig | None: raw = json.loads(target.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: raise AgentCoreConfigError( - f"failed to parse {target}: {exc}" + f"failed to parse {target}: {exc}. {_REMEDIATION}" ) from exc if not isinstance(raw, dict): - raise AgentCoreConfigError(f"{target} is not a JSON object") + raise AgentCoreConfigError( + f"{target} is not a JSON object. {_REMEDIATION}" + ) schema_version = raw.get("schema_version") if schema_version != _CURRENT_SCHEMA_VERSION: raise AgentCoreConfigError( f"{target} has unsupported schema_version={schema_version!r}; " - f"expected {_CURRENT_SCHEMA_VERSION}" + f"expected {_CURRENT_SCHEMA_VERSION} — this file may have been " + f"written by a newer better-memory. {_REMEDIATION}" ) for required in ("region", "semantic", "episodic"): if required not in raw: raise AgentCoreConfigError( - f"{target} missing required field {required!r}" + f"{target} missing required field {required!r}. {_REMEDIATION}" ) try: @@ -99,5 +111,6 @@ def load_agentcore_config(home: Path) -> AgentCoreConfig | None: ) except TypeError as exc: raise AgentCoreConfigError( - f"{target} has malformed semantic / episodic block: {exc}" + f"{target} has malformed semantic / episodic block: {exc}. " + f"{_REMEDIATION}" ) from exc diff --git a/better_memory/storage/factory.py b/better_memory/storage/factory.py index eb3c8e1..7efd033 100644 --- a/better_memory/storage/factory.py +++ b/better_memory/storage/factory.py @@ -12,9 +12,9 @@ class _ConfigLike(Protocol): - """Structural type — the factory only reads storage_backend + agentcore_region. + """Structural type — the factory only reads storage_backend. - Declared as read-only properties so frozen dataclasses (the real Config and + Declared as a read-only property so frozen dataclasses (the real Config and test FakeConfigs) satisfy the Protocol — pyright treats a Protocol class attribute as read+write, which conflicts with frozen dataclasses' read-only fields. @@ -23,9 +23,6 @@ class _ConfigLike(Protocol): @property def storage_backend(self) -> str: ... - @property - def agentcore_region(self) -> str: ... - def _resolve_home() -> Path: home = os.environ.get("BETTER_MEMORY_HOME", "~/.better-memory") @@ -52,10 +49,16 @@ def build_backend( ) if config.storage_backend == "agentcore": # Imports are local so sqlite-only deployments don't require boto3 / - # botocore to be installed (they're declared in the dev dependency - # group, not the runtime dependencies). - import boto3 - from botocore.config import Config as BotoConfig + # botocore to be installed (they're declared in the optional + # `agentcore` extra, not the runtime dependencies). + try: + import boto3 + from botocore.config import Config as BotoConfig + except ImportError as exc: + raise ModuleNotFoundError( + "boto3 is required for the agentcore storage backend. " + "Install it with: pip install 'better-memory[agentcore]'" + ) from exc from better_memory.storage.agentcore import AgentCoreBackend from better_memory.storage.agentcore_persistence import ( @@ -70,8 +73,11 @@ def build_backend( f"to create the memory resources and persist their IDs." ) + # Region is single-sourced from agentcore.json — the file that also + # carries the memory ids — so every client (server plane and hook + # plane) signs against the region the resources were created in. boto_config = BotoConfig( - region_name=config.agentcore_region, + region_name=ac_cfg.region, retries={"mode": "standard", "max_attempts": 5}, ) data_client = boto3.client("bedrock-agentcore", config=boto_config) diff --git a/tests/cli/test_agentcore_init.py b/tests/cli/test_agentcore_init.py index d03f31c..80b143f 100644 --- a/tests/cli/test_agentcore_init.py +++ b/tests/cli/test_agentcore_init.py @@ -9,17 +9,22 @@ import pytest -from better_memory.cli.agentcore import _handle_init +from better_memory.cli.agentcore import _handle_init, add_subparsers def _make_args( - home: Path, *, force: bool = False, region: str = "eu-west-2" + home: Path, + *, + force: bool = False, + region: str = "eu-west-2", + no_activate: bool = False, ) -> argparse.Namespace: """Build an argparse.Namespace the handler accepts.""" return argparse.Namespace( home=str(home), region=region, force=force, + no_activate=no_activate, subcommand="init", ) @@ -102,6 +107,31 @@ def test_init_creates_both_memories_and_writes_config( assert "sem-XYZ" in out +def _happy_control(epi_id: str = "epi-XYZ", sem_id: str = "sem-XYZ") -> MagicMock: + """Build a control-plane mock where both memories go ACTIVE immediately.""" + control = MagicMock(name="bedrock-agentcore-control") + paginator = MagicMock() + paginator.paginate.return_value = iter([{"memories": []}]) + control.get_paginator.return_value = paginator + control.create_memory.side_effect = [ + _create_memory_response(epi_id, "epi-strat-1"), + _create_memory_response(sem_id, "sem-strat-1"), + ] + control.get_memory.side_effect = [ + _active_memory_response(epi_id, "epi-strat-1"), + _active_memory_response(sem_id, "sem-strat-1"), + ] + return control + + +def _patch_control(monkeypatch, control: MagicMock) -> None: + monkeypatch.setattr( + "better_memory.cli.agentcore._build_control_client", + lambda region: control, + ) + monkeypatch.setattr("better_memory.cli.agentcore.time.sleep", lambda _s: None) + + def test_init_refuses_when_config_exists_without_force( tmp_path, monkeypatch ) -> None: @@ -110,6 +140,8 @@ def test_init_refuses_when_config_exists_without_force( rc = _handle_init(_make_args(tmp_path)) assert rc == 1 + # Refusal must not activate anything either. + assert not (tmp_path / "settings.json").exists() def test_init_overwrites_when_force_set(tmp_path, monkeypatch) -> None: @@ -335,3 +367,132 @@ def test_init_preflight_checks_both_names(tmp_path, monkeypatch, capsys) -> None control.create_memory.assert_not_called() err = capsys.readouterr().err assert "better_memory_semantic" in err + + +# --------------------------------------------------------------------------- +# settings.json activation (UD-3) + next-steps stdout contract +# --------------------------------------------------------------------------- + + +def test_init_argparse_accepts_no_activate() -> None: + """`--no-activate` is a registered init flag; defaults to False.""" + parser = argparse.ArgumentParser() + add_subparsers(parser) + args = parser.parse_args( + ["init", "--no-activate", "--home", "x", "--region", "us-east-1"] + ) + assert args.no_activate is True + assert parser.parse_args(["init"]).no_activate is False + + +def test_init_writes_settings_activation_by_default( + tmp_path, monkeypatch +) -> None: + """Successful init persists {"storage_backend": "agentcore"} into + /settings.json (atomically — no .tmp residue).""" + _patch_control(monkeypatch, _happy_control()) + + rc = _handle_init(_make_args(tmp_path)) + assert rc == 0 + + settings_path = tmp_path / "settings.json" + assert settings_path.exists() + settings = json.loads(settings_path.read_text(encoding="utf-8")) + assert settings["storage_backend"] == "agentcore" + assert not (tmp_path / "settings.json.tmp").exists() + + +def test_init_no_activate_skips_settings_write(tmp_path, monkeypatch, capsys) -> None: + """`--no-activate` provisions AWS + agentcore.json but leaves the + backend selection untouched; stdout explains how to activate later.""" + _patch_control(monkeypatch, _happy_control()) + + rc = _handle_init(_make_args(tmp_path, no_activate=True)) + assert rc == 0 + assert (tmp_path / "agentcore.json").exists() + assert not (tmp_path / "settings.json").exists() + + out = capsys.readouterr().out + assert "--no-activate" in out + assert "settings.json" in out + assert "Export BETTER_MEMORY_STORAGE_BACKEND" not in out + + +def test_init_failure_path_does_not_write_settings(tmp_path, monkeypatch) -> None: + """When memory creation fails, init must not activate the backend + (no settings.json), just as it writes no agentcore.json.""" + control = MagicMock(name="bedrock-agentcore-control") + paginator = MagicMock() + paginator.paginate.return_value = iter([{"memories": []}]) + control.get_paginator.return_value = paginator + control.create_memory.side_effect = [ + _create_memory_response("epi-orphan", "epi-strat"), + RuntimeError("simulated semantic create failure"), + ] + control.get_memory.side_effect = [ + _active_memory_response("epi-orphan", "epi-strat"), + ] + _patch_control(monkeypatch, control) + + with pytest.raises(RuntimeError, match="semantic create"): + _handle_init(_make_args(tmp_path)) + + assert not (tmp_path / "agentcore.json").exists() + assert not (tmp_path / "settings.json").exists() + + +def test_init_activation_preserves_existing_settings_keys( + tmp_path, monkeypatch +) -> None: + """An existing settings.json with unrelated keys is merged into, not + clobbered.""" + (tmp_path / "settings.json").write_text( + json.dumps({"other_key": 42}), encoding="utf-8" + ) + _patch_control(monkeypatch, _happy_control()) + + rc = _handle_init(_make_args(tmp_path)) + assert rc == 0 + + settings = json.loads((tmp_path / "settings.json").read_text(encoding="utf-8")) + assert settings["storage_backend"] == "agentcore" + assert settings["other_key"] == 42 + + +def test_init_activation_replaces_corrupt_settings(tmp_path, monkeypatch) -> None: + """A corrupt settings.json is replaced with a valid activation object + (init is the remediation path, it must not crash on the broken file).""" + (tmp_path / "settings.json").write_text("{not json", encoding="utf-8") + _patch_control(monkeypatch, _happy_control()) + + rc = _handle_init(_make_args(tmp_path)) + assert rc == 0 + + settings = json.loads((tmp_path / "settings.json").read_text(encoding="utf-8")) + assert settings == {"storage_backend": "agentcore"} + + +def test_init_next_steps_stdout_contract(tmp_path, monkeypatch, capsys) -> None: + """Next-steps text names the settings.json mechanism and env precedence, + and no longer tells the user to export an env var (the onboarding trap: + an exported var in one shell never reaches Claude Code's hooks).""" + _patch_control(monkeypatch, _happy_control()) + + rc = _handle_init(_make_args(tmp_path)) + assert rc == 0 + + out = capsys.readouterr().out + # The onboarding trap must be gone. + assert "Export BETTER_MEMORY_STORAGE_BACKEND" not in out + # Activation mechanism + env precedence are named. + assert "settings.json" in out + assert "BETTER_MEMORY_STORAGE_BACKEND" in out + assert "overrides" in out + # Revert path. + assert "revert" in out.lower() + assert "sqlite" in out + # Follow-ups: restart, status, then smoke — with the smoke caveat. + assert "Restart" in out + assert "agentcore status" in out + assert "agentcore smoke" in out + assert "not MCP registration" in out diff --git a/tests/cli/test_agentcore_status.py b/tests/cli/test_agentcore_status.py index b4cb377..40c8393 100644 --- a/tests/cli/test_agentcore_status.py +++ b/tests/cli/test_agentcore_status.py @@ -91,6 +91,114 @@ def test_status_prints_both_memories_and_exits_0_when_active( assert "sem-X" in out +def _active_control() -> MagicMock: + """Control-plane mock where both memories report ACTIVE.""" + control = MagicMock(name="bedrock-agentcore-control") + control.get_memory.side_effect = [ + {"memory": { + "id": "epi-X", "name": "better_memory_episodic", "status": "ACTIVE", + "strategies": [ + { + "strategyId": "epi-strat", + "status": "ACTIVE", + "name": "episodicReflections", + } + ], + "eventExpiryDuration": 90, + }}, + {"memory": { + "id": "sem-X", "name": "better_memory_semantic", "status": "ACTIVE", + "strategies": [ + { + "strategyId": "sem-strat", + "status": "ACTIVE", + "name": "userPreference", + } + ], + "eventExpiryDuration": 365, + }}, + ] + return control + + +def test_status_reports_effective_backend_from_settings( + tmp_path, monkeypatch, capsys +) -> None: + """settings.json activation (no env var) -> agentcore, source=settings.""" + _write_config(tmp_path) + (tmp_path / "settings.json").write_text( + json.dumps({"storage_backend": "agentcore"}), encoding="utf-8" + ) + monkeypatch.delenv("BETTER_MEMORY_STORAGE_BACKEND", raising=False) + monkeypatch.setattr( + "better_memory.cli.agentcore._build_control_client", + lambda region: _active_control(), + ) + + rc = _handle_status(_make_args(tmp_path)) + assert rc == 0 + out = capsys.readouterr().out + assert "effective backend: agentcore (source: settings)" in out + + +def test_status_reports_env_var_overriding_settings( + tmp_path, monkeypatch, capsys +) -> None: + """Env var wins over settings.json — status makes the precedence + surprise diagnosable by naming the source.""" + _write_config(tmp_path) + (tmp_path / "settings.json").write_text( + json.dumps({"storage_backend": "agentcore"}), encoding="utf-8" + ) + monkeypatch.setenv("BETTER_MEMORY_STORAGE_BACKEND", "sqlite") + monkeypatch.setattr( + "better_memory.cli.agentcore._build_control_client", + lambda region: _active_control(), + ) + + rc = _handle_status(_make_args(tmp_path)) + assert rc == 0 + out = capsys.readouterr().out + assert "effective backend: sqlite (source: env)" in out + + +def test_status_reports_default_backend_without_env_or_settings( + tmp_path, monkeypatch, capsys +) -> None: + """No env var, no settings.json -> sqlite, source=default.""" + _write_config(tmp_path) + monkeypatch.delenv("BETTER_MEMORY_STORAGE_BACKEND", raising=False) + monkeypatch.setattr( + "better_memory.cli.agentcore._build_control_client", + lambda region: _active_control(), + ) + + rc = _handle_status(_make_args(tmp_path)) + assert rc == 0 + out = capsys.readouterr().out + assert "effective backend: sqlite (source: default)" in out + + +def test_status_corrupt_settings_warns_but_still_reports_memories( + tmp_path, monkeypatch, capsys +) -> None: + """A corrupt settings.json must not crash status — it warns (naming the + file) and still prints the per-memory report.""" + _write_config(tmp_path) + (tmp_path / "settings.json").write_text("{not json", encoding="utf-8") + monkeypatch.delenv("BETTER_MEMORY_STORAGE_BACKEND", raising=False) + monkeypatch.setattr( + "better_memory.cli.agentcore._build_control_client", + lambda region: _active_control(), + ) + + rc = _handle_status(_make_args(tmp_path)) + assert rc == 0 + captured = capsys.readouterr() + assert "settings.json" in captured.err + assert "epi-X" in captured.out and "sem-X" in captured.out + + def test_status_exits_1_when_any_memory_not_active( tmp_path, monkeypatch ) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index c80ea24..2b2640f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -62,6 +62,26 @@ def _strip_leaked_claude_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv(var, raising=False) +@pytest.fixture(autouse=True) +def _isolate_better_memory_home( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Pin ``BETTER_MEMORY_HOME`` to a per-test tmp dir. + + The storage backend is resolved from ``$BETTER_MEMORY_HOME/settings.json`` + when the ``BETTER_MEMORY_STORAGE_BACKEND`` env var is unset, so any test + calling ``get_config()`` / ``resolve_storage_backend()`` without pinning + the home would read the developer's real ``~/.better-memory/settings.json`` + and could silently flip to the agentcore backend. Tests that deliberately + exercise the real-home default explicitly ``delenv`` the variable; tests + that need a specific home ``setenv`` over this pin. + + (E2E tests are unaffected: they build child-process environments from + scratch via the ``isolated_env`` helpers and never inherit this value.) + """ + monkeypatch.setenv("BETTER_MEMORY_HOME", str(tmp_path / "bm-home")) + + @pytest.fixture def tmp_memory_db(tmp_path: Path) -> Iterator[Path]: """Yield a path to a fresh (non-existent) SQLite database file. diff --git a/tests/e2e/_agentcore_env.py b/tests/e2e/_agentcore_env.py index 9ef4656..f227095 100644 --- a/tests/e2e/_agentcore_env.py +++ b/tests/e2e/_agentcore_env.py @@ -5,7 +5,8 @@ allowlist choke point, so the e2e_meta contract tests bless spawns whose env came from here): -* ``BETTER_MEMORY_STORAGE_BACKEND=agentcore`` + a pinned test region; +* ``BETTER_MEMORY_STORAGE_BACKEND=agentcore`` (pin ``None`` to test the + settings.json activation path instead — see ``write_backend_settings``); * ``AWS_ENDPOINT_URL`` → the local :class:`tests.e2e._fake_agentcore. FakeAgentCore` port, so every boto3 request from every child process lands on loopback (verified for both agentcore planes at boto3 1.43.14); @@ -17,13 +18,19 @@ var) never survives: ``isolated_env`` builds from an allowlist and drops all ``AWS_*`` keys case-insensitively. -This module is THE ONLY place the two dummy memory-ID vars are set — see -the FIXME below. ``tests/e2e/test_tripwires_aws.py`` grep-pins that -exclusivity. +Region is single-sourced from ``agentcore.json`` (the file +``write_fake_agentcore_json`` fabricates): the product's agentcore region +env var and the vestigial memory-ID env vars were deleted by the +onboarding-fix wave (2026-07-12-agentcore-onboarding-fix-design.md, +Task 1), so this helper no longer sets any of them — the tripwire in +``tests/e2e/test_tripwires_aws.py`` grep-pins their absence from the whole +e2e suite, and the poison entries in ``tests/e2e_meta/test_env_bleed.py`` +keep proving the removed knobs stay inert. """ from __future__ import annotations +import json from pathlib import Path from better_memory.storage.agentcore_persistence import ( @@ -38,44 +45,12 @@ FAKE_ACCESS_KEY_ID = "bm-e2e-fake" FAKE_SECRET_ACCESS_KEY = "bm-e2e-fake-secret" -#: Region pinned into the child env (BETTER_MEMORY_AGENTCORE_REGION). Note -#: this matches the product default in better_memory/config.py so removing -#: the pin (pin=None) still signs eu-west-2 — the region-split-brain test -#: relies on that. +#: Default region written into fabricated agentcore.json files. Purely a +#: test-data default — the product reads the region exclusively from +#: agentcore.json, so tests that assert region provenance override this +#: with a distinctive value (e.g. us-east-1). DEFAULT_TEST_REGION = "eu-west-2" -# FIXME(idvar-gate): the two BETTER_MEMORY_AGENTCORE_*_MEMORY_ID env vars -# below are a WORKAROUND for the dead presence-only gate at -# better_memory/config.py:293-301. Their values are consumed by NOTHING — -# runtime memory IDs come exclusively from agentcore.json (see -# better_memory/storage/factory.py; the region-split-brain test proves the -# dummies never reach the wire). This is the ONLY location that sets them -# (grep-pinned by tests/e2e/test_tripwires_aws.py). DELETE these two -# entries together with tests/e2e/test_agentcore_neg.py's idvar-gate -# defect-pin test when the product fix (remove the gate, or repoint it at -# agentcore.json existence) lands. -DUMMY_ID_VARS: dict[str, str] = { - "BETTER_MEMORY_AGENTCORE_SEMANTIC_MEMORY_ID": "DUMMY-SEM", - "BETTER_MEMORY_AGENTCORE_EPISODIC_MEMORY_ID": "DUMMY-EPI", -} - -#: Dummy values, exported so wire-absence assertions ("the dead env values -#: never appear in any request") don't have to hardcode them. -DUMMY_SEMANTIC_MEMORY_ID = DUMMY_ID_VARS["BETTER_MEMORY_AGENTCORE_SEMANTIC_MEMORY_ID"] -DUMMY_EPISODIC_MEMORY_ID = DUMMY_ID_VARS["BETTER_MEMORY_AGENTCORE_EPISODIC_MEMORY_ID"] - - -def remove_dummy_id_vars_pins() -> dict[str, None]: - """Pins that REMOVE the two dummy ID vars (misconfig scenarios). - - Kwarg-splat into :func:`agentcore_env` so tests never spell the var - names out (the tripwire grep-pins the literals to this module plus the - one defect-pin test):: - - env = agentcore_env(home, fake.port, **remove_dummy_id_vars_pins()) - """ - return dict.fromkeys(DUMMY_ID_VARS) - def agentcore_env( tmp_home: Path, fake_port: int, **pins: str | None @@ -85,10 +60,9 @@ def agentcore_env( ``fake_port`` is the local :class:`FakeAgentCore` port. ``pins`` override or extend the defaults exactly like ``isolated_env`` pins (``None`` removes a key — e.g. ``BETTER_MEMORY_STORAGE_BACKEND=None`` - for the session-close env-gate case).""" + for the settings.json-activation and sqlite-safety cases).""" defaults: dict[str, str | None] = { "BETTER_MEMORY_STORAGE_BACKEND": "agentcore", - "BETTER_MEMORY_AGENTCORE_REGION": DEFAULT_TEST_REGION, "AWS_ENDPOINT_URL": f"http://127.0.0.1:{fake_port}", "AWS_ACCESS_KEY_ID": FAKE_ACCESS_KEY_ID, "AWS_SECRET_ACCESS_KEY": FAKE_SECRET_ACCESS_KEY, @@ -99,12 +73,30 @@ def agentcore_env( ), "AWS_CONFIG_FILE": str(Path(tmp_home) / "no-such-aws-config"), "AWS_EC2_METADATA_DISABLED": "true", - **DUMMY_ID_VARS, # FIXME(idvar-gate) — see module comment above. } defaults.update(pins) return isolated_env(tmp_home, **defaults) +def write_backend_settings(bm_home: Path) -> Path: + """Write ``settings.json`` activating the agentcore backend. + + Fabricates exactly what ``better-memory agentcore init`` persists + (UD-3): ``{"storage_backend": "agentcore"}`` at + ``/settings.json``, where ``bm_home`` is the + BETTER_MEMORY_HOME dir (``/.better-memory``). Composes with + ``BETTER_MEMORY_STORAGE_BACKEND=None`` pins to prove the env-var-free + onboarding path: ``resolve_storage_backend()`` falls through to this + file. Returns the settings.json path. + """ + bm_home.mkdir(parents=True, exist_ok=True) + settings_path = bm_home / "settings.json" + settings_path.write_text( + json.dumps({"storage_backend": "agentcore"}), encoding="utf-8" + ) + return settings_path + + def write_fake_agentcore_json( bm_home: Path, *, diff --git a/tests/e2e/test_agentcore_journey.py b/tests/e2e/test_agentcore_journey.py new file mode 100644 index 0000000..675640d --- /dev/null +++ b/tests/e2e/test_agentcore_journey.py @@ -0,0 +1,750 @@ +"""E2E agentcore onboarding journey (J1-J8). + +Design: ``docs/superpowers/specs/2026-07-12-agentcore-journey-tests-design.md``. + +Every scenario runs under the EXACT state ``better-memory agentcore init`` +leaves behind (fix plan section 4 item 1): ``agentcore.json`` + +``settings.json {"storage_backend": "agentcore"}`` in the fake home and +**no** backend env var (``BETTER_MEMORY_STORAGE_BACKEND=None`` pin) — the +backend resolves from settings.json, the region and memory ids come from +agentcore.json. This is the shipped onboarding config, unlike the D-suite +(``test_agentcore_t2.py``) which force-activates via the env var. + +* J1 ``e2e-ac-journey-boot-wired-surface`` (complements D1) +* J2 ``e2e-ac-journey-bootstrap-hook-wire`` (NEW — zero prior coverage) +* J3 ``e2e-ac-journey-observe-retrieve-chain`` (inverse half of old D3 pin) +* J4 ``e2e-ac-journey-retrieve-fanout`` (complements D2) +* J5 ``e2e-ac-journey-semantic-roundtrip`` (NEW; complements D4) +* J6 ``e2e-ac-journey-rating-loop`` (complements D4) +* J7 ``e2e-ac-journey-contextual-inject`` (complements D6-A) +* J8 ``e2e-ac-journey-session-close-terminus`` (complements D5) + +No-duplication boundary (design section 6): exhaustive CreateEvent / +BatchCreate / BatchUpdate payload+metadata shape is owned by D4 +(``TestBackendWireFidelity``); polarity-filter internals by D2; the +env-gate matrix + json-region signing by D5; missing-agentcore.json +degradation by D6 B/C; region convergence by D7. The journey asserts only +(a) returned ids are the AWS ids (not local uuids), (b) exact operation + +target memory + request count, (c) zero local sqlite rows, and (d) +cross-step id chaining. + +Every subprocess env is built through :func:`tests.e2e._agentcore_env. +agentcore_env` at the spawn site (the choke-point AST contract requires +the helper call to be textually visible where the env name is assigned, +so the onboarding helper below writes FILES only and never builds envs). +""" + +from __future__ import annotations + +import json +import sqlite3 +from contextlib import closing +from pathlib import Path +from typing import Any + +import pytest + +from tests.e2e._agentcore_env import ( + agentcore_env, + write_backend_settings, + write_fake_agentcore_json, +) +from tests.e2e._fake_agentcore import FakeAgentCore +from tests.e2e.conftest import mcp_session, run_hook +from tests.e2e.test_agentcore_t2 import ( + _DOCKER_REFLECTION_SUMMARY, + EPISODE_AND_RETENTION_TOOLS, + EXPECTED_TOOL_SUBSET, + SYNTHESIZE_TOOLS, +) + +BOOTSTRAP_HOOK = "better_memory.hooks.session_bootstrap" +INJECT_HOOK = "better_memory.hooks.contextual_inject" +CLOSE_HOOK = "better_memory.hooks.session_close" + +#: The rating/exposure + semantic-CRUD tools the dispatch wiring exposes in +#: agentcore mode (design J1) — all wired to AgentCoreBackend by fix Task 2. +RATING_AND_SEMANTIC_TOOLS: frozenset[str] = frozenset( + { + "memory.retrieve_observations", + "memory.apply_session_ratings", + "memory.list_session_exposures", + "memory.credit", + "memory.semantic_retrieve", + "memory.semantic_update", + "memory.semantic_delete", + } +) + +#: >= 40 chars each: real botocore request validation enforces a 40-char +#: minimum on memoryRecordId, mirrored by the product-side guard in +#: memory.record_use (agentcore mode). +_REFL_ID = "refl-journey-" + "0" * 30 + "1" +_SEM_ID = "sem-journey-" + "1" * 30 + + +# --------------------------------------------------------------------------- +# Onboarding anchor + local helpers +# --------------------------------------------------------------------------- + + +def _bm_home(home: Path) -> Path: + return home / ".better-memory" + + +def _onboard(clean_slate_home: Path) -> Path: + """Fabricate the onboarding FILE state ``agentcore init`` leaves behind: + ``agentcore.json`` (provisioning) + ``settings.json`` (activation). + + Returns ``bm_home``. Deliberately does NOT build the child env — every + test builds it at the spawn site via ``agentcore_env(clean_slate_home, + fake.port, BETTER_MEMORY_STORAGE_BACKEND=None)`` so the env-choke-point + AST contract (tests/e2e_meta/test_env_helper_contract.py) can bless the + assignment textually. + """ + bm_home = _bm_home(clean_slate_home) + write_fake_agentcore_json(bm_home) + write_backend_settings(bm_home) + return bm_home + + +def _single_json_dict(result: Any) -> dict[str, Any]: + """Extract + parse the single TextContent payload of a successful call.""" + content = result.content + assert not getattr(result, "isError", False), f"tool errored: {content!r}" + assert len(content) == 1, f"expected one content block: {content!r}" + block = content[0] + assert getattr(block, "type", None) == "text", f"not a text block: {block!r}" + parsed = json.loads(block.text) + assert isinstance(parsed, dict), f"expected JSON object, got {block.text!r}" + return parsed + + +def _single_json_list(result: Any) -> list[Any]: + content = result.content + assert not getattr(result, "isError", False), f"tool errored: {content!r}" + assert len(content) == 1, f"expected one content block: {content!r}" + block = content[0] + assert getattr(block, "type", None) == "text", f"not a text block: {block!r}" + parsed = json.loads(block.text) + assert isinstance(parsed, list), f"expected JSON array, got {block.text!r}" + return parsed + + +def _error_text(result: Any) -> str: + """Assert the call failed at the MCP layer and return the error text.""" + content = result.content + assert getattr(result, "isError", False), f"expected isError, got: {content!r}" + assert len(content) >= 1, "error result carried no content blocks" + block = content[0] + assert getattr(block, "type", None) == "text", f"not a text block: {block!r}" + return block.text + + +def _hook_envelope(stdout: str) -> dict[str, Any]: + """Parse a hook's stdout as exactly one JSON envelope line.""" + lines = [line for line in stdout.splitlines() if line.strip()] + assert len(lines) == 1, f"expected exactly one JSON line on stdout: {stdout!r}" + parsed = json.loads(lines[0]) + assert isinstance(parsed, dict) + return parsed + + +def _table_names(db_path: Path) -> set[str]: + assert db_path.exists(), f"database file missing: {db_path}" + with closing(sqlite3.connect(f"file:{db_path.as_posix()}?mode=ro", uri=True)) as conn: + rows = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall() + return {row[0] for row in rows} + + +def _row_count(db_path: Path, table: str) -> int: + """Row count of ``table``; 0 when the file or table does not exist — + the no-sqlite-leakage oracle must pass whether or not a migration ran.""" + if not db_path.exists(): + return 0 + if table not in _table_names(db_path): + return 0 + with closing(sqlite3.connect(f"file:{db_path.as_posix()}?mode=ro", uri=True)) as conn: + (count,) = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone() # noqa: S608 — table from test constants + return int(count) + + +def _session_end_markers(spool_dir: Path) -> list[Path]: + """Top-level (NON-recursive) session_end markers (drain quarantines + malformed files into a subdirectory; recursion would count those).""" + if not spool_dir.is_dir(): + return [] + return sorted(spool_dir.glob("*_session_end_*.json")) + + +# --------------------------------------------------------------------------- +# J1 — onboarding boot advertises the wired tool surface +# --------------------------------------------------------------------------- + + +async def test_j1_onboarding_boot_advertises_wired_tool_surface( + clean_slate_home: Path, tmp_path: Path +) -> None: + """Server boot resolving the backend FROM settings.json (no env var): + the wired data tools plus the rating/semantic tools are advertised; + synthesize AND episode+retention tools are hidden (UD-1); boot makes + ZERO AWS calls; both local sqlite DBs are still created + migrated + (UD-4 doc-side resolution). + + regression_caught: reverting the supports_episodes gate re-advertises + episode tools; reverting create_server's settings.json resolution boots + sqlite (synthesize tools present, fake untouched but sqlite surface). + """ + with FakeAgentCore() as fake: + bm_home = _onboard(clean_slate_home) + env = agentcore_env( + clean_slate_home, fake.port, BETTER_MEMORY_STORAGE_BACKEND=None + ) + + with (tmp_path / "j1.stderr").open("w", encoding="utf-8") as errlog: + async with mcp_session(env, errlog=errlog) as session: + listed = await session.list_tools() + + names = {tool.name for tool in listed.tools} + assert EXPECTED_TOOL_SUBSET <= names, ( + f"missing wired tools: {sorted(EXPECTED_TOOL_SUBSET - names)}" + ) + assert RATING_AND_SEMANTIC_TOOLS <= names, ( + f"missing rating/semantic tools: " + f"{sorted(RATING_AND_SEMANTIC_TOOLS - names)}" + ) + assert not (SYNTHESIZE_TOOLS & names) + assert not (EPISODE_AND_RETENTION_TOOLS & names) + # Boot is client construction only — never a wire call. + assert fake.requests == [] + + # Local DBs still migrated in agentcore mode (subset, never exact set). + assert {"observations", "episodes", "hook_errors"} <= _table_names( + bm_home / "memory.db" + ) + assert (bm_home / "knowledge.db").exists() + + +# --------------------------------------------------------------------------- +# J2 — session_bootstrap hook reaches AgentCore, not sqlite +# --------------------------------------------------------------------------- + + +def test_j2_session_bootstrap_hook_reaches_agentcore( + clean_slate_home: Path, tmp_path: Path +) -> None: + """SessionStart hook under the onboarding config routes through + ``build_backend`` to ``AgentCoreBackend.session_bootstrap``: exactly 4 + ListMemoryRecords (3 EPI reflection buckets + 1 SEM), the envelope + carries the reflection/semantic summary, and NO memory content lands + in local sqlite. + + regression_caught: reverting the hook's build_backend routing (fix + Task 3) falls back to SessionBootstrapService on sqlite — zero wire + requests and sqlite rows appear. + """ + proj_dir = tmp_path / "proj" + proj_dir.mkdir() + with FakeAgentCore() as fake: + bm_home = _onboard(clean_slate_home) + fake.set_response("ListMemoryRecords", {"memoryRecordSummaries": []}) + env = agentcore_env( + clean_slate_home, fake.port, BETTER_MEMORY_STORAGE_BACKEND=None + ) + + rc, out, err = run_hook( + BOOTSTRAP_HOOK, + {"source": "startup", "session_id": "e2e-session-1", "cwd": str(proj_dir)}, + env, + ) + assert rc == 0, err + assert "Traceback" not in err + hso = _hook_envelope(out)["hookSpecificOutput"] + assert hso["hookEventName"] == "SessionStart" + ctx = hso["additionalContext"] + # The fallback directive would mean the backend path died. + assert "session bootstrap failed" not in ctx, ctx + assert "Reflections" in ctx + assert "Semantic memories:" in ctx + + lists = fake.requests_for("ListMemoryRecords") + assert len(lists) == 4 + assert len(fake.requests) == 4 + assert sum("EPI-FAKE-0001" in r.path for r in lists) == 3 + assert sum("SEM-FAKE-0001" in r.path for r in lists) == 1 + + # No memory-content sqlite write (a hook_errors-only migration would be + # tolerated; memory content must be zero either way). + assert _row_count(bm_home / "memory.db", "observations") == 0 + assert _row_count(bm_home / "memory.db", "semantic_memories") == 0 + + +# --------------------------------------------------------------------------- +# J3 — observe → retrieve_observations round-trip (dispatch anchor) +# --------------------------------------------------------------------------- + + +async def test_j3_observe_then_retrieve_observations_chains_aws_id( + clean_slate_home: Path, tmp_path: Path +) -> None: + """One MCP session: ``memory.observe`` returns the AWS eventId (a local + uuid means it hit sqlite), then ``memory.retrieve_observations`` + surfaces that same id via ListEvents with the metadata flattened — + the read-after-write chain plus the inverse half of the deleted D3 + pin (zero rows in local ``observations``). + + Hermetic note: the fake does not persist writes, so the ListEvents + response is canned to echo the observed id; TRUE persistence is E3's + job (live tier). Exhaustive CreateEvent payload shape is owned by D4. + + regression_caught: reverting the ``remote=`` wiring for either tool + returns a local uuid / writes a sqlite row / drops the wire counts to 0. + """ + with FakeAgentCore() as fake: + bm_home = _onboard(clean_slate_home) + fake.set_response("CreateEvent", {"event": {"eventId": "evt-journey-1"}}) + fake.set_response( + "ListEvents", + { + "events": [ + { + "eventId": "evt-journey-1", + "sessionId": "e2e-session-1", + "actorId": "e2e-project", + "payload": [ + { + "conversational": { + "content": {"text": "journey-obs-marker"} + } + } + ], + "metadata": { + "outcome": {"stringValue": "failure"}, + "theme": {"stringValue": "bug"}, + }, + } + ] + }, + ) + env = agentcore_env( + clean_slate_home, fake.port, BETTER_MEMORY_STORAGE_BACKEND=None + ) + + with (tmp_path / "j3.stderr").open("w", encoding="utf-8") as errlog: + async with mcp_session(env, errlog=errlog) as session: + observed = _single_json_dict( + await session.call_tool( + "memory.observe", + { + "content": "journey-obs-marker", + "outcome": "failure", + "theme": "bug", + }, + ) + ) + # The AWS eventId, not a local uuid — dispatch switched. + assert observed["id"] == "evt-journey-1" + creates = fake.requests_for("CreateEvent") + assert len(creates) == 1 + assert len(fake.requests) == 1 + assert "EPI-FAKE-0001" in creates[0].path + + fake.clear() + rows = _single_json_list( + await session.call_tool( + "memory.retrieve_observations", + {"query": "journey-obs-marker"}, + ) + ) + lists = fake.requests_for("ListEvents") + assert len(lists) == 1 + assert len(fake.requests) == 1 + assert "EPI-FAKE-0001" in lists[0].path + # sessionId/actorId are URI members on ListEvents. + assert "/sessions/e2e-session-1" in lists[0].path + assert lists[0].body["includePayloads"] is True + + assert len(rows) == 1 + row = rows[0] + assert row["id"] == "evt-journey-1" # cross-step id chain + assert row["content"] == "journey-obs-marker" + # Metadata flattened out of the stringValue wrappers. + assert row["outcome"] == "failure" + assert row["theme"] == "bug" + + # No sqlite leakage: the observation never landed locally. + assert _row_count(bm_home / "memory.db", "observations") == 0 + + +# --------------------------------------------------------------------------- +# J4 — memory.retrieve bucket fan-out under onboarding config +# --------------------------------------------------------------------------- + + +async def test_j4_retrieve_fans_out_three_epi_list_calls( + clean_slate_home: Path, tmp_path: Path +) -> None: + """``memory.retrieve`` under settings.json activation: exactly 3 + ListMemoryRecords to the EPI reflections namespace; buckets come back + as exactly ``{"do": [], "dont": [], "neutral": []}``. Polarity-filter + internals + the single-polarity restriction are owned by D2. + + regression_caught: collapsing the fan-out or reverting dispatch makes + the count != 3. + """ + with FakeAgentCore() as fake: + _onboard(clean_slate_home) + fake.set_response("ListMemoryRecords", {"memoryRecordSummaries": []}) + env = agentcore_env( + clean_slate_home, fake.port, BETTER_MEMORY_STORAGE_BACKEND=None + ) + + with (tmp_path / "j4.stderr").open("w", encoding="utf-8") as errlog: + async with mcp_session(env, errlog=errlog) as session: + buckets = _single_json_dict( + await session.call_tool("memory.retrieve", {}) + ) + + assert buckets == {"do": [], "dont": [], "neutral": []} + lists = fake.requests_for("ListMemoryRecords") + assert len(lists) == 3 + assert len(fake.requests) == 3 + for request in lists: + assert "EPI-FAKE-0001" in request.path + assert "reflections" in request.body.get("namespace", "") + + +# --------------------------------------------------------------------------- +# J5 — semantic_observe → semantic_retrieve round-trip (UD-2 merge) +# --------------------------------------------------------------------------- + + +async def test_j5_semantic_observe_then_retrieve_merges_two_namespaces( + clean_slate_home: Path, tmp_path: Path +) -> None: + """One MCP session: ``memory.semantic_observe`` fires exactly one + BatchCreateMemoryRecords to the SEMANTIC memory (never episodic) and + returns the AWS record id; ``memory.semantic_retrieve`` fires exactly + TWO ListMemoryRecords to SEM (project + general namespaces, the UD-2 + merge), dedupes by id, and each merged item carries the stable payload + keys with ``project``/``created_at``/``updated_at`` as None. Zero rows + in local ``semantic_memories``. sha256 requestIdentifier + strategy-id + routing are owned by D4. + + regression_caught: dropping the general-scope second call makes the + list count 1, not 2; reverting semantic dispatch writes/reads sqlite + with zero SEM wire traffic. + """ + with FakeAgentCore() as fake: + bm_home = _onboard(clean_slate_home) + fake.set_response( + "BatchCreateMemoryRecords", + { + "successfulRecords": [{"memoryRecordId": "sem-journey-1"}], + "failedRecords": [], + }, + ) + # The same canned summary answers BOTH list calls — the merged + # payload having exactly one item proves the id-dedupe. + fake.set_response( + "ListMemoryRecords", + { + "memoryRecordSummaries": [ + { + "memoryRecordId": "sem-journey-1", + "content": {"text": "journey-sem-marker"}, + "namespaces": ["projects/e2e-project/semantic/"], + } + ] + }, + ) + env = agentcore_env( + clean_slate_home, fake.port, BETTER_MEMORY_STORAGE_BACKEND=None + ) + + with (tmp_path / "j5.stderr").open("w", encoding="utf-8") as errlog: + async with mcp_session(env, errlog=errlog) as session: + created = _single_json_dict( + await session.call_tool( + "memory.semantic_observe", + {"content": "journey-sem-marker"}, + ) + ) + assert created["id"] == "sem-journey-1" + batches = fake.requests_for("BatchCreateMemoryRecords") + assert len(batches) == 1 + assert len(fake.requests) == 1 + assert "SEM-FAKE-0001" in batches[0].path + assert "EPI-FAKE-0001" not in batches[0].text() # never EPI + + fake.clear() + merged = _single_json_list( + await session.call_tool("memory.semantic_retrieve", {}) + ) + lists = fake.requests_for("ListMemoryRecords") + assert len(lists) == 2 # UD-2: project + general merge + assert len(fake.requests) == 2 + for request in lists: + assert "SEM-FAKE-0001" in request.path + assert {r.body["namespace"] for r in lists} == { + "projects/e2e-project/semantic/", + "general/semantic/", + } + + # Id-dedupe across the two calls: exactly one merged item. + assert len(merged) == 1 + item = merged[0] + assert item["id"] == "sem-journey-1" # cross-step id chain + assert item["content"] == "journey-sem-marker" + assert item["scope"] == "project" + # Stable keys, present as None (agentcore records carry no + # project/created_at/updated_at — UD-2 payload contract). + assert item["project"] is None + assert item["created_at"] is None + assert item["updated_at"] is None + + assert _row_count(bm_home / "memory.db", "semantic_memories") == 0 + + +# --------------------------------------------------------------------------- +# J6 — rating loop: record_use, short-id guard, semantic rating, exposures +# --------------------------------------------------------------------------- + + +async def test_j6_rating_loop_guard_and_empty_exposures( + clean_slate_home: Path, tmp_path: Path +) -> None: + """The rating loop over MCP dispatch in one session: + + * ``memory.record_use`` on a >= 40-char reflection id → GetMemoryRecord + + BatchUpdateMemoryRecords, both to EPI, system metadata stripped, + counter incremented from the GET response. + * a SHORT id → clear isError naming the 40-char floor with ZERO wire + requests (the Task-2 guard that avoids the ~20s transient-404 retry + stall inside the serialized dispatch loop). + * ``memory.apply_session_ratings`` kind=semantic → lookup + update both + routed to SEM. + * ``memory.list_session_exposures`` → the empty envelope with zero wire + (agentcore has no exposure log — the rating-model difference from + sqlite C5). + + Full-snapshot strip contract owned by D4. + """ + with FakeAgentCore() as fake: + bm_home = _onboard(clean_slate_home) + env = agentcore_env( + clean_slate_home, fake.port, BETTER_MEMORY_STORAGE_BACKEND=None + ) + + with (tmp_path / "j6.stderr").open("w", encoding="utf-8") as errlog: + async with mcp_session(env, errlog=errlog) as session: + # --- record_use on the reflection id (EPI) --------------- + fake.set_response( + "GetMemoryRecord", + { + "memoryRecord": { + "memoryRecordId": _REFL_ID, + "metadata": { + "useful_count": {"numberValue": 2}, + "status": {"stringValue": "active"}, + "x-amz-agentcore-memory-recordType": { + "stringValue": "EXTRACTED" + }, + }, + } + }, + ) + fake.set_response( + "BatchUpdateMemoryRecords", + { + "successfulRecords": [{"memoryRecordId": _REFL_ID}], + "failedRecords": [], + }, + ) + ok = _single_json_dict( + await session.call_tool( + "memory.record_use", + {"id": _REFL_ID, "outcome": "success"}, + ) + ) + assert ok == {"ok": True} + gets = fake.requests_for("GetMemoryRecord") + updates = fake.requests_for("BatchUpdateMemoryRecords") + assert len(gets) == 1 + assert len(updates) == 1 + assert "EPI-FAKE-0001" in gets[0].path + assert "EPI-FAKE-0001" in updates[0].path + metadata = updates[0].body["records"][0]["metadata"] + assert not any( + key.startswith("x-amz-agentcore-memory-") for key in metadata + ), metadata + # Counter incremented from the GET response: 2 → 3. + assert metadata["useful_count"]["numberValue"] == pytest.approx(3) + + # --- short-id guard: clear error, ZERO wire -------------- + fake.clear() + short = await session.call_tool( + "memory.record_use", {"id": "refl-1", "outcome": "success"} + ) + message = _error_text(short) + assert "40" in message, message + assert "refl-1" in message, message + assert fake.requests == [], ( + "short-id guard must reject BEFORE any wire call" + ) + + # --- semantic-kind rating routes to SEM ------------------ + fake.set_response( + "GetMemoryRecord", + { + "memoryRecord": { + "memoryRecordId": _SEM_ID, + "metadata": { + "useful_count": {"numberValue": 0}, + "status": {"stringValue": "active"}, + }, + } + }, + ) + fake.set_response( + "BatchUpdateMemoryRecords", + { + "successfulRecords": [{"memoryRecordId": _SEM_ID}], + "failedRecords": [], + }, + ) + applied = _single_json_dict( + await session.call_tool( + "memory.apply_session_ratings", + { + "ratings": [ + {"kind": "semantic", "id": _SEM_ID, "class": "cited"} + ] + }, + ) + ) + assert applied["applied"] == 1 + assert applied["failed"] == 0 + gets = fake.requests_for("GetMemoryRecord") + updates = fake.requests_for("BatchUpdateMemoryRecords") + assert len(gets) == 1 + assert len(updates) == 1 + assert "SEM-FAKE-0001" in gets[0].path + assert "SEM-FAKE-0001" in updates[0].path + + # --- exposures: empty envelope, zero wire ---------------- + fake.clear() + exposures = _single_json_dict( + await session.call_tool("memory.list_session_exposures", {}) + ) + assert exposures == {"session_id": "e2e-session-1", "exposures": []} + assert fake.requests == [] + + assert _row_count(bm_home / "memory.db", "observations") == 0 + + +# --------------------------------------------------------------------------- +# J7 — contextual_inject per-prompt honours settings.json activation +# --------------------------------------------------------------------------- + + +def test_j7_contextual_inject_honours_settings_activation( + clean_slate_home: Path, tmp_path: Path +) -> None: + """The per-prompt hook reaches AWS with NO backend env var — proving + ``contextual_inject``'s resolver honours settings.json (the load- + bearing difference from D6-A, which force-sets the env var): 3 EPI + + 1 SEM list calls and a ```` block in the envelope. + + regression_caught: reverting contextual_inject to env-only backend + resolution ignores settings.json → empty envelope, zero wire. + """ + with FakeAgentCore() as fake: + _onboard(clean_slate_home) + fake.set_response( + "ListMemoryRecords", + {"memoryRecordSummaries": [_DOCKER_REFLECTION_SUMMARY]}, + ) + env = agentcore_env( + clean_slate_home, + fake.port, + BETTER_MEMORY_STORAGE_BACKEND=None, + BETTER_MEMORY_CONTEXT_INJECT_MODE="userprompt", + ) + + rc, out, err = run_hook( + INJECT_HOOK, + { + "hook_event_name": "UserPromptSubmit", + "prompt": "how do I deploy with docker compose", + "session_id": "e2e-inject-session", + "cwd": str(tmp_path / "proj"), + }, + env, + ) + assert rc == 0 + assert "Traceback" not in err + hso = _hook_envelope(out)["hookSpecificOutput"] + assert hso["hookEventName"] == "UserPromptSubmit" + rendered = hso["additionalContext"] + assert " None: + """The terminal narrative step under the onboarding config: the Stop + hook fires exactly one closure CreateEvent (role=OTHER, EPI) resolved + from settings.json, writes the session_end spool marker, and never + creates memory.db. The full env-gate matrix + json-region signing are + owned by D5. + + regression_caught: reverting session_close's settings-aware gate makes + the env-absent hook return before the closure — zero wire (the exact + pre-fix defect-4 behavior). + """ + with FakeAgentCore() as fake: + bm_home = _onboard(clean_slate_home) + env = agentcore_env( + clean_slate_home, fake.port, BETTER_MEMORY_STORAGE_BACKEND=None + ) + + rc, out, err = run_hook( + CLOSE_HOOK, + { + "session_id": "e2e-session-1", + "cwd": str(tmp_path / "proj"), + "hook_event_name": "Stop", + }, + env, + ) + assert rc == 0 + assert out == "" # no rating block, no noise — the marker path + assert "Traceback" not in err + + closures = fake.requests_for("CreateEvent") + assert len(closures) == 1 + assert len(fake.requests) == 1 + assert "EPI-FAKE-0001" in closures[0].path + conversational = closures[0].body["payload"][0]["conversational"] + assert conversational["role"] == "OTHER" + assert closures[0].body["sessionId"] == "e2e-session-1" + + markers = _session_end_markers(bm_home / "spool") + assert len(markers) == 1, f"expected exactly one session_end marker: {markers}" + body = json.loads(markers[0].read_text(encoding="utf-8")) + assert body["event_type"] == "session_end" + # Closure succeeded → no hook_errors write → no memory.db at all. + assert not (bm_home / "memory.db").exists() diff --git a/tests/e2e/test_agentcore_neg.py b/tests/e2e/test_agentcore_neg.py index 2bcd728..1599cb6 100644 --- a/tests/e2e/test_agentcore_neg.py +++ b/tests/e2e/test_agentcore_neg.py @@ -2,15 +2,18 @@ The failure surfaces a brand-new agentcore user actually hits: -* D8 ``e2e-ac-neg-prehandshake-config-errors`` — parametrized idvar-gate - (KNOWN-DEFECT PIN) + backend-name typo, sharing one raw-spawn helper; +* D8 ``e2e-ac-neg-prehandshake-config-errors`` — backend-name typo dying + loudly pre-handshake (the idvar-gate KNOWN-DEFECT PIN param was + deleted with the gate itself, onboarding-fix wave Task 1); * D9 ``e2e-ac-neg-missing-agentcore-json`` — raw + SDK-client levels - (the enduring negative that survives the idvar-gate fix); + (the enduring negative that survived the idvar-gate fix); * D10 ``e2e-ac-neg-corrupt-agentcore-json`` — truncated JSON + forward - schema_version, both fail loudly, neither offers remediation (pin); + schema_version, both fail loudly naming the file WITH ``agentcore + init`` remediation (inverted from the old no-remediation pin); * D11 ``e2e-ac-neg-boto3-missing`` — PYTHONPATH shadow simulating a plain - ``pip install better-memory`` (no [agentcore] extra), raw traceback - with no install hint (PRODUCT GAP PIN), content-based control run. + ``pip install better-memory`` (no [agentcore] extra), now dying with + the ``better-memory[agentcore]`` install hint (inverted from the old + raw-traceback pin), content-based control run. """ from __future__ import annotations @@ -26,7 +29,6 @@ from tests.e2e._agentcore_env import ( agentcore_env, - remove_dummy_id_vars_pins, write_fake_agentcore_json, ) from tests.e2e._env import isolated_env @@ -68,69 +70,42 @@ def _assert_prehandshake_death( class TestPrehandshakeConfigErrors: - @pytest.mark.parametrize("case", ["idvar-gate", "backend-typo"]) + @pytest.mark.parametrize("case", ["backend-typo"]) def test_config_error_kills_server_prehandshake_before_any_disk_write( self, case: str, clean_slate_home: Path ) -> None: - """Two get_config() raises sharing one spawn helper + one shared - ordering pin (config is validated before ANY disk write — no - memory.db afterwards, distinguishing config-stage death from the - post-config failures in TestMissingAgentCoreJson). - - [idvar-gate] KNOWN-DEFECT PIN (design section 4 item 1): the - presence-only gate at better_memory/config.py:293-301 is vestigial - — its values are consumed by nothing (IDs come from - agentcore.json; commit 0056935 switched the transport) and its own - remediation text ('agentcore init') cannot clear it, since init - never sets env vars. Every documented agentcore setup dies - pre-handshake like this. DELETE this case together with the - FIXME(idvar-gate) dummy vars in tests/e2e/_agentcore_env.py when - the product fix lands. + """get_config() raise + ordering pin (config is validated before + ANY disk write — no memory.db afterwards, distinguishing + config-stage death from the post-config failures in + TestMissingAgentCoreJson). + + The old [idvar-gate] KNOWN-DEFECT PIN param was DELETED per its + own deletion contract when the vestigial presence-only gate was + removed from better_memory/config.py (onboarding-fix wave Task 1). [backend-typo] 'AgentCore' (case typo) must die loudly with the offending value echoed — never silently coerce/default to sqlite (the worst-case silent misconfig: the user believes they are on AWS while every memory lands in a local file).""" + assert case == "backend-typo" bm_home = _bm_home(clean_slate_home) - if case == "idvar-gate": - with FakeAgentCore() as fake: - write_fake_agentcore_json(bm_home) # valid json — gate still kills - env = agentcore_env( - clean_slate_home, fake.port, **remove_dummy_id_vars_pins() - ) - rc, out, err = run_hook(_SERVER_MODULE, None, env) - assert fake.requests == [] - _assert_prehandshake_death( - rc, - out, - err, - [ - "BETTER_MEMORY_STORAGE_BACKEND=agentcore requires both", - "BETTER_MEMORY_AGENTCORE_SEMANTIC_MEMORY_ID", - "BETTER_MEMORY_AGENTCORE_EPISODIC_MEMORY_ID", - # Pins the remediation that cannot actually clear the - # gate — anyone rewording it re-reads this docstring. - "agentcore init", - ], - ) - else: - env = isolated_env( - clean_slate_home, BETTER_MEMORY_STORAGE_BACKEND="AgentCore" - ) - rc, out, err = run_hook(_SERVER_MODULE, None, env) - _assert_prehandshake_death( - rc, - out, - err, - [ - "ValueError", - "is not one of", - "'AgentCore'", # repr'd — the user can SEE the typo - ], - ) + env = isolated_env( + clean_slate_home, BETTER_MEMORY_STORAGE_BACKEND="AgentCore" + ) + rc, out, err = run_hook(_SERVER_MODULE, None, env) + _assert_prehandshake_death( + rc, + out, + err, + [ + "ValueError", + "is not one of", + "'AgentCore'", # repr'd — the user can SEE the typo + ], + ) - # Shared ordering pin: get_config raises before connect() — + # Ordering pin: get_config raises before connect() — # config failures leave zero disk artifacts. assert not (bm_home / "memory.db").exists() @@ -144,9 +119,9 @@ class TestMissingAgentCoreJson: def test_raw_spawn_remediation_text_and_migrated_before_failure( self, clean_slate_home: Path ) -> None: - """ID vars set but no agentcore.json (the classic second-machine - setup failure): pre-handshake FileNotFoundError with the one - accurate remediation hint in this flow. Plus the ordering/docs + """backend=agentcore but no agentcore.json (the classic + second-machine setup failure): pre-handshake FileNotFoundError + with the accurate ``agentcore init`` remediation hint. Plus the ordering/docs pin: create_server migrates BOTH sqlite DBs BEFORE build_backend fails, so a *migrated* memory.db exists after the failed boot (sqlite_master > 0 — distinguishing it from the hook's schema-less @@ -228,11 +203,10 @@ def test_corrupt_json_dies_loudly_naming_the_file( ) -> None: """Truncated JSON (killed-mid-write / hand-edit) and a forward schema_version both raise AgentCoreConfigError naming the file — - the fail-loud contract the persistence module docstring promises. - NEGATIVE PIN (design section 4 item 8): unlike the missing-file - path, the corrupt-file error offers NO 'agentcore init' - remediation — a flagged product gap; flip the last assertion when - remediation text is added.""" + the fail-loud contract the persistence module docstring promises — + AND carry 'agentcore init' remediation text (inverted from the old + design-section-4-item-8 no-remediation pin when the onboarding-fix + wave added the shared remediation sentence).""" bm_home = _bm_home(clean_slate_home) if case == "truncated": bm_home.mkdir(parents=True) @@ -260,8 +234,9 @@ def test_corrupt_json_dies_loudly_naming_the_file( err, ["AgentCoreConfigError", "agentcore.json", *case_stderr], ) - # Product-gap pin: no remediation breadcrumb on the corrupt path. - assert "agentcore init" not in err + # Inverted (old product-gap pin): the corrupt path now carries the + # delete-then-re-init remediation breadcrumb. + assert "agentcore init" in err # --------------------------------------------------------------------------- @@ -270,15 +245,16 @@ def test_corrupt_json_dies_loudly_naming_the_file( class TestBoto3MissingImportSurface: - def test_shadowed_boto3_yields_raw_traceback_without_install_hint( + def test_shadowed_boto3_dies_with_install_hint( self, clean_slate_home: Path, tmp_path: Path ) -> None: - """PRODUCT GAP PIN (design section 4 item 7): a plain - ``pip install better-memory`` (no [agentcore] extra) + + """Inverted from the old design-section-4-item-7 product-gap pin: + a plain ``pip install better-memory`` (no [agentcore] extra) + backend=agentcore dies at the lazy ``import boto3`` in - better_memory/storage/factory.py with a RAW ModuleNotFoundError — - no 'better-memory[agentcore]' / 'pip install' breadcrumb. INVERT - the two 'not in' assertions when the friendly message lands. + better_memory/storage/factory.py with a ModuleNotFoundError that + NOW carries the friendly "pip install 'better-memory[agentcore]'" + breadcrumb (chained ``from exc``, so the original + "No module named 'boto3'" traceback line survives too). boto3 is a dev-group dep in this venv, so absence is simulated with a PYTHONPATH-front shadow module. The shadow's raise SITE @@ -311,9 +287,9 @@ def test_shadowed_boto3_yields_raw_traceback_without_install_hint( err, ["ModuleNotFoundError", "No module named 'boto3'"], ) - # PRODUCT GAP PIN — inverted assertions (see docstring). - assert "better-memory[agentcore]" not in err - assert "pip install" not in err + # Inverted (old product-gap pin): friendly install hint present. + assert "better-memory[agentcore]" in err + assert "pip install" in err # Vacuity guard: the same env WITHOUT the shadow must not die # on the boto3 import — proving the shadow (not some other diff --git a/tests/e2e/test_agentcore_t2.py b/tests/e2e/test_agentcore_t2.py index fffe99f..fe25300 100644 --- a/tests/e2e/test_agentcore_t2.py +++ b/tests/e2e/test_agentcore_t2.py @@ -8,11 +8,15 @@ * D1 ``e2e-ac-server-boot-tools-hidden`` * D2 ``e2e-ac-retrieve-polarity-fanout`` -* D3 ``e2e-ac-mcp-dispatch-gap-pin`` (KNOWN-DEFECT PIN) +* D3 ``e2e-ac-mcp-dispatch-wired`` (inverted from the old + dispatch-gap KNOWN-DEFECT PIN — observe/semantic/record_use now + dispatch to AgentCoreBackend over the wire) * D4 ``e2e-ac-backend-wire-fidelity`` (in-process backend, real boto3) * D5 ``e2e-ac-session-close-closure-and-env-gate`` * D6 ``e2e-ac-contextual-inject-wire-and-degradation`` -* D7 ``e2e-ac-region-split-brain-pin`` (KNOWN-DEFECT PIN) +* D7 ``e2e-ac-region-single-source`` (inverted from the old + region-split-brain KNOWN-DEFECT PIN — both planes sign + agentcore.json's region) """ from __future__ import annotations @@ -27,10 +31,8 @@ import pytest from tests.e2e._agentcore_env import ( - DUMMY_EPISODIC_MEMORY_ID, - DUMMY_ID_VARS, agentcore_env, - remove_dummy_id_vars_pins, + write_backend_settings, write_fake_agentcore_json, ) from tests.e2e._fake_agentcore import FakeAgentCore @@ -47,6 +49,16 @@ "memory.synthesize_next_get_context", "memory.synthesize_next_apply", } +#: Episode + retention tools are sqlite-only concepts: hidden in agentcore +#: mode via ``supports_episodes=False`` (fix plan UD-1); still advertised on +#: the sqlite path (owned by the sqlite journey + tools unit tests). +EPISODE_AND_RETENTION_TOOLS = { + "memory.start_episode", + "memory.close_episode", + "memory.reconcile_episodes", + "memory.list_episodes", + "memory.run_retention", +} def _bm_home(home: Path) -> Path: @@ -93,10 +105,11 @@ async def test_boot_hides_synthesize_tools_and_still_migrates_sqlite( ) -> None: """Real server boots from a fabricated agentcore.json against the fake endpoint. Boot makes ZERO AWS calls; synthesize tools are - hidden (supports_synthesis=False); and — docs-contradiction pin — - both local sqlite DBs are still created + migrated in agentcore - mode (design section 4 item 12: the 'No SQLite traffic' doc claim - is false).""" + hidden (supports_synthesis=False); episode + retention tools are + hidden too (supports_episodes=False, fix plan UD-1); and — docs- + contradiction pin — both local sqlite DBs are still created + + migrated in agentcore mode (design section 4 item 12 resolved + doc-side per UD-4: memory.db carries hook-error logging only).""" bm_home = _bm_home(clean_slate_home) with FakeAgentCore() as fake: write_fake_agentcore_json(bm_home) @@ -108,6 +121,10 @@ async def test_boot_hides_synthesize_tools_and_still_migrates_sqlite( names = {tool.name for tool in tools.tools} assert EXPECTED_TOOL_SUBSET <= names assert not (SYNTHESIZE_TOOLS & names) + # UD-1: backend no-op tools are not advertised in agentcore + # mode (their sqlite-path presence is owned by the sqlite + # journey and the tools unit tests). + assert not (EPISODE_AND_RETENTION_TOOLS & names) # Boot never touches AWS (boto3 client construction only). assert fake.requests == [] @@ -177,67 +194,139 @@ async def test_retrieve_fans_out_three_filtered_list_records( # --------------------------------------------------------------------------- -# D3 — MCP dispatch gap (KNOWN-DEFECT PIN) +# D3 — MCP dispatch wired to AgentCoreBackend (inverted from the old +# dispatch-gap KNOWN-DEFECT PIN, fix plan Task 2) # --------------------------------------------------------------------------- +#: ≥40 chars: real botocore request validation enforces a 40-char minimum +#: on memoryRecordId, so the MCP-level record_use leg must use a genuine- +#: shaped id (short observe-returned EVENT ids are guarded product-side). +_MCP_RECORD_USE_ID = "refl-mcp-dispatch-" + "0" * 30 + "7" + -class TestMcpDispatchGapPin: - async def test_observe_semantic_record_use_never_reach_the_wire( +class TestMcpDispatchWired: + async def test_observe_semantic_record_use_reach_the_wire( self, clean_slate_home: Path ) -> None: - """KNOWN-DEFECT PIN (design section 4 item 2, the highest-priority - product finding): in agentcore mode ``memory.observe``, - ``memory.semantic_observe`` and ``memory.record_use`` dispatch to - LOCAL sqlite services, never to AgentCoreBackend — the registry in - better_memory/mcp/server.py:249-271 constructs - ObservationToolHandlers/SemanticToolHandlers on sqlite services - unconditionally. Agentcore users' memories silently land in a - local file. - - DELETE (and replace with wire tests promoted from - TestBackendWireFidelity) the day the dispatch layer is wired to - the backend — this test flips loudly on that day.""" + """Replaces the old TestMcpDispatchGapPin per its deletion + contract (design section 4 item 2 FIXED by the dispatch wiring): + in agentcore mode ``memory.observe``, ``memory.semantic_observe`` + and ``memory.record_use`` now dispatch to AgentCoreBackend over + MCP — real requests on the wire, ZERO rows in the local sqlite + file (the exact inversion of the old pin's two halves).""" bm_home = _bm_home(clean_slate_home) + marker_obs = "dispatch-wired-marker-obs" + marker_sem = "dispatch-wired-marker-semantic" + expected_req_id = hashlib.sha256( + marker_sem.encode("utf-8") + ).hexdigest()[:80] + with FakeAgentCore() as fake: write_fake_agentcore_json(bm_home) + fake.set_response( + "CreateEvent", {"event": {"eventId": "fake-evt-mcp-1"}} + ) + fake.set_response( + "BatchCreateMemoryRecords", + { + "successfulRecords": [{"memoryRecordId": "fake-rec-mcp-1"}], + "failedRecords": [], + }, + ) + fake.set_response( + "GetMemoryRecord", + { + "memoryRecord": { + "memoryRecordId": _MCP_RECORD_USE_ID, + "metadata": { + "useful_count": {"numberValue": 1}, + "status": {"stringValue": "active"}, + "x-amz-agentcore-memory-recordType": { + "stringValue": "EXTRACTED" + }, + }, + } + }, + ) + fake.set_response( + "BatchUpdateMemoryRecords", + { + "successfulRecords": [ + {"memoryRecordId": _MCP_RECORD_USE_ID} + ], + "failedRecords": [], + }, + ) env = agentcore_env(clean_slate_home, fake.port) async with mcp_session(env) as session: + # -- memory.observe → backend.observe → CreateEvent ------- observe = await session.call_tool( - "memory.observe", {"content": "dispatch-gap-marker-obs"} + "memory.observe", {"content": marker_obs} ) assert not observe.isError - obs_id = json.loads(_tool_text(observe))["id"] - # A local uuid, not an AgentCore eventId from the fake. - assert obs_id - + # The returned id IS the AgentCore eventId, not a local uuid. + assert json.loads(_tool_text(observe))["id"] == "fake-evt-mcp-1" + + creates = fake.requests_for("CreateEvent") + assert len(creates) == 1 + assert len(fake.requests) == 1 # exactly ONE wire request + create = creates[0] + assert "EPI-FAKE-0001" in create.path + assert create.body["sessionId"] == "e2e-session-1" + conversational = create.body["payload"][0]["conversational"] + assert conversational["role"] == "USER" + assert conversational["content"]["text"] == marker_obs + + # -- memory.semantic_observe → BatchCreateMemoryRecords --- + fake.clear() semantic = await session.call_tool( - "memory.semantic_observe", - {"content": "dispatch-gap-marker-semantic"}, + "memory.semantic_observe", {"content": marker_sem} ) assert not semantic.isError + batch_creates = fake.requests_for("BatchCreateMemoryRecords") + assert len(batch_creates) == 1 + assert len(fake.requests) == 1 + batch = batch_creates[0] + assert "SEM-FAKE-0001" in batch.path + assert "EPI-FAKE-0001" not in batch.text() # never EPI + record = batch.body["records"][0] + assert record["requestIdentifier"] == expected_req_id + + # -- memory.record_use → GetMemoryRecord + BatchUpdate ---- + fake.clear() record_use = await session.call_tool( - "memory.record_use", {"id": obs_id, "outcome": "success"} + "memory.record_use", + {"id": _MCP_RECORD_USE_ID, "outcome": "success"}, ) assert not record_use.isError - assert json.loads(_tool_text(record_use)) == {"ok": True} - - # THE pin: zero wire requests across all three data tools. - assert fake.requests == [] - # ... and the writes landed in the local sqlite file instead. - with closing(sqlite3.connect(_bm_home(clean_slate_home) / "memory.db")) as conn: - obs_rows = conn.execute( - "SELECT content FROM observations WHERE content = ?", - ("dispatch-gap-marker-obs",), - ).fetchall() - sem_rows = conn.execute( - "SELECT content FROM semantic_memories WHERE content = ?", - ("dispatch-gap-marker-semantic",), - ).fetchall() - assert len(obs_rows) == 1 - assert len(sem_rows) == 1 + gets = fake.requests_for("GetMemoryRecord") + updates = fake.requests_for("BatchUpdateMemoryRecords") + assert len(gets) == 1 + assert len(updates) == 1 + assert "EPI-FAKE-0001" in gets[0].path + assert "EPI-FAKE-0001" in updates[0].path + metadata = updates[0].body["records"][0]["metadata"] + # System keys stripped (echoing them back is a real AWS 400). + assert not any( + key.startswith("x-amz-agentcore-memory-") + for key in metadata + ), metadata + + # The inverted half of the old pin: NOTHING landed in local sqlite. + with closing( + sqlite3.connect(_bm_home(clean_slate_home) / "memory.db") + ) as conn: + (obs_count,) = conn.execute( + "SELECT COUNT(*) FROM observations" + ).fetchone() + (sem_count,) = conn.execute( + "SELECT COUNT(*) FROM semantic_memories" + ).fetchone() + assert obs_count == 0 + assert sem_count == 0 # --------------------------------------------------------------------------- @@ -287,10 +376,11 @@ def scrubbed_aws_process_env( @pytest.mark.usefixtures("scrubbed_aws_process_env") class TestBackendWireFidelity: """Direct AgentCoreBackend with REAL boto3 clients against the fake — - real botocore serialization, no MagicMocks. Re-homed here from the MCP - layer per the judges: these paths are unreachable over MCP today (see - TestMcpDispatchGapPin), but hooks (contextual_inject, session_close) - and the future dispatch wiring hit them for real.""" + real botocore serialization, no MagicMocks. Complements the MCP-level + dispatch tests (TestMcpDispatchWired): since the fix-wave dispatch + wiring, these paths are reachable both over MCP and from the hooks + (contextual_inject, session_close); this class owns the exhaustive + wire-shape assertions at the backend boundary.""" def _make_backend( self, @@ -363,11 +453,13 @@ async def test_observe_create_event_wire_shape(self, tmp_path: Path) -> None: async def test_observe_without_session_id_raises_before_wire( self, tmp_path: Path ) -> None: - """observe with no session id → ValueError, ZERO wire requests + """observe with no session id, no CLAUDE_SESSION_ID env (the unit + conftest strips it) and no session marker in the isolated home → + lazy re-resolution finds nothing → ValueError, ZERO wire requests (no uuid4 fallback fabricating identities — folded set-3 gap-2).""" with FakeAgentCore() as fake: backend = self._make_backend(fake, tmp_path, session_id=None) - with pytest.raises(ValueError, match="requires session_id"): + with pytest.raises(ValueError, match="session"): await backend.observe(content="never-sent") assert fake.requests == [] @@ -531,12 +623,13 @@ def test_stop_hook_fires_one_closure_event_signed_with_json_region( """Case A: the Stop hook builds a REAL boto3 client (zero coverage elsewhere — the hook unit tests MagicMock it) and fires exactly one role=OTHER closure CreateEvent, SigV4-signed with agentcore.json's - region (json says us-east-1 while the env pin stays eu-west-2 — - proving cfg.region, not env, drives the hook client); the spool - marker is still written.""" + region (json says us-east-1, deliberately not the fabrication + default eu-west-2 — proving cfg.region drives the hook client); + the spool marker is still written. Env-precedence regression + guard: BETTER_MEMORY_STORAGE_BACKEND=agentcore is set here.""" bm_home = _bm_home(clean_slate_home) with FakeAgentCore() as fake: - # Deliberately different from the env region pin (eu-west-2). + # Deliberately different from the fabrication default region. write_fake_agentcore_json(bm_home, region="us-east-1") env = agentcore_env(clean_slate_home, fake.port) @@ -565,8 +658,8 @@ def test_stop_hook_fires_one_closure_event_signed_with_json_region( # env CLAUDE_SESSION_ID wins over the stdin payload session_id. assert request.body["sessionId"] == "e2e-session-1" # The hook signs with agentcore.json's region (session_close.py - # builds its client from cfg.region) — the other half of the - # region split-brain pinned in TestRegionSplitBrainPin. + # builds its client from cfg.region) — the hook half of the + # convergence asserted in TestRegionSingleSource. assert request.sigv4_region == "us-east-1" markers = list((bm_home / "spool").glob("*_session_end_*.json")) @@ -576,21 +669,24 @@ def test_stop_hook_fires_one_closure_event_signed_with_json_region( # Closure succeeded → no hook_errors write → no memory.db at all. assert not (bm_home / "memory.db").exists() - def test_stop_hook_without_backend_env_skips_aws_but_writes_marker( + def test_stop_hook_without_backend_env_resolves_settings_and_fires_closure( self, clean_slate_home: Path, tmp_path: Path ) -> None: - """Case B — KNOWN-DEFECT PIN (design section 4 item 5, hook - env-propagation gap): the installer writes NO env into hook - commands, so a real agentcore user's Stop hook runs WITHOUT - BETTER_MEMORY_STORAGE_BACKEND and the closure event silently never - fires (session_close.py's guard reads the raw env and returns - before its try block — no hook_errors row, no error, nothing). - The spool marker is still written. Flips loudly when the installer - starts propagating hook env (the intended fix) or the guard - condition changes.""" + """Case B — INVERTED from the defect-4 KNOWN-DEFECT PIN (design + section 4 item 5, hook env-propagation gap): the installer still + writes NO env into hook commands, but the Stop hook now resolves + the backend from settings.json (written by ``agentcore init``, + fabricated here via ``write_backend_settings``) when + BETTER_MEMORY_STORAGE_BACKEND is absent — so a real onboarded + user's closure event FIRES: exactly one CreateEvent (role=OTHER, + EPI-FAKE-0001 in path) SigV4-signed with agentcore.json's region. + The spool marker is still written and no memory.db is created.""" bm_home = _bm_home(clean_slate_home) with FakeAgentCore() as fake: - write_fake_agentcore_json(bm_home) + # Non-default json region: proves the closure client signs the + # json's region on the settings-resolved path too. + write_fake_agentcore_json(bm_home, region="us-east-1") + write_backend_settings(bm_home) env = agentcore_env( clean_slate_home, fake.port, BETTER_MEMORY_STORAGE_BACKEND=None ) @@ -607,15 +703,53 @@ def test_stop_hook_without_backend_env_skips_aws_but_writes_marker( assert rc == 0 assert out == "" assert "Traceback" not in err - # THE pin: zero wire requests — silent skip. - assert fake.requests == [] + + requests = list(fake.requests) + assert len(requests) == 1 + request = requests[0] + assert request.operation == "CreateEvent" + assert "EPI-FAKE-0001" in request.path + conversational = request.body["payload"][0]["conversational"] + assert conversational["role"] == "OTHER" + assert request.sigv4_region == "us-east-1" markers = list((bm_home / "spool").glob("*_session_end_*.json")) assert len(markers) == 1 - # Guard short-circuits BEFORE the try block: no record_hook_error, - # hence no memory.db (and therefore no hook_errors row) at all. + # Closure succeeded → no hook_errors write → no memory.db at all. assert not (bm_home / "memory.db").exists() + def test_stop_hook_sqlite_default_ignores_agentcore_json_existence( + self, clean_slate_home: Path, tmp_path: Path + ) -> None: + """Sqlite-safety oracle (fix plan Task 3 sibling): env absent AND + no settings.json → the hook stays on the sqlite default and makes + ZERO wire requests even though agentcore.json exists — existence + is not consent (a once-provisioned-then-reverted sqlite user must + never be silently switched back). Marker still written.""" + bm_home = _bm_home(clean_slate_home) + with FakeAgentCore() as fake: + write_fake_agentcore_json(bm_home) # provisioned, NOT activated + env = agentcore_env( + clean_slate_home, fake.port, BETTER_MEMORY_STORAGE_BACKEND=None + ) + + rc, out, err = run_hook( + "better_memory.hooks.session_close", + { + "session_id": "e2e-session-1", + "cwd": str(tmp_path / "proj"), + "hook_event_name": "Stop", + }, + env, + ) + assert rc == 0 + assert out == "" + assert "Traceback" not in err + assert fake.requests == [] + + markers = list((bm_home / "spool").glob("*_session_end_*.json")) + assert len(markers) == 1 + # --------------------------------------------------------------------------- # D6 — contextual_inject wire + degradation (the only shipped per-prompt @@ -700,20 +834,25 @@ def test_case_a_happy_path_hits_both_memories_and_injects( def test_case_b_misconfig_clean_slate_silent_noop_with_stray_db( self, clean_slate_home: Path, tmp_path: Path ) -> None: - """Case B — KNOWN-DEFECT PIN (design section 4 item 6): ID vars - unset (exactly the state a user who followed the documented setup - is in) on a clean slate → every prompt silently gets the empty - envelope, zero wire traffic, AND record_hook_error's connect() - leaves a stray schema-less memory.db behind while the hook_errors - INSERT silently no-ops (no table). Flips when the idvar gate is - fixed or record_hook_error stops creating the stray DB.""" + """Case B — misconfig re-expressed post-fix (was the idvar-gate + defect pin, design section 4 item 6): settings.json activates + agentcore (env var absent) but agentcore.json was never written — + the realistic broken state after a hand-rolled activation or a + half-copied home. Every prompt silently gets the empty envelope, + zero wire traffic; build_backend's FileNotFoundError is swallowed + by the hook, record_hook_error's connect() leaves a stray + schema-less memory.db behind (INSERT no-ops — no table), and the + SeenStore has already dropped its state/ dir (the failure now + happens AFTER the seen-store block, unlike the old config-stage + death).""" bm_home = _bm_home(clean_slate_home) with FakeAgentCore() as fake: + write_backend_settings(bm_home) # activation without provisioning env = agentcore_env( clean_slate_home, fake.port, BETTER_MEMORY_CONTEXT_INJECT_MODE="userprompt", - **remove_dummy_id_vars_pins(), + BETTER_MEMORY_STORAGE_BACKEND=None, ) rc, out, err = run_hook( @@ -731,18 +870,24 @@ def test_case_b_misconfig_clean_slate_silent_noop_with_stray_db( } assert fake.requests == [] - # Stray schema-less DB is the ONLY artifact; no state/ litter. - assert {child.name for child in bm_home.iterdir()} == {"memory.db"} + # Exact artifact set: the pre-written settings.json, the stray + # schema-less DB, and the seen-store's state/ dir. + assert {child.name for child in bm_home.iterdir()} == { + "memory.db", + "settings.json", + "state", + } assert _table_names(bm_home / "memory.db") == set() def test_case_c_misconfig_premigrated_db_records_one_hook_error_row( self, clean_slate_home: Path, tmp_path: Path ) -> None: - """Case C: same misconfig against a pre-migrated memory.db → - exactly one hook_errors row (hook_name='contextual_inject', - exception_type='ValueError', exception_message naming both ID - vars — column name per migration 0005_phase_c.sql); state/ still - never created (get_config raises before the SeenStore block).""" + """Case C: same misconfig (settings.json activation, no + agentcore.json) against a pre-migrated memory.db → exactly one + hook_errors row (hook_name='contextual_inject', + exception_type='FileNotFoundError', exception_message naming + agentcore.json and the init remediation — column names per + migration 0005_phase_c.sql).""" from better_memory.db.connection import connect from better_memory.db.schema import apply_migrations @@ -754,11 +899,12 @@ def test_case_c_misconfig_premigrated_db_records_one_hook_error_row( conn.close() with FakeAgentCore() as fake: + write_backend_settings(bm_home) env = agentcore_env( clean_slate_home, fake.port, BETTER_MEMORY_CONTEXT_INJECT_MODE="userprompt", - **remove_dummy_id_vars_pins(), + BETTER_MEMORY_STORAGE_BACKEND=None, ) rc, out, err = run_hook( "better_memory.hooks.contextual_inject", @@ -775,7 +921,6 @@ def test_case_c_misconfig_premigrated_db_records_one_hook_error_row( } assert fake.requests == [] - assert not (bm_home / "state").exists() with closing(sqlite3.connect(bm_home / "memory.db")) as check: rows = check.execute( "SELECT hook_name, exception_type, exception_message " @@ -784,30 +929,29 @@ def test_case_c_misconfig_premigrated_db_records_one_hook_error_row( assert len(rows) == 1 hook_name, exception_type, exception_message = rows[0] assert hook_name == "contextual_inject" - assert exception_type == "ValueError" - # Both var names (taken from the single helper's source of truth — - # never spelled out here; the tripwire grep-pins the literals). - for var_name in DUMMY_ID_VARS: - assert var_name in exception_message + assert exception_type == "FileNotFoundError" + # The error names the missing file and the accurate remediation. + assert "agentcore.json" in exception_message + assert "agentcore init" in exception_message # --------------------------------------------------------------------------- -# D7 — region split-brain (KNOWN-DEFECT PIN) +# D7 — region single-source (inverted from the old split-brain pin) # --------------------------------------------------------------------------- -class TestRegionSplitBrainPin: - async def test_server_signs_env_default_while_hook_signs_json_region( +class TestRegionSingleSource: + async def test_server_and_hook_both_sign_json_region( self, clean_slate_home: Path, tmp_path: Path ) -> None: - """KNOWN-DEFECT PIN (design section 4 item 4): with agentcore.json - region=us-east-1 and BETTER_MEMORY_AGENTCORE_REGION unset, the two - planes disagree — (a) the MCP server's wired ``memory.retrieve`` - signs SigV4 with eu-west-2 (the env DEFAULT from - better_memory/config.py) while consuming the json's memory id - (MEM-EPI-JSON on the wire; the dummy env value never is); - (b) the session_close hook signs us-east-1 (json-derived). - A single-source-of-truth fix flips both halves visibly.""" + """INVERTED from the old region-split-brain KNOWN-DEFECT PIN + (design section 4 item 4): the region env var was deleted and the + factory now signs with agentcore.json's region, so with json + region=us-east-1 BOTH planes converge — (a) the MCP server's + ``memory.retrieve`` signs SigV4 with us-east-1 while consuming the + json's memory id (MEM-EPI-JSON on the wire — id provenance proof); + (b) the session_close hook signs us-east-1 too (json-derived, as + it always did — the convergence target).""" bm_home = _bm_home(clean_slate_home) with FakeAgentCore() as fake: write_fake_agentcore_json( @@ -817,12 +961,7 @@ async def test_server_signs_env_default_while_hook_signs_json_region( semantic_memory_id="MEM-SEM-JSON", ) fake.set_response("ListMemoryRecords", {"memoryRecordSummaries": []}) - # The split-brain condition: env region var ABSENT. - env = agentcore_env( - clean_slate_home, - fake.port, - BETTER_MEMORY_AGENTCORE_REGION=None, - ) + env = agentcore_env(clean_slate_home, fake.port) # (a) server plane — memory.retrieve is the WIRED trigger. async with mcp_session(env) as session: @@ -832,15 +971,13 @@ async def test_server_signs_env_default_while_hook_signs_json_region( server_requests = fake.requests_for("ListMemoryRecords") assert len(server_requests) == 3 for request in server_requests: - assert request.sigv4_region == "eu-west-2" # env default - assert request.sigv4_region != "us-east-1" # NOT the json's - # Runtime IDs come from agentcore.json — presence of the - # real id, plus absence of the dummy env value, proves the - # dead env vars are never consumed. + # Single source of truth: the json's region, never a + # product default. + assert request.sigv4_region == "us-east-1" + # Runtime IDs come from agentcore.json too — id provenance. assert "MEM-EPI-JSON" in request.path - assert DUMMY_EPISODIC_MEMORY_ID not in request.text() - # (b) hook plane — session_close signs with the json's region. + # (b) hook plane — session_close signs the same json region. fake.clear() rc, out, _err = run_hook( "better_memory.hooks.session_close", diff --git a/tests/e2e/test_tripwires_aws.py b/tests/e2e/test_tripwires_aws.py index 9762877..89dbf7c 100644 --- a/tests/e2e/test_tripwires_aws.py +++ b/tests/e2e/test_tripwires_aws.py @@ -9,10 +9,13 @@ against the fake: every request's Host is ``127.0.0.1:*`` and every SigV4 scope is ``Credential=bm-e2e-fake/...`` (i.e. the default chain never reached a real ``~/.aws``, SSO cache, IMDS, or shell creds) — - red BEFORE anything could ever be billed; -* workaround containment — the dummy ``BETTER_MEMORY_AGENTCORE_*_MEMORY_ID`` - literals (FIXME(idvar-gate) workaround) are grep-pinned to exactly two - files: the single env helper and the one defect-pin negative test. + red BEFORE anything could ever be billed. + +The FIXME(idvar-gate) dummy-ID-var containment tripwire that used to live +here was deleted together with the workaround itself when the vestigial +config gate was removed (onboarding-fix wave, Task 1); the poison entries +in ``tests/e2e_meta/test_env_bleed.py`` now prove the removed env vars +stay inert. """ from __future__ import annotations @@ -24,7 +27,6 @@ import pytest from tests.e2e._agentcore_env import ( - DUMMY_ID_VARS, FAKE_ACCESS_KEY_ID, FAKE_SECRET_ACCESS_KEY, agentcore_env, @@ -81,10 +83,12 @@ def test_lockdown_contract_under_hostile_outer_shell( assert Path(str(tmp_path)) in locked.parents assert env["BETTER_MEMORY_STORAGE_BACKEND"] == "agentcore" - assert env["BETTER_MEMORY_AGENTCORE_REGION"] - # FIXME(idvar-gate) workaround values present (see _agentcore_env). - for var_name, dummy_value in DUMMY_ID_VARS.items(): - assert env[var_name] == dummy_value + # The deleted region/memory-ID env knobs must never reappear: the + # helper sets ONLY the backend switch among BETTER_MEMORY_* keys + # beyond isolated_env's own pins. + assert not any( + k.upper().startswith("BETTER_MEMORY_AGENTCORE_") for k in env + ) def test_pins_can_remove_lockdown_keys_for_negative_tests( self, tmp_path: Path @@ -162,20 +166,23 @@ def _get_memory(request: RecordedRequest) -> dict: assert request.operation == "GetMemory" -class TestDummyIdVarContainment: - """The FIXME(idvar-gate) workaround must never metastasize per-test.""" +class TestDeletedEnvKnobsStayDead: + """The deleted BETTER_MEMORY_AGENTCORE_* env knobs must never return. - #: The ONLY files allowed to spell out the dummy-ID var names: - #: the single env helper, and the defect-pin negative test that gets - #: deleted together with the workaround when the product fix lands. - _ALLOWED = frozenset({"_agentcore_env.py", "test_agentcore_neg.py"}) + Replaces the old TestDummyIdVarContainment (deleted with the + idvar-gate workaround): no e2e test module may spell out the removed + memory-ID / region env var names again — re-introducing them would + resurrect config surface the product no longer reads. + """ - @pytest.mark.parametrize("kind", ["SEMANTIC", "EPISODIC"]) - def test_dummy_idvar_literal_only_in_helper_and_defect_pin( - self, kind: str + @pytest.mark.parametrize( + "suffix", ["SEMANTIC_MEMORY_ID", "EPISODIC_MEMORY_ID", "REGION"] + ) + def test_deleted_env_var_literal_absent_from_e2e_suite( + self, suffix: str ) -> None: # Built by concatenation so THIS file never matches its own grep. - needle = "BETTER_MEMORY_AGENTCORE_" + kind + "_MEMORY_ID" + needle = "BETTER_MEMORY_AGENTCORE_" + suffix e2e_dir = Path(__file__).resolve().parent containing = { @@ -183,17 +190,8 @@ def test_dummy_idvar_literal_only_in_helper_and_defect_pin( for path in sorted(e2e_dir.glob("*.py")) if needle in path.read_text(encoding="utf-8", errors="ignore") } - - offenders = containing - self._ALLOWED - assert not offenders, ( - f"{needle} leaked outside the single helper + defect-pin test " - f"(see FIXME(idvar-gate) in _agentcore_env.py): {sorted(offenders)}. " - f"Use agentcore_env()/remove_dummy_id_vars_pins() instead." - ) - # Anti-vacuity: the two allowed files really do carry the literal — - # an over-eager cleanup that renamed the var would green this test - # while breaking the workaround. - assert self._ALLOWED <= containing, ( - f"expected {needle} in {sorted(self._ALLOWED)}, " - f"found only in {sorted(containing)}" + assert not containing, ( + f"{needle} was deleted by the onboarding-fix wave (region and " + f"memory ids are single-sourced from agentcore.json) but " + f"reappeared in: {sorted(containing)}" ) diff --git a/tests/e2e_meta/mutations/M2.patch b/tests/e2e_meta/mutations/M2.patch index 830e2eb..3208b66 100644 --- a/tests/e2e_meta/mutations/M2.patch +++ b/tests/e2e_meta/mutations/M2.patch @@ -1,24 +1,25 @@ diff --git a/better_memory/hooks/session_bootstrap.py b/better_memory/hooks/session_bootstrap.py -index 52f75de..9baacac 100644 +index d862de1..5097199 100644 --- a/better_memory/hooks/session_bootstrap.py +++ b/better_memory/hooks/session_bootstrap.py -@@ -68,23 +68,19 @@ def main() -> None: +@@ -69,6 +69,13 @@ def main() -> None: try: cfg = get_config() -+ # MUTATION M2: write_session_id hoisted above service.bootstrap() — ++ # MUTATION M2: write_session_id hoisted above the bootstrap work — + # the marker now lands even when bootstrap fails on a virgin home. + write_session_id( + cfg.home, + session_id, + project_dir=os.environ.get("CLAUDE_PROJECT_DIR") or cwd_str, + ) - with closing(connect(cfg.memory_db)) as conn: - service = SessionBootstrapService(conn) - result = service.bootstrap( - source=source, session_id=session_id, cwd=Path(cwd_str), - ) - rendered = result.additional_context + if cfg.storage_backend == "agentcore": + # Route through the storage factory like contextual_inject.py: + # agentcore mode must never open the local sqlite database. The +@@ -95,17 +102,6 @@ def main() -> None: + source=source, session_id=session_id, cwd=Path(cwd_str), + ) + rendered = result.additional_context - # Bridge session_id to the MCP server: it doesn't see CLAUDE_SESSION_ID - # in its spawn env. See better_memory/runtime/session_marker.py. - # Resolution MUST match read_session_id's _resolve_project_dir order: diff --git a/tests/hooks/test_contextual_inject.py b/tests/hooks/test_contextual_inject.py index 1bf28ba..a20300e 100644 --- a/tests/hooks/test_contextual_inject.py +++ b/tests/hooks/test_contextual_inject.py @@ -194,8 +194,6 @@ def test_agentcore_mode_does_not_open_sqlite_connection(bm_home, monkeypatch, ca """ monkeypatch.setenv("BETTER_MEMORY_CONTEXT_INJECT_MODE", "both") monkeypatch.setenv("BETTER_MEMORY_STORAGE_BACKEND", "agentcore") - monkeypatch.setenv("BETTER_MEMORY_AGENTCORE_SEMANTIC_MEMORY_ID", "sem-1") - monkeypatch.setenv("BETTER_MEMORY_AGENTCORE_EPISODIC_MEMORY_ID", "epi-1") connect_calls = [] diff --git a/tests/hooks/test_session_bootstrap.py b/tests/hooks/test_session_bootstrap.py index 34f7e5c..c582294 100644 --- a/tests/hooks/test_session_bootstrap.py +++ b/tests/hooks/test_session_bootstrap.py @@ -1,6 +1,11 @@ -"""Subprocess tests for better_memory.hooks.session_bootstrap.""" +"""Subprocess tests for better_memory.hooks.session_bootstrap. + +The agentcore-routing tests at the bottom run in-process (monkeypatched +``connect`` / ``build_backend``) so they need no boto3 stubs. +""" from __future__ import annotations +import io import json import os import subprocess @@ -11,6 +16,7 @@ from better_memory.db.connection import connect from better_memory.db.schema import apply_migrations +from better_memory.hooks import session_bootstrap as hook _MIGRATIONS = Path(__file__).resolve().parents[2] / "better_memory" / "db" / "migrations" @@ -240,3 +246,156 @@ def test_hook_session_id_resolves_from_env_var(home_with_schema, git_cwd): out2 = json.loads(proc2.stdout) text2 = out2["hookSpecificOutput"]["additionalContext"] assert "Episode: reused" in text2 + + +# --------------------------------------------------------------------------- +# Agentcore-mode routing (in-process): the SessionStart hook must route +# through build_backend and never open the local sqlite database when the +# resolved backend is agentcore. Mirrors tests/hooks/test_contextual_inject.py +# ::test_agentcore_mode_does_not_open_sqlite_connection. +# --------------------------------------------------------------------------- + + +class _FakeConn: + def close(self) -> None: + pass + + +class _FakeRemoteBackend: + def __init__(self) -> None: + self.bootstrap_calls: list[dict] = [] + + def session_bootstrap(self, **kwargs): + self.bootstrap_calls.append(kwargs) + return { + "additional_context": "remote-bootstrap-context", + "project": "/testproj", + "source": kwargs.get("source") or "", + "episode_id": kwargs.get("session_id"), + "episode_action": "opened", + "semantic_count": 0, + "reflections_counts": {"do": 0, "dont": 0, "neutral": 0}, + } + + +def _run_inprocess(payload: dict, monkeypatch, capsys) -> dict: + monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload))) + with pytest.raises(SystemExit) as e: + hook.main() + assert e.value.code == 0 + return json.loads(capsys.readouterr().out) + + +def test_agentcore_mode_does_not_open_sqlite_connection( + tmp_path, monkeypatch, capsys +): + """storage_backend=agentcore must never call connect(); bootstrap goes + through build_backend(memory_conn=None) and renders the backend dict's + additional_context. The session marker is still written (the MCP server + needs the session-id bridge in agentcore mode too).""" + from better_memory.runtime.session_marker import read_session_id + + home = tmp_path / "bm-home" + proj = tmp_path / "proj" + proj.mkdir() + monkeypatch.setenv("BETTER_MEMORY_HOME", str(home)) + monkeypatch.setenv("BETTER_MEMORY_STORAGE_BACKEND", "agentcore") + + connect_calls: list = [] + monkeypatch.setattr( + hook, "connect", + lambda *a, **kw: connect_calls.append(a) or _FakeConn(), + ) + + build_backend_calls: list[dict] = [] + fake_backend = _FakeRemoteBackend() + + def _fake_build_backend(**kwargs): + build_backend_calls.append(kwargs) + return fake_backend + + monkeypatch.setattr(hook, "build_backend", _fake_build_backend) + + res = _run_inprocess( + {"source": "startup", "session_id": "ac-sess-1", "cwd": str(proj)}, + monkeypatch, capsys, + ) + + assert res["hookSpecificOutput"]["hookEventName"] == "SessionStart" + assert ( + res["hookSpecificOutput"]["additionalContext"] + == "remote-bootstrap-context" + ) + assert connect_calls == [] + assert len(build_backend_calls) == 1 + assert build_backend_calls[0]["memory_conn"] is None + assert build_backend_calls[0]["session_id"] == "ac-sess-1" + assert len(fake_backend.bootstrap_calls) == 1 + call = fake_backend.bootstrap_calls[0] + assert call["session_id"] == "ac-sess-1" + assert call["source"] == "startup" + assert call["cwd"] == Path(str(proj)) + # Session-id bridge marker still written in agentcore mode. + assert read_session_id(home, project_dir=str(proj)) == "ac-sess-1" + + +def test_agentcore_mode_backend_failure_falls_back_to_directive( + tmp_path, monkeypatch, capsys +): + """Graceful degradation: build_backend failing (e.g. agentcore.json + missing) must not crash the hook and must NOT fall back to sqlite — + the envelope carries the manual-bootstrap directive instead.""" + home = tmp_path / "bm-home" + proj = tmp_path / "proj" + proj.mkdir() + monkeypatch.setenv("BETTER_MEMORY_HOME", str(home)) + monkeypatch.setenv("BETTER_MEMORY_STORAGE_BACKEND", "agentcore") + + connect_calls: list = [] + monkeypatch.setattr( + hook, "connect", + lambda *a, **kw: connect_calls.append(a) or _FakeConn(), + ) + + def _boom(**kwargs): + raise FileNotFoundError( + f"{home}/agentcore.json not found. Run `better-memory agentcore " + f"init` to create the memory resources and persist their IDs." + ) + + monkeypatch.setattr(hook, "build_backend", _boom) + + res = _run_inprocess( + {"source": "startup", "session_id": "ac-sess-2", "cwd": str(proj)}, + monkeypatch, capsys, + ) + + text = res["hookSpecificOutput"]["additionalContext"] + assert "session bootstrap failed" in text + assert "FileNotFoundError" in text + assert "memory_session_bootstrap" in text + # Misconfigured agentcore must not silently degrade INTO sqlite. + assert connect_calls == [] + + +def test_sqlite_mode_never_consults_build_backend( + home_with_schema, git_cwd, monkeypatch, capsys +): + """Byte-identical sqlite oracle: with the backend resolved to sqlite the + hook uses the direct SessionBootstrapService path and never touches the + storage factory.""" + monkeypatch.setenv("BETTER_MEMORY_HOME", str(home_with_schema)) + monkeypatch.delenv("BETTER_MEMORY_STORAGE_BACKEND", raising=False) + + def _forbidden(**kwargs): + raise AssertionError("build_backend consulted on the sqlite path") + + # raising=False: passes against pre-fix code that has no such symbol. + monkeypatch.setattr(hook, "build_backend", _forbidden, raising=False) + + res = _run_inprocess( + {"source": "startup", "session_id": "sq-sess-1", "cwd": str(git_cwd)}, + monkeypatch, capsys, + ) + text = res["hookSpecificOutput"]["additionalContext"] + assert "## better-memory: session bootstrap" in text diff --git a/tests/hooks/test_session_close_agentcore.py b/tests/hooks/test_session_close_agentcore.py index 313de18..627e27f 100644 --- a/tests/hooks/test_session_close_agentcore.py +++ b/tests/hooks/test_session_close_agentcore.py @@ -1,17 +1,32 @@ -"""Tests for Stop hook's agentcore-mode closure event.""" +"""Tests for Stop hook's agentcore-mode closure event. + +Backend-gate contract (defect 4 fix): the closure fires when EITHER the +BETTER_MEMORY_STORAGE_BACKEND env var says agentcore (env always wins, +zero file I/O) OR — env unset — ``$BETTER_MEMORY_HOME/settings.json`` +resolves to agentcore via the shared ``resolve_storage_backend`` helper. +Env unset + no settings.json stays sqlite even when agentcore.json exists +(existence is not consent). Resolver errors are recorded to hook_errors +and never block the spool marker. +""" from __future__ import annotations +import builtins +import io +import json +import sys +from pathlib import Path from unittest.mock import MagicMock import pytest +from better_memory.db.connection import connect +from better_memory.db.schema import apply_migrations -@pytest.fixture -def agentcore_config_present(tmp_path, monkeypatch): - """Set BETTER_MEMORY_HOME with a populated agentcore.json + env mode.""" - import json - (tmp_path / "agentcore.json").write_text(json.dumps({ + +def _write_agentcore_json(home: Path) -> None: + home.mkdir(parents=True, exist_ok=True) + (home / "agentcore.json").write_text(json.dumps({ "schema_version": 1, "region": "eu-west-2", "episodic": { @@ -30,13 +45,71 @@ def agentcore_config_present(tmp_path, monkeypatch): "strategy_name": "userPreference", "event_expiry_duration_days": 365, }, - })) + }), encoding="utf-8") + + +def _migrate_home_db(home: Path) -> None: + """Apply migrations so record_hook_error's INSERT actually lands.""" + home.mkdir(parents=True, exist_ok=True) + conn = connect(home / "memory.db") + try: + apply_migrations(conn) + finally: + conn.close() + + +def _hook_error_rows(home: Path) -> list[dict]: + conn = connect(home / "memory.db") + try: + return [ + dict(r) + for r in conn.execute( + "SELECT hook_name, exception_type, exception_message " + "FROM hook_errors" + ).fetchall() + ] + finally: + conn.close() + + +def _forbid_imports(monkeypatch, *fragments: str) -> list[str]: + """Install a builtins.__import__ guard that records (and rejects) any + import whose module name contains one of ``fragments``. Returns the + hit list — assert it stays empty.""" + real_import = builtins.__import__ + hits: list[str] = [] + + def _guard(name, *args, **kwargs): + if any(fragment in name for fragment in fragments): + hits.append(name) + raise AssertionError(f"forbidden import on this path: {name}") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _guard) + return hits + + +@pytest.fixture +def agentcore_config_present(tmp_path, monkeypatch): + """Set BETTER_MEMORY_HOME with a populated agentcore.json + env mode.""" + _write_agentcore_json(tmp_path) monkeypatch.setenv("BETTER_MEMORY_HOME", str(tmp_path)) monkeypatch.setenv("BETTER_MEMORY_STORAGE_BACKEND", "agentcore") monkeypatch.setenv("CLAUDE_SESSION_ID", "test-sess-abc") return tmp_path +@pytest.fixture +def agentcore_home_no_env(tmp_path, monkeypatch): + """agentcore.json present but BETTER_MEMORY_STORAGE_BACKEND unset — + the installed-hook reality: Claude Code passes hooks no env.""" + _write_agentcore_json(tmp_path) + monkeypatch.setenv("BETTER_MEMORY_HOME", str(tmp_path)) + monkeypatch.delenv("BETTER_MEMORY_STORAGE_BACKEND", raising=False) + monkeypatch.setenv("CLAUDE_SESSION_ID", "test-sess-abc") + return tmp_path + + def test_agentcore_mode_fires_closure_event(agentcore_config_present, monkeypatch): """In agentcore mode, the Stop hook fires one CreateEvent with role=OTHER.""" fake_data_client = MagicMock(name="bedrock-agentcore-data") @@ -93,9 +166,6 @@ def test_spool_marker_written_even_when_closure_event_raises( closure call after the spool write, this catches it; if someone moves the closure call into a try-block that early-exits on failure, this catches that too.""" - import sys - from pathlib import Path - fake_client = MagicMock() fake_client.create_event.side_effect = RuntimeError("AWS down") monkeypatch.setattr( @@ -108,7 +178,7 @@ def test_spool_marker_written_even_when_closure_event_raises( lambda: Path(agentcore_config_present) / "spool", ) # Feed an empty stdin so the hook synthesises the marker - monkeypatch.setattr(sys, "stdin", type("StdIn", (), {"read": lambda _self, n: ""})()) + monkeypatch.setattr(sys, "stdin", io.StringIO("")) from better_memory.hooks.session_close import main @@ -123,29 +193,231 @@ def test_spool_marker_written_even_when_closure_event_raises( assert len(markers) == 1, f"expected exactly one marker, got {markers}" -def test_env_guard_short_circuits_before_any_import(monkeypatch): - """If BETTER_MEMORY_STORAGE_BACKEND != 'agentcore', the env guard must - return False BEFORE any boto3-related import runs. Use a sentinel that - raises on import to prove no agentcore_persistence import happens.""" +# --------------------------------------------------------------------------- +# settings.json-aware backend resolution (defect 4) +# --------------------------------------------------------------------------- + + +def test_settings_json_resolves_agentcore_and_fires_closure( + agentcore_home_no_env, monkeypatch +): + """Env unset + settings.json {"storage_backend": "agentcore"} → the Stop + hook fires the closure event. This is the defect-4 fix: installed hooks + receive no env from Claude Code, so the settings file written by + `agentcore init` must be what activates the closure.""" + (agentcore_home_no_env / "settings.json").write_text( + json.dumps({"storage_backend": "agentcore"}), encoding="utf-8" + ) + fake_client = MagicMock(name="bedrock-agentcore-data") + fake_client.create_event.return_value = {"event": {"eventId": "evt-close"}} + monkeypatch.setattr( + "better_memory.hooks.session_close._build_agentcore_data_client", + lambda region: fake_client, + ) + + from better_memory.hooks.session_close import _fire_agentcore_closure + rc = _fire_agentcore_closure(session_id="test-sess-abc", project="testproj") + assert rc is True + assert fake_client.create_event.call_count == 1 + call = fake_client.create_event.call_args.kwargs + assert call["memoryId"] == "epi-test" + assert call["sessionId"] == "test-sess-abc" + + +def test_env_unset_no_settings_json_stays_sqlite_even_with_agentcore_json( + agentcore_home_no_env, monkeypatch +): + """Sqlite-safety oracle: env absent AND no settings.json → no closure, + even though agentcore.json exists. A sqlite user who once provisioned + (agentcore.json persists) must not be silently switched — existence is + not consent. Silent skip: no hook_errors row, no memory.db at all.""" + fake_client = MagicMock(name="bedrock-agentcore-data") + monkeypatch.setattr( + "better_memory.hooks.session_close._build_agentcore_data_client", + lambda region: fake_client, + ) + + from better_memory.hooks.session_close import _fire_agentcore_closure + rc = _fire_agentcore_closure(session_id="test-sess-abc", project="testproj") + assert rc is False + assert fake_client.create_event.call_count == 0 + # Silent skip, not an error path: nothing wrote memory.db. + assert not (agentcore_home_no_env / "memory.db").exists() + + +def test_explicit_env_sqlite_wins_over_settings_json( + agentcore_home_no_env, monkeypatch +): + """Env always wins: BETTER_MEMORY_STORAGE_BACKEND=sqlite beats a + settings.json that says agentcore, and the fast path performs zero file + I/O — the shared resolver is never even called.""" + (agentcore_home_no_env / "settings.json").write_text( + json.dumps({"storage_backend": "agentcore"}), encoding="utf-8" + ) + monkeypatch.setenv("BETTER_MEMORY_STORAGE_BACKEND", "sqlite") + + import better_memory.config as config_mod + + def _resolver_forbidden(): + raise AssertionError( + "resolve_storage_backend called on the explicit-env fast path" + ) + + monkeypatch.setattr( + config_mod, "resolve_storage_backend", _resolver_forbidden + ) + fake_client = MagicMock(name="bedrock-agentcore-data") + monkeypatch.setattr( + "better_memory.hooks.session_close._build_agentcore_data_client", + lambda region: fake_client, + ) + + from better_memory.hooks.session_close import _fire_agentcore_closure + rc = _fire_agentcore_closure(session_id="test-sess-abc", project="testproj") + assert rc is False + assert fake_client.create_event.call_count == 0 + # Fast path is silent — no error row, no memory.db. + assert not (agentcore_home_no_env / "memory.db").exists() + + +def test_explicit_env_agentcore_wins_over_settings_json_sqlite( + agentcore_home_no_env, monkeypatch +): + """Env precedence, other direction: env=agentcore fires the closure even + when settings.json pins sqlite.""" + (agentcore_home_no_env / "settings.json").write_text( + json.dumps({"storage_backend": "sqlite"}), encoding="utf-8" + ) + monkeypatch.setenv("BETTER_MEMORY_STORAGE_BACKEND", "agentcore") + fake_client = MagicMock(name="bedrock-agentcore-data") + fake_client.create_event.return_value = {"event": {"eventId": "evt-close"}} + monkeypatch.setattr( + "better_memory.hooks.session_close._build_agentcore_data_client", + lambda region: fake_client, + ) + + from better_memory.hooks.session_close import _fire_agentcore_closure + rc = _fire_agentcore_closure(session_id="test-sess-abc", project="testproj") + assert rc is True + assert fake_client.create_event.call_count == 1 + + +def test_corrupt_settings_json_records_hook_error_and_skips_closure( + agentcore_home_no_env, monkeypatch +): + """Env unset + malformed settings.json → resolver ValueError is caught, + recorded to hook_errors (hook_name=session_close_agentcore, message + naming the file), and the closure is skipped. Hooks never fail.""" + _migrate_home_db(agentcore_home_no_env) + (agentcore_home_no_env / "settings.json").write_text( + "{not json", encoding="utf-8" + ) + fake_client = MagicMock(name="bedrock-agentcore-data") + monkeypatch.setattr( + "better_memory.hooks.session_close._build_agentcore_data_client", + lambda region: fake_client, + ) + + from better_memory.hooks.session_close import _fire_agentcore_closure + rc = _fire_agentcore_closure(session_id="test-sess-abc", project="testproj") + assert rc is False + assert fake_client.create_event.call_count == 0 + rows = _hook_error_rows(agentcore_home_no_env) + closure_rows = [ + r for r in rows if r["hook_name"] == "session_close_agentcore" + ] + assert len(closure_rows) == 1 + assert closure_rows[0]["exception_type"] == "ValueError" + assert "settings.json" in closure_rows[0]["exception_message"] + + +def test_corrupt_settings_json_marker_still_written( + agentcore_home_no_env, monkeypatch +): + """main()-level never-fail contract: a corrupt settings.json must not + block the spool marker — exit 0, marker written, error row recorded.""" + _migrate_home_db(agentcore_home_no_env) + (agentcore_home_no_env / "settings.json").write_text( + "{not json", encoding="utf-8" + ) + monkeypatch.setattr( + "better_memory.hooks.session_close.default_spool_dir", + lambda: Path(agentcore_home_no_env) / "spool", + ) + monkeypatch.setattr(sys, "stdin", io.StringIO("")) + + from better_memory.hooks.session_close import main + + with pytest.raises(SystemExit) as excinfo: + main() + assert excinfo.value.code == 0 + + markers = list( + (Path(agentcore_home_no_env) / "spool").glob("*_session_end_*.json") + ) + assert len(markers) == 1, f"expected exactly one marker, got {markers}" + rows = _hook_error_rows(agentcore_home_no_env) + assert any(r["hook_name"] == "session_close_agentcore" for r in rows) + + +# --------------------------------------------------------------------------- +# Fast-path import hygiene + boto3 hint +# --------------------------------------------------------------------------- + + +def test_env_sqlite_fast_exit_skips_boto3_and_agentcore_imports( + monkeypatch, tmp_path +): + """Explicit env=sqlite returns False before any lazy import: no boto3, + no botocore, no agentcore_persistence import is attempted.""" + monkeypatch.setenv("BETTER_MEMORY_HOME", str(tmp_path)) monkeypatch.setenv("BETTER_MEMORY_STORAGE_BACKEND", "sqlite") - # Patch agentcore_persistence so any import raises — proves we never - # reach the lazy-import block - sentinel_raised = [] - import importlib - real_import = importlib.import_module + from better_memory.hooks.session_close import _fire_agentcore_closure + + hits = _forbid_imports( + monkeypatch, "boto3", "botocore", "agentcore_persistence" + ) + rc = _fire_agentcore_closure(session_id="x", project="p") + assert rc is False + assert hits == [] - def _raising_import(name, *a, **kw): - if "agentcore_persistence" in name: - sentinel_raised.append(name) - raise AssertionError( - "agentcore_persistence imported even though env=sqlite" - ) - return real_import(name, *a, **kw) - monkeypatch.setattr(importlib, "import_module", _raising_import) +def test_env_unset_no_settings_resolves_sqlite_without_boto3( + monkeypatch, tmp_path +): + """Env unset + no settings.json resolves sqlite via the shared resolver + and still never attempts a boto3/agentcore import.""" + monkeypatch.setenv("BETTER_MEMORY_HOME", str(tmp_path)) + monkeypatch.delenv("BETTER_MEMORY_STORAGE_BACKEND", raising=False) from better_memory.hooks.session_close import _fire_agentcore_closure + + hits = _forbid_imports( + monkeypatch, "boto3", "botocore", "agentcore_persistence" + ) rc = _fire_agentcore_closure(session_id="x", project="p") assert rc is False - assert sentinel_raised == [] + assert hits == [] + + +def test_build_client_missing_boto3_raises_install_hint(monkeypatch): + """The lazy boto3 import carries the same `better-memory[agentcore]` + install hint as the storage factory: ModuleNotFoundError, chained from + the original ImportError.""" + from better_memory.hooks.session_close import _build_agentcore_data_client + + real_import = builtins.__import__ + + def _no_boto3(name, *args, **kwargs): + if name == "boto3" or name.startswith("botocore"): + raise ImportError(f"No module named '{name}'") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _no_boto3) + + with pytest.raises(ModuleNotFoundError) as excinfo: + _build_agentcore_data_client("eu-west-2") + assert "better-memory[agentcore]" in str(excinfo.value) + assert "pip install" in str(excinfo.value) + assert isinstance(excinfo.value.__cause__, ImportError) diff --git a/tests/integration/test_agentcore_live_e2e.py b/tests/integration/test_agentcore_live_e2e.py index 6a9b82b..c9ba26a 100644 --- a/tests/integration/test_agentcore_live_e2e.py +++ b/tests/integration/test_agentcore_live_e2e.py @@ -21,13 +21,52 @@ E2 — ``e2e-live-smoke-retrieve-backend-roundtrip`` (rebuilt per both judges): (a) the shipped ``agentcore smoke`` 6-step data-plane loop against -real AWS; (b) live MCP ``memory.retrieve`` through the real server — the -one MCP data path actually wired to AgentCoreBackend; (c) direct -``AgentCoreBackend.observe -> list_observations`` read-after-write with -metadata survival. The author-round MCP observe->retrieve_observations -round-trip was REMOVED: in agentcore mode those tools dispatch to local -sqlite (the dispatch gap, design section 4 item 2), so it would greenwash -AWS coverage forever. +real AWS; (b) live MCP ``memory.retrieve`` through the real server under +the onboarding configuration (agentcore.json + settings.json, no backend/ +region/id env vars — exactly what ``agentcore init`` leaves behind, per +the onboarding-fix wave); (c) direct ``AgentCoreBackend.observe -> +list_observations`` read-after-write with metadata survival. The MCP +observe->retrieve_observations round-trip (non-tautological now that the +dispatch layer is wired to AgentCoreBackend) is owned by the E3+ journey +additions (2026-07-12-agentcore-journey-tests-design.md). + +E3-E7 — live onboarding-config journey additions (journey design section +3, T3 tier). Every child home carries agentcore.json + settings.json and +NO backend/region/id env vars — the shipped onboarding state: + +* E3 — MCP ``observe -> retrieve_observations`` round-trip (raw events are + promptly consistent) with zero local sqlite rows. +* E4 — MCP semantic CRUD round-trip (observe -> retrieve -> update -> + delete; record-level operations are promptly consistent). +* E5 — rating/credit against a REAL >= 40-char semantic record id (the + only live credit against a genuine AWS record id). +* E6 — session_bootstrap + contextual_inject hooks reach AWS under the + onboarding config (well-formed envelopes; no hook_errors rows). +* E7 — session_close Stop hook fires exactly one closure + CreateEvent(role=OTHER) with settings.json only, verified by a direct + ``list_events`` readback on the throwaway episodic memory. + +What the live tier deliberately SKIPS (journey design section 5): + +* **Async reflection extraction is not promptly assertable** — AWS's + built-in episodicMemoryStrategy extracts reflections MINUTES after + ``observe``, so no live scenario asserts an observed fact surfaces in + ``memory.retrieve`` buckets or bootstrap counts; E3 asserts + ``observe -> retrieve_observations`` (raw events) instead, and E2(b)'s + "buckets are exactly empty for fresh memories" contract stands. +* **``record_use`` on an EXTRACTED reflection is not promptly testable** + (no reflections exist yet) — E5 credits a real SEMANTIC record instead, + created synchronously by ``semantic_observe``. +* **Cross-session ``list_observations`` is out of scope** — ListEvents + requires a sessionId; each live test pins a unique per-run session id + so readback sees exactly its own events. + +Region single-source (fix plan item 5): run E3-E7 with +``BETTER_MEMORY_TEST_AGENTCORE_REGION`` set to a NON-default region — the +region env var is deleted, so a factory regression that signs the default +region cross-regions the data plane and every call 404s. Live SigV4 region +is not directly observable client-side; the assertion is indirect via +request success. """ from __future__ import annotations @@ -36,11 +75,14 @@ import asyncio import json import os +import sqlite3 import subprocess import sys import uuid +from contextlib import closing from datetime import timedelta from pathlib import Path +from typing import Any import pytest @@ -50,8 +92,9 @@ load_agentcore_config, save_agentcore_config, ) +from tests.e2e._agentcore_env import write_backend_settings from tests.e2e._env import isolated_env -from tests.e2e.conftest import mcp_session, text_of +from tests.e2e.conftest import mcp_session, run_hook, text_of pytestmark = [pytest.mark.integration] @@ -290,42 +333,29 @@ async def test_live_mcp_retrieve_wired_path( ) -> None: """E2(b): real MCP server boot with real creds; ``memory.retrieve`` live. - memory.retrieve is the ONE MCP data tool actually wired to - AgentCoreBackend (dispatch gap, design section 4 item 2), so this is the - only end-to-end MCP-through-AWS path that exists. It fires the - per-polarity ListMemoryRecords fan-out (reflections namespace + - metadataFilters) against the real service — live filter/namespace - validation the T2 fakes structurally cannot see. Fresh throwaway - memories hold no reflections (extraction is minutes-async and the smoke - leg's records live under the ``smoke`` actor's namespaces), so the - buckets are exactly empty. + Runs under the exact ONBOARDING configuration `agentcore init` leaves + behind (fix plan section 4 item 1): agentcore.json + settings.json in + the child home, NO backend/region/id env vars — the backend resolves + from settings.json and the region is single-sourced from + agentcore.json (a non-default test region makes a factory regression + 404 loudly). It fires the per-polarity ListMemoryRecords fan-out + (reflections namespace + metadataFilters) against the real service — + live filter/namespace validation the T2 fakes structurally cannot see. + Fresh throwaway memories hold no reflections (extraction is + minutes-async and the smoke leg's records live under the ``smoke`` + actor's namespaces), so the buckets are exactly empty. """ - sem_record, epi_record = agentcore_throwaway_memories home = tmp_path / "home" home.mkdir() # isolated_env pins BETTER_MEMORY_HOME=/.better-memory; the factory - # loads agentcore.json from there. + # loads agentcore.json from there and settings.json activates the + # backend without any env var. bm_home = home / ".better-memory" _write_throwaway_config(agentcore_throwaway_memories, agentcore_region, bm_home) + write_backend_settings(bm_home) env = _live_env( home, - BETTER_MEMORY_STORAGE_BACKEND="agentcore", - # EXPLICIT region: the runtime factory signs with env - # BETTER_MEMORY_AGENTCORE_REGION (default eu-west-2), NOT - # agentcore.json's region — the region split-brain (design section 4 - # item 4, pinned by e2e-ac-region-split-brain-pin). Without this pin - # a non-default test region silently cross-regions the data plane. - BETTER_MEMORY_AGENTCORE_REGION=agentcore_region, - # FIXME(idvar-gate): config.py:293-301 requires both ID vars in - # agentcore mode but nothing consumes their values (IDs come from - # agentcore.json). Set to the REAL throwaway ids — never dummies in a - # live test, so if the product ever starts consuming them they still - # point at the right memories. Delete when the gate is fixed - # (together with tests/e2e/_agentcore_env.py's dummy vars and - # e2e-ac-neg-prehandshake-config-errors[idvar]). - BETTER_MEMORY_AGENTCORE_SEMANTIC_MEMORY_ID=sem_record.memory_id, - BETTER_MEMORY_AGENTCORE_EPISODIC_MEMORY_ID=epi_record.memory_id, # No dashes: actor ids derive from the project name. BETTER_MEMORY_PROJECT="bmintproj", CLAUDE_SESSION_ID=f"bm-int-{uuid.uuid4().hex[:8]}", @@ -385,3 +415,369 @@ def test_live_backend_observe_metadata_survives_roundtrip(agentcore_backend) -> # flattened back out of ListEvents — the AWS round-trip must preserve them. assert item.get("outcome") == "success" assert item.get("theme") == "integration" + + +# --------------------------------------------------------------------------- +# E3-E7 — live onboarding-config journey +# (2026-07-12-agentcore-journey-tests-design.md, section 3 T3 tier) +# --------------------------------------------------------------------------- + +#: Actor ids derive from BETTER_MEMORY_PROJECT — no dashes (see E2(b)). +_JOURNEY_PROJECT = "bmintproj" + + +def _onboarding_home( + tmp_path: Path, throwaway_memories: tuple, region: str +) -> tuple[Path, Path]: + """A child home in the exact state ``agentcore init`` leaves behind: + agentcore.json (the throwaway memories) + settings.json activation. + Returns ``(home, bm_home)``.""" + home = tmp_path / "home" + home.mkdir() + bm_home = home / ".better-memory" + _write_throwaway_config(throwaway_memories, region, bm_home) + write_backend_settings(bm_home) + return home, bm_home + + +def _journey_session_id() -> str: + """Unique per-run session id: ListEvents readback (current session + only) sees exactly this run's events.""" + return f"bm-int-{uuid.uuid4().hex[:8]}" + + +def _tool_json(result: Any) -> Any: + """Parse the single text content block of a successful tool call.""" + content = result.content + assert not getattr(result, "isError", False), f"tool errored: {content!r}" + assert len(content) == 1, f"expected one content block: {content!r}" + return json.loads(text_of(content[0])) + + +def _local_row_count(bm_home: Path, table: str) -> int: + """Rows in a local sqlite table; 0 when the file/table is absent.""" + db = bm_home / "memory.db" + if not db.exists(): + return 0 + with closing( + sqlite3.connect(f"file:{db.as_posix()}?mode=ro", uri=True) + ) as conn: + names = { + row[0] + for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall() + } + if table not in names: + return 0 + (count,) = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone() # noqa: S608 — table from test constants + return int(count) + + +async def test_live_e3_mcp_observe_retrieve_observations_roundtrip( + tmp_path: Path, agentcore_throwaway_memories, agentcore_region: str +) -> None: + """E3: the non-tautological MCP write path the dispatch wiring enables + (fix plan section 4 item 2): ``memory.observe`` over MCP returns a real + AWS eventId and ``memory.retrieve_observations`` (raw events — promptly + consistent) reads exactly that event back with content/outcome/theme + surviving the round-trip. Zero rows land in the child home's local + ``observations`` table. + + regression_caught: dispatch not wired → observe returns a local uuid + and writes a sqlite row; region single-sourcing broken → cross-region + 404 (when run in a non-default region). + """ + home, bm_home = _onboarding_home( + tmp_path, agentcore_throwaway_memories, agentcore_region + ) + env = _live_env( + home, + BETTER_MEMORY_PROJECT=_JOURNEY_PROJECT, + CLAUDE_SESSION_ID=_journey_session_id(), + ) + marker = f"bm-int-journey-{uuid.uuid4().hex[:8]}" + + errlog_path = tmp_path / "e3-server.stderr" # outside the fake home + with errlog_path.open("w", encoding="utf-8") as errlog: + async with mcp_session( + env, errlog=errlog, read_timeout=timedelta(seconds=90) + ) as session: + observed = _tool_json( + await session.call_tool( + "memory.observe", + {"content": marker, "outcome": "failure", "theme": "bug"}, + ) + ) + event_id = observed["id"] + assert isinstance(event_id, str) and event_id + + rows = _tool_json( + await session.call_tool( + "memory.retrieve_observations", {"query": marker} + ) + ) + matches = [r for r in rows if r.get("content") == marker] + assert len(matches) == 1, ( + f"expected exactly the marker event, got {len(matches)} " + f"matches among {len(rows)} rows" + ) + assert matches[0]["id"] == event_id + assert matches[0].get("outcome") == "failure" + assert matches[0].get("theme") == "bug" + + # Dispatch switched: nothing landed in local sqlite. + assert _local_row_count(bm_home, "observations") == 0 + + +async def test_live_e4_mcp_semantic_crud_roundtrip( + tmp_path: Path, agentcore_throwaway_memories, agentcore_region: str +) -> None: + """E4: full MCP semantic round-trip against real AWS (fix plan section + 4 item 3): observe → retrieve surfaces the record (project + general + UD-2 merge) → update changes the text → delete removes it. All + record-level operations are promptly consistent. Zero local + ``semantic_memories`` rows. + """ + home, bm_home = _onboarding_home( + tmp_path, agentcore_throwaway_memories, agentcore_region + ) + env = _live_env( + home, + BETTER_MEMORY_PROJECT=_JOURNEY_PROJECT, + CLAUDE_SESSION_ID=_journey_session_id(), + ) + marker = f"bm-int-sem-{uuid.uuid4().hex[:8]}" + + def _mine(items: list, record_id: str) -> list: + return [i for i in items if i.get("id") == record_id] + + errlog_path = tmp_path / "e4-server.stderr" + with errlog_path.open("w", encoding="utf-8") as errlog: + async with mcp_session( + env, errlog=errlog, read_timeout=timedelta(seconds=90) + ) as session: + created = _tool_json( + await session.call_tool( + "memory.semantic_observe", {"content": marker} + ) + ) + record_id = created["id"] + # A genuine AWS memoryRecordId (>= 40 chars) — not a local uuid. + assert isinstance(record_id, str) and len(record_id) >= 40 + + listed = _tool_json( + await session.call_tool("memory.semantic_retrieve", {}) + ) + mine = _mine(listed, record_id) + assert len(mine) == 1, f"record not surfaced: {listed!r}" + assert mine[0]["content"] == marker + # Stable payload keys under the UD-2 merge contract. + assert {"project", "scope", "created_at", "updated_at"} <= set(mine[0]) + + updated_marker = f"{marker}-v2" + assert _tool_json( + await session.call_tool( + "memory.semantic_update", + {"id": record_id, "content": updated_marker}, + ) + ) == {"ok": True} + re_listed = _tool_json( + await session.call_tool("memory.semantic_retrieve", {}) + ) + mine = _mine(re_listed, record_id) + assert len(mine) == 1 + assert mine[0]["content"] == updated_marker + + assert _tool_json( + await session.call_tool( + "memory.semantic_delete", {"id": record_id} + ) + ) == {"ok": True} + final = _tool_json( + await session.call_tool("memory.semantic_retrieve", {}) + ) + assert _mine(final, record_id) == [] + + assert _local_row_count(bm_home, "semantic_memories") == 0 + + +async def test_live_e5_rating_credits_real_semantic_record_id( + tmp_path: Path, agentcore_throwaway_memories, agentcore_region: str +) -> None: + """E5: the only live credit against a GENUINE >= 40-char AWS record id + (fix plan section 4 item 4): ``memory.apply_session_ratings`` with + class=cited performs the full-snapshot update (system + x-amz-agentcore-memory-* keys stripped — echoing them is a real AWS + 400). The record is created synchronously by ``semantic_observe`` — + reflections are extraction-async and not promptly creditable. + + Creates its own record (rather than reusing E4's) so the test is + order-independent; cleaned up via semantic_delete + the throwaway + memory teardown. + """ + home, _bm_home = _onboarding_home( + tmp_path, agentcore_throwaway_memories, agentcore_region + ) + env = _live_env( + home, + BETTER_MEMORY_PROJECT=_JOURNEY_PROJECT, + CLAUDE_SESSION_ID=_journey_session_id(), + ) + marker = f"bm-int-rate-{uuid.uuid4().hex[:8]}" + + errlog_path = tmp_path / "e5-server.stderr" + with errlog_path.open("w", encoding="utf-8") as errlog: + async with mcp_session( + env, errlog=errlog, read_timeout=timedelta(seconds=90) + ) as session: + record_id = _tool_json( + await session.call_tool( + "memory.semantic_observe", {"content": marker} + ) + )["id"] + assert isinstance(record_id, str) and len(record_id) >= 40 + + payload = _tool_json( + await session.call_tool( + "memory.apply_session_ratings", + { + "ratings": [ + {"kind": "semantic", "id": record_id, "class": "cited"} + ] + }, + ) + ) + assert payload["applied"] == 1, payload + assert payload["failed"] == 0, payload + + # Best-effort cleanup; teardown deletes the whole memory anyway. + await session.call_tool("memory.semantic_delete", {"id": record_id}) + + +def test_live_e6_bootstrap_and_inject_hooks_reach_aws( + tmp_path: Path, agentcore_throwaway_memories, agentcore_region: str +) -> None: + """E6: the SessionStart bootstrap hook and the contextual_inject + UserPromptSubmit hook both reach AWS under the onboarding config (fix + plan section 4 item 7): well-formed envelopes, the bootstrap summary + rendered from real ListMemoryRecords responses (counts are empty for + fresh memories — extraction is minutes-async), and NO hook_errors rows + (the hooks' failure paths record-and-degrade; a clean home proves the + AWS path ran). + """ + home, bm_home = _onboarding_home( + tmp_path, agentcore_throwaway_memories, agentcore_region + ) + session_id = _journey_session_id() + proj_dir = tmp_path / "proj" + proj_dir.mkdir() + env = _live_env( + home, + BETTER_MEMORY_PROJECT=_JOURNEY_PROJECT, + CLAUDE_SESSION_ID=session_id, + BETTER_MEMORY_CONTEXT_INJECT_MODE="userprompt", + ) + + rc, out, err = run_hook( + "better_memory.hooks.session_bootstrap", + {"source": "startup", "session_id": session_id, "cwd": str(proj_dir)}, + env, + ) + assert rc == 0, err + assert "Traceback" not in err + hso = json.loads(out)["hookSpecificOutput"] + assert hso["hookEventName"] == "SessionStart" + ctx = hso["additionalContext"] + # The AWS-rendered summary — the fallback directive would mean the + # backend path died and was swallowed. + assert "session bootstrap failed" not in ctx, ctx + assert "Reflections" in ctx + assert "Semantic memories:" in ctx + + rc, out, err = run_hook( + "better_memory.hooks.contextual_inject", + { + "hook_event_name": "UserPromptSubmit", + "prompt": "how do I deploy with docker compose", + "session_id": session_id, + "cwd": str(proj_dir), + }, + env, + ) + assert rc == 0, err + assert "Traceback" not in err + envelope = json.loads(out) + assert envelope["hookSpecificOutput"]["hookEventName"] == "UserPromptSubmit" + + # Neither hook recorded a swallowed failure. + assert _local_row_count(bm_home, "hook_errors") == 0 + assert _local_row_count(bm_home, "observations") == 0 + assert _local_row_count(bm_home, "semantic_memories") == 0 + + +def test_live_e7_session_close_closure_with_settings_only( + tmp_path: Path, agentcore_throwaway_memories, agentcore_region: str +) -> None: + """E7: the live version of hermetic J8 (fix plan section 4 item 6): + with settings.json only (no backend env var) the Stop hook fires + exactly one closure CreateEvent(role=OTHER) against the REAL episodic + memory — verified by a direct ``list_events`` readback on the unique + per-run session id — and writes the spool marker. The closure event + rides the throwaway memory's teardown. + """ + import boto3 + from botocore.config import Config as BotoConfig + + home, bm_home = _onboarding_home( + tmp_path, agentcore_throwaway_memories, agentcore_region + ) + session_id = _journey_session_id() + # The hook derives the closure actorId from the payload cwd's basename. + proj_dir = tmp_path / "bmintclose" + proj_dir.mkdir() + env = _live_env( + home, + BETTER_MEMORY_PROJECT=_JOURNEY_PROJECT, + CLAUDE_SESSION_ID=session_id, + ) + + rc, out, err = run_hook( + "better_memory.hooks.session_close", + { + "session_id": session_id, + "cwd": str(proj_dir), + "hook_event_name": "Stop", + }, + env, + ) + assert rc == 0, err + assert out == "" + assert "Traceback" not in err + + markers = sorted((bm_home / "spool").glob("*_session_end_*.json")) + assert len(markers) == 1, f"expected exactly one session_end marker: {markers}" + marker_body = json.loads(markers[0].read_text(encoding="utf-8")) + assert marker_body["event_type"] == "session_end" + # Closure succeeded → no hook_errors write → no memory.db at all. + assert not (bm_home / "memory.db").exists() + + # Ground truth: exactly one role=OTHER closure event landed on the real + # episodic memory under this run's unique session id. + _sem_record, epi_record = agentcore_throwaway_memories + data = boto3.client( + "bedrock-agentcore", + config=BotoConfig( + region_name=agentcore_region, + retries={"mode": "standard", "max_attempts": 5}, + ), + ) + events = data.list_events( + memoryId=epi_record.memory_id, + actorId="bmintclose", + sessionId=session_id, + includePayloads=True, + maxResults=10, + )["events"] + assert len(events) == 1, events + assert events[0]["payload"][0]["conversational"]["role"] == "OTHER" diff --git a/tests/mcp/test_handlers_remote.py b/tests/mcp/test_handlers_remote.py new file mode 100644 index 0000000..03be04a --- /dev/null +++ b/tests/mcp/test_handlers_remote.py @@ -0,0 +1,555 @@ +"""Handler-level dispatch tests for the agentcore ``remote`` branches. + +Each data-tool handler class takes an additive keyword-only +``remote: StorageBackend | None = None``. ``remote=None`` (the default) +must leave the sqlite service path byte-identical; a non-None remote +routes the data tools to the backend (spec Task 2, defect 2). + +Everything here uses mocked services / mocked backends — no sqlite file, +no wire. The e2e proof over real botocore serialization lives in +``tests/e2e/test_agentcore_t2.py``. +""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from better_memory.mcp.handlers import ( + EpisodeToolHandlers, + ObservationToolHandlers, + ReflectionToolHandlers, + SemanticToolHandlers, + SessionToolHandlers, +) + +#: >= 40 chars — botocore's client-side validation floor for memoryRecordId. +_RECORD_ID_40 = "refl-unit-" + "0" * 30 +assert len(_RECORD_ID_40) == 40 + + +def _payload(result: list[Any]) -> Any: + assert len(result) == 1 + text = getattr(result[0], "text", None) + assert isinstance(text, str) + return json.loads(text) + + +@pytest.fixture +def observations() -> MagicMock: + svc = MagicMock(name="ObservationService") + svc.create = AsyncMock(return_value="local-obs-1") + svc.list_observations = AsyncMock(return_value=[{"id": "local-obs-1"}]) + svc.record_use = MagicMock(return_value=None) + return svc + + +@pytest.fixture +def retention() -> MagicMock: + return MagicMock(name="RetentionService") + + +@pytest.fixture +def remote() -> MagicMock: + backend = MagicMock(name="AgentCoreBackend") + backend.observe = AsyncMock(return_value="evt-remote-1") + backend.list_observations = AsyncMock(return_value=[]) + backend.record_use = MagicMock(return_value=None) + backend.semantic_observe = MagicMock(return_value="sem-remote-1") + backend.semantic_list = MagicMock(return_value=[]) + backend.semantic_update_text = MagicMock(return_value=None) + backend.semantic_delete = MagicMock(return_value=None) + backend.credit_one = MagicMock(return_value={"applied": "x", "skipped": None}) + backend.apply_session_ratings = MagicMock(return_value={"applied": 1, "failed": 0}) + backend.list_session_exposures = MagicMock( + return_value={"session_id": "s", "exposures": []} + ) + backend.session_bootstrap = MagicMock( + return_value={ + "additional_context": "ctx-from-backend", + "project": "projx", + "source": "startup", + "episode_id": "sess-1", + "episode_action": "opened", + "semantic_count": 2, + "reflections_counts": {"do": 1, "dont": 0, "neutral": 0}, + } + ) + return backend + + +# --------------------------------------------------------------------------- +# ObservationToolHandlers +# --------------------------------------------------------------------------- + + +class TestObservationHandlersSqlitePath: + """remote omitted / None → the existing service path, unchanged.""" + + async def test_observe_defaults_to_service(self, observations, retention) -> None: + handlers = ObservationToolHandlers(observations=observations, retention=retention) + result = await handlers.observe({"content": "hello"}) + assert _payload(result) == {"id": "local-obs-1"} + observations.create.assert_awaited_once() + assert observations.create.await_args.kwargs["content"] == "hello" + + async def test_retrieve_observations_defaults_to_service( + self, observations, retention + ) -> None: + handlers = ObservationToolHandlers(observations=observations, retention=retention) + result = await handlers.retrieve_observations({"project": "projx"}) + assert _payload(result) == [{"id": "local-obs-1"}] + observations.list_observations.assert_awaited_once() + + async def test_record_use_defaults_to_service(self, observations, retention) -> None: + handlers = ObservationToolHandlers(observations=observations, retention=retention) + result = await handlers.record_use({"id": "short-local-id", "outcome": "success"}) + assert _payload(result) == {"ok": True} + observations.record_use.assert_called_once_with("short-local-id", outcome="success") + + +class TestObservationHandlersRemoteBranch: + async def test_observe_routes_to_remote_not_service( + self, observations, retention, remote + ) -> None: + handlers = ObservationToolHandlers( + observations=observations, retention=retention, remote=remote + ) + result = await handlers.observe( + { + "content": "hello-remote", + "component": "comp", + "theme": "bug", + "trigger_type": "review", + "outcome": "failure", + "tech": "python", + } + ) + assert _payload(result) == {"id": "evt-remote-1"} + observations.create.assert_not_awaited() + remote.observe.assert_awaited_once() + kwargs = remote.observe.await_args.kwargs + assert kwargs["content"] == "hello-remote" + assert kwargs["component"] == "comp" + assert kwargs["theme"] == "bug" + assert kwargs["trigger_type"] == "review" + assert kwargs["outcome"] == "failure" + assert kwargs["tech"] == "python" + assert kwargs["scope"] == "project" + + async def test_observe_remote_null_scope_defaults_to_project( + self, observations, retention, remote + ) -> None: + """{"scope": null} from an MCP client must coerce to 'project' — + same defence as the sqlite path (PR #25 BugBot finding).""" + handlers = ObservationToolHandlers( + observations=observations, retention=retention, remote=remote + ) + await handlers.observe({"content": "x", "scope": None}) + assert remote.observe.await_args.kwargs["scope"] == "project" + + async def test_retrieve_observations_routes_to_remote_and_serializes_datetimes( + self, observations, retention, remote + ) -> None: + """AgentCore events carry datetime event_timestamps (botocore-parsed); + the remote branch must serialize them instead of crashing json.dumps.""" + list_mock = AsyncMock( + return_value=[ + { + "id": "evt-1", + "content": "obs one", + "session_id": "s-1", + "actor_id": "projx", + "event_timestamp": datetime(2026, 7, 12, 10, 30, tzinfo=UTC), + "outcome": "success", + } + ] + ) + remote.list_observations = list_mock + handlers = ObservationToolHandlers( + observations=observations, retention=retention, remote=remote + ) + result = await handlers.retrieve_observations({"project": "projx", "limit": 7}) + rows = _payload(result) + assert rows[0]["id"] == "evt-1" + assert "2026" in rows[0]["event_timestamp"] + observations.list_observations.assert_not_awaited() + assert list_mock.await_args is not None + kwargs = list_mock.await_args.kwargs + assert kwargs["project"] == "projx" + assert kwargs["limit"] == 7 + + async def test_record_use_short_id_rejected_before_remote( + self, observations, retention, remote + ) -> None: + """Event ids returned by memory.observe are NOT ratable AgentCore + record ids; without this guard the backend stalls ~20s in its + transient-404 retry loop inside the serialized dispatch loop.""" + handlers = ObservationToolHandlers( + observations=observations, retention=retention, remote=remote + ) + with pytest.raises(ValueError, match="40"): + await handlers.record_use({"id": "evt-too-short", "outcome": "success"}) + remote.record_use.assert_not_called() + observations.record_use.assert_not_called() + + async def test_record_use_routes_to_remote_for_record_ids( + self, observations, retention, remote + ) -> None: + handlers = ObservationToolHandlers( + observations=observations, retention=retention, remote=remote + ) + result = await handlers.record_use({"id": _RECORD_ID_40, "outcome": "success"}) + assert _payload(result) == {"ok": True} + remote.record_use.assert_called_once_with(_RECORD_ID_40, outcome="success") + observations.record_use.assert_not_called() + + +# --------------------------------------------------------------------------- +# SemanticToolHandlers +# --------------------------------------------------------------------------- + + +class TestSemanticHandlersSqlitePath: + async def test_semantic_retrieve_defaults_to_service(self) -> None: + svc = MagicMock(name="SemanticMemoryService") + svc.list_for_project = MagicMock( + return_value=[ + SimpleNamespace( + id="sm-1", + content="fact", + project="projx", + scope="project", + created_at="2026-01-01T00:00:00Z", + updated_at="2026-01-01T00:00:00Z", + ) + ] + ) + handlers = SemanticToolHandlers(semantic=svc) + result = await handlers.semantic_retrieve({"project": "projx"}) + rows = _payload(result) + assert rows[0]["id"] == "sm-1" + assert rows[0]["project"] == "projx" + svc.list_for_project.assert_called_once_with(project="projx") + + +class TestSemanticHandlersRemoteBranch: + async def test_semantic_observe_routes_to_remote(self, remote) -> None: + svc = MagicMock(name="SemanticMemoryService") + handlers = SemanticToolHandlers(semantic=svc, remote=remote) + result = await handlers.semantic_observe({"content": "pref", "scope": "general"}) + assert _payload(result) == {"id": "sem-remote-1"} + svc.create.assert_not_called() + kwargs = remote.semantic_observe.call_args.kwargs + assert kwargs["content"] == "pref" + assert kwargs["scope"] == "general" + + async def test_semantic_retrieve_merges_project_and_general_with_stable_keys( + self, remote + ) -> None: + """UD-2: two backend calls (project namespace + general namespace) + merged, payload keeps the sqlite key set with None placeholders.""" + + def _semantic_list(**kwargs: Any) -> list[dict[str, Any]]: + if kwargs.get("scope_filter") == "general": + return [{"id": "g1", "content": "general fact", "scope": "general"}] + return [{"id": "p1", "content": "project fact", "scope": "project"}] + + remote.semantic_list = MagicMock(side_effect=_semantic_list) + svc = MagicMock(name="SemanticMemoryService") + handlers = SemanticToolHandlers(semantic=svc, remote=remote) + + result = await handlers.semantic_retrieve({"project": "projx"}) + rows = _payload(result) + assert [r["id"] for r in rows] == ["p1", "g1"] + for row in rows: + assert set(row) == { + "id", "content", "project", "scope", "created_at", "updated_at", + } + assert row["project"] is None + assert row["created_at"] is None + assert row["updated_at"] is None + scope_filters = [ + call.kwargs.get("scope_filter") + for call in remote.semantic_list.call_args_list + ] + assert scope_filters == [None, "general"] + svc.list_for_project.assert_not_called() + + async def test_semantic_retrieve_dedupes_duplicate_ids(self, remote) -> None: + remote.semantic_list = MagicMock( + return_value=[{"id": "dup-1", "content": "same", "scope": "general"}] + ) + handlers = SemanticToolHandlers(semantic=MagicMock(), remote=remote) + rows = _payload(await handlers.semantic_retrieve({})) + assert [r["id"] for r in rows] == ["dup-1"] + + async def test_semantic_update_routes_to_remote(self, remote) -> None: + svc = MagicMock(name="SemanticMemoryService") + handlers = SemanticToolHandlers(semantic=svc, remote=remote) + result = await handlers.semantic_update({"id": "sm-9", "content": "new"}) + assert _payload(result) == {"ok": True} + remote.semantic_update_text.assert_called_once_with(id="sm-9", content="new") + svc.update_text.assert_not_called() + + async def test_semantic_delete_routes_to_remote(self, remote) -> None: + svc = MagicMock(name="SemanticMemoryService") + handlers = SemanticToolHandlers(semantic=svc, remote=remote) + result = await handlers.semantic_delete({"id": "sm-9"}) + assert _payload(result) == {"ok": True} + remote.semantic_delete.assert_called_once_with(id="sm-9") + svc.delete.assert_not_called() + + +# --------------------------------------------------------------------------- +# SessionToolHandlers +# --------------------------------------------------------------------------- + + +def _bootstrap_service() -> MagicMock: + svc = MagicMock(name="SessionBootstrapService") + svc.bootstrap = MagicMock( + return_value=SimpleNamespace( + additional_context="ctx-from-service", + project="projx", + source="startup", + episode_id="ep-1", + episode_action="opened", + semantic_count=1, + reflections_counts={"do": 0, "dont": 0, "neutral": 0}, + ) + ) + svc.list_session_exposures = MagicMock( + return_value={"session_id": "sid", "exposures": []} + ) + return svc + + +class TestSessionHandlersSqlitePath: + async def test_session_bootstrap_defaults_to_service(self, tmp_path: Path) -> None: + svc = _bootstrap_service() + handlers = SessionToolHandlers( + session_bootstrap=svc, memory_rating=MagicMock(), home=tmp_path + ) + payload = _payload(await handlers.session_bootstrap({"source": "startup"})) + assert payload["additionalContext"] == "ctx-from-service" + assert payload["episode"] == {"id": "ep-1", "action": "opened"} + assert payload["counts"]["semantic"] == 1 + svc.bootstrap.assert_called_once() + + +class TestSessionHandlersRemoteBranch: + async def test_session_bootstrap_unwraps_backend_dict( + self, tmp_path: Path, remote + ) -> None: + """AgentCoreBackend.session_bootstrap returns a DICT (not the sqlite + BootstrapResult dataclass); the remote branch must key-access it — + naive attribute unwrap AttributeErrors.""" + svc = _bootstrap_service() + handlers = SessionToolHandlers( + session_bootstrap=svc, + memory_rating=MagicMock(), + home=tmp_path, + remote=remote, + ) + payload = _payload( + await handlers.session_bootstrap( + {"source": "startup", "session_id": "sess-1"} + ) + ) + assert payload["additionalContext"] == "ctx-from-backend" + assert payload["project"] == "projx" + assert payload["source"] == "startup" + assert payload["episode"] == {"id": "sess-1", "action": "opened"} + assert payload["counts"] == { + "semantic": 2, + "reflections": {"do": 1, "dont": 0, "neutral": 0}, + } + svc.bootstrap.assert_not_called() + kwargs = remote.session_bootstrap.call_args.kwargs + assert kwargs["session_id"] == "sess-1" + assert kwargs["source"] == "startup" + + async def test_list_session_exposures_routes_to_remote( + self, tmp_path: Path, remote, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("CLAUDE_SESSION_ID", "sid-env-1") + svc = _bootstrap_service() + handlers = SessionToolHandlers( + session_bootstrap=svc, + memory_rating=MagicMock(), + home=tmp_path, + remote=remote, + ) + payload = _payload(await handlers.list_session_exposures({})) + assert payload == {"session_id": "s", "exposures": []} + remote.list_session_exposures.assert_called_once_with(session_id="sid-env-1") + svc.list_session_exposures.assert_not_called() + + async def test_apply_session_ratings_routes_to_remote( + self, tmp_path: Path, remote, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("CLAUDE_SESSION_ID", "sid-env-1") + rating = MagicMock(name="MemoryRatingService") + handlers = SessionToolHandlers( + session_bootstrap=_bootstrap_service(), + memory_rating=rating, + home=tmp_path, + remote=remote, + ) + ratings = [{"kind": "reflection", "id": _RECORD_ID_40, "class": "cited"}] + payload = _payload(await handlers.apply_session_ratings({"ratings": ratings})) + assert payload == {"applied": 1, "failed": 0} + remote.apply_session_ratings.assert_called_once_with( + session_id="sid-env-1", ratings=ratings + ) + rating.apply_session_ratings.assert_not_called() + + async def test_apply_session_ratings_keeps_no_session_guard_with_remote( + self, tmp_path: Path, remote + ) -> None: + handlers = SessionToolHandlers( + session_bootstrap=_bootstrap_service(), + memory_rating=MagicMock(), + home=tmp_path, + remote=remote, + ) + with pytest.raises(ValueError, match="No active session"): + await handlers.apply_session_ratings({"ratings": []}) + remote.apply_session_ratings.assert_not_called() + + async def test_credit_routes_to_remote( + self, tmp_path: Path, remote, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("CLAUDE_SESSION_ID", "sid-env-1") + rating = MagicMock(name="MemoryRatingService") + handlers = SessionToolHandlers( + session_bootstrap=_bootstrap_service(), + memory_rating=rating, + home=tmp_path, + remote=remote, + ) + payload = _payload( + await handlers.credit( + {"kind": "semantic", "id": _RECORD_ID_40, "class": "cited"} + ) + ) + assert payload == {"applied": "x", "skipped": None} + remote.credit_one.assert_called_once_with( + session_id="sid-env-1", + kind="semantic", + id=_RECORD_ID_40, + classification="cited", + ) + rating.credit_one.assert_not_called() + + async def test_credit_keeps_no_session_guard_with_remote( + self, tmp_path: Path, remote + ) -> None: + handlers = SessionToolHandlers( + session_bootstrap=_bootstrap_service(), + memory_rating=MagicMock(), + home=tmp_path, + remote=remote, + ) + payload = _payload( + await handlers.credit( + {"kind": "semantic", "id": _RECORD_ID_40, "class": "cited"} + ) + ) + assert payload == {"applied": None, "skipped": "no_session"} + remote.credit_one.assert_not_called() + + +# --------------------------------------------------------------------------- +# ReflectionToolHandlers — memory.retrieve pre-hooks gating +# --------------------------------------------------------------------------- + + +def _reflection_handlers(tmp_path: Path, **extra: Any) -> tuple[Any, MagicMock]: + backend = MagicMock(name="StorageBackend") + backend.retrieve = MagicMock(return_value={"do": [], "dont": [], "neutral": []}) + spool = MagicMock(name="SpoolService") + handlers = ReflectionToolHandlers( + backend=backend, + reflections=MagicMock(name="ReflectionSynthesisService"), + spool=spool, + memory_conn=MagicMock(name="memory_conn"), + home=tmp_path, + **extra, + ) + return handlers, spool + + +class TestRetrievePreHooksGating: + async def test_sqlite_mode_drains_spool_and_runs_retention( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """remote=None → the existing best-effort pre-hooks still fire.""" + scheduler_cls = MagicMock(name="RetentionScheduler") + monkeypatch.setattr( + "better_memory.mcp.handlers.reflections.RetentionScheduler", scheduler_cls + ) + handlers, spool = _reflection_handlers(tmp_path) + result = await handlers.retrieve({}) + assert set(_payload(result)) == {"do", "dont", "neutral"} + spool.drain.assert_called_once() + scheduler_cls.assert_called_once() + + async def test_remote_mode_skips_spool_and_retention_pre_hooks( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, remote + ) -> None: + """remote set → agentcore mode stops mutating local episode / + retention rows on every memory.retrieve.""" + scheduler_cls = MagicMock(name="RetentionScheduler") + monkeypatch.setattr( + "better_memory.mcp.handlers.reflections.RetentionScheduler", scheduler_cls + ) + handlers, spool = _reflection_handlers(tmp_path, remote=remote) + result = await handlers.retrieve({}) + assert set(_payload(result)) == {"do", "dont", "neutral"} + spool.drain.assert_not_called() + scheduler_cls.assert_not_called() + + +# --------------------------------------------------------------------------- +# EpisodeToolHandlers — reconcile_episodes remote no-op +# --------------------------------------------------------------------------- + + +class TestReconcileEpisodes: + async def test_sqlite_mode_lists_open_prior_episodes(self) -> None: + episodes = MagicMock(name="EpisodeService") + episodes.unclosed_episodes = MagicMock(return_value=[]) + observations = MagicMock(name="ObservationService") + observations.session_id = "sid-1" + handlers = EpisodeToolHandlers( + episodes=episodes, + observations=observations, + reflections=MagicMock(), + backend=MagicMock(), + ) + assert _payload(await handlers.reconcile_episodes({})) == [] + episodes.unclosed_episodes.assert_called_once() + + async def test_remote_mode_returns_empty_without_touching_sqlite( + self, remote + ) -> None: + """Tool is hidden in agentcore mode but the handler stays registered + defensively — a direct call must not read stale local episodes.""" + episodes = MagicMock(name="EpisodeService") + handlers = EpisodeToolHandlers( + episodes=episodes, + observations=MagicMock(), + reflections=MagicMock(), + backend=MagicMock(), + remote=remote, + ) + assert _payload(await handlers.reconcile_episodes({})) == [] + episodes.unclosed_episodes.assert_not_called() diff --git a/tests/mcp/test_server_backend_dispatch.py b/tests/mcp/test_server_backend_dispatch.py index 9bdb8d0..6dc6a7d 100644 --- a/tests/mcp/test_server_backend_dispatch.py +++ b/tests/mcp/test_server_backend_dispatch.py @@ -1,11 +1,30 @@ -"""MCP server selects the right StorageBackend and gates synthesis tools.""" +"""MCP server selects the right StorageBackend and gates synthesis tools. + +Also covers the agentcore dispatch wiring (spec Task 2): ``create_server`` +passes ``remote=backend`` to the data-tool handlers iff +``config.storage_backend == "agentcore"`` — never on backend truthiness — +and gates the episode/retention tool surface via ``supports_episodes``. +""" from __future__ import annotations import asyncio +import json +import sqlite3 +from contextlib import closing +from pathlib import Path +from typing import Any import pytest +EPISODE_GATED_TOOLS = { + "memory.start_episode", + "memory.close_episode", + "memory.reconcile_episodes", + "memory.list_episodes", + "memory.run_retention", +} + def test_synthesis_tools_registered_when_capability_true() -> None: from better_memory.mcp.server import _tool_definitions @@ -46,3 +65,205 @@ def test_create_server_returns_three_tuple_with_backend( assert isinstance(ctx.backend, SqliteBackend) finally: asyncio.run(result[1]()) + + +# --------------------------------------------------------------------------- +# supports_episodes tool-list gate (UD-1) +# --------------------------------------------------------------------------- + + +def test_episode_and_retention_tools_registered_by_default() -> None: + """The new supports_episodes param must default True — every existing + registration test calls tool_definitions() with defaults.""" + from better_memory.mcp.server import _tool_definitions + + names = {t.name for t in _tool_definitions()} + assert EPISODE_GATED_TOOLS <= names + + +def test_episode_and_retention_tools_hidden_when_capability_false() -> None: + from better_memory.mcp.server import _tool_definitions + + names = {t.name for t in _tool_definitions(supports_episodes=False)} + assert not (EPISODE_GATED_TOOLS & names) + # The rest of the surface is untouched by the episode gate. + assert {"memory.observe", "memory.retrieve", "memory.semantic_observe"} <= names + + +def test_episode_gate_is_independent_of_synthesis_gate() -> None: + from better_memory.mcp.server import _tool_definitions + + names = { + t.name + for t in _tool_definitions(supports_synthesis=False, supports_episodes=False) + } + assert not (EPISODE_GATED_TOOLS & names) + assert "memory.synthesize_next_get_context" not in names + assert "memory.observe" in names + + +# --------------------------------------------------------------------------- +# create_server dispatch wiring (agentcore remote vs sqlite None) +# --------------------------------------------------------------------------- + + +class _StubRemoteBackend: + """Records data-tool calls; agentcore-shaped capability flags.""" + + supports_synthesis = False + supports_episodes = False + + def __init__(self) -> None: + self.observe_calls: list[dict[str, Any]] = [] + self.bootstrap_calls: list[dict[str, Any]] = [] + + async def observe(self, **kwargs: Any) -> str: + self.observe_calls.append(kwargs) + return "stub-evt-0001" + + def retrieve(self, **kwargs: Any) -> dict[str, list[dict[str, Any]]]: + return {"do": [], "dont": [], "neutral": []} + + def session_bootstrap(self, **kwargs: Any) -> dict[str, Any]: + self.bootstrap_calls.append(kwargs) + return { + "additional_context": "stub-ctx", + "project": "stub-proj", + "source": kwargs.get("source") or "", + "episode_id": kwargs.get("session_id") or "", + "episode_action": "opened", + "semantic_count": 0, + "reflections_counts": {"do": 0, "dont": 0, "neutral": 0}, + } + + +def _server_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + home = tmp_path / "bm" + (home / "knowledge-base").mkdir(parents=True) + monkeypatch.setenv("BETTER_MEMORY_HOME", str(home)) + monkeypatch.setenv("BETTER_MEMORY_EMBEDDINGS_BACKEND", "sqlite") + return home + + +async def _dispatch(server: Any, name: str, arguments: dict[str, Any]) -> Any: + """Drive one tool call through the wired server's CallTool handler.""" + from mcp.types import CallToolRequest, CallToolRequestParams, CallToolResult + + handler = server.request_handlers[CallToolRequest] + req = CallToolRequest( + method="tools/call", + params=CallToolRequestParams(name=name, arguments=arguments), + ) + result = await handler(req) + assert isinstance(result.root, CallToolResult) + return result.root + + +def _observation_row_count(home: Path) -> int: + with closing(sqlite3.connect(home / "memory.db")) as conn: + return conn.execute("SELECT COUNT(*) FROM observations").fetchone()[0] + + +async def test_agentcore_mode_wires_remote_into_data_handlers( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """storage_backend == 'agentcore' → handlers receive the backend as + ``remote``: memory.observe hits the backend and writes ZERO local rows.""" + home = _server_home(tmp_path, monkeypatch) + monkeypatch.setenv("BETTER_MEMORY_STORAGE_BACKEND", "agentcore") + + import better_memory.mcp.server as server_mod + + stub = _StubRemoteBackend() + monkeypatch.setattr(server_mod, "build_backend", lambda **kwargs: stub) + + server, cleanup, ctx = server_mod.create_server() + try: + assert ctx.backend is stub + result = await _dispatch(server, "memory.observe", {"content": "wired"}) + assert not result.isError + assert json.loads(result.content[0].text) == {"id": "stub-evt-0001"} + assert len(stub.observe_calls) == 1 + assert stub.observe_calls[0]["content"] == "wired" + assert _observation_row_count(home) == 0 + finally: + await cleanup() + + +async def test_agentcore_mode_session_bootstrap_unwraps_dict_over_dispatch( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _server_home(tmp_path, monkeypatch) + monkeypatch.setenv("BETTER_MEMORY_STORAGE_BACKEND", "agentcore") + + import better_memory.mcp.server as server_mod + + stub = _StubRemoteBackend() + monkeypatch.setattr(server_mod, "build_backend", lambda **kwargs: stub) + + server, cleanup, _ctx = server_mod.create_server() + try: + result = await _dispatch( + server, + "memory.session_bootstrap", + {"source": "startup", "session_id": "sess-77"}, + ) + assert not result.isError, result + payload = json.loads(result.content[0].text) + assert payload["additionalContext"] == "stub-ctx" + assert payload["episode"] == {"id": "sess-77", "action": "opened"} + assert payload["counts"]["semantic"] == 0 + assert len(stub.bootstrap_calls) == 1 + finally: + await cleanup() + + +async def test_agentcore_mode_hides_episode_and_synthesis_tools_at_list_time( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + from mcp.types import ListToolsRequest, ListToolsResult + + _server_home(tmp_path, monkeypatch) + monkeypatch.setenv("BETTER_MEMORY_STORAGE_BACKEND", "agentcore") + + import better_memory.mcp.server as server_mod + + monkeypatch.setattr( + server_mod, "build_backend", lambda **kwargs: _StubRemoteBackend() + ) + server, cleanup, _ctx = server_mod.create_server() + try: + handler = server.request_handlers[ListToolsRequest] + result = await handler(ListToolsRequest(method="tools/list")) + assert isinstance(result.root, ListToolsResult) + names = {t.name for t in result.root.tools} + assert not (EPISODE_GATED_TOOLS & names) + assert "memory.synthesize_next_get_context" not in names + assert {"memory.observe", "memory.retrieve", "memory.record_use"} <= names + finally: + await cleanup() + + +async def test_sqlite_mode_dispatch_never_routes_to_backend_object( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The branch predicate is the config STRING, not backend truthiness: + in sqlite mode a (stubbed) backend object must NOT receive observe — + the sqlite service path writes the local row exactly as before.""" + home = _server_home(tmp_path, monkeypatch) + monkeypatch.delenv("BETTER_MEMORY_STORAGE_BACKEND", raising=False) + + import better_memory.mcp.server as server_mod + + stub = _StubRemoteBackend() + monkeypatch.setattr(server_mod, "build_backend", lambda **kwargs: stub) + + server, cleanup, ctx = server_mod.create_server() + try: + assert ctx.backend is stub + result = await _dispatch(server, "memory.observe", {"content": "local-row"}) + assert not result.isError, result + assert stub.observe_calls == [] + assert _observation_row_count(home) == 1 + finally: + await cleanup() diff --git a/tests/storage/test_agentcore_persistence.py b/tests/storage/test_agentcore_persistence.py index 7527d00..fc5f4d6 100644 --- a/tests/storage/test_agentcore_persistence.py +++ b/tests/storage/test_agentcore_persistence.py @@ -9,10 +9,10 @@ from better_memory.storage.agentcore_persistence import ( AgentCoreConfig, + AgentCoreConfigError, MemoryRecord, load_agentcore_config, save_agentcore_config, - AgentCoreConfigError, ) @@ -46,10 +46,26 @@ def test_load_returns_none_when_file_missing(tmp_path: Path) -> None: assert load_agentcore_config(tmp_path) is None +def _assert_remediation(msg: str) -> None: + """Every AgentCoreConfigError must carry actionable remediation text.""" + assert "agentcore init" in msg + assert "--force" in msg + assert "re-linked by hand-editing" in msg + + def test_load_raises_on_corrupt_json(tmp_path: Path) -> None: (tmp_path / "agentcore.json").write_text("{not valid json", encoding="utf-8") - with pytest.raises(AgentCoreConfigError, match="parse"): + with pytest.raises(AgentCoreConfigError, match="parse") as excinfo: + load_agentcore_config(tmp_path) + _assert_remediation(str(excinfo.value)) + assert str(tmp_path / "agentcore.json") in str(excinfo.value) + + +def test_load_raises_on_non_object_json(tmp_path: Path) -> None: + (tmp_path / "agentcore.json").write_text('["not", "an", "object"]', encoding="utf-8") + with pytest.raises(AgentCoreConfigError, match="not a JSON object") as excinfo: load_agentcore_config(tmp_path) + _assert_remediation(str(excinfo.value)) def test_load_raises_on_unsupported_schema_version(tmp_path: Path) -> None: @@ -57,8 +73,14 @@ def test_load_raises_on_unsupported_schema_version(tmp_path: Path) -> None: json.dumps({"schema_version": 999, "region": "eu-west-2"}), encoding="utf-8", ) - with pytest.raises(AgentCoreConfigError, match="schema_version"): + with pytest.raises(AgentCoreConfigError, match="schema_version") as excinfo: load_agentcore_config(tmp_path) + msg = str(excinfo.value) + _assert_remediation(msg) + assert "unsupported schema_version=999" in msg + assert "expected 1" in msg + # Forward-compat hint: an unknown schema likely came from a newer release. + assert "newer better-memory" in msg def test_load_raises_on_missing_required_field(tmp_path: Path) -> None: @@ -66,5 +88,21 @@ def test_load_raises_on_missing_required_field(tmp_path: Path) -> None: json.dumps({"schema_version": 1, "region": "eu-west-2"}), # semantic + episodic missing encoding="utf-8", ) - with pytest.raises(AgentCoreConfigError, match="semantic"): + with pytest.raises(AgentCoreConfigError, match="semantic") as excinfo: + load_agentcore_config(tmp_path) + _assert_remediation(str(excinfo.value)) + + +def test_load_raises_on_malformed_memory_block(tmp_path: Path) -> None: + (tmp_path / "agentcore.json").write_text( + json.dumps({ + "schema_version": 1, + "region": "eu-west-2", + "semantic": {"memory_id": "only-this"}, + "episodic": {"memory_id": "only-this"}, + }), + encoding="utf-8", + ) + with pytest.raises(AgentCoreConfigError, match="malformed") as excinfo: load_agentcore_config(tmp_path) + _assert_remediation(str(excinfo.value)) diff --git a/tests/storage/test_agentcore_unit.py b/tests/storage/test_agentcore_unit.py index 4cde7c2..7d9c567 100644 --- a/tests/storage/test_agentcore_unit.py +++ b/tests/storage/test_agentcore_unit.py @@ -203,20 +203,63 @@ async def test_observe_drops_none_metadata_keys(backend, mock_data_client) -> No assert metadata["theme"]["stringValue"] == "bug" -@pytest.mark.asyncio -async def test_observe_raises_value_error_when_session_id_is_none(ac_config, mock_data_client, mock_control_client) -> None: - """CreateEvent on the episodic memory requires sessionId (per the - output schema and our usage pattern). A backend with session_id=None - cannot fire events — raise so the operator sees the misconfiguration.""" - backend = AgentCoreBackend( +@pytest.fixture +def backend_without_session( + ac_config, mock_data_client, mock_control_client +) -> AgentCoreBackend: + return AgentCoreBackend( config=ac_config, data_client=mock_data_client, control_client=mock_control_client, session_id=None, project="testproj", ) + + +@pytest.mark.asyncio +async def test_observe_raises_when_session_id_unresolvable( + backend_without_session, mock_data_client +) -> None: + """session_id=None triggers lazy re-resolution (env var → SessionStart + marker under $BETTER_MEMORY_HOME). With neither available observe must + still raise — and make ZERO wire calls. (The autouse conftest fixtures + strip CLAUDE_*SESSION_ID and pin BETTER_MEMORY_HOME to an empty tmp + dir, so nothing resolves here.)""" with pytest.raises(ValueError, match="session_id"): - await backend.observe(content="x") + await backend_without_session.observe(content="x") + mock_data_client.create_event.assert_not_called() + + +@pytest.mark.asyncio +async def test_observe_lazily_resolves_session_id_from_env( + backend_without_session, mock_data_client, monkeypatch: pytest.MonkeyPatch +) -> None: + """The MCP server may spawn before CLAUDE_SESSION_ID / the marker + exists; a backend frozen at session_id=None must re-resolve at first + observe instead of raising forever.""" + monkeypatch.setenv("CLAUDE_SESSION_ID", "env-resolved-session") + mock_data_client.create_event.return_value = {"event": {"eventId": "evt-x"}} + result = await backend_without_session.observe(content="x") + assert result == "evt-x" + kwargs = mock_data_client.create_event.call_args.kwargs + assert kwargs["sessionId"] == "env-resolved-session" + + +@pytest.mark.asyncio +async def test_observe_lazily_resolves_session_id_from_marker( + backend_without_session, mock_data_client, monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + """Marker-file fallback: the SessionStart hook writes the marker after + the server spawns; observe picks it up on first use.""" + from better_memory.runtime.session_marker import write_session_id + + bm_home = tmp_path / "bm-marker-home" + monkeypatch.setenv("BETTER_MEMORY_HOME", str(bm_home)) + write_session_id(bm_home, "marker-resolved-session") + mock_data_client.create_event.return_value = {"event": {"eventId": "evt-x"}} + await backend_without_session.observe(content="x") + kwargs = mock_data_client.create_event.call_args.kwargs + assert kwargs["sessionId"] == "marker-resolved-session" @pytest.mark.asyncio @@ -277,16 +320,25 @@ async def test_list_observations_returns_empty_when_no_events(backend, mock_data @pytest.mark.asyncio -async def test_list_observations_raises_when_session_id_is_none(ac_config, mock_data_client, mock_control_client) -> None: - backend = AgentCoreBackend( - config=ac_config, - data_client=mock_data_client, - control_client=mock_control_client, - session_id=None, - project="testproj", - ) +async def test_list_observations_raises_when_session_id_unresolvable( + backend_without_session, mock_data_client +) -> None: + """Same lazy re-resolution contract as observe: env → marker → raise + with zero wire calls when nothing resolves.""" with pytest.raises(ValueError, match="session_id"): - await backend.list_observations(limit=5) + await backend_without_session.list_observations(limit=5) + mock_data_client.list_events.assert_not_called() + + +@pytest.mark.asyncio +async def test_list_observations_lazily_resolves_session_id_from_env( + backend_without_session, mock_data_client, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("CLAUDE_SESSION_ID", "env-resolved-session") + mock_data_client.list_events.return_value = {"events": []} + assert await backend_without_session.list_observations(limit=5) == [] + kwargs = mock_data_client.list_events.call_args.kwargs + assert kwargs["sessionId"] == "env-resolved-session" def test_retrieve_returns_dict_with_polarity_buckets(backend, mock_data_client) -> None: diff --git a/tests/storage/test_factory.py b/tests/storage/test_factory.py index 0e28e39..2824fa6 100644 --- a/tests/storage/test_factory.py +++ b/tests/storage/test_factory.py @@ -2,13 +2,18 @@ from __future__ import annotations +import json import sqlite3 +import sys +from pathlib import Path +from types import ModuleType +from typing import Any, cast from unittest.mock import MagicMock import pytest import sqlite_vec -from better_memory.storage import StorageBackend, SqliteBackend +from better_memory.storage import SqliteBackend, StorageBackend from better_memory.storage.factory import build_backend @@ -20,13 +25,37 @@ def _config(**overrides): @dataclass(frozen=True) class FakeConfig: storage_backend: str = "sqlite" - agentcore_region: str = "eu-west-2" - agentcore_semantic_memory_id: str | None = None - agentcore_episodic_memory_id: str | None = None return FakeConfig(**overrides) +def _write_agentcore_json(home: Path, region: str = "eu-west-2") -> None: + """Write a schema-valid agentcore.json into ``home``.""" + (home / "agentcore.json").write_text( + json.dumps({ + "schema_version": 1, + "region": region, + "semantic": { + "memory_id": "mem-sem-abc1234567", + "memory_arn": f"arn:aws:bedrock-agentcore:{region}:123:memory/mem-sem-abc1234567", + "memory_name": "better-memory-semantic", + "strategy_id": "userPreference-zXy1234567", + "strategy_name": "userPreference", + "event_expiry_duration_days": 365, + }, + "episodic": { + "memory_id": "mem-epi-def4567890", + "memory_arn": f"arn:aws:bedrock-agentcore:{region}:123:memory/mem-epi-def4567890", + "memory_name": "better-memory-episodic", + "strategy_id": "episodicReflections-qPr9876543", + "strategy_name": "episodicReflections", + "event_expiry_duration_days": 90, + }, + }), + encoding="utf-8", + ) + + @pytest.fixture def memory_conn() -> sqlite3.Connection: """An in-memory sqlite connection with sqlite-vec loaded and migrations applied.""" @@ -70,39 +99,11 @@ def test_build_backend_raises_for_unknown(memory_conn) -> None: def test_build_backend_returns_agentcore_when_config_loaded(tmp_path, monkeypatch) -> None: - """With agentcore.json present + valid memory IDs, factory returns AgentCoreBackend.""" - import json - home = tmp_path - (home / "agentcore.json").write_text( - json.dumps({ - "schema_version": 1, - "region": "eu-west-2", - "semantic": { - "memory_id": "mem-sem-abc1234567", - "memory_arn": "arn:aws:bedrock-agentcore:eu-west-2:123:memory/mem-sem-abc1234567", - "memory_name": "better-memory-semantic", - "strategy_id": "userPreference-zXy1234567", - "strategy_name": "userPreference", - "event_expiry_duration_days": 365, - }, - "episodic": { - "memory_id": "mem-epi-def4567890", - "memory_arn": "arn:aws:bedrock-agentcore:eu-west-2:123:memory/mem-epi-def4567890", - "memory_name": "better-memory-episodic", - "strategy_id": "episodicReflections-qPr9876543", - "strategy_name": "episodicReflections", - "event_expiry_duration_days": 90, - }, - }), - encoding="utf-8", - ) - monkeypatch.setenv("BETTER_MEMORY_HOME", str(home)) + """With agentcore.json present, factory returns AgentCoreBackend.""" + _write_agentcore_json(tmp_path) + monkeypatch.setenv("BETTER_MEMORY_HOME", str(tmp_path)) - cfg = _config( - storage_backend="agentcore", - agentcore_semantic_memory_id="mem-sem-abc1234567", - agentcore_episodic_memory_id="mem-epi-def4567890", - ) + cfg = _config(storage_backend="agentcore") from better_memory.storage.agentcore import AgentCoreBackend backend = build_backend( @@ -118,11 +119,7 @@ def test_build_backend_returns_agentcore_when_config_loaded(tmp_path, monkeypatc def test_build_backend_agentcore_raises_when_config_missing(tmp_path, monkeypatch) -> None: """Without agentcore.json, factory raises — operator must run `agentcore init`.""" monkeypatch.setenv("BETTER_MEMORY_HOME", str(tmp_path)) - cfg = _config( - storage_backend="agentcore", - agentcore_semantic_memory_id="mem-sem-abc1234567", - agentcore_episodic_memory_id="mem-epi-def4567890", - ) + cfg = _config(storage_backend="agentcore") with pytest.raises(FileNotFoundError, match="agentcore.json"): build_backend( config=cfg, @@ -131,3 +128,68 @@ def test_build_backend_agentcore_raises_when_config_missing(tmp_path, monkeypatc session_id="s", project="p", ) + + +def test_build_backend_agentcore_clients_signed_with_json_region( + tmp_path, monkeypatch +) -> None: + """Both boto3 clients are configured with agentcore.json's region. + + Region is single-sourced from agentcore.json (the split-brain fix): + the factory must read ``ac_cfg.region``, never an env var or a Config + field. Uses a non-default region so a revert to any default cannot pass. + """ + _write_agentcore_json(tmp_path, region="us-east-1") + monkeypatch.setenv("BETTER_MEMORY_HOME", str(tmp_path)) + + import boto3 + + captured: list[tuple[str, Any]] = [] + + def _fake_client(service_name: str, config: Any = None) -> Any: + captured.append((service_name, config)) + return MagicMock() + + monkeypatch.setattr(boto3, "client", _fake_client) + + from better_memory.storage.agentcore import AgentCoreBackend + backend = build_backend( + config=_config(storage_backend="agentcore"), + memory_conn=None, + embedder=None, + session_id="s", + project="p", + ) + assert isinstance(backend, AgentCoreBackend) + assert [service for service, _ in captured] == [ + "bedrock-agentcore", + "bedrock-agentcore-control", + ] + for service, boto_config in captured: + assert boto_config is not None, service + assert boto_config.region_name == "us-east-1", service + + +def test_build_backend_agentcore_missing_boto3_raises_install_hint( + tmp_path, monkeypatch +) -> None: + """Missing boto3 surfaces a ModuleNotFoundError with the extras install + hint, chained from the original ImportError (class + chain preserved so + existing except ImportError / traceback expectations still hold).""" + _write_agentcore_json(tmp_path) + monkeypatch.setenv("BETTER_MEMORY_HOME", str(tmp_path)) + # None in sys.modules makes `import boto3` raise ImportError. + monkeypatch.setitem(sys.modules, "boto3", cast(ModuleType, None)) + + with pytest.raises(ModuleNotFoundError) as excinfo: + build_backend( + config=_config(storage_backend="agentcore"), + memory_conn=None, + embedder=None, + session_id="s", + project="p", + ) + msg = str(excinfo.value) + assert "better-memory[agentcore]" in msg + assert "pip install" in msg + assert isinstance(excinfo.value.__cause__, ImportError) diff --git a/tests/test_config.py b/tests/test_config.py index fea5dfc..4e962dd 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -315,14 +315,15 @@ def test_storage_backend_defaults_to_sqlite(monkeypatch: pytest.MonkeyPatch) -> def test_storage_backend_agentcore_when_env_set(monkeypatch: pytest.MonkeyPatch) -> None: + """The env var alone selects agentcore — no memory-id env vars required. + + The vestigial idvar gate (BETTER_MEMORY_AGENTCORE_{SEMANTIC,EPISODIC}_MEMORY_ID) + is deleted; runtime memory ids come exclusively from agentcore.json via the + storage factory. + """ monkeypatch.setenv("BETTER_MEMORY_STORAGE_BACKEND", "agentcore") - monkeypatch.setenv("BETTER_MEMORY_AGENTCORE_SEMANTIC_MEMORY_ID", "mem-sem-abc1234567") - monkeypatch.setenv("BETTER_MEMORY_AGENTCORE_EPISODIC_MEMORY_ID", "mem-epi-xyz1234567") cfg = get_config() assert cfg.storage_backend == "agentcore" - assert cfg.agentcore_region == "eu-west-2" - assert cfg.agentcore_semantic_memory_id == "mem-sem-abc1234567" - assert cfg.agentcore_episodic_memory_id == "mem-epi-xyz1234567" def test_storage_backend_unknown_value_raises(monkeypatch: pytest.MonkeyPatch) -> None: @@ -331,48 +332,125 @@ def test_storage_backend_unknown_value_raises(monkeypatch: pytest.MonkeyPatch) - get_config() -def test_agentcore_mode_without_memory_ids_raises(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("BETTER_MEMORY_STORAGE_BACKEND", "agentcore") - monkeypatch.delenv("BETTER_MEMORY_AGENTCORE_SEMANTIC_MEMORY_ID", raising=False) - monkeypatch.delenv("BETTER_MEMORY_AGENTCORE_EPISODIC_MEMORY_ID", raising=False) - with pytest.raises(ValueError, match="agentcore init"): - get_config() +# --------------------------------------------------------------------------- +# $BETTER_MEMORY_HOME/settings.json storage_backend resolution +# --------------------------------------------------------------------------- + + +def _pin_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + """Point BETTER_MEMORY_HOME at a fresh tmp dir and return it.""" + home = tmp_path / "bm" + home.mkdir() + monkeypatch.setenv("BETTER_MEMORY_HOME", str(home)) + return home + + +def _write_settings(home: Path, text: str) -> None: + (home / "settings.json").write_text(text, encoding="utf-8") + + +def test_storage_backend_no_env_no_settings_defaults_to_sqlite( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Byte-identical default: no env var, no settings.json → sqlite.""" + _pin_home(monkeypatch, tmp_path) + monkeypatch.delenv("BETTER_MEMORY_STORAGE_BACKEND", raising=False) + assert get_config().storage_backend == "sqlite" + + +def test_storage_backend_settings_file_selects_agentcore( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """settings.json {"storage_backend": "agentcore"} + no env → agentcore.""" + home = _pin_home(monkeypatch, tmp_path) + monkeypatch.delenv("BETTER_MEMORY_STORAGE_BACKEND", raising=False) + _write_settings(home, '{"storage_backend": "agentcore"}') + assert get_config().storage_backend == "agentcore" @pytest.mark.parametrize( - "set_var,unset_var", + "env_value,file_value,expected", [ - ( - "BETTER_MEMORY_AGENTCORE_SEMANTIC_MEMORY_ID", - "BETTER_MEMORY_AGENTCORE_EPISODIC_MEMORY_ID", - ), - ( - "BETTER_MEMORY_AGENTCORE_EPISODIC_MEMORY_ID", - "BETTER_MEMORY_AGENTCORE_SEMANTIC_MEMORY_ID", - ), + ("sqlite", "agentcore", "sqlite"), + ("agentcore", "sqlite", "agentcore"), ], ) -def test_agentcore_mode_with_only_one_memory_id_raises( - monkeypatch: pytest.MonkeyPatch, set_var: str, unset_var: str +def test_storage_backend_env_wins_over_settings( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + env_value: str, + file_value: str, + expected: str, ) -> None: - """Setting only one of the two memory IDs still trips the cross-field check. + """BETTER_MEMORY_STORAGE_BACKEND beats settings.json in both directions.""" + home = _pin_home(monkeypatch, tmp_path) + monkeypatch.setenv("BETTER_MEMORY_STORAGE_BACKEND", env_value) + _write_settings(home, f'{{"storage_backend": "{file_value}"}}') + assert get_config().storage_backend == expected - The validation uses ``or``, so each side of the disjunction needs coverage. - """ - monkeypatch.setenv("BETTER_MEMORY_STORAGE_BACKEND", "agentcore") - monkeypatch.setenv(set_var, "mem-only-one-1234567") - monkeypatch.delenv(unset_var, raising=False) - with pytest.raises(ValueError, match="agentcore init"): + +def test_storage_backend_settings_without_key_defaults_to_sqlite( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A settings.json without a storage_backend key falls back to sqlite.""" + home = _pin_home(monkeypatch, tmp_path) + monkeypatch.delenv("BETTER_MEMORY_STORAGE_BACKEND", raising=False) + _write_settings(home, '{"unrelated": true}') + assert get_config().storage_backend == "sqlite" + + +def test_storage_backend_settings_malformed_json_raises_naming_file( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + home = _pin_home(monkeypatch, tmp_path) + monkeypatch.delenv("BETTER_MEMORY_STORAGE_BACKEND", raising=False) + _write_settings(home, "{not valid json") + with pytest.raises(ValueError, match="settings.json") as excinfo: get_config() + msg = str(excinfo.value) + assert str(home / "settings.json") in msg + assert "BETTER_MEMORY_STORAGE_BACKEND" in msg # remediation: env override -def test_agentcore_region_override(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("BETTER_MEMORY_STORAGE_BACKEND", "agentcore") - monkeypatch.setenv("BETTER_MEMORY_AGENTCORE_REGION", "us-west-2") - monkeypatch.setenv("BETTER_MEMORY_AGENTCORE_SEMANTIC_MEMORY_ID", "mem-sem-abc1234567") - monkeypatch.setenv("BETTER_MEMORY_AGENTCORE_EPISODIC_MEMORY_ID", "mem-epi-xyz1234567") - cfg = get_config() - assert cfg.agentcore_region == "us-west-2" +def test_storage_backend_settings_non_object_raises_naming_file( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + home = _pin_home(monkeypatch, tmp_path) + monkeypatch.delenv("BETTER_MEMORY_STORAGE_BACKEND", raising=False) + _write_settings(home, '["storage_backend"]') + with pytest.raises(ValueError, match="settings.json"): + get_config() + + +def test_storage_backend_settings_invalid_value_raises_naming_file( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Invalid storage_backend in settings.json errors symmetrically to the env var.""" + home = _pin_home(monkeypatch, tmp_path) + monkeypatch.delenv("BETTER_MEMORY_STORAGE_BACKEND", raising=False) + _write_settings(home, '{"storage_backend": "bogus"}') + with pytest.raises(ValueError, match="storage_backend") as excinfo: + get_config() + msg = str(excinfo.value) + assert str(home / "settings.json") in msg + assert "'bogus'" in msg + assert "sqlite" in msg and "agentcore" in msg # valid values listed + + +def test_resolve_storage_backend_public_helper_re_resolves_per_call( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """resolve_storage_backend() is exported for hooks and never memoized — + editing settings.json between calls takes effect immediately.""" + from better_memory.config import resolve_storage_backend + + home = _pin_home(monkeypatch, tmp_path) + monkeypatch.delenv("BETTER_MEMORY_STORAGE_BACKEND", raising=False) + assert resolve_storage_backend() == "sqlite" + _write_settings(home, '{"storage_backend": "agentcore"}') + assert resolve_storage_backend() == "agentcore" + _write_settings(home, '{"storage_backend": "sqlite"}') + assert resolve_storage_backend() == "sqlite" def test_project_name_caches_negative_lookup(tmp_path: Path) -> None: diff --git a/website/agentcore-setup.md b/website/agentcore-setup.md index 0c522fd..f93082d 100644 --- a/website/agentcore-setup.md +++ b/website/agentcore-setup.md @@ -4,7 +4,7 @@ ## Prerequisites -- AWS account with Bedrock AgentCore Memory enabled in `eu-west-2`. +- AWS account with Bedrock AgentCore Memory available in your chosen region (`init --region` defaults to `eu-west-2`). - IAM principal (user or role) with the policy below attached. - AWS credentials discoverable by boto3 (env vars, `~/.aws/credentials`, EC2/EKS role, etc.). - `better-memory[agentcore]` installed: `pip install 'better-memory[agentcore]'` or `uv pip install '.[agentcore]'`. @@ -43,13 +43,23 @@ For tighter scoping, restrict `Resource` to the two memory ARNs after `init` wri ## Initialise ```bash -export BETTER_MEMORY_STORAGE_BACKEND=agentcore -better-memory agentcore init +better-memory agentcore init --region ``` -The `init` command creates two AgentCore memories — one for episodic reflections, one for semantic preferences — and writes their IDs to `$BETTER_MEMORY_HOME/agentcore.json`. Creation takes 90-115 seconds per memory; progress prints every 5 seconds so you can confirm the process isn't hung. +That's the whole activation — no environment variable to export. The `init` command: -After `init` returns, restart your Claude Code session (or the MCP server). The MCP server now reads `agentcore.json` and constructs an `AgentCoreBackend` instead of `SqliteBackend`. +1. Creates two AgentCore memories — one for episodic reflections, one for semantic preferences. Creation takes 90-115 seconds per memory; progress prints every 5 seconds so you can confirm the process isn't hung. +2. Writes their IDs **and the region** to `$BETTER_MEMORY_HOME/agentcore.json`. That file's region is the single source of truth for every client better-memory builds — the MCP server's data/control clients and the Stop hook's closure client all sign against it. (`--region` defaults to `eu-west-2`.) +3. Activates the backend by writing `{"storage_backend": "agentcore"}` into `$BETTER_MEMORY_HOME/settings.json` (merged into any existing keys, written atomically). The MCP server, all hooks, and the CLI resolve the backend as: `BETTER_MEMORY_STORAGE_BACKEND` env var if set → else `settings.json` → else `sqlite`. The env var always wins, so if you have it exported to `sqlite` somewhere, unset it or set it to `agentcore`. + +To provision without activating (scripting), pass `--no-activate`: agentcore.json is written but the backend selection is unchanged. + +To revert to sqlite: remove the `storage_backend` key from `settings.json`, or set `BETTER_MEMORY_STORAGE_BACKEND=sqlite`. + +After `init` returns, restart your Claude Code session (or the MCP server) so it picks up the new backend. The MCP server reads `settings.json`, then `agentcore.json`, and constructs an `AgentCoreBackend` instead of `SqliteBackend`. + +!!! note "Custom `BETTER_MEMORY_HOME`" + The installer injects `BETTER_MEMORY_HOME` only into the MCP server's env block in `~/.claude.json`; hooks run without it and fall back to `~/.better-memory`. If you use a custom home, run `init` against it (`--home` or the env var) **and** make sure hooks can see the same home — otherwise the Stop hook looks for `~/.better-memory/settings.json` and stays on sqlite. ## Verify @@ -57,21 +67,20 @@ After `init` returns, restart your Claude Code session (or the MCP server). The better-memory agentcore status ``` -Should print `ACTIVE` for both memories. If you see `CREATING`, wait a minute and re-run. +Prints an `effective backend: (source: env|settings|default)` line — confirm it says `agentcore` — then per-memory state. Should print `ACTIVE` for both memories. If you see `CREATING`, wait a minute and re-run. If the effective backend says `sqlite (source: env)`, a `BETTER_MEMORY_STORAGE_BACKEND` env var is overriding your settings.json. ```bash better-memory agentcore smoke ``` -Drives a minimal observe → list_events → batch_create → list_records → batch_delete cycle. Exit 0 means the round-trip works end-to-end. This is the recommended ops check after any region or credential change. +Drives a minimal observe → list_events → batch_create → list_records → batch_delete cycle. Exit 0 means the round-trip works end-to-end. Smoke validates AWS credentials and wire access, not MCP registration. This is the recommended ops check after any region or credential change. ## What changes in agentcore mode -- **No SQLite traffic.** The MCP server doesn't open `memory.db` and doesn't run synthesis (AgentCore's built-in episodic strategy handles extraction). -- **`memory.synthesize_next_*` tools are not registered** — the strategy extracts in the cloud on its own ~15-20 minute cadence (~1-3 minutes after a closure event). -- **`pending_synthesis` is omitted from `memory.start_episode`'s response** — there's no local pending queue. -- **Closure events fire automatically.** The Stop hook emits a `CreateEvent(role=OTHER)` against the current AgentCore session, which tells the episodic strategy "extract now". Failure is logged but never blocks the hook. -- **Episode lifecycle methods are no-ops.** AgentCore manages event grouping via `sessionId`; better-memory's episodes table has no equivalent. +- **Memory data lives in AWS.** Observations, reflections, semantic memories, and reinforcement all go to Bedrock AgentCore — `memory.observe`, `memory.retrieve`, `memory.retrieve_observations`, `memory.record_use`, the four `memory.semantic_*` tools, the rating tools (`memory.credit`, `memory.apply_session_ratings`, `memory.list_session_exposures`), and `memory.session_bootstrap` all dispatch to the AgentCore backend. A local `memory.db` is still created for hook-error logging and `knowledge.db` for knowledge tools; no memory content is stored in them. +- **`memory.synthesize_next_*` tools are not registered** — the built-in episodic strategy extracts in the cloud on its own ~15-20 minute cadence (~1-3 minutes after a closure event). +- **Episode and retention tools are not registered.** `memory.start_episode`, `memory.close_episode`, `memory.reconcile_episodes`, `memory.list_episodes`, and `memory.run_retention` are hidden from the advertised tool list — AgentCore manages event grouping via `sessionId` and applies its own event expiry, so better-memory's local episode/retention machinery has no equivalent. +- **Closure events fire automatically.** The Stop hook resolves the backend the same way the server does (env var, else `settings.json`) and, when it resolves to agentcore, emits a `CreateEvent(role=OTHER)` against the current AgentCore session, which tells the episodic strategy "extract now". Failure is logged but never blocks the hook. The mere existence of `agentcore.json` does **not** activate this — only the env var or `settings.json` does. See [Architecture > Storage backends](architecture.md#storage-backends) for the data-flow diagram. diff --git a/website/architecture.md b/website/architecture.md index 34d9dc4..b809740 100644 --- a/website/architecture.md +++ b/website/architecture.md @@ -1,6 +1,6 @@ # Architecture -better-memory is a four-layer epistemic hierarchy backed by a single SQLite database, with embeddings supplied by one of two pluggable backends (Ollama, or an in-SQL trigram-FTS5 fusion). +better-memory is a four-layer epistemic hierarchy backed by a pluggable storage backend — a single SQLite database by default — with embeddings supplied by one of two pluggable backends (Ollama, or an in-SQL trigram-FTS5 fusion). ## The four layers @@ -19,27 +19,28 @@ better-memory is a four-layer epistemic hierarchy backed by a single SQLite data ## Storage backends -better-memory abstracts persistence behind the `StorageBackend` protocol (`better_memory/storage/protocol.py`). At server startup, the factory (`better_memory/storage/factory.py`) selects an implementation based on `BETTER_MEMORY_STORAGE_BACKEND`: +better-memory abstracts persistence behind the `StorageBackend` protocol (`better_memory/storage/protocol.py`). At server startup, the factory (`better_memory/storage/factory.py`) selects an implementation based on the resolved backend — `BETTER_MEMORY_STORAGE_BACKEND` env var if set, else the `storage_backend` key in `$BETTER_MEMORY_HOME/settings.json`, else `sqlite`: ```mermaid flowchart LR - ENV["BETTER_MEMORY_STORAGE_BACKEND"] - ENV -->|sqlite| SQLITE["SqliteBackend
(local memory.db + sqlite-vec)"] - ENV -->|agentcore| AGENTCORE["AgentCoreBackend
(AWS Bedrock AgentCore Memory)"] + RESOLVE["env var, else settings.json,
else sqlite"] + RESOLVE -->|sqlite| SQLITE["SqliteBackend
(local memory.db + sqlite-vec)"] + RESOLVE -->|agentcore| AGENTCORE["AgentCoreBackend
(AWS Bedrock AgentCore Memory)"] SQLITE -->|sync I/O| DB[("memory.db")] - AGENTCORE -->|boto3| AWS[("eu-west-2
bedrock-agentcore")] + AGENTCORE -->|boto3| AWS[("region from agentcore.json
bedrock-agentcore")] ``` | Aspect | `sqlite` | `agentcore` | |---|---|---| -| Data location | Local file (`memory.db`) | AWS-managed (`eu-west-2`) | +| Data location | Local file (`memory.db`) | AWS-managed (region from `agentcore.json`) | +| Local files | `memory.db` + `knowledge.db` (all memory content) | `memory.db` + `knowledge.db` still created — hook-error log + knowledge index only (no memory content) | | Extraction | Local Claude (synthesize_next_* tools) | Cloud (built-in strategies) | | Latency | Single-digit ms | 100-500 ms per AWS call | | Cost | Free | Per-API-call + per-record pricing | | Multi-machine sync | No | Yes (shared memory resources) | | Closure events | N/A | `CreateEvent(role=OTHER)` from Stop hook | | Episode tracking | Local `episodes` table | Internal to AgentCore (sessionId) | -| Exposure log (`record_exposures`) | Writes `session_memory_exposure` rows (sources `bootstrap` / `retrieve` / `contextual`) | No-op — no exposure log; rating flows through `memory.credit` only | +| Exposure log (`record_exposures`) | Writes `session_memory_exposure` rows (sources `bootstrap` / `retrieve` / `contextual`) | No-op — no exposure log, so the end-of-session rating sweep has nothing to trigger on; rating flows through `memory.credit` (and direct `memory.apply_session_ratings` calls) | See [Configuration](configuration.md) for env vars and [AgentCore setup](agentcore-setup.md) for the agentcore path. diff --git a/website/configuration.md b/website/configuration.md index 8f63cdd..8842b62 100644 --- a/website/configuration.md +++ b/website/configuration.md @@ -13,8 +13,7 @@ One environment variable roots the runtime filesystem layout. Everything else ha | `BETTER_MEMORY_EMBEDDINGS_BACKEND` | `ollama` | `ollama` (default) — local Ollama at `OLLAMA_HOST`; `sqlite` — pure-SQL trigram-FTS5 fusion, no model downloads and no in-memory state. See [Architecture](architecture.md#embeddings-backends). | | `BETTER_MEMORY_AUTO_PRUNE` | unset (`false`) | When `1`, the auto-retention runner that fires on `memory.retrieve` (throttled to once per 24h) ALSO hard-deletes archived observations older than 365 days. **Irreversible.** Default is archive-only (status flip, reversible). Opt in only if you actively want disk space reclaimed. | | `BETTER_MEMORY_PROJECT` | unset | Force the project name for all calls in this process. Highest-priority project-resolution signal — overrides both the `.better-memory` file and the git-derived name. Designed for subprocess scoping (e.g. ralph's executor sets it per-iteration so subagent observations land in the PBI's target_repo regardless of the worktree's cwd). Empty/whitespace-only values are treated as unset. | -| `BETTER_MEMORY_STORAGE_BACKEND` | `sqlite` | `sqlite` (default) or `agentcore`. Selects the storage backend at MCP-server startup. `agentcore` requires `pip install 'better-memory[agentcore]'` and a populated `agentcore.json` (see [AgentCore setup](agentcore-setup.md)). | -| `BETTER_MEMORY_AGENTCORE_REGION` | `eu-west-2` | AWS region for `bedrock-agentcore` / `bedrock-agentcore-control` clients when in `agentcore` mode. Only `eu-west-2` is verified by the maintainers; other regions may work if Bedrock AgentCore Memory is GA there. | +| `BETTER_MEMORY_STORAGE_BACKEND` | unset | `sqlite` or `agentcore`. Explicit **override** of the storage backend. When unset, the backend is read from `$BETTER_MEMORY_HOME/settings.json` (written by `better-memory agentcore init`), falling back to `sqlite`. The env var always wins over settings.json. `agentcore` requires `pip install 'better-memory[agentcore]'` and a populated `agentcore.json` (see [AgentCore setup](agentcore-setup.md)). | | `BETTER_MEMORY_TEST_AGENTCORE` | unset | `1` enables integration tests against real AWS. Default off; never set in CI. | | `BETTER_MEMORY_TEST_AGENTCORE_REGION` | inherits `eu-west-2` | Override region used by integration tests. | | `BETTER_MEMORY_CONTEXT_INJECT_MODE` | `both` | Contextual memory-injection hook trigger: `userprompt` (on prompt only), `pretool` (on tool calls only), `both` (default), or `off`. The `contextual_inject` hook surfaces curated memories (semantic + reflections) relevant to the current prompt / tool-input. | @@ -23,6 +22,9 @@ One environment variable roots the runtime filesystem layout. Everything else ha | `BETTER_MEMORY_CONTEXT_MAX_ITEMS` | `3` | Max number of memories `contextual_inject` injects per firing, after the min-hits floor and ranking. | | `BETTER_MEMORY_CONTEXT_REINJECT_TURNS` | `0` | Turns to wait before `contextual_inject` will re-inject a memory already seen this session. `0` means never re-inject (each memory surfaces at most once per session). A "turn" here means one firing of the `contextual_inject` hook, not one user prompt-response cycle: each user prompt counts as a turn, and in mode `both` each matched tool call (`Skill`, `Task`, `Write`) counts as a separate turn too. | +!!! note "Removed: `BETTER_MEMORY_AGENTCORE_REGION`" + The region env var is gone. The AWS region is single-sourced from `agentcore.json` (written by `better-memory agentcore init --region `); a differing env value could only produce a split-brain where events were written to a region the memories don't live in. If you had it exported, remove it — it is ignored. To change region, re-run `init` (see [AgentCore troubleshooting](troubleshooting/agentcore.md)). + ## Project-name override Memory is bucketed by project name, resolved in this order (highest priority first): @@ -51,6 +53,8 @@ Under `BETTER_MEMORY_HOME`: .better-memory/ ├── memory.db # observations, episodes, reflections, audit_log ├── knowledge.db # FTS5 index over knowledge-base/ +├── settings.json # optional; persists storage_backend selection (written by `agentcore init`) +├── agentcore.json # agentcore mode only: memory IDs + region (written by `agentcore init`) ├── spool/ # hook payloads awaiting drain │ └── .quarantine/ # malformed payloads (not deleted; kept for debug) ├── state/ # per-session context_seen_.json files (contextual_inject dedup) @@ -67,9 +71,9 @@ The two SQLite files are never shared between processes — the MCP server owns Four Claude Code hooks ship with better-memory and read or write the filesystem layout above. They are installed automatically by `./scripts/setup.sh` (which calls `python -m better_memory.cli.install_hooks` to merge them idempotently into `~/.claude/settings.json`). The list below is reference material: -- **`better_memory.hooks.session_bootstrap`** (SessionStart) — opens or reuses a background episode for the session and injects the project's curated context (project-scoped and general-scope semantic memories plus distilled reflections in `do` / `dont` / `neutral` buckets, retrieved up to 20 per bucket and ranked by usefulness then confidence) as `additionalContext` for Claude's first turn. Only the top `BETTER_MEMORY_BOOTSTRAP_TOP_N` project-scoped items (default 5; general-scope semantic memories are always shown in full) render in full; the rest collapse into a one-line index plus a `memory.retrieve` / `memory.retrieve_observations` affordance. Setting `BETTER_MEMORY_BOOTSTRAP_TOP_N=0` restores the legacy full-dump behavior. Runs in-process against `memory.db`; failure-isolated: if bootstrap breaks, a fallback directive is injected and the failure is recorded in the `hook_errors` table. +- **`better_memory.hooks.session_bootstrap`** (SessionStart) — opens or reuses a background episode for the session and injects the project's curated context (project-scoped and general-scope semantic memories plus distilled reflections in `do` / `dont` / `neutral` buckets, retrieved up to 20 per bucket and ranked by usefulness then confidence) as `additionalContext` for Claude's first turn. Only the top `BETTER_MEMORY_BOOTSTRAP_TOP_N` project-scoped items (default 5; general-scope semantic memories are always shown in full) render in full; the rest collapse into a one-line index plus a `memory.retrieve` / `memory.retrieve_observations` affordance. Setting `BETTER_MEMORY_BOOTSTRAP_TOP_N=0` restores the legacy full-dump behavior. Runs in-process against `memory.db` (in agentcore mode it routes through the storage backend instead and never opens the local database); failure-isolated: if bootstrap breaks, a fallback directive is injected and the failure is recorded in the `hook_errors` table. - **`better_memory.hooks.observer`** (PostToolUse) — captures tool-call snapshots into `spool/` for later observation creation. -- **`better_memory.hooks.session_close`** (Stop) — writes a session-close marker into `spool/`; also emits the rating directive described in [Self-rating loop](architecture.md#self-rating-loop). +- **`better_memory.hooks.session_close`** (Stop) — writes a session-close marker into `spool/`; also emits the rating directive described in [Self-rating loop](architecture.md#self-rating-loop). In agentcore mode (resolved via the env var, else `settings.json`) it additionally fires one `CreateEvent(role=OTHER)` closure event against the current AgentCore session so the episodic strategy extracts within minutes; failure is logged to `hook_errors` and never blocks the marker write. - **`better_memory.hooks.contextual_inject`** (UserPromptSubmit + PreToolUse) — scores the curated memory set (semantic + reflections) against the current prompt or tool-input via keyword-hit x activation, drops candidates below `BETTER_MEMORY_CONTEXT_MIN_HITS`, caps survivors to `BETTER_MEMORY_CONTEXT_MAX_ITEMS`, and injects them as a `` block in `additionalContext`. A per-session seen-file dedups repeats (`BETTER_MEMORY_CONTEXT_REINJECT_TURNS` controls re-injection). Gated by `BETTER_MEMORY_CONTEXT_INJECT_MODE`. See [Architecture](architecture.md#injection-strategies) for detail. See [`README.md`](https://github.com/emp3thy/better-memory/blob/main/README.md#manual-setup) for the exact `~/.claude/settings.json` registration JSON. diff --git a/website/mcp-tools.md b/website/mcp-tools.md index d37bfbf..201e4da 100644 --- a/website/mcp-tools.md +++ b/website/mcp-tools.md @@ -2,6 +2,8 @@ better-memory exposes its functionality via the [Model Context Protocol](https://modelcontextprotocol.io/) over stdio. Once installed, Claude Code calls these tools directly. +In [agentcore mode](agentcore-setup.md), the observation, retrieval, semantic, rating, and session-bootstrap tools dispatch to AWS Bedrock AgentCore instead of the local SQLite database; the synthesis, episode, and retention tools are not registered (noted per tool below). + ## Memory tools ### `memory.observe` @@ -58,6 +60,9 @@ Stamp reinforcement outcome on a memory after validation. Use sparingly and only when the signal is clear. Reinforcement decays stale memories and promotes reliable ones. +!!! note "Id domain in agentcore mode" + In agentcore mode this dispatches to AgentCore and only accepts genuine AgentCore memory-record ids (40+ characters, as returned by `memory.retrieve` / `memory.semantic_retrieve` in that mode). Shorter ids — including the event ids returned by `memory.observe` — are rejected with a clear error before any AWS call. + ### `memory.start_episode` Start a foreground episode for a specific goal. Triggers synthesis on the prior episode if one was open. @@ -69,11 +74,8 @@ Start a foreground episode for a specific goal. Triggers synthesis on the prior Returns `{"episode_id": "", "reflections": {...}}`. -!!! note "No-op in agentcore mode" - AgentCore manages event grouping internally via `sessionId`. In agentcore mode this tool succeeds but is effectively a no-op — episode IDs are synthetic and not used downstream. - -!!! note "Response shape differs in agentcore mode" - `pending_synthesis` is omitted from the response — AgentCore has no local pending queue. UI consumers should check whether the field is present rather than assuming it always is. +!!! note "Not registered in agentcore mode" + AgentCore manages event grouping internally via `sessionId`, so the episode-lifecycle tools are hidden from the advertised tool list in agentcore mode. (The handler stays registered defensively; if called anyway it returns a synthetic episode id and omits `pending_synthesis` — there is no local pending queue.) ### `memory.close_episode` @@ -88,27 +90,30 @@ Close the active episode. !!! note "Outcome enum differs from observations" Episode outcomes do **not** include `failure`. The valid set is `success` / `partial` / `abandoned` / `no_outcome`. `failure` is valid for `memory.observe` and `memory.record_use`, not for episodes. -!!! note "No-op in agentcore mode" - AgentCore manages event grouping internally via `sessionId`. In agentcore mode this tool succeeds but is effectively a no-op — episode IDs are synthetic and not used downstream. +!!! note "Not registered in agentcore mode" + AgentCore manages event grouping internally via `sessionId`, so this tool is hidden from the advertised tool list in agentcore mode. ### `memory.list_episodes` List recent episodes with their open/closed state. -!!! note "No-op in agentcore mode" - AgentCore manages event grouping internally via `sessionId`. In agentcore mode this tool succeeds but is effectively a no-op — episode IDs are synthetic and not used downstream. +!!! note "Not registered in agentcore mode" + AgentCore manages event grouping internally via `sessionId`, so this tool is hidden from the advertised tool list in agentcore mode. ### `memory.reconcile_episodes` Surface and resolve inconsistencies in episode state. -!!! note "No-op in agentcore mode" - AgentCore manages event grouping internally via `sessionId`. In agentcore mode this tool succeeds but is effectively a no-op — episode IDs are synthetic and not used downstream. +!!! note "Not registered in agentcore mode" + AgentCore manages event grouping internally via `sessionId`, so this tool is hidden from the advertised tool list in agentcore mode. ### `memory.run_retention` Manually trigger the retention service. (Auto-fires on `memory.retrieve` once per 24h.) +!!! note "Not registered in agentcore mode" + Local retention rules only apply to `memory.db` content. AgentCore applies its own event expiry (set at `init`), so this tool is hidden from the advertised tool list in agentcore mode — and the auto-fire on `memory.retrieve` is skipped too. + ### `memory.start_ui` Spawn or reuse the management UI. Returns `{"url": "...", "reused": bool}`. @@ -174,7 +179,7 @@ Return the next pending episode's full context: episode metadata, all observatio | `project` | string | optional | Defaults to cwd-derived. | !!! note "Not available in agentcore mode" - These tools are NOT registered when `BETTER_MEMORY_STORAGE_BACKEND=agentcore`. AgentCore's built-in episodic strategy performs extraction in the cloud; there is no local pending queue to drain. + These tools are NOT registered in agentcore mode (backend resolved from `BETTER_MEMORY_STORAGE_BACKEND`, else `settings.json`). AgentCore's built-in episodic strategy performs extraction in the cloud; there is no local pending queue to drain. ### `memory.synthesize_next_apply` @@ -189,7 +194,7 @@ Apply a synthesis decision for one episode. Atomically creates new reflections, Returns a step summary: `{episode_id, counts, queue, failure}`. !!! note "Not available in agentcore mode" - These tools are NOT registered when `BETTER_MEMORY_STORAGE_BACKEND=agentcore`. AgentCore's built-in episodic strategy performs extraction in the cloud; there is no local pending queue to drain. + These tools are NOT registered in agentcore mode (backend resolved from `BETTER_MEMORY_STORAGE_BACKEND`, else `settings.json`). AgentCore's built-in episodic strategy performs extraction in the cloud; there is no local pending queue to drain. ## Rating tools diff --git a/website/troubleshooting/agentcore.md b/website/troubleshooting/agentcore.md index fa93a42..952dfdc 100644 --- a/website/troubleshooting/agentcore.md +++ b/website/troubleshooting/agentcore.md @@ -40,12 +40,44 @@ Memory creation entered a terminal `FAILED` state. Check the AWS console for the In agentcore mode, the Stop hook emits a `CreateEvent(role=OTHER)` to tell the episodic strategy "this session is done, extract now." If episodic extraction is taking 15+ minutes instead of 1-3, check: -- `~/.better-memory/agentcore.json` exists at session-close time (Stop hooks run in the same process; if `BETTER_MEMORY_HOME` is unset and the home directory isn't `~/.better-memory/`, the hook can't find the config). +- The effective backend is actually `agentcore`: run `better-memory agentcore status` and read the `effective backend: (source: env|settings|default)` line. The hook resolves the backend exactly like the server — `BETTER_MEMORY_STORAGE_BACKEND` env var first, else `settings.json`, else `sqlite`. A stale `BETTER_MEMORY_STORAGE_BACKEND=sqlite` in your shell or `~/.claude.json` env block **overrides** the `settings.json` written by `init` — unset it or set it to `agentcore`. +- `settings.json` carries `"storage_backend": "agentcore"`. `agentcore.json` existing on its own does **not** activate the closure — existence is not consent. +- `~/.better-memory/settings.json` and `agentcore.json` exist at session-close time. Hooks don't receive the `BETTER_MEMORY_HOME` the installer puts into the MCP server's env block, so they fall back to `~/.better-memory` — if you ran `init --home `, the hook won't find either file there. - The IAM principal has `bedrock-agentcore:CreateEvent` permission. - The Stop hook's error log (`hook_errors` table in `memory.db` — or stdout if running interactively) shows no `session_close_agentcore` entries. Failure to fire is non-fatal; the episodic strategy still triggers eventually via 15-20 minute idle detection. -## "Region mismatch" — events written but never extracted +## `ModuleNotFoundError: boto3 is required for the agentcore storage backend` -`init` writes the region to `agentcore.json`; the MCP server reads it from there and builds clients targeting that region. If you change region via `BETTER_MEMORY_AGENTCORE_REGION` mid-flight, you'll write events to the new region but `init`'s memories live in the old region. Either re-`init` in the new region or revert the env var. +The full message is: + +``` +boto3 is required for the agentcore storage backend. Install it with: pip install 'better-memory[agentcore]' +``` + +The agentcore backend's AWS dependencies live in an optional extra so sqlite-only installs stay lean. Install the extra into the same environment the MCP server (or hook) runs from: `pip install 'better-memory[agentcore]'` or `uv pip install '.[agentcore]'`. Both the server factory and the Stop hook's lazy import raise this same hint. + +## `AgentCoreConfigError` — corrupt or unreadable `agentcore.json` + +Parse failures, a missing required field, a malformed `semantic`/`episodic` block, or an unsupported `schema_version` all raise `AgentCoreConfigError` naming the file, ending with: + +``` +Delete the file and re-run `better-memory agentcore init` (or use `--force`); existing AWS memories can be re-linked by hand-editing the file. +``` + +`init` without `--force` refuses to run while the file exists, so either delete it first or pass `--force`. Re-running `init` creates **new** AWS memories; if you want to keep the existing ones, fix the file by hand instead (schema in `better_memory/storage/agentcore_persistence.py`). The `schema_version` variant additionally notes the file may have been written by a newer better-memory. + +## Corrupt `settings.json` + +A malformed `settings.json` (or an invalid `storage_backend` value in it) raises a `ValueError` naming the file: + +``` +Fix or delete the file, or set BETTER_MEMORY_STORAGE_BACKEND to override it. It is written by `better-memory agentcore init`. +``` + +`better-memory agentcore status` survives this (it prints a `WARN` and keeps reporting), and re-running `init` rewrites the file. Hooks record the error to `hook_errors` and degrade to doing nothing rather than failing. + +## Region mismatch — events written but never extracted + +`init` writes the region to `agentcore.json`, and that file is the single source of truth: the MCP server's clients and the Stop hook's closure client all build against it, so the server and hooks can no longer disagree about region. To change region, re-run `init` in the new region (`--force`, or delete `agentcore.json` first) — or hand-edit `agentcore.json` if the memories genuinely live elsewhere. There is no region environment variable. From a4b7d7e214d6813dd1be6d996e57012b4d988912 Mon Sep 17 00:00:00 2001 From: gethin Date: Sun, 12 Jul 2026 23:09:21 +0100 Subject: [PATCH 3/6] fix: align agentcore record plane with real AWS dialect + review majors (Hive repair wave) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live-AWS scout empirically mapped the record-plane dialect against a throwaway memory pair (notes: aws_record_dialect.md); repairs per findings: - storage/agentcore.py + cli smoke: BatchCreateMemoryRecords uses requestIdentifier+timestamp (memoryRecordId rejected by the real model); polarity filtering reworked to what ListMemoryRecords actually supports; semantic read path queries the exact write namespaces; BatchUpdate full-snapshot shape corrected for record_use/credit/ratings - backend majors: session id re-resolved per operation (no process-lifetime freeze on a stale marker); promoted/general reflections fanned into retrieve() + session_bootstrap() (two-namespace merge, deduped) - hooks majors: session marker written BEFORE backend wire calls (contract change — degraded first sessions now still bridge their session id; C2/C3 pins updated, M2 mutation replaced accordingly); session_close closure actorId uses canonical project_name() instead of basename(cwd) - payload parity: apply_session_ratings returns the sqlite shape, retrieve strips internal ranking keys, retrieve_observations key parity, session_bootstrap honors cwd - fake endpoint now REJECTS what real AWS rejects (unknown record keys, unregistered metadata filter keys) so the hermetic tier cannot drift - hygiene: settings read tolerates OSError; unit tests no longer read the developer's real settings.json; live-test Namespace fixes Full suite: 1416 passed, 22 skipped. Pyright 0 errors. Co-Authored-By: Claude Fable 5 --- better_memory/cli/agentcore.py | 46 +- better_memory/config.py | 12 +- better_memory/hooks/session_bootstrap.py | 30 +- better_memory/hooks/session_close.py | 35 +- better_memory/storage/agentcore.py | 438 +++++++++++----- scripts/e2e_mutation_smoke.py | 9 +- tests/cli/test_agentcore_smoke.py | 92 +++- tests/e2e/_fake_agentcore.py | 193 ++++++- tests/e2e/test_agentcore_journey.py | 73 ++- tests/e2e/test_agentcore_t2.py | 188 +++++-- tests/e2e/test_sqlite_journey.py | 59 ++- tests/e2e_meta/mutations/M2.patch | 35 +- tests/hooks/test_session_bootstrap.py | 45 +- tests/hooks/test_session_close_agentcore.py | 81 ++- tests/integration/test_agentcore_live_e2e.py | 50 +- tests/mcp/test_handlers_remote.py | 45 +- tests/mcp/test_server_backend_dispatch.py | 198 +++++++- tests/storage/test_agentcore_unit.py | 505 ++++++++++++++++--- tests/test_config.py | 79 ++- 19 files changed, 1829 insertions(+), 384 deletions(-) diff --git a/better_memory/cli/agentcore.py b/better_memory/cli/agentcore.py index aa4221a..d6796f5 100644 --- a/better_memory/cli/agentcore.py +++ b/better_memory/cli/agentcore.py @@ -512,13 +512,20 @@ def _handle_smoke(args: argparse.Namespace) -> int: print(f" ok ({len(events)} events)") print(">> 4. BatchCreateMemoryRecords — semantic write") - record_id = f"smoke-rec-{int(time.time())}" + # Live-verified request shape (aws_record_dialect.md §1): per-record + # required keys are requestIdentifier + namespaces + content + + # timestamp. memoryRecordId is NOT a valid input key (botocore + # ParamValidationError) — the SERVER mints the durable mem- + # id, returned in successfulRecords[0].memoryRecordId; + # requestIdentifier is correlation-only. + request_identifier = f"smoke-rec-{int(time.time())}" create_resp = data.batch_create_memory_records( memoryId=cfg.semantic.memory_id, records=[{ - "memoryRecordId": record_id, + "requestIdentifier": request_identifier, "namespaces": [f"projects/{actor_id}/semantic/"], "content": {"text": "smoke test semantic record"}, + "timestamp": datetime.now(UTC), "metadata": { "useful_count": {"numberValue": 0}, "status": {"stringValue": "active"}, @@ -536,16 +543,31 @@ def _handle_smoke(args: argparse.Namespace) -> int: real_id = successful[0]["memoryRecordId"] print(f" ok (id={real_id})") - print(">> 5. ListMemoryRecords — readback") - list_resp = data.list_memory_records( - memoryId=cfg.semantic.memory_id, - namespace=f"projects/{actor_id}/semantic/", - maxResults=10, - ) - summaries = list_resp.get("memoryRecordSummaries", []) - if not summaries: - raise RuntimeError("list_memory_records returned no summaries") - print(f" ok ({len(summaries)} records)") + print(">> 5. GetMemoryRecord — readback") + # get_memory_record is read-your-write (~1s); list_memory_records + # shares an index with ~60s lag and would force the smoke to poll + # for a minute (aws_record_dialect.md §3). Retry a few times to + # cover the sub-second window all the same. + record = None + for attempt in range(1, 6): + try: + record = data.get_memory_record( + memoryId=cfg.semantic.memory_id, + memoryRecordId=real_id, + )["memoryRecord"] + break + except Exception as get_exc: # noqa: BLE001 — retried, re-raised below + code = getattr(get_exc, "response", {}).get( + "Error", {} + ).get("Code", "") + if code != "ResourceNotFoundException" or attempt == 5: + raise + time.sleep(2) + if record is None or record.get("memoryRecordId") != real_id: + raise RuntimeError( + f"get_memory_record readback mismatch: {record!r}" + ) + print(" ok (readback id matches)") print(">> 6. BatchDeleteMemoryRecords — cleanup") del_resp = data.batch_delete_memory_records( diff --git a/better_memory/config.py b/better_memory/config.py index f23d47e..d069c6c 100644 --- a/better_memory/config.py +++ b/better_memory/config.py @@ -263,16 +263,18 @@ def _resolve_embeddings_backend() -> Literal["ollama", "sqlite"]: def _read_settings_storage_backend(home: Path) -> str | None: """Read ``storage_backend`` from ``/settings.json``. - Returns ``None`` when the file does not exist or carries no - ``storage_backend`` key (callers fall back to the default). Raises + Returns ``None`` when the file does not exist, cannot be read (any + :class:`OSError` — e.g. ``PermissionError``; the backend selection must + never crash ``get_config`` over an unreadable optional file), or carries + no ``storage_backend`` key (callers fall back to the default). Raises :class:`ValueError` — naming the file, with remediation — when the file - is malformed JSON, not a JSON object, or carries an invalid value - (symmetric to the invalid-env-var error). + is readable but malformed JSON, not a JSON object, or carries an invalid + value (symmetric to the invalid-env-var error). """ settings_path = home / _SETTINGS_FILE try: text = settings_path.read_text(encoding="utf-8") - except FileNotFoundError: + except OSError: return None remediation = ( "Fix or delete the file, or set BETTER_MEMORY_STORAGE_BACKEND to " diff --git a/better_memory/hooks/session_bootstrap.py b/better_memory/hooks/session_bootstrap.py index d862de1..345e2cc 100644 --- a/better_memory/hooks/session_bootstrap.py +++ b/better_memory/hooks/session_bootstrap.py @@ -69,6 +69,25 @@ def main() -> None: try: cfg = get_config() + # Bridge session_id to the MCP server: it doesn't see CLAUDE_SESSION_ID + # in its spawn env. See better_memory/runtime/session_marker.py. + # Resolution MUST match read_session_id's _resolve_project_dir order: + # CLAUDE_PROJECT_DIR env first, then a non-empty fallback. The hook has + # the payload's cwd as a stronger fallback than os.getcwd() because the + # MCP server's cwd may not equal the project root. + # + # Deliberately hoisted ABOVE the bootstrap/backend call (both modes): + # the session id comes from the stdin payload, not from bootstrap, so + # the marker is valid even when bootstrap fails (e.g. virgin home, + # missing agentcore.json) — the first-session server can then resolve + # the session id and the manual memory_session_bootstrap remediation + # works on the first try. write_session_id is best-effort (it swallows + # OSError internally); a marker failure never blocks the bootstrap. + write_session_id( + cfg.home, + session_id, + project_dir=os.environ.get("CLAUDE_PROJECT_DIR") or cwd_str, + ) if cfg.storage_backend == "agentcore": # Route through the storage factory like contextual_inject.py: # agentcore mode must never open the local sqlite database. The @@ -95,17 +114,6 @@ def main() -> None: source=source, session_id=session_id, cwd=Path(cwd_str), ) rendered = result.additional_context - # Bridge session_id to the MCP server: it doesn't see CLAUDE_SESSION_ID - # in its spawn env. See better_memory/runtime/session_marker.py. - # Resolution MUST match read_session_id's _resolve_project_dir order: - # CLAUDE_PROJECT_DIR env first, then a non-empty fallback. The hook has - # the payload's cwd as a stronger fallback than os.getcwd() because the - # MCP server's cwd may not equal the project root. - write_session_id( - cfg.home, - session_id, - project_dir=os.environ.get("CLAUDE_PROJECT_DIR") or cwd_str, - ) except BaseException as exc: # noqa: BLE001 try: record_hook_error(hook_name="session_bootstrap", exc=exc) diff --git a/better_memory/hooks/session_close.py b/better_memory/hooks/session_close.py index df65258..4ac8ae6 100644 --- a/better_memory/hooks/session_close.py +++ b/better_memory/hooks/session_close.py @@ -16,6 +16,7 @@ import sys import time from datetime import UTC, datetime +from pathlib import Path from better_memory._common import ( default_spool_dir, @@ -24,7 +25,7 @@ resolve_home, safe_timestamp, ) -from better_memory.config import get_config +from better_memory.config import get_config, project_name # Mirror the observer cap: reject any stdin payload above 1 MiB without # raising. Hooks must never fail. @@ -65,7 +66,7 @@ def _build_agentcore_data_client(region: str): ) -def _fire_agentcore_closure(*, session_id: str, project: str) -> bool: +def _fire_agentcore_closure(*, session_id: str, cwd: str) -> bool: """In agentcore mode, fire one CreateEvent(role=OTHER) against the current session. Returns True if a closure event was fired, False if we short-circuited (sqlite mode, missing config, or any failure). @@ -76,7 +77,16 @@ def _fire_agentcore_closure(*, session_id: str, project: str) -> bool: Reuses Plan 2's `closure_event_payload()` + `resolve_actor_id()` from `better_memory/storage/session.py` so there's a single source of truth for the payload shape and actor-id resolution — AgentCoreBackend.observe - uses the same helpers.""" + uses the same helpers. + + ``cwd`` is the payload's working directory; the project is resolved from + it via ``config.project_name`` INSIDE this function (after the backend + gate) so it matches the server's ``resolve_actor_id(project_name())`` + exactly — a plain ``basename(cwd)`` diverges on git worktrees, + subdirectories, and ``BETTER_MEMORY_PROJECT`` / ``.better-memory`` + overrides, sending the closure event to an actor stream the session's + events never used. Empty ``cwd`` resolves to the ``"general"`` bucket, + and sqlite mode never pays the git-walk cost.""" # Env-var check BEFORE any lazy import or file I/O: an explicit env value # keeps today's semantics — sqlite-mode (or any non-agentcore value) pays # nothing and skips even the settings.json resolver. @@ -116,6 +126,11 @@ def _fire_agentcore_closure(*, session_id: str, project: str) -> bool: if cfg is None: return False + # Actor-id parity with the server (see docstring): resolve the + # project through the same helper the server uses. None (empty cwd) + # falls back to the "general" bucket inside resolve_actor_id. + project = project_name(Path(cwd)) if cwd.strip() else None + client = _build_agentcore_data_client(cfg.region) client.create_event( memoryId=cfg.episodic.memory_id, @@ -299,17 +314,13 @@ def main() -> None: # strategy triggers extraction within minutes rather than waiting # ~15-20m for idle detection (spec § "Spike findings" Finding 2). # Non-fatal: failure is logged but does not block the spool marker. - project_for_closure = ( - data.get("cwd", "general") or "general" - ) - if isinstance(project_for_closure, str): - # Use git-derived project name when possible; fall back to "general" - project_for_closure = os.path.basename( - project_for_closure.rstrip("/\\") - ) or "general" + # Project resolution happens INSIDE _fire_agentcore_closure (after + # the backend gate) via config.project_name, matching the server's + # actor-id resolution — see the function docstring. + cwd_for_closure = data.get("cwd") _fire_agentcore_closure( session_id=str(session_id_str or ""), - project=str(project_for_closure), + cwd=cwd_for_closure if isinstance(cwd_for_closure, str) else "", ) spool_dir = default_spool_dir() diff --git a/better_memory/storage/agentcore.py b/better_memory/storage/agentcore.py index d670f69..d61ca54 100644 --- a/better_memory/storage/agentcore.py +++ b/better_memory/storage/agentcore.py @@ -84,38 +84,50 @@ def supports_synthesis(self) -> bool: def supports_episodes(self) -> bool: return False - # ----- Session-id lazy re-resolution ----- + # ----- Session-id per-operation re-resolution ----- def _require_session_id(self, operation: str) -> str: - """Return the session id, lazily re-resolving when frozen at None. + """Resolve the session id fresh on EVERY operation — never freeze. The MCP server resolves the session id once at startup, but real Claude Code does not propagate CLAUDE_SESSION_ID into the spawned - stdio server's env and the server may spawn BEFORE the SessionStart - hook writes the marker file — so construction-time resolution can - legitimately yield None. Re-resolve (env var, then the marker under - the resolved BETTER_MEMORY_HOME) at first use instead of raising - forever. When neither source resolves, raise WITHOUT any wire call - (no uuid4 fallback fabricating identities). + stdio server's env; the server may spawn BEFORE the SessionStart + hook writes the marker file, and — the live-review major — a + long-lived server process outlives Claude sessions, so a marker + adopted once would go stale for the rest of the process lifetime. + + Resolution order per call: + 1. live sources: CLAUDE_SESSION_ID / CLAUDE_CODE_SESSION_ID env, + then the SessionStart marker under the resolved + BETTER_MEMORY_HOME (a fresh marker always beats any value seen + at construction time); + 2. the construction-time session_id, as a fallback when no live + source resolves; + 3. otherwise raise WITHOUT any wire call (no uuid4 fallback + fabricating identities). + + The result is deliberately NOT cached on self — the whole point is + that a new session's marker takes effect on the next operation. """ - if self._session_id is None: - # Local import: keeps the storage layer free of an mcp import - # edge at module scope (and off the hooks' lightweight-import - # path — this only loads when an event operation runs). - from better_memory._common import resolve_home - from better_memory.mcp._util import resolve_session_id - - self._session_id = resolve_session_id(resolve_home()) - if self._session_id is None: - raise ValueError( - f"AgentCoreBackend.{operation} requires session_id: none was " - "available at construction time and re-resolution found " - "neither CLAUDE_SESSION_ID / CLAUDE_CODE_SESSION_ID in the " - "environment nor a SessionStart marker file under " - "BETTER_MEMORY_HOME (the SessionStart hook writes it; if you " - "see this in production the hook has not run yet)." - ) - return self._session_id + # Local import: keeps the storage layer free of an mcp import + # edge at module scope (and off the hooks' lightweight-import + # path — this only loads when an event operation runs). + from better_memory._common import resolve_home + from better_memory.mcp._util import resolve_session_id + + live = resolve_session_id(resolve_home()) + if live is not None: + return live + if self._session_id is not None: + return self._session_id + raise ValueError( + f"AgentCoreBackend.{operation} requires session_id: none was " + "available at construction time and re-resolution found " + "neither CLAUDE_SESSION_ID / CLAUDE_CODE_SESSION_ID in the " + "environment nor a SessionStart marker file under " + "BETTER_MEMORY_HOME (the SessionStart hook writes it; if you " + "see this in production the hook has not run yet)." + ) # ----- Observations: filled in by Tasks 5-6 ----- @@ -196,9 +208,13 @@ def retrieve( ) -> dict[str, list[dict[str, Any]]]: """Bucketed reflection retrieval matching ReflectionSynthesisService.retrieve_reflections. - Per-polarity list_memory_records against the reflections namespace, - parse JSON content, rank via the sqlite ordering rule - (useful_count + 3*times_overlooked DESC, confidence DESC, updated_at DESC). + One list_memory_records per reflections namespace (project + + general — the promoted/cross-project merge, mirroring the sqlite + ``project = ? OR scope = 'general'`` clause), parse JSON content, + bucket by polarity CLIENT-SIDE (live AWS rejects ``polarity`` as a + metadata filter key — only the CreateMemory indexedKeys are legal), + rank via the sqlite ordering rule (useful_count + + 3*times_overlooked DESC, confidence DESC, updated_at DESC). Returns dict[polarity, list[reflection_dict]] in the same shape sqlite mode returns; the MCP memory.retrieve handler json-dumps this directly to Claude. @@ -207,66 +223,124 @@ def retrieve( not part of the agentcore-mode rating model. """ actor_id = resolve_actor_id(project or self._project) - namespace = resolve_namespace(actor_id, "reflections") effective_limit = limit_per_bucket if limit_per_bucket is not None else 20 + return self._fetch_reflection_buckets( + actor_id=actor_id, + limit_per_bucket=effective_limit, + tech=tech, + phase=phase, + polarity=polarity if polarity in _POLARITIES else None, + ) - # Restrict fan-out to a single polarity if the caller specified one - polarities_to_fetch: tuple[str, ...] = ( - (polarity,) if polarity in _POLARITIES else _POLARITIES + def _fetch_reflection_buckets( + self, + *, + actor_id: str, + limit_per_bucket: int, + tech: str | None = None, + phase: str | None = None, + polarity: str | None = None, + ) -> dict[str, list[dict[str, Any]]]: + """Two-namespace reflection fan-out shared by retrieve() and + session_bootstrap(). + + Wire shape (live-verified dialect, aws_record_dialect.md): + + * NO ``metadataFilters`` — ``polarity`` is not a legal filter key + (only the indexedKeys status / last_credited_at / + overlooked_count are), and a server-side ``status`` EQUALS_TO + filter would hide AgentCore-extracted reflections that carry no + status metadata at all. All polarity/status filtering happens + client-side on the parsed records. + * One call per namespace: ``projects/{actor}/reflections/`` and + ``general/reflections/`` (promoted reflections move to general/ + with status=promoted — without this second call they become + invisible). The general call is skipped when actor == general + (same namespace). + + Client-side status semantics (mirrors sqlite's + ``(project = ? OR scope='general') AND status IN (...)``): + project namespace admits status == active; the general namespace + admits active AND promoted. Records with no status metadata parse + as active; records with no/unknown polarity bucket as neutral — + AgentCore's own extraction strategy writes neither key, and those + records must still be retrievable. Dedup by record id across the + two calls (a just-promoted record can appear in both while the + list index lags), project namespace winning. + """ + project_ns = resolve_namespace(actor_id, "reflections") + general_ns = resolve_namespace("general", "reflections") + allowed_general = frozenset({"active", "promoted"}) + allowed_project = ( + allowed_general if actor_id == "general" else frozenset({"active"}) ) + namespaces: list[tuple[str, frozenset[str]]] = [ + (project_ns, allowed_project) + ] + if general_ns != project_ns: + namespaces.append((general_ns, allowed_general)) + # One page covers all three buckets now — scale the slack with the + # bucket count instead of the old per-polarity *2. + max_results = limit_per_bucket * len(_POLARITIES) * 2 - def _fetch(p: str) -> list[dict[str, Any]]: - filters: list[dict[str, Any]] = [ - { - "left": {"metadataKey": "polarity"}, - "operator": "EQUALS_TO", - "right": {"metadataValue": {"stringValue": p}}, - }, - { - "left": {"metadataKey": "status"}, - "operator": "EQUALS_TO", - "right": {"metadataValue": {"stringValue": "active"}}, - }, - ] + def _fetch(namespace: str) -> list[dict[str, Any]]: response = self._data.list_memory_records( memoryId=self._cfg.episodic.memory_id, namespace=namespace, - maxResults=effective_limit * 2, # fetch some slack for client-side ranking - metadataFilters=filters, + maxResults=max_results, ) - parsed_raw = [ - self._parse_reflection_record(rec, tech_filter=tech, phase_filter=phase) - for rec in response.get("memoryRecordSummaries", []) + return response.get("memoryRecordSummaries", []) + + # Parallel fan-out via a thread pool. This path is sync but is called + # from inside the MCP server's async `_call_tool`, so we cannot use + # asyncio.run here (would raise "event loop is already running"). + # ThreadPoolExecutor gives the same wire-level parallelism without + # touching the outer event loop. + with concurrent.futures.ThreadPoolExecutor( + max_workers=len(namespaces) + ) as pool: + futures = [ + (allowed, pool.submit(_fetch, namespace)) + for namespace, allowed in namespaces ] - # Drop None entries (filtered out by tech/phase post-filter) - parsed: list[dict[str, Any]] = [r for r in parsed_raw if r is not None] + fetched = [(allowed, future.result()) for allowed, future in futures] + + buckets: dict[str, list[dict[str, Any]]] = {p: [] for p in _POLARITIES} + seen_ids: set[str] = set() + for allowed_statuses, summaries in fetched: + for rec in summaries: + parsed = self._parse_reflection_record( + rec, tech_filter=tech, phase_filter=phase + ) + if parsed is None: + continue + if parsed["id"] in seen_ids: + continue + if parsed["_status"] not in allowed_statuses: + continue + bucket = parsed["_polarity"] + if polarity is not None and bucket != polarity: + continue + seen_ids.add(parsed["id"]) + buckets[bucket].append(parsed) + + for bucket_name, items in buckets.items(): # Sqlite ordering: (useful_count + 3*times_overlooked) DESC, # confidence DESC, updated_at DESC. - parsed.sort( + items.sort( key=lambda r: ( -(r["useful_count"] + _OVERLOOKED_RANKING_WEIGHT * r["_overlooked_count"]), -r["confidence"], -r["_updated_at_ts"], ) ) - return parsed[:effective_limit] - - # Parallel fan-out via a thread pool. `retrieve` is sync but is called - # from inside the MCP server's async `_call_tool`, so we cannot use - # asyncio.run here (would raise "event loop is already running"). - # ThreadPoolExecutor gives the same wire-level parallelism without - # touching the outer event loop. - def _gather_all() -> dict[str, list[dict[str, Any]]]: - with concurrent.futures.ThreadPoolExecutor( - max_workers=len(polarities_to_fetch) or 1 - ) as pool: - futures = {p: pool.submit(_fetch, p) for p in polarities_to_fetch} - return {p: f.result() for p, f in futures.items()} - - fetched = _gather_all() - - # Always return all 3 keys for stable shape; unfetched buckets are []. - return {p: fetched.get(p, []) for p in _POLARITIES} + # Strip the internal ranking/bucketing helpers — the payload + # must be key-identical to the sqlite reflection dicts. + buckets[bucket_name] = [ + {k: v for k, v in r.items() if not k.startswith("_")} + for r in items[:limit_per_bucket] + ] + return buckets def _parse_reflection_record( self, @@ -277,7 +351,11 @@ def _parse_reflection_record( ) -> dict[str, Any] | None: """Map MemoryRecordSummary -> sqlite-shaped reflection dict. - Returns None if tech_filter / phase_filter excludes this record.""" + Returns None if tech_filter / phase_filter excludes this record. + + Underscore-prefixed keys are internal (ranking + client-side + polarity/status bucketing); _fetch_reflection_buckets strips them + before the payload leaves the backend.""" text = rec.get("content", {}).get("text", "") try: body = json.loads(text) if isinstance(text, str) else {} @@ -309,9 +387,24 @@ def _num(key: str) -> float: except (TypeError, ValueError): confidence = 0.0 - updated_at = rec.get("updatedAt") or rec.get("createdAt") + # Live dialect: NO updatedAt field exists on any record shape + # (createdAt only). Update recency lives in the system metadata key + # x-amz-agentcore-memory-updatedAt (dateTimeValue). + sys_updated = metadata_raw.get( + f"{_AGENTCORE_SYSTEM_METADATA_PREFIX}updatedAt", {} + ).get("dateTimeValue") + updated_at = sys_updated or rec.get("updatedAt") or rec.get("createdAt") updated_at_ts = updated_at.timestamp() if isinstance(updated_at, datetime) else 0.0 + # Client-side bucketing inputs. Missing/unknown polarity buckets as + # neutral and missing status parses as active — AgentCore's own + # extraction strategy writes neither key, and those records must + # still be retrievable (see _fetch_reflection_buckets). + polarity_value = metadata_raw.get("polarity", {}).get("stringValue") + if polarity_value not in _POLARITIES: + polarity_value = "neutral" + status_value = metadata_raw.get("status", {}).get("stringValue") or "active" + return { # Public shape — must match ReflectionSynthesisService.retrieve_reflections # return: {id, title, phase, use_cases, hints (list), confidence (float), @@ -329,9 +422,12 @@ def _num(key: str) -> float: "updated_at": ( updated_at.isoformat() if isinstance(updated_at, datetime) else None ), - # Internal ranking helpers — leading underscore so callers ignore + # Internal ranking/bucketing helpers — stripped by + # _fetch_reflection_buckets before the payload leaves the backend. "_overlooked_count": int(_num("overlooked_count")), "_updated_at_ts": updated_at_ts, + "_polarity": polarity_value, + "_status": status_value, } async def list_observations( @@ -380,14 +476,27 @@ async def list_observations( k: v.get("stringValue") for k, v in event.get("metadata", {}).items() } + event_ts = event.get("eventTimestamp") results.append( { + # Key parity with the sqlite rows list_observations + # returns ({id, content, component, theme, outcome, + # reinforcement_score, created_at}) — no extra + # agentcore-only keys leak to the MCP payload. + # reinforcement_score has no event-plane equivalent; + # stable None placeholder (same convention as the + # semantic_retrieve UD-2 payload contract). "id": event["eventId"], "content": payload_text, - "session_id": event.get("sessionId"), - "actor_id": event.get("actorId"), - "event_timestamp": event.get("eventTimestamp"), - **flat_metadata, + "component": flat_metadata.get("component"), + "theme": flat_metadata.get("theme"), + "outcome": flat_metadata.get("outcome"), + "reinforcement_score": None, + "created_at": ( + event_ts.isoformat() + if isinstance(event_ts, datetime) + else event_ts + ), } ) @@ -491,7 +600,13 @@ def record_use( updates: dict[str, dict[str, Any]] = { counter_key: {"numberValue": current_count + 1}, - "last_credited_at": {"dateTimeValue": datetime.now(UTC)}, + # last_credited_at is declared STRING in the CreateMemory + # indexedKeys; a dateTimeValue fails the whole record update + # ("value type does not match declared indexed key type") — + # the live root cause of apply_session_ratings applied:0. + "last_credited_at": { + "stringValue": datetime.now(UTC).isoformat() + }, } snapshot = self._full_metadata_snapshot(metadata, updates) @@ -617,9 +732,14 @@ def semantic_list( "id": rec["memoryRecordId"], "content": rec.get("content", {}).get("text", ""), "namespaces": rec.get("namespaces", []), + # Live dialect: stored namespaces gain a leading slash + # ("/general/semantic/") — normalize before classifying or + # every read-back record misreports as project scope. "scope": ( "general" - if (next(iter(rec.get("namespaces") or [""]), "")).startswith("general/") + if (next(iter(rec.get("namespaces") or [""]), "")) + .lstrip("/") + .startswith("general/") else "project" ), } @@ -817,36 +937,30 @@ def session_bootstrap( matching the BootstrapResult shape the MCP handler at server.py:1398-1411 unwraps. - Uses list_memory_records (not retrieve_memory_records) — bootstrap is - metadata-only, no semantic search query. 4 boto3 calls are independent - network round-trips, so we fan out via concurrent.futures.ThreadPoolExecutor. + Reflections go through the same two-namespace fan-out as retrieve() + (_fetch_reflection_buckets: project + general/promoted merge, + client-side polarity bucketing — live AWS rejects polarity as a + metadata filter key). Uses list_memory_records (not + retrieve_memory_records) — bootstrap is metadata-only, no semantic + search query. The boto3 calls are independent network round-trips, + so we fan out via concurrent.futures.ThreadPoolExecutor. The Protocol signature is sync, but session_bootstrap is reached via the MCP server's async `_call_tool` — meaning an event loop is already running; asyncio.run() would raise. ThreadPoolExecutor provides the - same parallelism without touching the outer loop.""" + same parallelism without touching the outer loop. + + Project resolution honours the cwd param (sqlite-parity): explicit + project wins, then project_name(cwd), then the construction-time + project.""" + if project is None and cwd is not None: + from pathlib import Path + + from better_memory.config import project_name + + project = project_name(Path(cwd)) actor_id = resolve_actor_id(project or self._project) - reflections_namespace = resolve_namespace(actor_id, "reflections") semantic_namespace = resolve_namespace(actor_id, "semantic") - def _fetch_reflections(polarity: str) -> dict[str, Any]: - return self._data.list_memory_records( - memoryId=self._cfg.episodic.memory_id, - namespace=reflections_namespace, - maxResults=5, - metadataFilters=[ - { - "left": {"metadataKey": "polarity"}, - "operator": "EQUALS_TO", - "right": {"metadataValue": {"stringValue": polarity}}, - }, - { - "left": {"metadataKey": "status"}, - "operator": "EQUALS_TO", - "right": {"metadataValue": {"stringValue": "active"}}, - }, - ], - ) - def _fetch_semantic() -> dict[str, Any]: return self._data.list_memory_records( memoryId=self._cfg.semantic.memory_id, @@ -854,21 +968,19 @@ def _fetch_semantic() -> dict[str, Any]: maxResults=10, ) - with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool: - reflection_futures = { - polarity: pool.submit(_fetch_reflections, polarity) - for polarity in _POLARITIES - } + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + reflections_future = pool.submit( + self._fetch_reflection_buckets, + actor_id=actor_id, + limit_per_bucket=5, + ) semantic_future = pool.submit(_fetch_semantic) - reflection_calls = { - polarity: future.result() - for polarity, future in reflection_futures.items() - } + reflection_buckets = reflections_future.result() semantic_response = semantic_future.result() reflections_counts = { - polarity: len(reflection_calls[polarity].get("memoryRecordSummaries", [])) + polarity: len(reflection_buckets[polarity]) for polarity in _POLARITIES } semantic_items = semantic_response.get("memoryRecordSummaries", []) @@ -959,7 +1071,10 @@ def credit_one( updates: dict[str, dict[str, Any]] = { counter_key: {"numberValue": current + 1}, - "last_credited_at": {"dateTimeValue": datetime.now(UTC)}, + # STRING indexed key — dateTimeValue is rejected (see record_use). + "last_credited_at": { + "stringValue": datetime.now(UTC).isoformat() + }, } snapshot = self._full_metadata_snapshot(metadata, updates) @@ -981,7 +1096,9 @@ def credit_one( "applied": None, "skipped": failed[0].get("errorMessage", "unknown"), } - return {"applied": id, "skipped": None} + # Sqlite parity: MemoryRatingService.credit_one returns the applied + # CLASSIFICATION (not the memory id) on success. + return {"applied": classification, "skipped": None} def apply_session_ratings( self, @@ -989,16 +1106,68 @@ def apply_session_ratings( session_id: str, ratings: list[dict[str, str]], ) -> dict[str, Any]: - """Iterate ratings, call credit_one per entry. Return summary dict - with `applied` (count of successful credits) and `failed` (count of - skip / error results).""" - applied = 0 - failed = 0 + """Batch rating apply — sqlite-parity validation and return shape. + + Mirrors MemoryRatingService.apply_session_ratings: the whole batch + is validated BEFORE any wire call (non-empty session_id/ratings, + kind in {reflection, semantic}, class in the five rating classes, + non-empty string id, no duplicate (kind, id) pairs — ValueError on + any violation), then each entry runs through credit_one. + + Returns the sqlite shape:: + + {"session_id": str, + "applied": {"cited": int, "shaped": int, "ignored": int, + "misled": int, "overlooked": int}, + "skipped": {"not_exposed": int, "already_rated": int, + "memory_missing": int, "memory_retired": int}} + + Agentcore has no exposure log, so not_exposed / already_rated / + memory_retired never fire here; a missing record (GetMemoryRecord + 404) or a per-record batch-update failure counts as + memory_missing. There is no AWS-side atomicity — entries already + credited stay credited if a later entry raises.""" + if not session_id: + raise ValueError("session_id must be non-empty") + if not ratings: + raise ValueError("ratings must be non-empty") + + valid_kinds = {"reflection", "semantic"} + seen: set[tuple[str, str]] = set() + for i, r in enumerate(ratings): + for field_name in ("kind", "class", "id"): + if field_name not in r: + raise ValueError( + f"ratings[{i}].{field_name}: missing required field" + ) + kind = r["kind"] + rid = r["id"] + cls = r["class"] + if kind not in valid_kinds: + raise ValueError( + f"ratings[{i}].kind: invalid {kind!r}; " + f"expected one of {valid_kinds}" + ) + if cls not in _RATING_TO_COUNTER: + raise ValueError( + f"ratings[{i}].class: invalid {cls!r}; " + f"expected one of {set(_RATING_TO_COUNTER)}" + ) + if not isinstance(rid, str) or not rid: + raise ValueError(f"ratings[{i}].id: must be non-empty string") + key = (kind, rid) + if key in seen: + raise ValueError(f"ratings[{i}]: duplicate (kind, id) = {key!r}") + seen.add(key) + + applied: dict[str, int] = {cls: 0 for cls in _RATING_TO_COUNTER} + skipped: dict[str, int] = { + "not_exposed": 0, + "already_rated": 0, + "memory_missing": 0, + "memory_retired": 0, + } for r in ratings: - # Guard each iteration: a single malformed entry (missing key or - # unknown classification raising ValueError inside credit_one) - # must not abort the remainder of the list or drop the - # accumulated counts. try: result = self.credit_one( session_id=session_id, @@ -1006,14 +1175,21 @@ def apply_session_ratings( id=r["id"], classification=r["class"], ) - except (KeyError, ValueError): - failed += 1 + except _ClientError as exc: + code = exc.response.get("Error", {}).get("Code", "") + if code != "ResourceNotFoundException": + raise + # The record no longer exists — sqlite calls this + # memory_missing; keep rating the rest of the batch. + skipped["memory_missing"] += 1 continue if result["applied"] is not None: - applied += 1 + applied[r["class"]] += 1 else: - failed += 1 - return {"applied": applied, "failed": failed} + # Per-record batch-update failure (failedRecords) — the + # closest sqlite skip reason is memory_missing. + skipped["memory_missing"] += 1 + return {"session_id": session_id, "applied": applied, "skipped": skipped} # ----- Synthesis: no-ops in agentcore mode ----- diff --git a/scripts/e2e_mutation_smoke.py b/scripts/e2e_mutation_smoke.py index 34f7874..6b77b16 100644 --- a/scripts/e2e_mutation_smoke.py +++ b/scripts/e2e_mutation_smoke.py @@ -15,9 +15,10 @@ * M1 ``M1.patch`` — ``better_memory/mcp/server.py``: skip ``apply_migrations`` for memory.db at boot. Sentinel: ``test_first_boot_migrates_tools_knowledge``. -* M2 ``M2.patch`` — ``better_memory/hooks/session_bootstrap.py``: hoist - ``write_session_id`` above ``service.bootstrap()``. Sentinel: - ``test_hook_before_server_degraded`` (runtime/sessions-absent assertion). +* M2 ``M2.patch`` — ``better_memory/hooks/session_bootstrap.py``: delete the + ``write_session_id`` call entirely (the session-id bridge marker is never + written). Sentinel: ``test_hook_before_server_degraded`` (marker-exists + assertion — the hoisted-write contract). * M3 ``M3.patch`` — ``better_memory/hooks/contextual_inject.py``: except path exits without printing the envelope. Sentinel: ``test_a_config_error_swallowed_envelope_still_printed`` (exactly-one-JSON-line). @@ -97,7 +98,7 @@ class Mutation: ), Mutation( num=2, - title="session_bootstrap.py: write_session_id hoisted above bootstrap()", + title="session_bootstrap.py: write_session_id call deleted", kind="patch", artifact="M2.patch", targets=("better_memory/hooks/session_bootstrap.py",), diff --git a/tests/cli/test_agentcore_smoke.py b/tests/cli/test_agentcore_smoke.py index b6c996d..0c6e757 100644 --- a/tests/cli/test_agentcore_smoke.py +++ b/tests/cli/test_agentcore_smoke.py @@ -65,20 +65,31 @@ def test_smoke_runs_full_cycle_against_existing_memories( {"eventId": "evt-2", "sessionId": "smoke-sess"}, ] } - # BatchCreateMemoryRecords for a semantic write + # BatchCreateMemoryRecords for a semantic write — the SERVER mints the + # durable id (live dialect); the smoke must consume it. data.batch_create_memory_records.return_value = { - "successfulRecords": [{"memoryRecordId": "mem-rec-1"}], + "successfulRecords": [ + { + "memoryRecordId": "mem-11111111-2222-3333-4444-555555555555", + "status": "SUCCEEDED", + "requestIdentifier": "echoed-back", + } + ], "failedRecords": [], } - # ListMemoryRecords returns the record - data.list_memory_records.return_value = { - "memoryRecordSummaries": [ - {"memoryRecordId": "mem-rec-1", "content": {"text": "hi"}} - ] + # GetMemoryRecord readback (read-your-write — list_memory_records has a + # ~60s indexing lag, so the smoke reads by id). + data.get_memory_record.return_value = { + "memoryRecord": { + "memoryRecordId": "mem-11111111-2222-3333-4444-555555555555", + "content": {"text": "smoke test semantic record"}, + } } # BatchDeleteMemoryRecords cleans up data.batch_delete_memory_records.return_value = { - "successfulRecords": [{"memoryRecordId": "mem-rec-1"}], + "successfulRecords": [ + {"memoryRecordId": "mem-11111111-2222-3333-4444-555555555555"} + ], "failedRecords": [], } @@ -93,7 +104,72 @@ def test_smoke_runs_full_cycle_against_existing_memories( assert data.create_event.call_count == 2 assert data.list_events.call_count >= 1 assert data.batch_create_memory_records.call_count == 1 + + # Live-verified BatchCreate record shape (aws_record_dialect.md §1): + # requestIdentifier + namespaces + content + timestamp; memoryRecordId + # is NOT a legal input key (real botocore raises ParamValidationError). + record = data.batch_create_memory_records.call_args.kwargs["records"][0] + assert "memoryRecordId" not in record + assert record["requestIdentifier"] + assert record["namespaces"] == ["projects/smoke/semantic/"] + assert record["content"] == {"text": "smoke test semantic record"} + assert "timestamp" in record + + # Readback by the SERVER-minted id, then cleanup with the same id. + get_kwargs = data.get_memory_record.call_args.kwargs + assert ( + get_kwargs["memoryRecordId"] + == "mem-11111111-2222-3333-4444-555555555555" + ) assert data.batch_delete_memory_records.call_count == 1 + delete_kwargs = data.batch_delete_memory_records.call_args.kwargs + assert delete_kwargs["records"] == [ + {"memoryRecordId": "mem-11111111-2222-3333-4444-555555555555"} + ] + # The smoke never lists the lagging index. + data.list_memory_records.assert_not_called() + + +def test_smoke_readback_retries_transient_404(tmp_path, monkeypatch) -> None: + """get_memory_record is read-your-write at ~1s on real AWS; the smoke + still retries a transient ResourceNotFoundException in the sub-second + window instead of failing the whole run.""" + _write_config(tmp_path) + + class _FakeClientError(Exception): + def __init__(self) -> None: + super().__init__("not found yet") + self.response = {"Error": {"Code": "ResourceNotFoundException"}} + + data = MagicMock(name="bedrock-agentcore") + data.create_event.side_effect = [ + {"event": {"eventId": "evt-1"}}, + {"event": {"eventId": "evt-2"}}, + ] + data.list_events.return_value = { + "events": [{"eventId": "evt-1"}, {"eventId": "evt-2"}] + } + data.batch_create_memory_records.return_value = { + "successfulRecords": [{"memoryRecordId": "mem-retry-1", "status": "SUCCEEDED"}], + "failedRecords": [], + } + data.get_memory_record.side_effect = [ + _FakeClientError(), + {"memoryRecord": {"memoryRecordId": "mem-retry-1"}}, + ] + data.batch_delete_memory_records.return_value = { + "successfulRecords": [{"memoryRecordId": "mem-retry-1"}], + "failedRecords": [], + } + monkeypatch.setattr( + "better_memory.cli.agentcore._build_data_client", + lambda region: data, + ) + monkeypatch.setattr("better_memory.cli.agentcore.time.sleep", lambda _s: None) + + rc = _handle_smoke(_make_args(tmp_path)) + assert rc == 0 + assert data.get_memory_record.call_count == 2 def test_smoke_exits_1_when_any_step_fails(tmp_path, monkeypatch) -> None: diff --git a/tests/e2e/_fake_agentcore.py b/tests/e2e/_fake_agentcore.py index 831f3ec..6c130b9 100644 --- a/tests/e2e/_fake_agentcore.py +++ b/tests/e2e/_fake_agentcore.py @@ -17,7 +17,15 @@ per-request behavior); * any un-canned (or unroutable) request gets a ``200 {}`` fallback — botocore's rest-json parser tolerates missing output members, and the - repo's backend code uses ``response.get(...)`` throughout. + repo's backend code uses ``response.get(...)`` throughout; +* the live-verified record-plane dialect is ENFORCED before any canned + response is consulted (see :func:`dialect_violation`): unknown + BatchCreate/BatchUpdate record keys and non-indexed metadata-filter keys + come back as HTTP 400 ValidationException, and BatchUpdate metadata + violations (reserved ``x-amz-agentcore-memory-*`` keys, a non-STRING + ``last_credited_at``) come back the way real AWS reports them — HTTP 200 + with ``failedRecords`` — so the hermetic tier cannot lie about shapes + real AWS rejects. Plain HTTP, no TLS — verified end-to-end against boto3 1.43.14 (the ``AWS_ENDPOINT_URL`` seam covers both the ``bedrock-agentcore`` data plane @@ -56,6 +64,170 @@ _PLACEHOLDER_RE = re.compile(r"\{([^}]+)\}") +# --------------------------------------------------------------------------- +# Live-verified record-plane dialect rules (aws_record_dialect.md, scouted +# against real eu-west-2 on 2026-07-12). The fake REJECTS what real AWS +# rejects so the hermetic tier can never lie about this dialect again. +# --------------------------------------------------------------------------- + +#: BatchCreateMemoryRecords per-record input keys. memoryRecordId is NOT +#: legal — the server mints the durable id. +_BATCH_CREATE_ALLOWED_KEYS = frozenset( + { + "requestIdentifier", + "namespaces", + "content", + "timestamp", + "memoryStrategyId", + "metadata", + } +) +_BATCH_CREATE_REQUIRED_KEYS = frozenset( + {"requestIdentifier", "namespaces", "content", "timestamp"} +) + +#: BatchUpdateMemoryRecords per-record input keys. +_BATCH_UPDATE_ALLOWED_KEYS = frozenset( + { + "memoryRecordId", + "timestamp", + "content", + "namespaces", + "memoryStrategyId", + "metadata", + } +) +_BATCH_UPDATE_REQUIRED_KEYS = frozenset({"memoryRecordId", "timestamp"}) + +#: Only the CreateMemory indexedKeys are legal metadata-filter keys — +#: mirrors better_memory.cli._agentcore_strategies.INDEXED_KEYS. polarity, +#: outcome, useful_count etc. are all rejected by real AWS. +_VALID_METADATA_FILTER_KEYS = frozenset( + {"status", "last_credited_at", "overlooked_count"} +) + +#: System-managed metadata keys; echoing them back on update is a real 400. +_RESERVED_METADATA_PREFIX = "x-amz-agentcore-memory-" + + +def _validation_exception(message: str) -> tuple[int, dict[str, Any]]: + """HTTP 400 ValidationException payload (botocore parses __type/message).""" + return 400, {"__type": "ValidationException", "message": message} + + +def _check_metadata_filters(body: Any) -> tuple[int, dict[str, Any]] | None: + filters = body.get("metadataFilters") or ( + (body.get("searchCriteria") or {}).get("metadataFilters") + ) or [] + for entry in filters: + key = (entry.get("left") or {}).get("metadataKey") + if key not in _VALID_METADATA_FILTER_KEYS: + # Verbatim live error text (the real message does NOT enumerate + # the valid set). + return _validation_exception( + f"Filter key '{key}' is not a valid filter key" + ) + if not body.get("namespace") and not body.get("namespacePath"): + return _validation_exception( + "At least one of 'namespace' or 'namespacePath' must be provided" + ) + return None + + +def _check_batch_update_record_metadata(rec: dict[str, Any]) -> str | None: + """Return the failedRecords errorMessage for an invalid metadata map.""" + metadata = rec.get("metadata") + if not isinstance(metadata, dict): + return None + reserved = sorted(k for k in metadata if k.startswith(_RESERVED_METADATA_PREFIX)) + if reserved: + return ( + "Metadata keys cannot use reserved names or prefixes: " + + ", ".join(reserved) + ) + last_credited = metadata.get("last_credited_at") + if isinstance(last_credited, dict) and "stringValue" not in last_credited: + # last_credited_at is declared STRING in indexedKeys; dateTimeValue + # (or any other type) fails the whole record update on real AWS. + return ( + "Metadata key 'last_credited_at' value type does not match " + "declared indexed key type 'STRING'." + ) + return None + + +def dialect_violation( + operation: str | None, body: Any +) -> tuple[int, dict[str, Any]] | None: + """Return an overriding ``(http_status, payload)`` when the request + violates the live-verified record-plane dialect, else ``None`` (the + canned response is used). Batch-update metadata violations come back the + way real AWS reports them: HTTP 200 with ``failedRecords`` entries.""" + if not isinstance(body, dict): + return None + + if operation == "BatchCreateMemoryRecords": + for i, rec in enumerate(body.get("records") or []): + unknown = sorted(set(rec) - _BATCH_CREATE_ALLOWED_KEYS) + if unknown: + return _validation_exception( + f"Unknown parameter in records[{i}]: " + f"{', '.join(unknown)}, must be one of: " + + ", ".join(sorted(_BATCH_CREATE_ALLOWED_KEYS)) + ) + missing = sorted(_BATCH_CREATE_REQUIRED_KEYS - set(rec)) + if missing: + return _validation_exception( + f"Missing required parameter in records[{i}]: " + + ", ".join(missing) + ) + return None + + if operation in ("ListMemoryRecords", "RetrieveMemoryRecords"): + return _check_metadata_filters(body) + + if operation == "BatchUpdateMemoryRecords": + successful: list[dict[str, Any]] = [] + failed: list[dict[str, Any]] = [] + for i, rec in enumerate(body.get("records") or []): + unknown = sorted(set(rec) - _BATCH_UPDATE_ALLOWED_KEYS) + if unknown: + return _validation_exception( + f"Unknown parameter in records[{i}]: " + f"{', '.join(unknown)}, must be one of: " + + ", ".join(sorted(_BATCH_UPDATE_ALLOWED_KEYS)) + ) + missing = sorted(_BATCH_UPDATE_REQUIRED_KEYS - set(rec)) + if missing: + return _validation_exception( + f"Missing required parameter in records[{i}]: " + + ", ".join(missing) + ) + error_message = _check_batch_update_record_metadata(rec) + if error_message is not None: + failed.append( + { + "memoryRecordId": rec.get("memoryRecordId", ""), + "status": "FAILED", + "errorCode": 400, + "errorMessage": error_message, + } + ) + else: + successful.append( + { + "memoryRecordId": rec.get("memoryRecordId", ""), + "status": "SUCCEEDED", + } + ) + if failed: + # Real AWS: HTTP 200, per-record failures in failedRecords — + # never a raised ClientError. + return 200, {"successfulRecords": successful, "failedRecords": failed} + return None + + return None + def _uri_to_regex(request_uri: str) -> re.Pattern[str]: """Compile a botocore ``requestUri`` (e.g. ``/memories/{memoryId}/events``) @@ -265,12 +437,23 @@ def _serve(self, handler: BaseHTTPRequestHandler) -> None: with self._lock: self.requests.append(record) - payload = self._responses.get(operation or "", {}) - if callable(payload): - payload = payload(record) + # Dialect enforcement BEFORE the canned-response lookup: requests + # real AWS rejects must fail here too, no matter what a test canned. + override = dialect_violation(operation, body) + if override is not None: + status_code, payload = override + else: + status_code = 200 + payload = self._responses.get(operation or "", {}) + if callable(payload): + payload = payload(record) data = json.dumps(payload).encode("utf-8") - handler.send_response(200) + handler.send_response(status_code) + if status_code == 400: + # botocore's rest-json error parser reads this header (and the + # __type body member) to raise ClientError ValidationException. + handler.send_header("x-amzn-ErrorType", "ValidationException") handler.send_header("Content-Type", "application/json") handler.send_header("Content-Length", str(len(data))) handler.end_headers() diff --git a/tests/e2e/test_agentcore_journey.py b/tests/e2e/test_agentcore_journey.py index 675640d..74afc5c 100644 --- a/tests/e2e/test_agentcore_journey.py +++ b/tests/e2e/test_agentcore_journey.py @@ -236,10 +236,11 @@ def test_j2_session_bootstrap_hook_reaches_agentcore( clean_slate_home: Path, tmp_path: Path ) -> None: """SessionStart hook under the onboarding config routes through - ``build_backend`` to ``AgentCoreBackend.session_bootstrap``: exactly 4 - ListMemoryRecords (3 EPI reflection buckets + 1 SEM), the envelope - carries the reflection/semantic summary, and NO memory content lands - in local sqlite. + ``build_backend`` to ``AgentCoreBackend.session_bootstrap``: exactly 3 + ListMemoryRecords (2 EPI reflection namespaces — project + the + general/promoted merge, polarity bucketed client-side — + 1 SEM), the + envelope carries the reflection/semantic summary, and NO memory + content lands in local sqlite. regression_caught: reverting the hook's build_backend routing (fix Task 3) falls back to SessionBootstrapService on sqlite — zero wire @@ -270,10 +271,13 @@ def test_j2_session_bootstrap_hook_reaches_agentcore( assert "Semantic memories:" in ctx lists = fake.requests_for("ListMemoryRecords") - assert len(lists) == 4 - assert len(fake.requests) == 4 - assert sum("EPI-FAKE-0001" in r.path for r in lists) == 3 + assert len(lists) == 3 + assert len(fake.requests) == 3 + assert sum("EPI-FAKE-0001" in r.path for r in lists) == 2 assert sum("SEM-FAKE-0001" in r.path for r in lists) == 1 + # No metadataFilters anywhere — polarity is not a legal filter key + # on real AWS (the fake 400s it) and status is client-side. + assert all("metadataFilters" not in r.body for r in lists) # No memory-content sqlite write (a hook_errors-only migration would be # tolerated; memory content must be zero either way). @@ -373,6 +377,13 @@ async def test_j3_observe_then_retrieve_observations_chains_aws_id( # Metadata flattened out of the stringValue wrappers. assert row["outcome"] == "failure" assert row["theme"] == "bug" + # Key parity with the sqlite retrieve_observations rows — + # no agentcore-only keys (session_id/actor_id/ + # event_timestamp) leak to the MCP payload. + assert set(row) == { + "id", "content", "component", "theme", "outcome", + "reinforcement_score", "created_at", + } # No sqlite leakage: the observation never landed locally. assert _row_count(bm_home / "memory.db", "observations") == 0 @@ -386,13 +397,15 @@ async def test_j3_observe_then_retrieve_observations_chains_aws_id( async def test_j4_retrieve_fans_out_three_epi_list_calls( clean_slate_home: Path, tmp_path: Path ) -> None: - """``memory.retrieve`` under settings.json activation: exactly 3 - ListMemoryRecords to the EPI reflections namespace; buckets come back - as exactly ``{"do": [], "dont": [], "neutral": []}``. Polarity-filter - internals + the single-polarity restriction are owned by D2. - - regression_caught: collapsing the fan-out or reverting dispatch makes - the count != 3. + """``memory.retrieve`` under settings.json activation: exactly 2 + ListMemoryRecords to the EPI memory — the project reflections + namespace plus the general/promoted merge — with no metadataFilters; + buckets come back as exactly ``{"do": [], "dont": [], "neutral": []}``. + Client-side bucketing internals are owned by D2. + + regression_caught: collapsing to a single namespace (promoted records + invisible) or reverting dispatch makes the count != 2; resurrecting + the polarity server filter now 400s against the dialect-enforcing fake. """ with FakeAgentCore() as fake: _onboard(clean_slate_home) @@ -409,11 +422,15 @@ async def test_j4_retrieve_fans_out_three_epi_list_calls( assert buckets == {"do": [], "dont": [], "neutral": []} lists = fake.requests_for("ListMemoryRecords") - assert len(lists) == 3 - assert len(fake.requests) == 3 + assert len(lists) == 2 + assert len(fake.requests) == 2 for request in lists: assert "EPI-FAKE-0001" in request.path - assert "reflections" in request.body.get("namespace", "") + assert "metadataFilters" not in request.body + assert {r.body.get("namespace") for r in lists} == { + "projects/e2e-project/reflections/", + "general/reflections/", + } # --------------------------------------------------------------------------- @@ -447,7 +464,9 @@ async def test_j5_semantic_observe_then_retrieve_merges_two_namespaces( }, ) # The same canned summary answers BOTH list calls — the merged - # payload having exactly one item proves the id-dedupe. + # payload having exactly one item proves the id-dedupe. The stored + # namespace carries the leading slash real AWS adds on read-back + # (live dialect) — the scope classifier must normalize it. fake.set_response( "ListMemoryRecords", { @@ -455,7 +474,7 @@ async def test_j5_semantic_observe_then_retrieve_merges_two_namespaces( { "memoryRecordId": "sem-journey-1", "content": {"text": "journey-sem-marker"}, - "namespaces": ["projects/e2e-project/semantic/"], + "namespaces": ["/projects/e2e-project/semantic/"], } ] }, @@ -498,6 +517,8 @@ async def test_j5_semantic_observe_then_retrieve_merges_two_namespaces( item = merged[0] assert item["id"] == "sem-journey-1" # cross-step id chain assert item["content"] == "journey-sem-marker" + # "/projects/..." (stored leading slash) classifies as + # project, not general — the read-back normalization. assert item["scope"] == "project" # Stable keys, present as None (agentcore records carry no # project/created_at/updated_at — UD-2 payload contract). @@ -625,8 +646,12 @@ async def test_j6_rating_loop_guard_and_empty_exposures( }, ) ) - assert applied["applied"] == 1 - assert applied["failed"] == 0 + # Sqlite-parity envelope: per-class applied counts + the + # per-reason skipped counts, keyed by session_id. + assert applied["session_id"] == "e2e-session-1" + assert applied["applied"]["cited"] == 1 + assert sum(applied["applied"].values()) == 1 + assert sum(applied["skipped"].values()) == 0 gets = fake.requests_for("GetMemoryRecord") updates = fake.requests_for("BatchUpdateMemoryRecords") assert len(gets) == 1 @@ -655,7 +680,7 @@ def test_j7_contextual_inject_honours_settings_activation( ) -> None: """The per-prompt hook reaches AWS with NO backend env var — proving ``contextual_inject``'s resolver honours settings.json (the load- - bearing difference from D6-A, which force-sets the env var): 3 EPI + + bearing difference from D6-A, which force-sets the env var): 2 EPI + 1 SEM list calls and a ```` block in the envelope. regression_caught: reverting contextual_inject to env-only backend @@ -693,7 +718,9 @@ def test_j7_contextual_inject_honours_settings_activation( assert "refl-fake-docker" in rendered paths = [r.path for r in fake.requests] - assert sum("EPI-FAKE-0001" in p for p in paths) == 3 + # retrieve → 2 EPI namespace calls (project + general/promoted + # merge); semantic_list → 1 SEM call. + assert sum("EPI-FAKE-0001" in p for p in paths) == 2 assert sum("SEM-FAKE-0001" in p for p in paths) == 1 diff --git a/tests/e2e/test_agentcore_t2.py b/tests/e2e/test_agentcore_t2.py index fe25300..734e944 100644 --- a/tests/e2e/test_agentcore_t2.py +++ b/tests/e2e/test_agentcore_t2.py @@ -77,21 +77,14 @@ def _tool_text(result: Any) -> str: return result.content[0].text -def _polarity_filter_value(body: dict) -> str | None: - for entry in body.get("metadataFilters", []): - if entry.get("left", {}).get("metadataKey") == "polarity": - return entry["right"]["metadataValue"]["stringValue"] - return None - - -def _has_active_status_filter(body: dict) -> bool: - return any( - entry.get("left", {}).get("metadataKey") == "status" - and entry.get("operator") == "EQUALS_TO" - and entry.get("right", {}).get("metadataValue", {}).get("stringValue") - == "active" +def _filter_keys(body: dict) -> set[str]: + """metadataFilters keys present on a ListMemoryRecords body (empty set + when the request carries no filters — the live-verified dialect for the + reflections read path: polarity is NOT a legal filter key).""" + return { + entry.get("left", {}).get("metadataKey") for entry in body.get("metadataFilters", []) - ) + } # --------------------------------------------------------------------------- @@ -140,16 +133,18 @@ async def test_boot_hides_synthesize_tools_and_still_migrates_sqlite( class TestRetrievePolarityFanout: - async def test_retrieve_fans_out_three_filtered_list_records( + async def test_retrieve_fans_out_two_namespace_list_records( self, clean_slate_home: Path ) -> None: """``memory.retrieve`` is the ONE data tool wired to ``AgentCoreBackend`` (ReflectionToolHandlers → backend.retrieve): - exactly 3 ListMemoryRecords against the EPISODIC memory's - reflections namespace, order-insensitive polarity set - {do,dont,neutral}, each carrying a status=active filter; buckets - come back as exact empty lists. A single-polarity call restricts - the fan-out to exactly 1 request.""" + exactly 2 ListMemoryRecords against the EPISODIC memory — the + project reflections namespace plus general/reflections/ (the + promoted merge) — carrying NO metadataFilters at all (live AWS + rejects 'polarity' as a filter key, and the fake now 400s it too; + polarity/status filtering is client-side); buckets come back as + exact empty lists. A single-polarity call keeps the same wire + fan-out (polarity cannot narrow the wire any more).""" bm_home = _bm_home(clean_slate_home) with FakeAgentCore() as fake: write_fake_agentcore_json(bm_home) @@ -168,29 +163,33 @@ async def test_retrieve_fans_out_three_filtered_list_records( assert buckets["neutral"] == [] requests = list(fake.requests) - # Exactly 3 wire requests, all ListMemoryRecords — a - # collapsed/unfiltered rewrite flips this red. - assert len(requests) == 3 + # Exactly 2 wire requests, all ListMemoryRecords — a + # collapsed single-namespace rewrite (promoted records + # invisible again) or a resurrected polarity filter flips + # this red. + assert len(requests) == 2 assert {r.operation for r in requests} == {"ListMemoryRecords"} for request in requests: assert "EPI-FAKE-0001" in request.path - assert "reflections" in request.body.get("namespace", "") - assert _has_active_status_filter(request.body), request.body - polarities = { - _polarity_filter_value(r.body) for r in requests - } + assert _filter_keys(request.body) == set(), request.body # Order-insensitive: ThreadPoolExecutor fan-out is unordered. - assert polarities == {"do", "dont", "neutral"} + assert {r.body.get("namespace") for r in requests} == { + "projects/e2e-project/reflections/", + "general/reflections/", + } - # Single-polarity leg: exactly one request, filter 'do'. + # Single-polarity leg: same 2-namespace wire fan-out, + # non-requested buckets still exactly empty. fake.clear() result2 = await session.call_tool( "memory.retrieve", {"polarity": "do"} ) assert not result2.isError + buckets2 = json.loads(_tool_text(result2)) + assert buckets2["dont"] == [] + assert buckets2["neutral"] == [] single = fake.requests_for("ListMemoryRecords") - assert len(single) == 1 - assert _polarity_filter_value(single[0].body) == "do" + assert len(single) == 2 # --------------------------------------------------------------------------- @@ -553,7 +552,11 @@ def test_record_use_strips_system_metadata_full_snapshot( assert metadata["useful_count"]["numberValue"] == pytest.approx(3) # Full snapshot, not a diff: non-counter keys preserved. assert metadata["status"] == {"stringValue": "active"} - assert "last_credited_at" in metadata # presence only + # last_credited_at as stringValue iso8601 — the indexedKey is + # declared STRING; real AWS fails the whole record update on a + # dateTimeValue (and the fake now mirrors that failedRecords + # rejection, so this line is belt-and-braces). + assert set(metadata["last_credited_at"]) == {"stringValue"} def test_credit_one_semantic_kind_routes_to_sem_and_strips( self, tmp_path: Path @@ -593,7 +596,8 @@ def test_credit_one_semantic_kind_routes_to_sem_and_strips( id=_SEM_RECORD_ID, classification="cited", ) - assert result == {"applied": _SEM_RECORD_ID, "skipped": None} + # Sqlite parity: the applied CLASSIFICATION, not the record id. + assert result == {"applied": "cited", "skipped": None} gets = fake.requests_for("GetMemoryRecord") updates = fake.requests_for("BatchUpdateMemoryRecords") @@ -608,7 +612,111 @@ def test_credit_one_semantic_kind_routes_to_sem_and_strips( ), metadata # cited → useful_count += 1 (4 → 5). assert metadata["useful_count"]["numberValue"] == pytest.approx(5) - assert "last_credited_at" in metadata + assert set(metadata["last_credited_at"]) == {"stringValue"} + + +@pytest.mark.usefixtures("scrubbed_aws_process_env") +class TestFakeDialectEnforcement: + """The fake REJECTS what real AWS rejects (aws_record_dialect.md) — a + hermetic tripwire so no future rewrite can reintroduce the shapes the + live run caught. Raw boto3 against the fake, no product code.""" + + def _client(self, fake: FakeAgentCore) -> Any: + import boto3 + from botocore.config import Config as BotoConfig + + return boto3.client( + "bedrock-agentcore", + endpoint_url=fake.endpoint_url, + region_name="eu-west-2", + aws_access_key_id="bm-e2e-fake", + aws_secret_access_key="bm-e2e-fake-secret", + config=BotoConfig(retries={"mode": "standard", "max_attempts": 1}), + ) + + def test_polarity_metadata_filter_is_rejected(self) -> None: + """polarity is not a CreateMemory indexedKey → ValidationException + with the verbatim live error text (this is what broke retrieve's + fan-out + session_bootstrap against real AWS).""" + from botocore.exceptions import ClientError + + with FakeAgentCore() as fake: + fake.set_response("ListMemoryRecords", {"memoryRecordSummaries": []}) + client = self._client(fake) + with pytest.raises(ClientError, match="not a valid filter key"): + client.list_memory_records( + memoryId="EPI-FAKE-0001", + namespace="projects/x/reflections/", + metadataFilters=[{ + "left": {"metadataKey": "polarity"}, + "operator": "EQUALS_TO", + "right": {"metadataValue": {"stringValue": "do"}}, + }], + ) + # An indexed key still passes and reaches the canned response. + ok = client.list_memory_records( + memoryId="EPI-FAKE-0001", + namespace="projects/x/reflections/", + metadataFilters=[{ + "left": {"metadataKey": "status"}, + "operator": "EQUALS_TO", + "right": {"metadataValue": {"stringValue": "active"}}, + }], + ) + assert ok["memoryRecordSummaries"] == [] + + def test_batch_update_datetime_last_credited_at_fails_record(self) -> None: + """A dateTimeValue last_credited_at comes back exactly the way real + AWS reports it: HTTP 200 with a failedRecords entry (the live root + cause of apply_session_ratings applied:0/failed:1) — regardless of + any canned success response.""" + from datetime import UTC, datetime + + with FakeAgentCore() as fake: + fake.set_response( + "BatchUpdateMemoryRecords", + { + "successfulRecords": [{"memoryRecordId": _REFL_RECORD_ID}], + "failedRecords": [], + }, + ) + client = self._client(fake) + response = client.batch_update_memory_records( + memoryId="EPI-FAKE-0001", + records=[{ + "memoryRecordId": _REFL_RECORD_ID, + "timestamp": datetime.now(UTC), + "metadata": { + "last_credited_at": {"dateTimeValue": datetime.now(UTC)}, + }, + }], + ) + assert response["successfulRecords"] == [] + failed = response["failedRecords"] + assert len(failed) == 1 + assert "last_credited_at" in failed[0]["errorMessage"] + assert "STRING" in failed[0]["errorMessage"] + + def test_batch_update_reserved_metadata_keys_fail_record(self) -> None: + with FakeAgentCore() as fake: + from datetime import UTC, datetime + + client = self._client(fake) + response = client.batch_update_memory_records( + memoryId="EPI-FAKE-0001", + records=[{ + "memoryRecordId": _REFL_RECORD_ID, + "timestamp": datetime.now(UTC), + "metadata": { + "x-amz-agentcore-memory-recordType": { + "stringValue": "BASE" + }, + }, + }], + ) + failed = response["failedRecords"] + assert len(failed) == 1 + assert "reserved names or prefixes" in failed[0]["errorMessage"] # --------------------------------------------------------------------------- @@ -777,6 +885,9 @@ def test_stop_hook_sqlite_default_ignores_agentcore_json_existence( "metadata": { "useful_count": {"numberValue": 2}, "status": {"stringValue": "active"}, + # Client-side bucketing (polarity is not a legal AWS filter key): + # 'do' keeps this record inside retrieve_relevant's do/dont order. + "polarity": {"stringValue": "do"}, }, } @@ -826,9 +937,10 @@ def test_case_a_happy_path_hits_both_memories_and_injects( assert "refl-fake-docker" in rendered paths = [r.path for r in fake.requests] - # backend.retrieve → 3 polarity-filtered EPI list calls; + # backend.retrieve → 2 namespace EPI list calls (project + + # general/promoted merge, polarity client-side); # backend.semantic_list → 1 SEM list call. - assert sum("EPI-FAKE-0001" in p for p in paths) == 3 + assert sum("EPI-FAKE-0001" in p for p in paths) == 2 assert sum("SEM-FAKE-0001" in p for p in paths) == 1 def test_case_b_misconfig_clean_slate_silent_noop_with_stray_db( @@ -969,7 +1081,7 @@ async def test_server_and_hook_both_sign_json_region( assert not result.isError server_requests = fake.requests_for("ListMemoryRecords") - assert len(server_requests) == 3 + assert len(server_requests) == 2 for request in server_requests: # Single source of truth: the json's region, never a # product default. diff --git a/tests/e2e/test_sqlite_journey.py b/tests/e2e/test_sqlite_journey.py index fe563fa..e6c1e1a 100644 --- a/tests/e2e/test_sqlite_journey.py +++ b/tests/e2e/test_sqlite_journey.py @@ -31,6 +31,7 @@ import pytest +from better_memory.runtime.session_marker import encode_project_dir from tests.e2e._env import isolated_env from tests.e2e.conftest import mcp_session, run_hook @@ -223,14 +224,17 @@ async def test_first_boot_migrates_tools_knowledge( def test_hook_before_server_degraded( clean_slate_home: Path, tmp_path: Path ) -> None: - """KNOWN-DEFECT PIN (design §4 item 3): SessionStart hook fired before - any server boot on a virgin home. Contract: exit 0, empty stderr, a - single fallback-directive envelope, a schema-less ``memory.db`` (the - file exists, 0 tables), NO ``runtime/sessions`` marker, no other dirs, - and the ``hook_errors`` write silently no-ops (no table to insert into). - - This test flips red on the intended fix (hook-side migrations or a - hoisted ``write_session_id``) — that is deliberate: the fix must update + """SessionStart hook fired before any server boot on a virgin home. + Contract: exit 0, empty stderr, a single fallback-directive envelope, a + schema-less ``memory.db`` (the file exists, 0 tables), the + ``runtime/sessions`` marker IS written with the payload's session id + (``write_session_id`` is hoisted above the bootstrap call — the id + comes from stdin, so even the degraded first session leaves a valid + bridge for the server to resolve), no spool, and the ``hook_errors`` + write silently no-ops (no table to insert into). + + The remaining KNOWN-DEFECT PIN (design §4 item 3) is the no-migrations + half: flipping the 0-tables assertion (hook-side migrations) must update this contract and the heal-sequence test together. """ env = isolated_env(clean_slate_home) @@ -259,15 +263,23 @@ def test_hook_before_server_degraded( assert db_path.exists() assert _table_names(db_path) == set(), "hook must not run migrations" - # Marker skipped: write_session_id sits after bootstrap() in the try. - assert not (bm_home / "runtime").exists() + # Marker WRITTEN before bootstrap (hoisted write_session_id): the id + # comes from the stdin payload, so the degraded path still leaves a + # valid session bridge keyed by the payload cwd. Exactly one file — + # no .sid-* tempfile droppings. + sessions_dir = bm_home / "runtime" / "sessions" + marker = sessions_dir / encode_project_dir(str(proj_dir)) + assert marker.is_file(), f"session marker missing: {marker}" + assert marker.read_text(encoding="utf-8").strip() == "e2e-session-1" + assert [p for p in sessions_dir.iterdir()] == [marker] assert not (bm_home / "spool").exists() - # No other dirs at all; only the db file (+ possible WAL side files). - assert [p for p in bm_home.iterdir() if p.is_dir()] == [] + # Only the marker tree beside the db file (+ possible WAL side files). + assert [p.name for p in bm_home.iterdir() if p.is_dir()] == ["runtime"] assert {p.name for p in bm_home.iterdir()} <= { "memory.db", "memory.db-wal", "memory.db-shm", + "runtime", } @@ -280,12 +292,13 @@ async def test_hook_first_then_server_heals( clean_slate_home: Path, tmp_path: Path ) -> None: """The real new-user SEQUENCE: degraded SessionStart hook leaves a - schema-less WAL ``memory.db`` → the first server boot heals it - (migrations apply onto the pre-existing 0-table file) → the fallback - directive's promised ``memory.session_bootstrap`` MCP tool works - (``episode.action == 'opened'``) → a re-run of the hook reports - ``Episode: reused`` and writes the session marker. Exactly one open - episode row exists throughout. + schema-less WAL ``memory.db`` plus the session marker (hoisted + ``write_session_id`` fires before bootstrap) → the first server boot + heals the db (migrations apply onto the pre-existing 0-table file) → + the fallback directive's promised ``memory.session_bootstrap`` MCP tool + works (``episode.action == 'opened'``) → a re-run of the hook reports + ``Episode: reused`` and the marker still holds (rewritten to the same + key — exactly one file). Exactly one open episode row exists throughout. """ env = isolated_env(clean_slate_home) proj_dir = tmp_path / "proj" @@ -300,7 +313,15 @@ async def test_hook_first_then_server_heals( assert ctx.startswith("better-memory: session bootstrap failed (") db_path = bm_home / "memory.db" assert _table_names(db_path) == set() - assert not (bm_home / "runtime" / "sessions").exists() + # Hoisted write_session_id: the marker lands even on the degraded leg, + # so the server booted in leg 2 can already resolve the session id. + degraded_marker = ( + bm_home / "runtime" / "sessions" / encode_project_dir(str(proj_dir)) + ) + assert degraded_marker.is_file() + assert ( + degraded_marker.read_text(encoding="utf-8").strip() == "e2e-session-1" + ) # Leg 2 — server boots onto the pre-existing 0-table WAL file and # heals it; the directive's remediation tool actually works. diff --git a/tests/e2e_meta/mutations/M2.patch b/tests/e2e_meta/mutations/M2.patch index 3208b66..70ece68 100644 --- a/tests/e2e_meta/mutations/M2.patch +++ b/tests/e2e_meta/mutations/M2.patch @@ -1,36 +1,33 @@ diff --git a/better_memory/hooks/session_bootstrap.py b/better_memory/hooks/session_bootstrap.py -index d862de1..5097199 100644 +index 264229e..ad69d10 100644 --- a/better_memory/hooks/session_bootstrap.py +++ b/better_memory/hooks/session_bootstrap.py -@@ -69,6 +69,13 @@ def main() -> None: +@@ -69,25 +69,9 @@ def main() -> None: try: cfg = get_config() -+ # MUTATION M2: write_session_id hoisted above the bootstrap work — -+ # the marker now lands even when bootstrap fails on a virgin home. -+ write_session_id( -+ cfg.home, -+ session_id, -+ project_dir=os.environ.get("CLAUDE_PROJECT_DIR") or cwd_str, -+ ) - if cfg.storage_backend == "agentcore": - # Route through the storage factory like contextual_inject.py: - # agentcore mode must never open the local sqlite database. The -@@ -95,17 +102,6 @@ def main() -> None: - source=source, session_id=session_id, cwd=Path(cwd_str), - ) - rendered = result.additional_context - # Bridge session_id to the MCP server: it doesn't see CLAUDE_SESSION_ID - # in its spawn env. See better_memory/runtime/session_marker.py. - # Resolution MUST match read_session_id's _resolve_project_dir order: - # CLAUDE_PROJECT_DIR env first, then a non-empty fallback. The hook has - # the payload's cwd as a stronger fallback than os.getcwd() because the - # MCP server's cwd may not equal the project root. +- # +- # Deliberately hoisted ABOVE the bootstrap/backend call (both modes): +- # the session id comes from the stdin payload, not from bootstrap, so +- # the marker is valid even when bootstrap fails (e.g. virgin home, +- # missing agentcore.json) — the first-session server can then resolve +- # the session id and the manual memory_session_bootstrap remediation +- # works on the first try. write_session_id is best-effort (it swallows +- # OSError internally); a marker failure never blocks the bootstrap. - write_session_id( - cfg.home, - session_id, - project_dir=os.environ.get("CLAUDE_PROJECT_DIR") or cwd_str, - ) - except BaseException as exc: # noqa: BLE001 - try: - record_hook_error(hook_name="session_bootstrap", exc=exc) ++ # MUTATION M2: write_session_id call deleted — the session-id bridge ++ # marker is never written, so the MCP server can never resolve the ++ # hook's session id from the runtime/sessions marker. + if cfg.storage_backend == "agentcore": + # Route through the storage factory like contextual_inject.py: + # agentcore mode must never open the local sqlite database. The diff --git a/tests/hooks/test_session_bootstrap.py b/tests/hooks/test_session_bootstrap.py index c582294..b681d0b 100644 --- a/tests/hooks/test_session_bootstrap.py +++ b/tests/hooks/test_session_bootstrap.py @@ -106,6 +106,42 @@ def test_hook_falls_back_on_db_failure(tmp_path, git_cwd): assert "memory_session_bootstrap" in text +def test_hook_writes_marker_even_when_bootstrap_fails(tmp_path, git_cwd): + """Fix 6 pin: write_session_id is hoisted ABOVE the bootstrap call, so + the degraded path (bootstrap raises) still leaves a valid session marker + — the session id comes from the stdin payload, not from bootstrap, so + the first-session server can resolve it and the fallback directive's + manual memory_session_bootstrap remediation works on the first try.""" + from better_memory.runtime.session_marker import read_session_id + + bad_home = tmp_path / "bad-home" + bad_home.mkdir() + (bad_home / "memory.db").mkdir() # directory in place of file → bootstrap fails + payload = json.dumps({ + "source": "startup", + "session_id": "degraded-sid", + "cwd": str(git_cwd), + }) + + proc = _run_hook( + bad_home, + stdin=payload, + cwd=git_cwd, + # Pin the marker key deterministically (the outer test env may or + # may not carry CLAUDE_PROJECT_DIR). + extra_env={"CLAUDE_PROJECT_DIR": str(git_cwd)}, + ) + + assert proc.returncode == 0 + out = json.loads(proc.stdout) + text = out["hookSpecificOutput"]["additionalContext"] + assert "session bootstrap failed" in text + # Marker written despite the failure — hoisted, best-effort contract. + assert ( + read_session_id(bad_home, project_dir=str(git_cwd)) == "degraded-sid" + ) + + def test_hook_handles_oversized_stdin(home_with_schema, git_cwd): # Pipe ~1.5 MiB of garbage. Hook must drop it and proceed with defaults. big = "x" * (1_572_864) # 1.5 MiB @@ -344,12 +380,17 @@ def test_agentcore_mode_backend_failure_falls_back_to_directive( ): """Graceful degradation: build_backend failing (e.g. agentcore.json missing) must not crash the hook and must NOT fall back to sqlite — - the envelope carries the manual-bootstrap directive instead.""" + the envelope carries the manual-bootstrap directive instead. The + session marker is STILL written (fix 6: hoisted write_session_id) so + the manual remediation can resolve the session id.""" + from better_memory.runtime.session_marker import read_session_id + home = tmp_path / "bm-home" proj = tmp_path / "proj" proj.mkdir() monkeypatch.setenv("BETTER_MEMORY_HOME", str(home)) monkeypatch.setenv("BETTER_MEMORY_STORAGE_BACKEND", "agentcore") + monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False) connect_calls: list = [] monkeypatch.setattr( @@ -376,6 +417,8 @@ def _boom(**kwargs): assert "memory_session_bootstrap" in text # Misconfigured agentcore must not silently degrade INTO sqlite. assert connect_calls == [] + # Fix 6: marker written BEFORE the failing backend call. + assert read_session_id(home, project_dir=str(proj)) == "ac-sess-2" def test_sqlite_mode_never_consults_build_backend( diff --git a/tests/hooks/test_session_close_agentcore.py b/tests/hooks/test_session_close_agentcore.py index 627e27f..5586dcb 100644 --- a/tests/hooks/test_session_close_agentcore.py +++ b/tests/hooks/test_session_close_agentcore.py @@ -110,8 +110,20 @@ def agentcore_home_no_env(tmp_path, monkeypatch): return tmp_path -def test_agentcore_mode_fires_closure_event(agentcore_config_present, monkeypatch): - """In agentcore mode, the Stop hook fires one CreateEvent with role=OTHER.""" +def test_agentcore_mode_fires_closure_event( + agentcore_config_present, tmp_path, monkeypatch +): + """In agentcore mode, the Stop hook fires one CreateEvent with role=OTHER. + + The actorId comes from ``config.project_name(Path(cwd))`` — proven here + via a ``.better-memory`` override file in the cwd, which a plain + ``basename(cwd)`` resolution would ignore (fix 7: hook/server actor-id + parity).""" + monkeypatch.delenv("BETTER_MEMORY_PROJECT", raising=False) + proj_dir = tmp_path / "some-worktree-dir" + proj_dir.mkdir() + (proj_dir / ".better-memory").write_text("testproj", encoding="utf-8") + fake_data_client = MagicMock(name="bedrock-agentcore-data") fake_data_client.create_event.return_value = {"event": {"eventId": "evt-close"}} @@ -121,17 +133,62 @@ def test_agentcore_mode_fires_closure_event(agentcore_config_present, monkeypatc ) from better_memory.hooks.session_close import _fire_agentcore_closure - rc = _fire_agentcore_closure(session_id="test-sess-abc", project="testproj") + rc = _fire_agentcore_closure(session_id="test-sess-abc", cwd=str(proj_dir)) assert rc is True assert fake_data_client.create_event.call_count == 1 call = fake_data_client.create_event.call_args.kwargs assert call["memoryId"] == "epi-test" assert call["sessionId"] == "test-sess-abc" + # project_name honoured the override file — NOT basename(cwd). + assert call["actorId"] == "testproj" payload = call["payload"][0]["conversational"] assert payload["role"] == "OTHER" +def test_closure_actor_id_env_override_wins( + agentcore_config_present, tmp_path, monkeypatch +): + """BETTER_MEMORY_PROJECT overrides any cwd-derived name — the exact + parity contract the server's resolve_actor_id(project_name()) follows.""" + monkeypatch.setenv("BETTER_MEMORY_PROJECT", "env-scoped-project") + proj_dir = tmp_path / "unrelated-dir" + proj_dir.mkdir() + + fake_client = MagicMock(name="bedrock-agentcore-data") + fake_client.create_event.return_value = {"event": {"eventId": "evt-close"}} + monkeypatch.setattr( + "better_memory.hooks.session_close._build_agentcore_data_client", + lambda region: fake_client, + ) + + from better_memory.hooks.session_close import _fire_agentcore_closure + rc = _fire_agentcore_closure(session_id="test-sess-abc", cwd=str(proj_dir)) + assert rc is True + call = fake_client.create_event.call_args.kwargs + assert call["actorId"] == "env-scoped-project" + + +def test_closure_actor_id_general_on_empty_cwd( + agentcore_config_present, monkeypatch +): + """Empty cwd resolves to the cross-project 'general' bucket without ever + calling project_name (no git walk on a meaningless path).""" + monkeypatch.delenv("BETTER_MEMORY_PROJECT", raising=False) + fake_client = MagicMock(name="bedrock-agentcore-data") + fake_client.create_event.return_value = {"event": {"eventId": "evt-close"}} + monkeypatch.setattr( + "better_memory.hooks.session_close._build_agentcore_data_client", + lambda region: fake_client, + ) + + from better_memory.hooks.session_close import _fire_agentcore_closure + rc = _fire_agentcore_closure(session_id="test-sess-abc", cwd=" ") + assert rc is True + call = fake_client.create_event.call_args.kwargs + assert call["actorId"] == "general" + + def test_sqlite_mode_does_not_fire_closure(monkeypatch, tmp_path): """In sqlite mode, _fire_agentcore_closure short-circuits to False.""" monkeypatch.setenv("BETTER_MEMORY_HOME", str(tmp_path)) @@ -139,7 +196,7 @@ def test_sqlite_mode_does_not_fire_closure(monkeypatch, tmp_path): monkeypatch.setenv("CLAUDE_SESSION_ID", "x") from better_memory.hooks.session_close import _fire_agentcore_closure - rc = _fire_agentcore_closure(session_id="x", project="testproj") + rc = _fire_agentcore_closure(session_id="x", cwd="") assert rc is False @@ -154,7 +211,7 @@ def test_agentcore_failure_is_non_fatal(agentcore_config_present, monkeypatch): from better_memory.hooks.session_close import _fire_agentcore_closure # Must NOT raise - rc = _fire_agentcore_closure(session_id="test-sess-abc", project="testproj") + rc = _fire_agentcore_closure(session_id="test-sess-abc", cwd="") assert rc is False @@ -216,7 +273,7 @@ def test_settings_json_resolves_agentcore_and_fires_closure( ) from better_memory.hooks.session_close import _fire_agentcore_closure - rc = _fire_agentcore_closure(session_id="test-sess-abc", project="testproj") + rc = _fire_agentcore_closure(session_id="test-sess-abc", cwd="") assert rc is True assert fake_client.create_event.call_count == 1 call = fake_client.create_event.call_args.kwargs @@ -238,7 +295,7 @@ def test_env_unset_no_settings_json_stays_sqlite_even_with_agentcore_json( ) from better_memory.hooks.session_close import _fire_agentcore_closure - rc = _fire_agentcore_closure(session_id="test-sess-abc", project="testproj") + rc = _fire_agentcore_closure(session_id="test-sess-abc", cwd="") assert rc is False assert fake_client.create_event.call_count == 0 # Silent skip, not an error path: nothing wrote memory.db. @@ -273,7 +330,7 @@ def _resolver_forbidden(): ) from better_memory.hooks.session_close import _fire_agentcore_closure - rc = _fire_agentcore_closure(session_id="test-sess-abc", project="testproj") + rc = _fire_agentcore_closure(session_id="test-sess-abc", cwd="") assert rc is False assert fake_client.create_event.call_count == 0 # Fast path is silent — no error row, no memory.db. @@ -297,7 +354,7 @@ def test_explicit_env_agentcore_wins_over_settings_json_sqlite( ) from better_memory.hooks.session_close import _fire_agentcore_closure - rc = _fire_agentcore_closure(session_id="test-sess-abc", project="testproj") + rc = _fire_agentcore_closure(session_id="test-sess-abc", cwd="") assert rc is True assert fake_client.create_event.call_count == 1 @@ -319,7 +376,7 @@ def test_corrupt_settings_json_records_hook_error_and_skips_closure( ) from better_memory.hooks.session_close import _fire_agentcore_closure - rc = _fire_agentcore_closure(session_id="test-sess-abc", project="testproj") + rc = _fire_agentcore_closure(session_id="test-sess-abc", cwd="") assert rc is False assert fake_client.create_event.call_count == 0 rows = _hook_error_rows(agentcore_home_no_env) @@ -378,7 +435,7 @@ def test_env_sqlite_fast_exit_skips_boto3_and_agentcore_imports( hits = _forbid_imports( monkeypatch, "boto3", "botocore", "agentcore_persistence" ) - rc = _fire_agentcore_closure(session_id="x", project="p") + rc = _fire_agentcore_closure(session_id="x", cwd="p") assert rc is False assert hits == [] @@ -396,7 +453,7 @@ def test_env_unset_no_settings_resolves_sqlite_without_boto3( hits = _forbid_imports( monkeypatch, "boto3", "botocore", "agentcore_persistence" ) - rc = _fire_agentcore_closure(session_id="x", project="p") + rc = _fire_agentcore_closure(session_id="x", cwd="p") assert rc is False assert hits == [] diff --git a/tests/integration/test_agentcore_live_e2e.py b/tests/integration/test_agentcore_live_e2e.py index c9ba26a..433120a 100644 --- a/tests/integration/test_agentcore_live_e2e.py +++ b/tests/integration/test_agentcore_live_e2e.py @@ -224,14 +224,31 @@ def _cleanup() -> None: request.addfinalizer(_cleanup) # --- init: provisions both memories, writes agentcore.json ------------- + # no_activate=False mirrors the CLI default (--no-activate not passed): + # init also activates the backend by writing settings.json. argparse + # would supply the attribute automatically; a hand-built Namespace must + # carry every init flag or handle() AttributeErrors mid-journey. rc = agentcore_cli.handle( argparse.Namespace( - subcommand="init", home=str(bm_home), region=agentcore_region, force=False + subcommand="init", + home=str(bm_home), + region=agentcore_region, + force=False, + no_activate=False, ) ) assert rc == 0 assert config_path.exists() + # Activation side effect (no_activate=False): settings.json persists the + # backend selection — the exact onboarding state init leaves behind. + settings_path = bm_home / "settings.json" + assert settings_path.exists() + assert ( + json.loads(settings_path.read_text(encoding="utf-8"))["storage_backend"] + == "agentcore" + ) + cfg = load_agentcore_config(bm_home) assert cfg is not None assert cfg.schema_version == 1 @@ -252,7 +269,11 @@ def _cleanup() -> None: capsys.readouterr() # drain init's progress output rc2 = agentcore_cli.handle( argparse.Namespace( - subcommand="init", home=str(bm_home), region=agentcore_region, force=False + subcommand="init", + home=str(bm_home), + region=agentcore_region, + force=False, + no_activate=False, ) ) captured = capsys.readouterr() @@ -279,6 +300,31 @@ def _cleanup() -> None: assert "episodic:" in status.stdout assert "semantic:" in status.stdout assert "ACTIVE" in status.stdout + # Activation is visible through status: backend resolved from the + # settings.json init just wrote (isolated_env strips any outer + # BETTER_MEMORY_STORAGE_BACKEND, so 'settings' is the only source). + assert "effective backend: agentcore (source: settings)" in status.stdout + + # --- status with a corrupt settings.json: WARN, keep reporting --------- + # status is the diagnostic tool the operator reaches for when the config + # is broken — a corrupt settings.json must produce a WARN on stderr, not + # take the memory-state report down (cli/agentcore.py _handle_status). + settings_path.write_text("{not valid json", encoding="utf-8") + status_corrupt = subprocess.run( # noqa: S603 — test harness, fixed argv + _cli_argv("agentcore", "status", "--home", str(bm_home)), + env=_live_env(proc_home), + capture_output=True, + text=True, + timeout=120, + ) + assert status_corrupt.returncode == 0, ( + status_corrupt.stdout + status_corrupt.stderr + ) + assert "WARN: could not resolve the effective backend" in status_corrupt.stderr + assert "effective backend:" not in status_corrupt.stdout + assert cfg.episodic.memory_id in status_corrupt.stdout + assert cfg.semantic.memory_id in status_corrupt.stdout + assert "ACTIVE" in status_corrupt.stdout # --------------------------------------------------------------------------- diff --git a/tests/mcp/test_handlers_remote.py b/tests/mcp/test_handlers_remote.py index 03be04a..5e6c1d5 100644 --- a/tests/mcp/test_handlers_remote.py +++ b/tests/mcp/test_handlers_remote.py @@ -65,8 +65,21 @@ def remote() -> MagicMock: backend.semantic_list = MagicMock(return_value=[]) backend.semantic_update_text = MagicMock(return_value=None) backend.semantic_delete = MagicMock(return_value=None) - backend.credit_one = MagicMock(return_value={"applied": "x", "skipped": None}) - backend.apply_session_ratings = MagicMock(return_value={"applied": 1, "failed": 0}) + backend.credit_one = MagicMock(return_value={"applied": "cited", "skipped": None}) + # Sqlite-parity shape (AgentCoreBackend mirrors MemoryRatingService). + backend.apply_session_ratings = MagicMock( + return_value={ + "session_id": "sid-env-1", + "applied": { + "cited": 1, "shaped": 0, "ignored": 0, "misled": 0, + "overlooked": 0, + }, + "skipped": { + "not_exposed": 0, "already_rated": 0, + "memory_missing": 0, "memory_retired": 0, + }, + } + ) backend.list_session_exposures = MagicMock( return_value={"session_id": "s", "exposures": []} ) @@ -157,17 +170,22 @@ async def test_observe_remote_null_scope_defaults_to_project( async def test_retrieve_observations_routes_to_remote_and_serializes_datetimes( self, observations, retention, remote ) -> None: - """AgentCore events carry datetime event_timestamps (botocore-parsed); - the remote branch must serialize them instead of crashing json.dumps.""" + """The remote branch passes the backend's sqlite-parity rows through + (and json.dumps(default=str) keeps any residual datetime safe).""" list_mock = AsyncMock( return_value=[ { + # AgentCoreBackend.list_observations returns rows key- + # identical to the sqlite list_observations rows. "id": "evt-1", "content": "obs one", - "session_id": "s-1", - "actor_id": "projx", - "event_timestamp": datetime(2026, 7, 12, 10, 30, tzinfo=UTC), + "component": None, + "theme": None, "outcome": "success", + "reinforcement_score": None, + "created_at": datetime( + 2026, 7, 12, 10, 30, tzinfo=UTC + ).isoformat(), } ] ) @@ -178,7 +196,11 @@ async def test_retrieve_observations_routes_to_remote_and_serializes_datetimes( result = await handlers.retrieve_observations({"project": "projx", "limit": 7}) rows = _payload(result) assert rows[0]["id"] == "evt-1" - assert "2026" in rows[0]["event_timestamp"] + assert "2026" in rows[0]["created_at"] + assert set(rows[0]) == { + "id", "content", "component", "theme", "outcome", + "reinforcement_score", "created_at", + } observations.list_observations.assert_not_awaited() assert list_mock.await_args is not None kwargs = list_mock.await_args.kwargs @@ -405,7 +427,10 @@ async def test_apply_session_ratings_routes_to_remote( ) ratings = [{"kind": "reflection", "id": _RECORD_ID_40, "class": "cited"}] payload = _payload(await handlers.apply_session_ratings({"ratings": ratings})) - assert payload == {"applied": 1, "failed": 0} + # The backend's sqlite-parity dict passes through unchanged. + assert payload["session_id"] == "sid-env-1" + assert payload["applied"]["cited"] == 1 + assert payload["skipped"]["memory_missing"] == 0 remote.apply_session_ratings.assert_called_once_with( session_id="sid-env-1", ratings=ratings ) @@ -440,7 +465,7 @@ async def test_credit_routes_to_remote( {"kind": "semantic", "id": _RECORD_ID_40, "class": "cited"} ) ) - assert payload == {"applied": "x", "skipped": None} + assert payload == {"applied": "cited", "skipped": None} remote.credit_one.assert_called_once_with( session_id="sid-env-1", kind="semantic", diff --git a/tests/mcp/test_server_backend_dispatch.py b/tests/mcp/test_server_backend_dispatch.py index 6dc6a7d..4791558 100644 --- a/tests/mcp/test_server_backend_dispatch.py +++ b/tests/mcp/test_server_backend_dispatch.py @@ -107,8 +107,73 @@ def test_episode_gate_is_independent_of_synthesis_gate() -> None: # --------------------------------------------------------------------------- +# --- sqlite payload-parity contracts (pinned shapes) ----------------------- +# The AgentCoreBackend must hand the dispatch layer the SAME payload shapes +# sqlite mode produces; the dispatch layer must transmit them to the MCP +# wire unmodified. Sources of truth: +# - reflection bucket item: ReflectionSynthesisService.retrieve_reflections +# (services/reflection.py bucket.append(...)) — internal ranking helpers +# (_overlooked_count / _updated_at_ts) are backend-private and must never +# reach the wire. +# - observation row: ObservationService._list_observations_via_filter +# SELECT list (services/observation.py). +# - apply_session_ratings: MemoryRatingService.apply_session_ratings +# documented return (services/memory_rating.py) — NOT the legacy +# agentcore {"applied": int, "failed": int} pair. + +SQLITE_REFLECTION_ITEM_KEYS = frozenset({ + "id", "title", "phase", "use_cases", "hints", "confidence", "tech", + "evidence_count", "useful_count", "times_misled", "updated_at", +}) + +_PARITY_REFLECTION_ITEM: dict[str, Any] = { + "id": "mem-" + "0" * 36, + "title": "Use uv run for pytest", + "phase": "general", + "use_cases": "running the unit gate", + "hints": ["always uv run pytest"], + "confidence": 0.8, + "tech": "python", + "evidence_count": 3, + "useful_count": 2, + "times_misled": 0, + "updated_at": "2026-07-12T10:00:00+00:00", +} + +SQLITE_OBSERVATION_ROW_KEYS = frozenset({ + "id", "content", "component", "theme", "outcome", + "reinforcement_score", "created_at", +}) + +_PARITY_OBSERVATION_ROW: dict[str, Any] = { + "id": "evt-parity-1", + "content": "observation body", + "component": None, + "theme": "bug", + "outcome": "failure", + "reinforcement_score": None, + "created_at": "2026-07-12T09:00:00+00:00", +} + +SQLITE_RATINGS_RESULT: dict[str, Any] = { + "session_id": "sid-parity-1", + "applied": { + "cited": 1, "shaped": 0, "ignored": 0, "misled": 0, "overlooked": 0, + }, + "skipped": { + "not_exposed": 0, "already_rated": 0, + "memory_missing": 0, "memory_retired": 0, + }, +} + + class _StubRemoteBackend: - """Records data-tool calls; agentcore-shaped capability flags.""" + """Records data-tool calls; agentcore-shaped capability flags. + + The payload-returning methods hand back the SQLITE-PARITY shapes above + (the contract AgentCoreBackend implements) so the parity tests can pin + that the dispatch layer transmits them to the MCP wire unmodified. + """ supports_synthesis = False supports_episodes = False @@ -116,13 +181,32 @@ class _StubRemoteBackend: def __init__(self) -> None: self.observe_calls: list[dict[str, Any]] = [] self.bootstrap_calls: list[dict[str, Any]] = [] + self.list_observation_calls: list[dict[str, Any]] = [] + self.ratings_calls: list[dict[str, Any]] = [] + # Per-test overridable returns (defaults keep the older tests + # in this module byte-identical in behavior). + self.retrieve_buckets: dict[str, list[dict[str, Any]]] = { + "do": [], "dont": [], "neutral": [], + } + self.observation_rows: list[dict[str, Any]] = [] + self.ratings_result: dict[str, Any] = json.loads( + json.dumps(SQLITE_RATINGS_RESULT) + ) async def observe(self, **kwargs: Any) -> str: self.observe_calls.append(kwargs) return "stub-evt-0001" def retrieve(self, **kwargs: Any) -> dict[str, list[dict[str, Any]]]: - return {"do": [], "dont": [], "neutral": []} + return self.retrieve_buckets + + async def list_observations(self, **kwargs: Any) -> list[dict[str, Any]]: + self.list_observation_calls.append(kwargs) + return self.observation_rows + + def apply_session_ratings(self, **kwargs: Any) -> dict[str, Any]: + self.ratings_calls.append(kwargs) + return self.ratings_result def session_bootstrap(self, **kwargs: Any) -> dict[str, Any]: self.bootstrap_calls.append(kwargs) @@ -267,3 +351,113 @@ async def test_sqlite_mode_dispatch_never_routes_to_backend_object( assert _observation_row_count(home) == 1 finally: await cleanup() + + +# --------------------------------------------------------------------------- +# Payload parity with sqlite mode (agentcore dispatch → MCP wire pins) +# --------------------------------------------------------------------------- + + +def _agentcore_server( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> tuple[Any, Any, _StubRemoteBackend]: + _server_home(tmp_path, monkeypatch) + monkeypatch.setenv("BETTER_MEMORY_STORAGE_BACKEND", "agentcore") + + import better_memory.mcp.server as server_mod + + stub = _StubRemoteBackend() + monkeypatch.setattr(server_mod, "build_backend", lambda **kwargs: stub) + server, cleanup, _ctx = server_mod.create_server() + return server, cleanup, stub + + +async def test_agentcore_retrieve_payload_matches_sqlite_item_shape( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """memory.retrieve items reach the wire with EXACTLY the sqlite bucket + item key set — in particular no backend-internal ranking helpers + (_overlooked_count / _updated_at_ts) — and the dispatch layer transmits + the backend's items unmodified (no reshaping, no added keys).""" + server, cleanup, stub = _agentcore_server(tmp_path, monkeypatch) + stub.retrieve_buckets = { + "do": [dict(_PARITY_REFLECTION_ITEM)], + "dont": [], + "neutral": [], + } + try: + result = await _dispatch(server, "memory.retrieve", {}) + assert not result.isError, result + buckets = json.loads(result.content[0].text) + assert set(buckets) == {"do", "dont", "neutral"} + assert buckets["dont"] == [] and buckets["neutral"] == [] + (item,) = buckets["do"] + assert set(item) == SQLITE_REFLECTION_ITEM_KEYS + assert not any( + key.startswith("_") + for bucket in buckets.values() + for row in bucket + for key in row + ) + assert item == _PARITY_REFLECTION_ITEM # unmangled passthrough + finally: + await cleanup() + + +async def test_agentcore_retrieve_observations_payload_matches_sqlite_row_shape( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """memory.retrieve_observations rows carry the sqlite SELECT-list key + set (id, content, component, theme, outcome, reinforcement_score, + created_at — None where agentcore has no value) and pass through the + dispatch layer unmodified.""" + server, cleanup, stub = _agentcore_server(tmp_path, monkeypatch) + stub.observation_rows = [dict(_PARITY_OBSERVATION_ROW)] + try: + result = await _dispatch( + server, "memory.retrieve_observations", {"project": "projx"} + ) + assert not result.isError, result + rows = json.loads(result.content[0].text) + assert len(rows) == 1 + assert set(rows[0]) == SQLITE_OBSERVATION_ROW_KEYS + assert rows[0] == _PARITY_OBSERVATION_ROW + assert len(stub.list_observation_calls) == 1 + assert stub.list_observation_calls[0]["project"] == "projx" + finally: + await cleanup() + + +async def test_agentcore_apply_session_ratings_payload_matches_sqlite_shape( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """memory.apply_session_ratings in agentcore mode returns the sqlite + ApplySessionRatingsResult shape — {session_id, applied: {per-class}, + skipped: {per-reason}} — NOT the legacy {"applied": int, "failed": int} + pair, and the dispatch layer transmits it verbatim.""" + server, cleanup, stub = _agentcore_server(tmp_path, monkeypatch) + monkeypatch.setenv("CLAUDE_SESSION_ID", "sid-parity-1") + ratings = [ + {"kind": "semantic", "id": "mem-" + "1" * 36, "class": "cited"}, + ] + try: + result = await _dispatch( + server, "memory.apply_session_ratings", {"ratings": ratings} + ) + assert not result.isError, result + payload = json.loads(result.content[0].text) + assert payload == SQLITE_RATINGS_RESULT + assert set(payload) == {"session_id", "applied", "skipped"} + assert isinstance(payload["applied"], dict) # per-class counts + assert set(payload["applied"]) == { + "cited", "shaped", "ignored", "misled", "overlooked", + } + assert set(payload["skipped"]) == { + "not_exposed", "already_rated", "memory_missing", "memory_retired", + } + assert "failed" not in payload # legacy agentcore-only key is gone + assert stub.ratings_calls == [ + {"session_id": "sid-parity-1", "ratings": ratings} + ] + finally: + await cleanup() diff --git a/tests/storage/test_agentcore_unit.py b/tests/storage/test_agentcore_unit.py index 7d9c567..09c3ba6 100644 --- a/tests/storage/test_agentcore_unit.py +++ b/tests/storage/test_agentcore_unit.py @@ -4,6 +4,8 @@ from __future__ import annotations +import hashlib +from datetime import UTC, datetime from unittest.mock import MagicMock import pytest @@ -135,9 +137,6 @@ def test_list_episodes_returns_empty_list(backend) -> None: assert backend.list_episodes() == [] -from datetime import datetime, UTC - - @pytest.mark.asyncio async def test_observe_calls_create_event_with_correct_kwargs(backend, mock_data_client) -> None: """observe builds a CreateEvent against the EPISODIC memory with @@ -262,6 +261,60 @@ async def test_observe_lazily_resolves_session_id_from_marker( assert kwargs["sessionId"] == "marker-resolved-session" +@pytest.mark.asyncio +async def test_observe_reresolves_session_id_on_every_operation( + backend_without_session, mock_data_client, monkeypatch: pytest.MonkeyPatch +) -> None: + """Live-review major: the session id must NOT freeze at first + resolution. A long-lived server process spans Claude sessions — each + operation resolves the CURRENT env/marker value.""" + mock_data_client.create_event.return_value = {"event": {"eventId": "evt-x"}} + + monkeypatch.setenv("CLAUDE_SESSION_ID", "session-one") + await backend_without_session.observe(content="first") + assert ( + mock_data_client.create_event.call_args.kwargs["sessionId"] + == "session-one" + ) + + monkeypatch.setenv("CLAUDE_SESSION_ID", "session-two") + await backend_without_session.observe(content="second") + assert ( + mock_data_client.create_event.call_args.kwargs["sessionId"] + == "session-two" + ) + + +@pytest.mark.asyncio +async def test_observe_fresh_marker_beats_construction_time_session_id( + backend, mock_data_client, monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + """A marker written AFTER construction (a new Claude session started + against the same long-lived server) supersedes the construction-time + session id — the exact stale-adoption the freeze caused.""" + from better_memory.runtime.session_marker import write_session_id + + bm_home = tmp_path / "bm-fresh-marker-home" + monkeypatch.setenv("BETTER_MEMORY_HOME", str(bm_home)) + write_session_id(bm_home, "fresh-marker-session") + mock_data_client.create_event.return_value = {"event": {"eventId": "evt-x"}} + await backend.observe(content="x") + kwargs = mock_data_client.create_event.call_args.kwargs + assert kwargs["sessionId"] == "fresh-marker-session" # not test-session-xyz + + +@pytest.mark.asyncio +async def test_observe_falls_back_to_construction_session_id( + backend, mock_data_client +) -> None: + """No live env var, no marker → the construction-time session id still + works (the conftest strips CLAUDE_*SESSION_ID and pins an empty home).""" + mock_data_client.create_event.return_value = {"event": {"eventId": "evt-x"}} + await backend.observe(content="x") + kwargs = mock_data_client.create_event.call_args.kwargs + assert kwargs["sessionId"] == "test-session-xyz" + + @pytest.mark.asyncio async def test_list_observations_returns_current_session_events(backend, mock_data_client) -> None: mock_data_client.list_events.return_value = { @@ -303,6 +356,17 @@ async def test_list_observations_returns_current_session_events(backend, mock_da assert result[0]["content"] == "obs one" assert result[0]["outcome"] == "success" assert result[0]["theme"] == "test" + # Key parity with the sqlite list_observations rows — no agentcore-only + # keys (session_id/actor_id/event_timestamp) leak; created_at is the + # iso-formatted event timestamp; reinforcement_score is a stable None + # placeholder (no event-plane counter). + assert set(result[0]) == { + "id", "content", "component", "theme", "outcome", + "reinforcement_score", "created_at", + } + assert result[0]["created_at"] == "2026-05-25T12:00:00+00:00" + assert result[0]["reinforcement_score"] is None + assert result[0]["component"] is None # ListEvents call shape call_kwargs = mock_data_client.list_events.call_args.kwargs @@ -351,25 +415,51 @@ def test_retrieve_returns_dict_with_polarity_buckets(backend, mock_data_client) assert isinstance(result[bucket], list) -def test_retrieve_fires_one_list_call_per_polarity(backend, mock_data_client) -> None: - """Three list_memory_records calls (no semantic search): one per polarity.""" +def test_retrieve_fires_project_and_general_namespace_calls(backend, mock_data_client) -> None: + """Two list_memory_records calls — the project reflections namespace plus + the general/promoted namespace — with NO metadataFilters (live AWS + rejects 'polarity' as a filter key; a server-side status filter would + hide extraction-strategy records that carry no status metadata).""" mock_data_client.list_memory_records.return_value = {"memoryRecordSummaries": []} backend.retrieve(project="testproj") - assert mock_data_client.list_memory_records.call_count == 3 + assert mock_data_client.list_memory_records.call_count == 2 - polarities_filtered = [] + namespaces = set() for call in mock_data_client.list_memory_records.call_args_list: - for f in call.kwargs["metadataFilters"]: - if f["left"]["metadataKey"] == "polarity": - polarities_filtered.append(f["right"]["metadataValue"]["stringValue"]) - assert set(polarities_filtered) == {"do", "dont", "neutral"} + assert call.kwargs["memoryId"] == "mem-epi-def4567890" + assert "metadataFilters" not in call.kwargs + namespaces.add(call.kwargs["namespace"]) + assert namespaces == { + "projects/testproj/reflections/", + "general/reflections/", + } -def test_retrieve_with_polarity_kwarg_fetches_only_that_bucket(backend, mock_data_client) -> None: - """polarity='do' -> only the do bucket gets fetched; others empty.""" +def test_retrieve_general_actor_skips_duplicate_namespace_call( + ac_config, mock_data_client, mock_control_client +) -> None: + """actor == general → project and general namespaces coincide; exactly + one wire call, never two identical ones.""" + backend = AgentCoreBackend( + config=ac_config, + data_client=mock_data_client, + control_client=mock_control_client, + session_id="test-session-xyz", + project="general", + ) mock_data_client.list_memory_records.return_value = {"memoryRecordSummaries": []} - result = backend.retrieve(project="testproj", polarity="do") + backend.retrieve(project="general") assert mock_data_client.list_memory_records.call_count == 1 + call = mock_data_client.list_memory_records.call_args + assert call.kwargs["namespace"] == "general/reflections/" + + +def test_retrieve_with_polarity_kwarg_buckets_only_that_polarity(backend, mock_data_client) -> None: + """polarity='do' -> other buckets stay empty. The namespace fan-out is + unchanged (polarity is client-side now — it cannot narrow the wire).""" + mock_data_client.list_memory_records.return_value = {"memoryRecordSummaries": []} + result = backend.retrieve(project="testproj", polarity="do") + assert mock_data_client.list_memory_records.call_count == 2 assert result["dont"] == [] assert result["neutral"] == [] @@ -406,6 +496,8 @@ def test_retrieve_parses_reflection_json_content(backend, mock_data_client) -> N } result = backend.retrieve(project="testproj") do_bucket = result["do"] + # The same canned summary answers BOTH namespace calls — a single merged + # entry proves the cross-namespace id-dedup. assert len(do_bucket) == 1 refl = do_bucket[0] # Match the sqlite-mode reflection dict shape @@ -415,6 +507,8 @@ def test_retrieve_parses_reflection_json_content(backend, mock_data_client) -> N assert refl["hints"] == ["First hint.", "Second hint.", "Third hint."] assert refl["confidence"] == 0.85 # float assert refl["useful_count"] == 3 + # Internal ranking/bucketing helpers must NOT leak to the payload. + assert not any(key.startswith("_") for key in refl), refl def test_retrieve_ranks_by_useful_plus_3x_overlooked(backend, mock_data_client) -> None: @@ -442,18 +536,15 @@ def make_record(rec_id: str, useful: int, overlooked: int) -> dict: } def stub(**kwargs): - for f in kwargs.get("metadataFilters", []): - if f["left"]["metadataKey"] == "polarity": - pol = f["right"]["metadataValue"]["stringValue"] - if pol == "do": - # high-rank: useful=10 -> score 10 - # mid-rank: useful=2, overlooked=3 -> score 2 + 9 = 11 - # low-rank: useful=0, overlooked=0 -> score 0 - return {"memoryRecordSummaries": [ - make_record("low", useful=0, overlooked=0), - make_record("high-via-useful", useful=10, overlooked=0), - make_record("highest-via-overlooked", useful=2, overlooked=3), - ]} + if kwargs["namespace"] == "projects/testproj/reflections/": + # high-rank: useful=10 -> score 10 + # mid-rank: useful=2, overlooked=3 -> score 2 + 9 = 11 + # low-rank: useful=0, overlooked=0 -> score 0 + return {"memoryRecordSummaries": [ + make_record("low", useful=0, overlooked=0), + make_record("high-via-useful", useful=10, overlooked=0), + make_record("highest-via-overlooked", useful=2, overlooked=3), + ]} return {"memoryRecordSummaries": []} mock_data_client.list_memory_records.side_effect = stub @@ -466,6 +557,133 @@ def stub(**kwargs): assert do_titles == ["highest-via-overlooked", "high-via-useful", "low"] +def _reflection_summary( + rec_id: str, + *, + namespace: str, + status: str | None = "active", + polarity: str | None = "do", +) -> dict: + """MemoryRecordSummary for the client-side status/polarity tests. + status/polarity=None omit the metadata key entirely (the shape + AgentCore's own extraction strategy produces).""" + import json + metadata: dict = {"useful_count": {"numberValue": 0}} + if status is not None: + metadata["status"] = {"stringValue": status} + if polarity is not None: + metadata["polarity"] = {"stringValue": polarity} + return { + "memoryRecordId": rec_id, + "content": {"text": json.dumps({ + "title": rec_id, "use_cases": "u", "hints": "h", "confidence": "0.5", + })}, + "namespaces": [namespace], + "createdAt": datetime(2026, 5, 24, tzinfo=UTC), + "metadata": metadata, + } + + +def test_retrieve_includes_promoted_records_from_general_namespace( + backend, mock_data_client +) -> None: + """Live-review major: promote_reflection moves records to + general/reflections/ with status=promoted — retrieve must surface them + (the old status==active project-only query made them invisible).""" + def stub(**kwargs): + if kwargs["namespace"] == "general/reflections/": + return {"memoryRecordSummaries": [ + _reflection_summary( + "rec-promoted", + namespace="/general/reflections/", + status="promoted", + polarity="dont", + ) + ]} + return {"memoryRecordSummaries": []} + + mock_data_client.list_memory_records.side_effect = stub + result = backend.retrieve(project="testproj") + assert [r["id"] for r in result["dont"]] == ["rec-promoted"] + + +def test_retrieve_excludes_non_active_status_in_project_namespace( + backend, mock_data_client +) -> None: + """A project-namespace record whose status is explicitly not active + (e.g. retired, or promoted served stale by the lagging index) is + excluded — promoted records are only admitted via general/.""" + def stub(**kwargs): + if kwargs["namespace"] == "projects/testproj/reflections/": + return {"memoryRecordSummaries": [ + _reflection_summary( + "rec-retired", + namespace="/projects/testproj/reflections/", + status="retired", + ), + _reflection_summary( + "rec-stale-promoted", + namespace="/projects/testproj/reflections/", + status="promoted", + ), + ]} + return {"memoryRecordSummaries": []} + + mock_data_client.list_memory_records.side_effect = stub + result = backend.retrieve(project="testproj") + assert result == {"do": [], "dont": [], "neutral": []} + + +def test_retrieve_defaults_missing_status_and_polarity( + backend, mock_data_client +) -> None: + """Records written by AgentCore's own extraction strategy carry NO + status/polarity metadata; they must still be retrievable — missing + status parses as active, missing polarity buckets as neutral.""" + def stub(**kwargs): + if kwargs["namespace"] == "projects/testproj/reflections/": + return {"memoryRecordSummaries": [ + _reflection_summary( + "rec-extracted", + namespace="/projects/testproj/reflections/", + status=None, + polarity=None, + ) + ]} + return {"memoryRecordSummaries": []} + + mock_data_client.list_memory_records.side_effect = stub + result = backend.retrieve(project="testproj") + assert [r["id"] for r in result["neutral"]] == ["rec-extracted"] + assert result["do"] == [] + assert result["dont"] == [] + + +def test_retrieve_dedupes_record_seen_in_both_namespaces( + backend, mock_data_client +) -> None: + """A just-promoted record can be served by BOTH namespace queries while + the list index lags — it must appear exactly once.""" + def stub(**kwargs): + if kwargs["namespace"] == "projects/testproj/reflections/": + return {"memoryRecordSummaries": [ + _reflection_summary( + "rec-dup", namespace="/projects/testproj/reflections/", + status="active", polarity="do", + ) + ]} + return {"memoryRecordSummaries": [ + _reflection_summary( + "rec-dup", namespace="/general/reflections/", + status="promoted", polarity="do", + ) + ]} + + mock_data_client.list_memory_records.side_effect = stub + result = backend.retrieve(project="testproj") + assert [r["id"] for r in result["do"]] == ["rec-dup"] + + def _make_record_response(rec_id: str, **counters) -> dict: """Helper: build a MemoryRecord response with the standard metadata.""" base = { @@ -503,8 +721,13 @@ def test_record_use_success_bumps_useful_count(backend, mock_data_client) -> Non assert rec["memoryRecordId"] == "rec-x" assert rec["metadata"]["useful_count"]["numberValue"] == 3 assert rec["metadata"]["missed_count"]["numberValue"] == 0 - # last_credited_at refreshed - assert "last_credited_at" in rec["metadata"] + # last_credited_at refreshed — as stringValue iso8601, NEVER + # dateTimeValue: the indexedKey is declared STRING and real AWS fails + # the whole record update on a type mismatch (live root cause of + # apply_session_ratings applied:0/failed:1). + last_credited = rec["metadata"]["last_credited_at"] + assert set(last_credited) == {"stringValue"} + assert isinstance(last_credited["stringValue"], str) def test_record_use_failure_bumps_missed_count(backend, mock_data_client) -> None: @@ -624,9 +847,12 @@ def test_credit_one_bumps_correct_counter( id="rec-c", classification=classification, ) - assert result["applied"] == "rec-c" + # Sqlite parity: credit_one returns the applied CLASSIFICATION. + assert result == {"applied": classification, "skipped": None} rec = mock_data_client.batch_update_memory_records.call_args.kwargs["records"][0] assert rec["metadata"][counter_key]["numberValue"] == 1 + # STRING indexed key — dateTimeValue fails the record on real AWS. + assert set(rec["metadata"]["last_credited_at"]) == {"stringValue"} def test_credit_one_rejects_unknown_classification(backend) -> None: @@ -669,7 +895,7 @@ def test_credit_one_routes_semantic_kind_to_semantic_memory(backend, mock_data_c id="sm-rec", classification="cited", ) - assert result["applied"] == "sm-rec" + assert result == {"applied": "cited", "skipped": None} # Verify both calls targeted SEMANTIC memory, not episodic get_call = mock_data_client.get_memory_record.call_args.kwargs @@ -679,6 +905,9 @@ def test_credit_one_routes_semantic_kind_to_semantic_memory(backend, mock_data_c def test_apply_session_ratings_credits_each_rating(backend, mock_data_client) -> None: + """Return shape is sqlite-parity: {session_id, applied: {per-class + counts}, skipped: {per-reason counts}} — NOT the old flat + {applied: int, failed: int}.""" mock_data_client.get_memory_record.side_effect = [ _make_record_response("rec-1"), _make_record_response("rec-2"), @@ -695,32 +924,88 @@ def test_apply_session_ratings_credits_each_rating(backend, mock_data_client) -> ], ) assert mock_data_client.batch_update_memory_records.call_count == 2 - assert result["applied"] == 2 - assert result["failed"] == 0 + assert result == { + "session_id": "test-session-xyz", + "applied": { + "cited": 1, "shaped": 0, "ignored": 0, "misled": 0, + "overlooked": 1, + }, + "skipped": { + "not_exposed": 0, "already_rated": 0, + "memory_missing": 0, "memory_retired": 0, + }, + } -def test_apply_session_ratings_empty_returns_zero_summary(backend) -> None: - result = backend.apply_session_ratings(session_id="x", ratings=[]) - assert result == {"applied": 0, "failed": 0} +def test_apply_session_ratings_empty_raises_like_sqlite(backend) -> None: + """Sqlite parity: MemoryRatingService.apply_session_ratings raises on + an empty ratings list.""" + with pytest.raises(ValueError, match="non-empty"): + backend.apply_session_ratings(session_id="x", ratings=[]) -def test_apply_session_ratings_skips_malformed_entries(backend, mock_data_client) -> None: - """Malformed entries (missing key, unknown class) increment `failed` instead of crashing the loop.""" - mock_data_client.get_memory_record.return_value = _make_record_response("rec-ok") - mock_data_client.batch_update_memory_records.return_value = { - "successfulRecords": [{"memoryRecordId": "rec-ok", "status": "SUCCEEDED"}], - "failedRecords": [], - } +@pytest.mark.parametrize( + "ratings,match", + [ + ([{"kind": "reflection", "id": "rec-x"}], "missing required field"), + ([{"kind": "reflection", "id": "rec-x", "class": "bogus"}], "invalid 'bogus'"), + ([{"kind": "widget", "id": "rec-x", "class": "cited"}], "invalid 'widget'"), + ([{"kind": "reflection", "id": "", "class": "cited"}], "non-empty string"), + ( + [ + {"kind": "reflection", "id": "rec-x", "class": "cited"}, + {"kind": "reflection", "id": "rec-x", "class": "misled"}, + ], + "duplicate", + ), + ], +) +def test_apply_session_ratings_validates_batch_before_any_wire_call( + backend, mock_data_client, ratings, match +) -> None: + """Sqlite parity: the whole batch is validated up front — ValueError, + zero wire calls (nothing partially credited).""" + with pytest.raises(ValueError, match=match): + backend.apply_session_ratings(session_id="s", ratings=ratings) + mock_data_client.get_memory_record.assert_not_called() + mock_data_client.batch_update_memory_records.assert_not_called() + + +def test_apply_session_ratings_counts_wire_failures_as_memory_missing( + backend, mock_data_client +) -> None: + """A per-record batch-update failure (real AWS reports these as HTTP + 200 failedRecords) counts under skipped.memory_missing and does not + abort the rest of the batch.""" + mock_data_client.get_memory_record.side_effect = [ + _make_record_response("rec-fail"), + _make_record_response("rec-ok"), + ] + mock_data_client.batch_update_memory_records.side_effect = [ + { + "successfulRecords": [], + "failedRecords": [{ + "memoryRecordId": "rec-fail", + "status": "FAILED", + "errorCode": 400, + "errorMessage": "boom", + }], + }, + { + "successfulRecords": [{"memoryRecordId": "rec-ok", "status": "SUCCEEDED"}], + "failedRecords": [], + }, + ] result = backend.apply_session_ratings( session_id="test-session-xyz", ratings=[ - {"kind": "reflection", "id": "rec-ok", "class": "cited"}, # OK - {"kind": "reflection", "id": "rec-missing-class"}, # KeyError - {"kind": "reflection", "id": "rec-bad", "class": "bogus"}, # ValueError + {"kind": "reflection", "id": "rec-fail", "class": "cited"}, + {"kind": "reflection", "id": "rec-ok", "class": "shaped"}, ], ) - assert result["applied"] == 1 - assert result["failed"] == 2 + assert result["applied"]["shaped"] == 1 + assert result["applied"]["cited"] == 0 + assert result["skipped"]["memory_missing"] == 1 def test_promote_reflection_moves_to_general_namespace(backend, mock_data_client) -> None: @@ -751,19 +1036,28 @@ def test_promote_reflection_raises_when_batch_fails(backend, mock_data_client) - mock_data_client.get_memory_record.return_value = _make_record_response("rec-fail") mock_data_client.batch_update_memory_records.return_value = { "successfulRecords": [], - "failedRecords": [{"memoryRecordId": "rec-fail", "status": "FAILED", "errorMessage": "boom"}], + "failedRecords": [ + {"memoryRecordId": "rec-fail", "status": "FAILED", "errorMessage": "boom"} + ], } with pytest.raises(RuntimeError, match="rec-fail"): backend.promote_reflection(reflection_id="rec-fail") # ===== Task 11: semantic CRUD ===== -import hashlib -def test_semantic_observe_calls_batch_create_against_semantic_memory(backend, mock_data_client) -> None: +def test_semantic_observe_calls_batch_create_against_semantic_memory( + backend, mock_data_client +) -> None: mock_data_client.batch_create_memory_records.return_value = { - "successfulRecords": [{"memoryRecordId": "sm-1", "status": "SUCCEEDED", "requestIdentifier": "any"}], + "successfulRecords": [ + { + "memoryRecordId": "sm-1", + "status": "SUCCEEDED", + "requestIdentifier": "any", + } + ], "failedRecords": [], } sm_id = backend.semantic_observe(content="prefer uv over pip") @@ -822,6 +1116,34 @@ def test_semantic_list_without_search_uses_list_memory_records(backend, mock_dat mock_data_client.retrieve_memory_records.assert_not_called() +def test_semantic_list_scope_classification_normalizes_leading_slash( + backend, mock_data_client +) -> None: + """Live dialect: stored namespaces come back WITH a leading slash + ("/general/semantic/") — the scope classifier must normalize or every + read-back general record misreports as project scope.""" + mock_data_client.list_memory_records.return_value = { + "memoryRecordSummaries": [ + { + "memoryRecordId": "sm-slash-general", + "content": {"text": "g"}, + "namespaces": ["/general/semantic/"], + }, + { + "memoryRecordId": "sm-slash-project", + "content": {"text": "p"}, + "namespaces": ["/projects/testproj/semantic/"], + }, + ] + } + result = backend.semantic_list() + scopes = {r["id"]: r["scope"] for r in result} + assert scopes == { + "sm-slash-general": "general", + "sm-slash-project": "project", + } + + def test_semantic_update_text_calls_batch_update(backend, mock_data_client) -> None: mock_data_client.get_memory_record.return_value = { "memoryRecord": { @@ -920,18 +1242,14 @@ async def test_observe_propagates_transport_timeout(backend, mock_data_client) - await backend.observe(content="x") -def test_retrieve_propagates_when_one_polarity_fetch_fails(backend, mock_data_client) -> None: - """One polarity's list_memory_records raising must propagate out of +def test_retrieve_propagates_when_one_namespace_fetch_fails(backend, mock_data_client) -> None: + """One namespace's list_memory_records raising must propagate out of retrieve via Future.result() — no silent partial result.""" good = {"memoryRecordSummaries": []} def stub(**kwargs): - for f in kwargs.get("metadataFilters", []): - if ( - f["left"]["metadataKey"] == "polarity" - and f["right"]["metadataValue"]["stringValue"] == "dont" - ): - raise _FakeClientError(code="ThrottlingException", message="rate exceeded") + if kwargs["namespace"] == "general/reflections/": + raise _FakeClientError(code="ThrottlingException", message="rate exceeded") return good mock_data_client.list_memory_records.side_effect = stub @@ -955,7 +1273,9 @@ def test_retrieve_propagates_worker_timeout(backend, mock_data_client) -> None: backend.retrieve(project="testproj") -def test_retrieve_malformed_json_content_falls_back_to_valid_shape(backend, mock_data_client) -> None: +def test_retrieve_malformed_json_content_falls_back_to_valid_shape( + backend, mock_data_client +) -> None: """_parse_reflection_record swallows json.JSONDecodeError and degrades to an empty body — the record still comes back in the public reflection shape with defaulted body fields and metadata-derived counters intact.""" @@ -1020,7 +1340,9 @@ def test_retrieve_missing_content_key_falls_back_to_valid_shape(backend, mock_da assert refl["confidence"] == 0.0 -def test_retrieve_non_dict_json_content_falls_back_to_valid_shape(backend, mock_data_client) -> None: +def test_retrieve_non_dict_json_content_falls_back_to_valid_shape( + backend, mock_data_client +) -> None: """Valid JSON that is not an object (e.g. a list) exercises the isinstance(body, dict) guards — every body-derived field defaults.""" mock_data_client.list_memory_records.return_value = { @@ -1050,26 +1372,75 @@ def test_retrieve_non_dict_json_content_falls_back_to_valid_shape(backend, mock_ assert refl["phase"] == "general" -def test_session_bootstrap_fires_4_parallel_list_calls(backend, mock_data_client) -> None: - """One per polarity (do/dont/neutral) against episodic + one against - semantic — all 4 dispatched via asyncio.gather + run_in_executor. +def test_session_bootstrap_fires_3_parallel_list_calls(backend, mock_data_client) -> None: + """Two reflection namespace calls (project + general/promoted merge, + shared with retrieve()) against episodic + one against semantic. No + metadataFilters anywhere — polarity is not a legal filter key on real + AWS and status filtering is client-side. Uses list_memory_records (not retrieve_memory_records) because bootstrap is recency / metadata-only — no semantic search query.""" mock_data_client.list_memory_records.return_value = {"memoryRecordSummaries": []} backend.session_bootstrap(session_id="test-session", project="testproj") - # 4 calls total — 3 reflection (episodic) + 1 semantic - assert mock_data_client.list_memory_records.call_count == 4 + # 3 calls total — 2 reflection (episodic) + 1 semantic + assert mock_data_client.list_memory_records.call_count == 3 targets = [] for call in mock_data_client.list_memory_records.call_args_list: + assert "metadataFilters" not in call.kwargs targets.append((call.kwargs["memoryId"], call.kwargs["namespace"])) assert ("mem-epi-def4567890", "projects/testproj/reflections/") in targets + assert ("mem-epi-def4567890", "general/reflections/") in targets assert ("mem-sem-abc1234567", "projects/testproj/semantic/") in targets -def test_session_bootstrap_returns_envelope_matching_sqlite_shape(backend, mock_data_client) -> None: +def test_session_bootstrap_counts_promoted_general_reflections( + backend, mock_data_client +) -> None: + """Promoted reflections (general namespace, status=promoted) show up in + the bootstrap counts — the live-review invisibility major.""" + def stub(**kwargs): + if kwargs.get("namespace") == "general/reflections/": + return {"memoryRecordSummaries": [ + _reflection_summary( + "rec-promoted", namespace="/general/reflections/", + status="promoted", polarity="do", + ) + ]} + return {"memoryRecordSummaries": []} + + mock_data_client.list_memory_records.side_effect = stub + result = backend.session_bootstrap(session_id="s", project="testproj") + assert result["reflections_counts"] == {"do": 1, "dont": 0, "neutral": 0} + + +def test_session_bootstrap_honours_cwd_param( + backend, mock_data_client, monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + """project=None + cwd → the project resolves via project_name(cwd) + (sqlite parity), NOT the construction-time project. A .better-memory + override file in cwd makes the resolution deterministic.""" + proj_dir = tmp_path / "somewhere" + proj_dir.mkdir() + (proj_dir / ".better-memory").write_text("cwdproj\n", encoding="utf-8") + monkeypatch.delenv("BETTER_MEMORY_PROJECT", raising=False) + + mock_data_client.list_memory_records.return_value = {"memoryRecordSummaries": []} + result = backend.session_bootstrap(session_id="s", cwd=proj_dir) + assert result["project"] == "cwdproj" + namespaces = { + call.kwargs["namespace"] + for call in mock_data_client.list_memory_records.call_args_list + } + assert "projects/cwdproj/reflections/" in namespaces + assert "projects/cwdproj/semantic/" in namespaces + assert "projects/testproj/reflections/" not in namespaces + + +def test_session_bootstrap_returns_envelope_matching_sqlite_shape( + backend, mock_data_client +) -> None: """Envelope must match the BootstrapResult shape the MCP handler at server.py:1398-1411 unwraps. Keys: additional_context, project, source, episode_id, episode_action, semantic_count, reflections_counts. In diff --git a/tests/test_config.py b/tests/test_config.py index 4e962dd..14e4b22 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -14,8 +14,29 @@ def _git_init(path: Path) -> None: subprocess.run(["git", "init", "--quiet"], cwd=str(path), check=True) -def test_defaults_resolve_under_home(monkeypatch: pytest.MonkeyPatch) -> None: +def _fake_user_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + """Point ``~`` (``Path.home()`` / ``expanduser``) at a tmp dir. + + Tests that ``delenv("BETTER_MEMORY_HOME")`` to exercise the default-home + branch must NOT resolve against the developer's real home: ``get_config`` + reads ``/settings.json`` for the storage backend, so an un-isolated + default-home test reads (and depends on) the developer's REAL + ``~/.better-memory/settings.json``. Both ``HOME`` (POSIX) and + ``USERPROFILE`` (Windows, checked first by ``ntpath.expanduser``) are + patched so ``Path.home()`` and ``Path("~/...").expanduser()`` agree. + """ + fake_home = tmp_path / "fake-user-home" + fake_home.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setenv("USERPROFILE", str(fake_home)) + return fake_home + + +def test_defaults_resolve_under_home( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: """With no env vars set, everything lands under ``~/.better-memory``.""" + fake_home = _fake_user_home(monkeypatch, tmp_path) for var in ( "BETTER_MEMORY_HOME", "OLLAMA_HOST", @@ -26,6 +47,9 @@ def test_defaults_resolve_under_home(monkeypatch: pytest.MonkeyPatch) -> None: cfg = get_config() home = Path.home() / ".better-memory" + # Isolation guard: the default home is the FAKE user home, not the + # developer's real one. + assert cfg.home == fake_home / ".better-memory" assert cfg.home == home assert cfg.memory_db == home / "memory.db" @@ -53,11 +77,15 @@ def test_home_override_roots_all_paths( assert cfg.spool_dir == root / "spool" -def test_home_expands_tilde(monkeypatch: pytest.MonkeyPatch) -> None: +def test_home_expands_tilde( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: """``BETTER_MEMORY_HOME`` expands ``~`` to the user's home directory.""" + fake_home = _fake_user_home(monkeypatch, tmp_path) monkeypatch.setenv("BETTER_MEMORY_HOME", "~/custom-bm") cfg = get_config() + assert cfg.home == fake_home / "custom-bm" assert cfg.home == Path.home() / "custom-bm" assert cfg.memory_db == Path.home() / "custom-bm" / "memory.db" @@ -75,8 +103,11 @@ def test_external_service_overrides(monkeypatch: pytest.MonkeyPatch) -> None: assert cfg.audit_log_retrieved is False -def test_paths_are_path_objects(monkeypatch: pytest.MonkeyPatch) -> None: +def test_paths_are_path_objects( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: """All path fields are :class:`pathlib.Path`, not strings.""" + _fake_user_home(monkeypatch, tmp_path) monkeypatch.delenv("BETTER_MEMORY_HOME", raising=False) cfg = get_config() for attr in ("home", "memory_db", "knowledge_db", "knowledge_base", "spool_dir"): @@ -437,6 +468,48 @@ def test_storage_backend_settings_invalid_value_raises_naming_file( assert "sqlite" in msg and "agentcore" in msg # valid values listed +def test_storage_backend_settings_unreadable_oserror_falls_back_to_sqlite( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Any OSError reading settings.json (not just FileNotFoundError) falls + back to the default instead of crashing get_config. + + A directory named ``settings.json`` makes ``read_text`` raise + ``PermissionError`` on Windows / ``IsADirectoryError`` on POSIX — both + OSError subclasses. Before the broadening, only FileNotFoundError was + caught and get_config crashed on an unreadable file. + """ + home = _pin_home(monkeypatch, tmp_path) + monkeypatch.delenv("BETTER_MEMORY_STORAGE_BACKEND", raising=False) + (home / "settings.json").mkdir() + + assert get_config().storage_backend == "sqlite" + + +def test_storage_backend_settings_permission_error_falls_back_to_sqlite( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Explicit PermissionError (the live-found crash) returns the default; + ValueError semantics for a READABLE-but-malformed file are untouched + (covered by the malformed/non-object/invalid-value tests above).""" + from better_memory.config import _read_settings_storage_backend + + home = _pin_home(monkeypatch, tmp_path) + monkeypatch.delenv("BETTER_MEMORY_STORAGE_BACKEND", raising=False) + _write_settings(home, '{"storage_backend": "agentcore"}') + + original_read_text = Path.read_text + + def _deny(self: Path, *args: object, **kwargs: object) -> str: + if self.name == "settings.json": + raise PermissionError(13, "Permission denied", str(self)) + return original_read_text(self, *args, **kwargs) # type: ignore[arg-type] + + monkeypatch.setattr(Path, "read_text", _deny) + assert _read_settings_storage_backend(home) is None + assert get_config().storage_backend == "sqlite" + + def test_resolve_storage_backend_public_helper_re_resolves_per_call( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: From e403703ef8559c61eafb425872ba731155b67d7a Mon Sep 17 00:00:00 2001 From: gethin Date: Sun, 12 Jul 2026 23:25:40 +0100 Subject: [PATCH 4/6] fix: respect AWS 100-item maxResults cap + live-test consistency/shape updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - storage/agentcore.py: reflections fetch pages with nextToken under the real service's maxResults<=100 constraint (live ValidationException at 120); list_events clamped - fake endpoint enforces the cap so the hermetic tier pins it - live tests: E4 polls retrieves with a bounded retry (record index lags ~60s per the dialect scout — writes are synchronous, the LIST index is not); E5 asserts the sqlite-parity ratings shape; E7 reads back the closure event under the canonical project actor (the actor-consistency fix moved it off basename(cwd)) Co-Authored-By: Claude Fable 5 --- better_memory/storage/agentcore.py | 30 ++++++--- tests/e2e/_fake_agentcore.py | 8 +++ tests/integration/test_agentcore_live_e2e.py | 67 ++++++++++++++------ 3 files changed, 78 insertions(+), 27 deletions(-) diff --git a/better_memory/storage/agentcore.py b/better_memory/storage/agentcore.py index d61ca54..b1731ca 100644 --- a/better_memory/storage/agentcore.py +++ b/better_memory/storage/agentcore.py @@ -279,17 +279,28 @@ def _fetch_reflection_buckets( ] if general_ns != project_ns: namespaces.append((general_ns, allowed_general)) - # One page covers all three buckets now — scale the slack with the - # bucket count instead of the old per-polarity *2. + # Scale the fetch budget with the bucket count. The real service + # caps maxResults at 100 (live ValidationException above that), so + # page with nextToken until the budget is met or the index is dry. max_results = limit_per_bucket * len(_POLARITIES) * 2 def _fetch(namespace: str) -> list[dict[str, Any]]: - response = self._data.list_memory_records( - memoryId=self._cfg.episodic.memory_id, - namespace=namespace, - maxResults=max_results, - ) - return response.get("memoryRecordSummaries", []) + summaries: list[dict[str, Any]] = [] + token: str | None = None + while len(summaries) < max_results: + kwargs: dict[str, Any] = { + "memoryId": self._cfg.episodic.memory_id, + "namespace": namespace, + "maxResults": min(100, max_results - len(summaries)), + } + if token: + kwargs["nextToken"] = token + response = self._data.list_memory_records(**kwargs) + summaries.extend(response.get("memoryRecordSummaries", [])) + token = response.get("nextToken") + if not token: + break + return summaries # Parallel fan-out via a thread pool. This path is sync but is called # from inside the MCP server's async `_call_tool`, so we cannot use @@ -458,7 +469,8 @@ async def list_observations( memoryId=self._cfg.episodic.memory_id, actorId=actor_id, sessionId=session_id, - maxResults=limit, + # Real service caps maxResults at 100. + maxResults=min(limit, 100), includePayloads=True, ), ) diff --git a/tests/e2e/_fake_agentcore.py b/tests/e2e/_fake_agentcore.py index 6c130b9..5ade1c5 100644 --- a/tests/e2e/_fake_agentcore.py +++ b/tests/e2e/_fake_agentcore.py @@ -116,6 +116,14 @@ def _validation_exception(message: str) -> tuple[int, dict[str, Any]]: def _check_metadata_filters(body: Any) -> tuple[int, dict[str, Any]] | None: + max_results = body.get("maxResults") + if isinstance(max_results, int) and max_results > 100: + # Verbatim live constraint (T3 run 2 hit this with maxResults=120). + return _validation_exception( + "1 validation error detected: Value at 'maxResults' failed to " + "satisfy constraint: Member must have value less than or equal " + "to 100" + ) filters = body.get("metadataFilters") or ( (body.get("searchCriteria") or {}).get("metadataFilters") ) or [] diff --git a/tests/integration/test_agentcore_live_e2e.py b/tests/integration/test_agentcore_live_e2e.py index 433120a..17e97e7 100644 --- a/tests/integration/test_agentcore_live_e2e.py +++ b/tests/integration/test_agentcore_live_e2e.py @@ -581,8 +581,10 @@ async def test_live_e4_mcp_semantic_crud_roundtrip( ) -> None: """E4: full MCP semantic round-trip against real AWS (fix plan section 4 item 3): observe → retrieve surfaces the record (project + general - UD-2 merge) → update changes the text → delete removes it. All - record-level operations are promptly consistent. Zero local + UD-2 merge) → update changes the text → delete removes it. The record + LIST index is eventually consistent (~60s lag measured by the dialect + scout — aws_record_dialect.md section 3), so every retrieve step polls + with a bounded retry; writes themselves are synchronous. Zero local ``semantic_memories`` rows. """ home, bm_home = _onboarding_home( @@ -601,8 +603,28 @@ def _mine(items: list, record_id: str) -> list: errlog_path = tmp_path / "e4-server.stderr" with errlog_path.open("w", encoding="utf-8") as errlog: async with mcp_session( - env, errlog=errlog, read_timeout=timedelta(seconds=90) + env, errlog=errlog, read_timeout=timedelta(seconds=120) ) as session: + + async def _retrieve_until( + predicate, description: str, budget_s: float = 120.0 + ) -> list: + """Poll semantic_retrieve until predicate(items) — the LIST + index lags writes by ~60s (dialect notes section 3).""" + deadline = asyncio.get_running_loop().time() + budget_s + while True: + items = _tool_json( + await session.call_tool("memory.semantic_retrieve", {}) + ) + if predicate(items): + return items + if asyncio.get_running_loop().time() >= deadline: + raise AssertionError( + f"{description} not observed within {budget_s}s: " + f"{items!r}" + ) + await asyncio.sleep(5) + created = _tool_json( await session.call_tool( "memory.semantic_observe", {"content": marker} @@ -612,11 +634,11 @@ def _mine(items: list, record_id: str) -> list: # A genuine AWS memoryRecordId (>= 40 chars) — not a local uuid. assert isinstance(record_id, str) and len(record_id) >= 40 - listed = _tool_json( - await session.call_tool("memory.semantic_retrieve", {}) + listed = await _retrieve_until( + lambda items: len(_mine(items, record_id)) == 1, + "created record surfaced", ) mine = _mine(listed, record_id) - assert len(mine) == 1, f"record not surfaced: {listed!r}" assert mine[0]["content"] == marker # Stable payload keys under the UD-2 merge contract. assert {"project", "scope", "created_at", "updated_at"} <= set(mine[0]) @@ -628,22 +650,24 @@ def _mine(items: list, record_id: str) -> list: {"id": record_id, "content": updated_marker}, ) ) == {"ok": True} - re_listed = _tool_json( - await session.call_tool("memory.semantic_retrieve", {}) + re_listed = await _retrieve_until( + lambda items: bool( + _mine(items, record_id) + and _mine(items, record_id)[0]["content"] == updated_marker + ), + "updated content surfaced", ) - mine = _mine(re_listed, record_id) - assert len(mine) == 1 - assert mine[0]["content"] == updated_marker + assert len(_mine(re_listed, record_id)) == 1 assert _tool_json( await session.call_tool( "memory.semantic_delete", {"id": record_id} ) ) == {"ok": True} - final = _tool_json( - await session.call_tool("memory.semantic_retrieve", {}) + await _retrieve_until( + lambda items: _mine(items, record_id) == [], + "deleted record gone from the index", ) - assert _mine(final, record_id) == [] assert _local_row_count(bm_home, "semantic_memories") == 0 @@ -694,8 +718,11 @@ async def test_live_e5_rating_credits_real_semantic_record_id( }, ) ) - assert payload["applied"] == 1, payload - assert payload["failed"] == 0, payload + # Sqlite-parity result shape (repair-wave payload parity). + assert payload["session_id"], payload + assert payload["applied"]["cited"] == 1, payload + assert sum(payload["skipped"].values()) == 0, payload + assert "failed" not in payload, payload # Best-effort cleanup; teardown deletes the whole memory anyway. await session.call_tool("memory.semantic_delete", {"id": record_id}) @@ -779,7 +806,9 @@ def test_live_e7_session_close_closure_with_settings_only( tmp_path, agentcore_throwaway_memories, agentcore_region ) session_id = _journey_session_id() - # The hook derives the closure actorId from the payload cwd's basename. + # The hook derives the closure actorId from the canonical project_name() + # resolution (repair-wave major 7) — BETTER_MEMORY_PROJECT wins here, so + # the closure lands under the SAME actor the MCP server uses. proj_dir = tmp_path / "bmintclose" proj_dir.mkdir() env = _live_env( @@ -818,9 +847,11 @@ def test_live_e7_session_close_closure_with_settings_only( retries={"mode": "standard", "max_attempts": 5}, ), ) + from better_memory.storage.session import resolve_actor_id + events = data.list_events( memoryId=epi_record.memory_id, - actorId="bmintclose", + actorId=resolve_actor_id(_JOURNEY_PROJECT), sessionId=session_id, includePayloads=True, maxResults=10, From fcac2aca0ce67247f261d45b327002055551d839 Mon Sep 17 00:00:00 2001 From: gethin Date: Mon, 13 Jul 2026 00:05:10 +0100 Subject: [PATCH 5/6] fix: agentcore status flags invalid BETTER_MEMORY_STORAGE_BACKEND values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BugBot (PR #79): _effective_backend presented a raw invalid env value as a working backend while the runtime resolver raises on it and the server refuses to start. Status now reports 'env — INVALID value; the MCP server will refuse to start' and keeps going; triggering test added. Co-Authored-By: Claude Fable 5 --- better_memory/cli/agentcore.py | 12 ++++++++++-- tests/cli/test_agentcore_status.py | 23 +++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/better_memory/cli/agentcore.py b/better_memory/cli/agentcore.py index d6796f5..b13b680 100644 --- a/better_memory/cli/agentcore.py +++ b/better_memory/cli/agentcore.py @@ -128,14 +128,22 @@ def _effective_backend(home: Path) -> tuple[str, str]: Mirrors :func:`better_memory.config.resolve_storage_backend` but honours the CLI's ``--home`` override (the config resolver only reads ``$BETTER_MEMORY_HOME``) and reports the source: ``env`` / ``settings`` / - ``default``. May raise ``ValueError`` on a corrupt settings.json. + ``default``. Where the runtime resolver would RAISE on an invalid env + value (killing the server at boot), status keeps going and flags the + value instead — a diagnostic tool must surface the misconfiguration, not + reproduce the crash. May raise ``ValueError`` on a corrupt settings.json. """ import os - from better_memory.config import _read_settings_storage_backend + from better_memory.config import ( + _VALID_STORAGE_BACKENDS, + _read_settings_storage_backend, + ) raw = os.environ.get("BETTER_MEMORY_STORAGE_BACKEND") if raw is not None: + if raw not in _VALID_STORAGE_BACKENDS: + return raw, "env — INVALID value; the MCP server will refuse to start" return raw, "env" from_file = _read_settings_storage_backend(home) if from_file is not None: diff --git a/tests/cli/test_agentcore_status.py b/tests/cli/test_agentcore_status.py index 40c8393..d1b6b6a 100644 --- a/tests/cli/test_agentcore_status.py +++ b/tests/cli/test_agentcore_status.py @@ -162,6 +162,29 @@ def test_status_reports_env_var_overriding_settings( assert "effective backend: sqlite (source: env)" in out +def test_status_flags_invalid_env_backend_value( + tmp_path, monkeypatch, capsys +) -> None: + """BugBot PR#79: an invalid BETTER_MEMORY_STORAGE_BACKEND (typo like + 'agentcor') makes the runtime resolver raise and the server refuse to + start — status must flag it, not present it as a working backend.""" + _write_config(tmp_path) + monkeypatch.setenv("BETTER_MEMORY_STORAGE_BACKEND", "agentcor") + monkeypatch.setattr( + "better_memory.cli.agentcore._build_control_client", + lambda region: _active_control(), + ) + + rc = _handle_status(_make_args(tmp_path)) + assert rc == 0 + out = capsys.readouterr().out + assert "agentcor" in out + assert "INVALID" in out + assert "refuse to start" in out + # The invalid value must never be presented as a plain working backend. + assert "effective backend: agentcor (source: env)" not in out + + def test_status_reports_default_backend_without_env_or_settings( tmp_path, monkeypatch, capsys ) -> None: From ed34708529511125427de14e6af74802eb0d0913 Mon Sep 17 00:00:00 2001 From: gethin Date: Mon, 13 Jul 2026 00:22:45 +0100 Subject: [PATCH 6/6] fix: branch init next-steps on activation state BugBot (PR #79): with --no-activate the unconditional next-steps block still said 'restart ... so it picks up the new backend', contradicting the skip message printed one line earlier. Next steps now branch on activate; stdout-contract test extended. Co-Authored-By: Claude Fable 5 --- better_memory/cli/agentcore.py | 25 ++++++++++++++++++++----- tests/cli/test_agentcore_init.py | 4 ++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/better_memory/cli/agentcore.py b/better_memory/cli/agentcore.py index b13b680..cf60960 100644 --- a/better_memory/cli/agentcore.py +++ b/better_memory/cli/agentcore.py @@ -394,11 +394,26 @@ def _handle_init(args: argparse.Namespace) -> int: ) print() print("Next steps:") - print(" 1. Restart Claude Code (or your MCP server) so it picks up the new backend") - print(" 2. Run `better-memory agentcore status` to confirm the effective backend") - print(" and that both memories are ACTIVE") - print(" 3. Run `better-memory agentcore smoke` to verify the AWS round-trip") - print(" (smoke validates AWS credentials and wire access, not MCP registration)") + if activate: + print( + " 1. Restart Claude Code (or your MCP server) so it picks up the new backend" + ) + print( + " 2. Run `better-memory agentcore status` to confirm the effective backend" + ) + print(" and that both memories are ACTIVE") + print(" 3. Run `better-memory agentcore smoke` to verify the AWS round-trip") + print( + " (smoke validates AWS credentials and wire access, not MCP registration)" + ) + else: + print(" 1. Activate when ready (see above) — no backend change was made yet") + print(" 2. Run `better-memory agentcore status` to confirm both memories are") + print(" ACTIVE (the effective backend is unchanged until you activate)") + print(" 3. Run `better-memory agentcore smoke` to verify the AWS round-trip") + print( + " (smoke validates AWS credentials and wire access, not MCP registration)" + ) return 0 diff --git a/tests/cli/test_agentcore_init.py b/tests/cli/test_agentcore_init.py index 80b143f..129d992 100644 --- a/tests/cli/test_agentcore_init.py +++ b/tests/cli/test_agentcore_init.py @@ -416,6 +416,10 @@ def test_init_no_activate_skips_settings_write(tmp_path, monkeypatch, capsys) -> assert "--no-activate" in out assert "settings.json" in out assert "Export BETTER_MEMORY_STORAGE_BACKEND" not in out + # BugBot PR#79: next-steps must not contradict the skip message — no + # "picks up the new backend" restart instruction when nothing changed. + assert "picks up the new backend" not in out + assert "no backend change was made yet" in out def test_init_failure_path_does_not_write_settings(tmp_path, monkeypatch) -> None: