From c40c04d0c6ba99e68f6aca9c4d76c62b0b230f09 Mon Sep 17 00:00:00 2001 From: gethin Date: Wed, 27 May 2026 08:17:15 +0100 Subject: [PATCH 01/18] feat(cli): add better-memory top-level CLI dispatcher with agentcore subgroup --- better_memory/cli/__init__.py | 5 ++++ better_memory/cli/agentcore.py | 30 ++++++++++++++++++++ better_memory/cli/main.py | 51 ++++++++++++++++++++++++++++++++++ pyproject.toml | 9 ++++++ tests/cli/test_main.py | 48 ++++++++++++++++++++++++++++++++ 5 files changed, 143 insertions(+) create mode 100644 better_memory/cli/agentcore.py create mode 100644 better_memory/cli/main.py create mode 100644 tests/cli/test_main.py diff --git a/better_memory/cli/__init__.py b/better_memory/cli/__init__.py index e69de29..59d987f 100644 --- a/better_memory/cli/__init__.py +++ b/better_memory/cli/__init__.py @@ -0,0 +1,5 @@ +"""CLI subcommands for better-memory.""" + +from better_memory.cli.main import main + +__all__ = ["main"] diff --git a/better_memory/cli/agentcore.py b/better_memory/cli/agentcore.py new file mode 100644 index 0000000..428c032 --- /dev/null +++ b/better_memory/cli/agentcore.py @@ -0,0 +1,30 @@ +"""`better-memory agentcore ...` subcommand group. + +Subcommands: init, status, smoke, migrate-from-sqlite. Implemented in +Tasks 2-5 of Plan 3. This module is loaded only when the user invokes +`better-memory agentcore ` so sqlite-only users never pay the +boto3 import cost. +""" + +from __future__ import annotations + +import argparse + + +def add_subparsers(parent: argparse.ArgumentParser) -> None: + """Register agentcore subcommands on the given parent parser.""" + subparsers = parent.add_subparsers( + dest="subcommand", + required=True, + metavar="", + ) + # Subcommands land in Tasks 2-5 + for name in ("init", "status", "smoke", "migrate-from-sqlite"): + subparsers.add_parser(name, help=f"(not yet implemented) {name}") + + +def handle(args: argparse.Namespace) -> int: + """Route to the right subcommand handler.""" + raise NotImplementedError( + f"agentcore {args.subcommand} is implemented in a later Plan-3 task" + ) diff --git a/better_memory/cli/main.py b/better_memory/cli/main.py new file mode 100644 index 0000000..bd97864 --- /dev/null +++ b/better_memory/cli/main.py @@ -0,0 +1,51 @@ +"""Top-level CLI dispatcher: `better-memory `. + +Registered via `[project.scripts]` in `pyproject.toml`. Subcommand modules +live alongside this one in `better_memory/cli/`. Today: `agentcore`. +""" + +from __future__ import annotations + +import argparse +import sys +from typing import Sequence + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="better-memory", + description="better-memory operator CLI.", + ) + subparsers = parser.add_subparsers( + dest="command", + required=True, + metavar="", + ) + + # ----- agentcore subcommand group ----- + ac_parser = subparsers.add_parser( + "agentcore", + help="Manage AWS Bedrock AgentCore Memory backend resources.", + ) + # The agentcore subgroup builds its own subparsers — import lazily so + # `better-memory --help` doesn't pull in boto3 (it's an optional dep). + from better_memory.cli import agentcore as agentcore_cli + agentcore_cli.add_subparsers(ac_parser) + + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + + if args.command == "agentcore": + from better_memory.cli import agentcore as agentcore_cli + return agentcore_cli.handle(args) + + parser.error(f"unknown command: {args.command}") + return 2 # unreachable; parser.error raises SystemExit + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index e15d052..595680b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,15 @@ dependencies = [ "markupsafe>=2", ] +[project.scripts] +better-memory = "better_memory.cli.main:main" + +[project.optional-dependencies] +agentcore = [ + "boto3>=1.43.14", + "botocore>=1.43.14", +] + [dependency-groups] dev = [ "pytest", diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py new file mode 100644 index 0000000..aa5dd20 --- /dev/null +++ b/tests/cli/test_main.py @@ -0,0 +1,48 @@ +"""Tests for `better-memory ...` CLI dispatcher.""" + +from __future__ import annotations + +import subprocess +import sys + +import pytest + +from better_memory.cli.main import main + + +def test_main_with_no_args_prints_help_and_exits_nonzero(capsys) -> None: + """`better-memory` with no subcommand should print help and exit 2 (argparse default).""" + with pytest.raises(SystemExit) as excinfo: + main([]) + assert excinfo.value.code == 2 + + +def test_main_help_lists_agentcore_subcommand(capsys) -> None: + with pytest.raises(SystemExit) as excinfo: + main(["--help"]) + assert excinfo.value.code == 0 + captured = capsys.readouterr() + assert "agentcore" in captured.out + + +def test_main_dispatches_to_agentcore_handler(monkeypatch) -> None: + """`better-memory agentcore status` should call the agentcore subcommand.""" + called = {} + + def fake_handle(args: object) -> int: + called["yes"] = True + return 0 + + monkeypatch.setattr( + "better_memory.cli.agentcore.handle", + fake_handle, + ) + rc = main(["agentcore", "status"]) + assert rc == 0 + assert called == {"yes": True} + + +def test_main_unknown_subcommand_exits_nonzero() -> None: + with pytest.raises(SystemExit) as excinfo: + main(["bogus"]) + assert excinfo.value.code == 2 From 6e741079dde3f07e813d6947b80f84679cdfb7c5 Mon Sep 17 00:00:00 2001 From: gethin Date: Wed, 27 May 2026 08:20:24 +0100 Subject: [PATCH 02/18] style(cli): drop unused subprocess/sys imports from test_main --- tests/cli/test_main.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py index aa5dd20..3d6493a 100644 --- a/tests/cli/test_main.py +++ b/tests/cli/test_main.py @@ -2,9 +2,6 @@ from __future__ import annotations -import subprocess -import sys - import pytest from better_memory.cli.main import main From 0146ebbe6153f99701985f3e64491aa1591276cc Mon Sep 17 00:00:00 2001 From: gethin Date: Wed, 27 May 2026 08:37:41 +0100 Subject: [PATCH 03/18] =?UTF-8?q?feat(cli):=20implement=20agentcore=20init?= =?UTF-8?q?=20=E2=80=94=20create=20both=20memories=20and=20write=20agentco?= =?UTF-8?q?re.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- better_memory/cli/_agentcore_strategies.py | 103 +++++++ better_memory/cli/agentcore.py | 312 +++++++++++++++++++-- tests/cli/test_agentcore_init.py | 294 +++++++++++++++++++ 3 files changed, 692 insertions(+), 17 deletions(-) create mode 100644 better_memory/cli/_agentcore_strategies.py create mode 100644 tests/cli/test_agentcore_init.py diff --git a/better_memory/cli/_agentcore_strategies.py b/better_memory/cli/_agentcore_strategies.py new file mode 100644 index 0000000..6282e4a --- /dev/null +++ b/better_memory/cli/_agentcore_strategies.py @@ -0,0 +1,103 @@ +"""Memory-strategy definitions used by `agentcore init` and the smoke script. + +Lifted from `scripts/agentcore_smoke.py` so the CLI and the smoke share a +single source of truth — diverging the two has bitten us before. +""" + +from __future__ import annotations + +# Episodic memory: extracts reflections from session events. Metadata schema +# carries the rating counters + polarity classification. +EPISODIC_METADATA_SCHEMA: list[dict] = [ + { + "key": "polarity", + "type": "STRING", + "extractionConfig": { + "llmExtractionConfig": { + "definition": ( + "Whether this reflection prescribes a positive practice " + "('do'), warns against a negative practice ('dont'), or " + "is informational only ('neutral')." + ), + "llmExtractionInstruction": ( + "Classify this reflection as 'do', 'dont', or 'neutral'." + ), + "validation": { + "stringValidation": { + "allowedValues": ["do", "dont", "neutral"] + } + }, + } + }, + }, + {"key": "useful_count", "type": "NUMBER"}, + {"key": "missed_count", "type": "NUMBER"}, + {"key": "ignored_count", "type": "NUMBER"}, + {"key": "times_misled", "type": "NUMBER"}, + {"key": "overlooked_count", "type": "NUMBER"}, + {"key": "last_credited_at", "type": "STRING"}, + {"key": "status", "type": "STRING"}, +] + +SEMANTIC_METADATA_SCHEMA: list[dict] = [ + {"key": "useful_count", "type": "NUMBER"}, + {"key": "missed_count", "type": "NUMBER"}, + {"key": "ignored_count", "type": "NUMBER"}, + {"key": "times_misled", "type": "NUMBER"}, + {"key": "overlooked_count", "type": "NUMBER"}, + {"key": "last_credited_at", "type": "STRING"}, + {"key": "status", "type": "STRING"}, +] + +INDEXED_KEYS: list[dict] = [ + {"key": "status", "type": "STRING"}, + {"key": "last_credited_at", "type": "STRING"}, + {"key": "overlooked_count", "type": "NUMBER"}, +] + +# Names — must match the AWS regex `[a-zA-Z][a-zA-Z0-9_]{0,47}` (no dashes!). +DEFAULT_EPISODIC_NAME = "better_memory_episodic" +DEFAULT_SEMANTIC_NAME = "better_memory_semantic" +DEFAULT_EPISODIC_STRATEGY_NAME = "episodicReflections" +DEFAULT_SEMANTIC_STRATEGY_NAME = "userPreference" + +# Event TTL: episodic events are kept ~90 days (long enough to span a multi- +# month project); semantic records last ~365 days. +DEFAULT_EPISODIC_EVENT_EXPIRY_DAYS = 90 +DEFAULT_SEMANTIC_EVENT_EXPIRY_DAYS = 365 + + +def episodic_strategy_block( + *, + name: str = DEFAULT_EPISODIC_STRATEGY_NAME, +) -> dict: + return { + "episodicMemoryStrategy": { + "name": name, + "namespaces": ["projects/{actorId}/reflections/"], + "namespaceTemplates": ["projects/{actorId}/reflections/"], + "reflectionConfiguration": { + "namespaces": ["projects/{actorId}/reflections/"], + "namespaceTemplates": ["projects/{actorId}/reflections/"], + "memoryRecordSchema": { + "metadataSchema": EPISODIC_METADATA_SCHEMA + }, + }, + } + } + + +def semantic_strategy_block( + *, + name: str = DEFAULT_SEMANTIC_STRATEGY_NAME, +) -> dict: + return { + "userPreferenceMemoryStrategy": { + "name": name, + "namespaces": ["projects/{actorId}/semantic/"], + "namespaceTemplates": ["projects/{actorId}/semantic/"], + "memoryRecordSchema": { + "metadataSchema": SEMANTIC_METADATA_SCHEMA + }, + } + } diff --git a/better_memory/cli/agentcore.py b/better_memory/cli/agentcore.py index 428c032..ef8d35b 100644 --- a/better_memory/cli/agentcore.py +++ b/better_memory/cli/agentcore.py @@ -1,30 +1,308 @@ -"""`better-memory agentcore ...` subcommand group. - -Subcommands: init, status, smoke, migrate-from-sqlite. Implemented in -Tasks 2-5 of Plan 3. This module is loaded only when the user invokes -`better-memory agentcore ` so sqlite-only users never pay the -boto3 import cost. -""" +"""`better-memory agentcore ...` subcommand group.""" from __future__ import annotations import argparse +import sys +import time +from pathlib import Path +from typing import Any + +from better_memory.cli._agentcore_strategies import ( + DEFAULT_EPISODIC_EVENT_EXPIRY_DAYS, + DEFAULT_EPISODIC_NAME, + DEFAULT_EPISODIC_STRATEGY_NAME, + DEFAULT_SEMANTIC_EVENT_EXPIRY_DAYS, + DEFAULT_SEMANTIC_NAME, + DEFAULT_SEMANTIC_STRATEGY_NAME, + INDEXED_KEYS, + episodic_strategy_block, + semantic_strategy_block, +) +from better_memory.storage.agentcore_persistence import ( + AgentCoreConfig, + MemoryRecord, + save_agentcore_config, +) + +_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. +_POLL_TIMEOUT_S = 240 def add_subparsers(parent: argparse.ArgumentParser) -> None: - """Register agentcore subcommands on the given parent parser.""" subparsers = parent.add_subparsers( - dest="subcommand", - required=True, - metavar="", + dest="subcommand", required=True, metavar="", + ) + + p_init = subparsers.add_parser( + "init", + help="Create AgentCore memories and write agentcore.json", + ) + p_init.add_argument("--home", default=None, help="Override BETTER_MEMORY_HOME") + p_init.add_argument("--region", default="eu-west-2", help="AWS region") + p_init.add_argument( + "--force", + action="store_true", + help="Overwrite existing agentcore.json", + ) + + p_status = subparsers.add_parser( + "status", + help="Show memory IDs and ACTIVE/CREATING/FAILED states", + ) + p_status.add_argument("--home", default=None) + p_status.add_argument("--region", default=None) + + p_smoke = subparsers.add_parser("smoke", help="Run an observe + retrieve smoke loop") + p_smoke.add_argument("--home", default=None) + p_smoke.add_argument("--region", default=None) + + subparsers.add_parser( + "migrate-from-sqlite", + help="(deferred) Bulk-migrate sqlite data to AgentCore", ) - # Subcommands land in Tasks 2-5 - for name in ("init", "status", "smoke", "migrate-from-sqlite"): - subparsers.add_parser(name, help=f"(not yet implemented) {name}") def handle(args: argparse.Namespace) -> int: - """Route to the right subcommand handler.""" - raise NotImplementedError( - f"agentcore {args.subcommand} is implemented in a later Plan-3 task" + if args.subcommand == "init": + return _handle_init(args) + if args.subcommand == "status": + return _handle_status(args) + if args.subcommand == "smoke": + return _handle_smoke(args) + if args.subcommand == "migrate-from-sqlite": + return _handle_migrate(args) + print(f"unknown subcommand: {args.subcommand}", file=sys.stderr) + return 2 + + +def _resolve_home(arg_home: str | None) -> Path: + import os + if arg_home: + return Path(arg_home).expanduser() + return Path(os.environ.get("BETTER_MEMORY_HOME", "~/.better-memory")).expanduser() + + +def _build_control_client(region: str) -> Any: + """Build the bedrock-agentcore-control boto3 client. Patched out in tests.""" + import boto3 + from botocore.config import Config as BotoConfig + return boto3.client( + "bedrock-agentcore-control", + config=BotoConfig(region_name=region, retries={"mode": "standard", "max_attempts": 5}), + ) + + +def _build_data_client(region: str) -> Any: + import boto3 + from botocore.config import Config as BotoConfig + return boto3.client( + "bedrock-agentcore", + config=BotoConfig(region_name=region, retries={"mode": "standard", "max_attempts": 5}), ) + + +def _poll_until_active(control: Any, memory_id: str, *, label: str) -> dict: + """Poll GetMemory until the memory AND every strategy are ACTIVE. + + Prints progress every poll so the user sees the long ~90-115s creation + isn't a hang. Returns the final memory dict.""" + start = time.monotonic() + while time.monotonic() - start < _POLL_TIMEOUT_S: + response = control.get_memory(memoryId=memory_id) + memory = response["memory"] + memory_status = memory.get("status") + strategies = memory.get("strategies", []) + all_strategies_active = strategies and all( + s.get("status") == "ACTIVE" for s in strategies + ) + print( + f" .. {label} memory_status={memory_status} " + f"strategies_active={bool(all_strategies_active)}" + ) + if memory_status == "ACTIVE" and all_strategies_active: + return memory + if memory_status == "FAILED": + raise RuntimeError(f"{label} memory entered FAILED state: {memory!r}") + time.sleep(_POLL_INTERVAL_S) + raise TimeoutError( + f"{label} memory did not become ACTIVE within {_POLL_TIMEOUT_S}s" + ) + + +def _find_existing_memory(control: Any, name: str) -> str | None: + """Return memory_id if a non-deleting memory with this name already exists.""" + paginator = control.get_paginator("list_memories") + for page in paginator.paginate(): + for summary in page.get("memories", []): + if summary.get("status") == "DELETING": + continue + try: + memory = control.get_memory(memoryId=summary["id"])["memory"] + except Exception: + continue + if memory.get("name") == name: + return memory["id"] + return None + + +def _create_one_memory( + control: Any, + *, + name: str, + strategy_block: dict, + strategy_name: str, + event_expiry_days: int, + label: str, +) -> MemoryRecord: + print(f">> Creating {label} memory ({name!r})...") + response = control.create_memory( + name=name, + eventExpiryDuration=event_expiry_days, + memoryStrategies=[strategy_block], + indexedKeys=INDEXED_KEYS, + ) + initial = response["memory"] + memory_id = initial["id"] + print(f" created: memory_id={memory_id}") + + final = _poll_until_active(control, memory_id, label=label) + strategies = final.get("strategies") or [] + if not strategies: + raise RuntimeError(f"{label} memory has no strategies after ACTIVE: {final!r}") + return MemoryRecord( + memory_id=memory_id, + memory_arn=final["arn"], + memory_name=final.get("name", name), + strategy_id=strategies[0]["strategyId"], + strategy_name=strategies[0].get("name", strategy_name), + event_expiry_duration_days=event_expiry_days, + ) + + +def _handle_init(args: argparse.Namespace) -> int: + home = _resolve_home(args.home) + config_path = home / "agentcore.json" + + if config_path.exists() and not args.force: + print( + f"agentcore.json already exists at {config_path}. " + f"Pass --force to recreate (this will leave the old memories " + f"in AWS — clean them up via the console if you no longer " + f"want them).", + file=sys.stderr, + ) + return 1 + + control = _build_control_client(args.region) + + # Pre-flight name check for BOTH names so partial existing state is + # surfaced before any CreateMemory runs (and we don't get a half-done + # account where one name is taken and the other isn't). + for name in (DEFAULT_EPISODIC_NAME, DEFAULT_SEMANTIC_NAME): + if _find_existing_memory(control, name) is not None: + print( + f"A memory named {name!r} already exists in {args.region}. " + f"Either delete it via the AWS console or re-use it by " + f"hand-editing agentcore.json. init refuses to create a " + f"second copy.", + file=sys.stderr, + ) + return 1 + + episodic: MemoryRecord | None = None + try: + episodic = _create_one_memory( + control, + name=DEFAULT_EPISODIC_NAME, + strategy_block=episodic_strategy_block(), + strategy_name=DEFAULT_EPISODIC_STRATEGY_NAME, + event_expiry_days=DEFAULT_EPISODIC_EVENT_EXPIRY_DAYS, + label="episodic", + ) + + semantic = _create_one_memory( + control, + name=DEFAULT_SEMANTIC_NAME, + strategy_block=semantic_strategy_block(), + strategy_name=DEFAULT_SEMANTIC_STRATEGY_NAME, + event_expiry_days=DEFAULT_SEMANTIC_EVENT_EXPIRY_DAYS, + label="semantic", + ) + except Exception as exc: + # ValidationException on the name regex is the most common + # operator error — surface the boto3 message + a pointer to the + # troubleshooting page rather than dumping a raw ClientError trace. + code = "" + try: + from botocore.exceptions import ClientError + if isinstance(exc, ClientError): + code = exc.response.get("Error", {}).get("Code", "") + except Exception: + pass + + # Orphan cleanup: if episodic was created but semantic failed, + # delete the episodic memory so a re-run of `init` starts clean. + if episodic is not None: + print( + f"\n!! Second memory create failed ({exc!r}). " + f"Deleting orphan episodic memory {episodic.memory_id} " + f"so a re-run starts clean...", + file=sys.stderr, + ) + try: + control.delete_memory(memoryId=episodic.memory_id) + print(f" deleted {episodic.memory_id}", file=sys.stderr) + except Exception as del_exc: + print( + f" WARN: failed to delete orphan {episodic.memory_id}: " + f"{del_exc!r}. Delete it manually via the AWS console " + f"before re-running init.", + file=sys.stderr, + ) + + if code == "ValidationException": + print( + f"\nAWS rejected the memory create as invalid: {exc}. " + f"Memory names must match `[a-zA-Z][a-zA-Z0-9_]{{0,47}}` " + f"— underscores only, no dashes. See " + f"docs/troubleshooting/agentcore.md for the full list.", + file=sys.stderr, + ) + return 1 + raise + + cfg = AgentCoreConfig( + schema_version=1, + region=args.region, + semantic=semantic, + episodic=episodic, + ) + save_agentcore_config(cfg, 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() + 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") + return 0 + + +def _handle_status(args: argparse.Namespace) -> int: + raise NotImplementedError("status lands in Task 3") + + +def _handle_smoke(args: argparse.Namespace) -> int: + raise NotImplementedError("smoke lands in Task 4") + + +def _handle_migrate(args: argparse.Namespace) -> int: + raise NotImplementedError("migrate-from-sqlite lands in Task 5") diff --git a/tests/cli/test_agentcore_init.py b/tests/cli/test_agentcore_init.py new file mode 100644 index 0000000..23c84e6 --- /dev/null +++ b/tests/cli/test_agentcore_init.py @@ -0,0 +1,294 @@ +"""Tests for `better-memory agentcore init`.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from better_memory.cli.agentcore import _handle_init + + +def _make_args( + home: Path, *, force: bool = False, region: str = "eu-west-2" +) -> argparse.Namespace: + """Build an argparse.Namespace the handler accepts.""" + return argparse.Namespace( + home=str(home), + region=region, + force=force, + subcommand="init", + ) + + +def _active_memory_response(memory_id: str, strategy_id: str) -> dict: + """Mimic GetMemory's ACTIVE response shape.""" + return { + "memory": { + "id": memory_id, + "arn": f"arn:aws:bedrock-agentcore:eu-west-2:123:memory/{memory_id}", + "name": memory_id.split("-")[0], + "status": "ACTIVE", + "strategies": [ + {"strategyId": strategy_id, "status": "ACTIVE", "name": "foo"} + ], + "eventExpiryDuration": 30, + } + } + + +def _create_memory_response(memory_id: str, strategy_id: str) -> dict: + """Mimic CreateMemory's response shape (status: CREATING).""" + return { + "memory": { + "id": memory_id, + "arn": f"arn:aws:bedrock-agentcore:eu-west-2:123:memory/{memory_id}", + "status": "CREATING", + "strategies": [ + {"strategyId": strategy_id, "status": "CREATING", "name": "foo"} + ], + } + } + + +def test_init_creates_both_memories_and_writes_config( + tmp_path, monkeypatch, capsys +) -> None: + """Happy path: both memories transition ACTIVE; agentcore.json written.""" + control = MagicMock(name="bedrock-agentcore-control") + + # list_memories paginator returns no existing memories (clean slate) + paginator = MagicMock() + paginator.paginate.return_value = iter([{"memories": []}]) + control.get_paginator.return_value = paginator + + # CreateMemory called twice: once for episodic, once for semantic + control.create_memory.side_effect = [ + _create_memory_response("epi-XYZ", "epi-strat-1"), + _create_memory_response("sem-XYZ", "sem-strat-1"), + ] + + # GetMemory polled: return ACTIVE immediately + control.get_memory.side_effect = [ + _active_memory_response("epi-XYZ", "epi-strat-1"), + _active_memory_response("sem-XYZ", "sem-strat-1"), + ] + + monkeypatch.setattr( + "better_memory.cli.agentcore._build_control_client", + lambda region: control, + ) + monkeypatch.setattr( + "better_memory.cli.agentcore.time.sleep", + lambda _s: None, + ) + + rc = _handle_init(_make_args(tmp_path)) + + assert rc == 0 + config_path = tmp_path / "agentcore.json" + assert config_path.exists() + config = json.loads(config_path.read_text()) + assert config["schema_version"] == 1 + assert config["region"] == "eu-west-2" + assert config["episodic"]["memory_id"] == "epi-XYZ" + assert config["semantic"]["memory_id"] == "sem-XYZ" + + out = capsys.readouterr().out + assert "epi-XYZ" in out + assert "sem-XYZ" in out + + +def test_init_refuses_when_config_exists_without_force( + tmp_path, monkeypatch +) -> None: + """If agentcore.json already exists, init refuses unless --force.""" + (tmp_path / "agentcore.json").write_text("{}") + + rc = _handle_init(_make_args(tmp_path)) + assert rc == 1 + + +def test_init_overwrites_when_force_set(tmp_path, monkeypatch) -> None: + """With --force, init proceeds even if agentcore.json exists.""" + (tmp_path / "agentcore.json").write_text(json.dumps({"old": True})) + + 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-NEW", "epi-strat"), + _create_memory_response("sem-NEW", "sem-strat"), + ] + control.get_memory.side_effect = [ + _active_memory_response("epi-NEW", "epi-strat"), + _active_memory_response("sem-NEW", "sem-strat"), + ] + + monkeypatch.setattr( + "better_memory.cli.agentcore._build_control_client", + lambda region: control, + ) + monkeypatch.setattr("better_memory.cli.agentcore.time.sleep", lambda _s: None) + + rc = _handle_init(_make_args(tmp_path, force=True)) + assert rc == 0 + + config = json.loads((tmp_path / "agentcore.json").read_text()) + assert config["episodic"]["memory_id"] == "epi-NEW" + assert "old" not in config + + +def test_init_polls_until_active(tmp_path, monkeypatch) -> None: + """If GetMemory returns CREATING, init polls until ACTIVE.""" + 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-X", "epi-s"), + _create_memory_response("sem-X", "sem-s"), + ] + + creating_epi = { + "memory": { + **_active_memory_response("epi-X", "epi-s")["memory"], + "status": "CREATING", + "strategies": [ + {"strategyId": "epi-s", "status": "CREATING", "name": "foo"} + ], + } + } + creating_sem = { + "memory": { + **_active_memory_response("sem-X", "sem-s")["memory"], + "status": "CREATING", + "strategies": [ + {"strategyId": "sem-s", "status": "CREATING", "name": "foo"} + ], + } + } + + # Episodic: 2 polls CREATING then ACTIVE; Semantic: 1 poll CREATING then ACTIVE + control.get_memory.side_effect = [ + creating_epi, creating_epi, + _active_memory_response("epi-X", "epi-s"), + creating_sem, + _active_memory_response("sem-X", "sem-s"), + ] + + monkeypatch.setattr( + "better_memory.cli.agentcore._build_control_client", + lambda region: control, + ) + monkeypatch.setattr("better_memory.cli.agentcore.time.sleep", lambda _s: None) + + rc = _handle_init(_make_args(tmp_path)) + assert rc == 0 + assert control.get_memory.call_count == 5 + + +def test_init_deletes_orphan_when_second_create_fails( + tmp_path, monkeypatch +) -> None: + """Episodic create succeeds, semantic create raises -> init must delete + the orphan episodic memory so a re-run of `init` starts clean.""" + control = MagicMock(name="bedrock-agentcore-control") + paginator = MagicMock() + paginator.paginate.return_value = iter([{"memories": []}]) + control.get_paginator.return_value = paginator + + # First create (episodic) succeeds; second (semantic) raises + 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"), + ] + + monkeypatch.setattr( + "better_memory.cli.agentcore._build_control_client", + lambda region: control, + ) + monkeypatch.setattr("better_memory.cli.agentcore.time.sleep", lambda _s: None) + + with pytest.raises(RuntimeError, match="semantic create"): + _handle_init(_make_args(tmp_path)) + + # Orphan delete fired exactly once against the episodic memory + control.delete_memory.assert_called_once_with(memoryId="epi-orphan") + + # No agentcore.json was written (init aborted) + assert not (tmp_path / "agentcore.json").exists() + + +def test_init_rejects_validation_error_with_friendly_message( + tmp_path, monkeypatch, capsys +) -> None: + """ValidationException on the name regex should map to a clean error, + not a raw boto3 trace.""" + from botocore.exceptions import ClientError + + 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 = ClientError( + error_response={ + "Error": { + "Code": "ValidationException", + "Message": "Memory name does not match required pattern", + } + }, + operation_name="CreateMemory", + ) + + monkeypatch.setattr( + "better_memory.cli.agentcore._build_control_client", + lambda region: control, + ) + monkeypatch.setattr("better_memory.cli.agentcore.time.sleep", lambda _s: None) + + rc = _handle_init(_make_args(tmp_path)) + assert rc == 1 + err = capsys.readouterr().err + assert "ValidationException" in err or "required pattern" in err + assert "troubleshooting" in err.lower() + + +def test_init_preflight_checks_both_names(tmp_path, monkeypatch, capsys) -> None: + """If EITHER default name already exists, init refuses before any + CreateMemory runs (no orphan risk).""" + control = MagicMock(name="bedrock-agentcore-control") + + # list_memories returns ONE existing memory matching the SEMANTIC name + paginator = MagicMock() + paginator.paginate.side_effect = lambda *a, **kw: iter([{ + "memories": [{"id": "existing-sem", "status": "ACTIVE"}] + }]) + control.get_paginator.return_value = paginator + control.get_memory.return_value = { + "memory": { + "id": "existing-sem", + "name": "better_memory_semantic", + "status": "ACTIVE", + "strategies": [], + } + } + + monkeypatch.setattr( + "better_memory.cli.agentcore._build_control_client", + lambda region: control, + ) + + rc = _handle_init(_make_args(tmp_path)) + assert rc == 1 + # CreateMemory must not have been called + control.create_memory.assert_not_called() + err = capsys.readouterr().err + assert "better_memory_semantic" in err From 3eaefe97b10d99a2a91a5d0cd7033d1654334537 Mon Sep 17 00:00:00 2001 From: gethin Date: Wed, 27 May 2026 08:45:44 +0100 Subject: [PATCH 04/18] =?UTF-8?q?feat(cli):=20implement=20agentcore=20stat?= =?UTF-8?q?us=20=E2=80=94=20print=20memory=20+=20strategy=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- better_memory/cli/agentcore.py | 41 +++++++++- tests/cli/test_agentcore_status.py | 116 +++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 tests/cli/test_agentcore_status.py diff --git a/better_memory/cli/agentcore.py b/better_memory/cli/agentcore.py index ef8d35b..459fcb2 100644 --- a/better_memory/cli/agentcore.py +++ b/better_memory/cli/agentcore.py @@ -22,6 +22,7 @@ from better_memory.storage.agentcore_persistence import ( AgentCoreConfig, MemoryRecord, + load_agentcore_config, save_agentcore_config, ) @@ -297,7 +298,45 @@ def _handle_init(args: argparse.Namespace) -> int: def _handle_status(args: argparse.Namespace) -> int: - raise NotImplementedError("status lands in Task 3") + home = _resolve_home(args.home) + cfg = load_agentcore_config(home) + if cfg is None: + print( + f"No agentcore.json found at {home / 'agentcore.json'}. " + f"Run `better-memory agentcore init` first.", + file=sys.stderr, + ) + return 1 + + region = args.region or cfg.region + control = _build_control_client(region) + + all_active = True + for label, record in (("episodic", cfg.episodic), ("semantic", cfg.semantic)): + response = control.get_memory(memoryId=record.memory_id) + memory = response["memory"] + status = memory.get("status", "UNKNOWN") + strategies = memory.get("strategies") or [] + strategy_summary = ", ".join( + f"{s.get('name','?')}={s.get('status','?')}" + for s in strategies + ) or "(none)" + expiry = memory.get("eventExpiryDuration", "?") + is_active = ( + status == "ACTIVE" + and strategies + and all(s.get("status") == "ACTIVE" for s in strategies) + ) + if not is_active: + all_active = False + print(f"{label}:") + print(f" memory_id: {record.memory_id}") + print(f" name: {memory.get('name', '?')}") + print(f" status: {status}") + print(f" strategies: {strategy_summary}") + print(f" expiry_days: {expiry}") + + return 0 if all_active else 1 def _handle_smoke(args: argparse.Namespace) -> int: diff --git a/tests/cli/test_agentcore_status.py b/tests/cli/test_agentcore_status.py new file mode 100644 index 0000000..b4cb377 --- /dev/null +++ b/tests/cli/test_agentcore_status.py @@ -0,0 +1,116 @@ +"""Tests for `better-memory agentcore status`.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from unittest.mock import MagicMock + +from better_memory.cli.agentcore import _handle_status + + +def _make_args(home: Path, region: str | None = None) -> argparse.Namespace: + """Build an argparse.Namespace the handler accepts.""" + return argparse.Namespace( + home=str(home), + region=region, + subcommand="status", + ) + + +def _write_config(home: Path) -> None: + cfg = { + "schema_version": 1, + "region": "eu-west-2", + "episodic": { + "memory_id": "epi-X", + "memory_arn": "arn:aws:bedrock-agentcore:eu-west-2:123:memory/epi-X", + "memory_name": "better_memory_episodic", + "strategy_id": "epi-strat", + "strategy_name": "episodicReflections", + "event_expiry_duration_days": 90, + }, + "semantic": { + "memory_id": "sem-X", + "memory_arn": "arn:aws:bedrock-agentcore:eu-west-2:123:memory/sem-X", + "memory_name": "better_memory_semantic", + "strategy_id": "sem-strat", + "strategy_name": "userPreference", + "event_expiry_duration_days": 365, + }, + } + (home / "agentcore.json").write_text(json.dumps(cfg)) + + +def test_status_exits_1_when_config_missing(tmp_path, capsys) -> None: + rc = _handle_status(_make_args(tmp_path)) + assert rc == 1 + err = capsys.readouterr().err + assert "agentcore.json" in err + + +def test_status_prints_both_memories_and_exits_0_when_active( + tmp_path, monkeypatch, capsys +) -> None: + _write_config(tmp_path) + 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, + }}, + ] + monkeypatch.setattr( + "better_memory.cli.agentcore._build_control_client", + lambda region: control, + ) + + rc = _handle_status(_make_args(tmp_path)) + assert rc == 0 + out = capsys.readouterr().out + assert "epi-X" in out and "ACTIVE" in out + assert "sem-X" in out + + +def test_status_exits_1_when_any_memory_not_active( + tmp_path, monkeypatch +) -> None: + _write_config(tmp_path) + control = MagicMock() + control.get_memory.side_effect = [ + {"memory": { + "id": "epi-X", "name": "better_memory_episodic", "status": "CREATING", + "strategies": [{"strategyId": "epi-strat", "status": "CREATING", "name": "x"}], + "eventExpiryDuration": 90, + }}, + {"memory": { + "id": "sem-X", "name": "better_memory_semantic", "status": "ACTIVE", + "strategies": [{"strategyId": "sem-strat", "status": "ACTIVE", "name": "y"}], + "eventExpiryDuration": 365, + }}, + ] + monkeypatch.setattr( + "better_memory.cli.agentcore._build_control_client", + lambda region: control, + ) + rc = _handle_status(_make_args(tmp_path)) + assert rc == 1 From 7f9cd7f23b6f76b95b6e8c3a184990851af39348 Mon Sep 17 00:00:00 2001 From: gethin Date: Wed, 27 May 2026 08:51:35 +0100 Subject: [PATCH 05/18] =?UTF-8?q?feat(cli):=20implement=20agentcore=20smok?= =?UTF-8?q?e=20=E2=80=94=20observe=20+=20retrieve=20verification=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- better_memory/cli/agentcore.py | 109 ++++++++++++++++++++++++++++- tests/cli/test_agentcore_smoke.py | 110 ++++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 tests/cli/test_agentcore_smoke.py diff --git a/better_memory/cli/agentcore.py b/better_memory/cli/agentcore.py index 459fcb2..46187cc 100644 --- a/better_memory/cli/agentcore.py +++ b/better_memory/cli/agentcore.py @@ -340,7 +340,114 @@ def _handle_status(args: argparse.Namespace) -> int: def _handle_smoke(args: argparse.Namespace) -> int: - raise NotImplementedError("smoke lands in Task 4") + """Minimal observe + closure + retrieve cycle for ops verification.""" + home = _resolve_home(args.home) + cfg = load_agentcore_config(home) + if cfg is None: + print( + f"No agentcore.json found at {home / 'agentcore.json'}. " + f"Run `better-memory agentcore init` first.", + file=sys.stderr, + ) + return 1 + + region = args.region or cfg.region + data = _build_data_client(region) + actor_id = "smoke" + session_id = f"smoke-{int(time.time())}" + from datetime import UTC, datetime + + try: + print(">> 1. CreateEvent — observation") + data.create_event( + memoryId=cfg.episodic.memory_id, + actorId=actor_id, + sessionId=session_id, + eventTimestamp=datetime.now(UTC), + payload=[{"conversational": { + "role": "USER", + "content": {"text": "smoke test observation"}, + }}], + metadata={"theme": {"stringValue": "smoke"}}, + ) + print(" ok") + + print(">> 2. CreateEvent — closure marker (role=OTHER)") + data.create_event( + memoryId=cfg.episodic.memory_id, + actorId=actor_id, + sessionId=session_id, + eventTimestamp=datetime.now(UTC), + payload=[{"conversational": { + "role": "OTHER", + "content": {"text": "session closed"}, + }}], + ) + print(" ok") + + print(">> 3. ListEvents — confirm events readable") + response = data.list_events( + memoryId=cfg.episodic.memory_id, + actorId=actor_id, + sessionId=session_id, + maxResults=10, + includePayloads=True, + ) + events = response.get("events", []) + if len(events) < 2: + raise RuntimeError( + f"list_events returned {len(events)} events; expected >= 2" + ) + print(f" ok ({len(events)} events)") + + print(">> 4. BatchCreateMemoryRecords — semantic write") + record_id = f"smoke-rec-{int(time.time())}" + create_resp = data.batch_create_memory_records( + memoryId=cfg.semantic.memory_id, + records=[{ + "memoryRecordId": record_id, + "namespaces": [f"projects/{actor_id}/semantic/"], + "content": {"text": "smoke test semantic record"}, + "metadata": { + "useful_count": {"numberValue": 0}, + "status": {"stringValue": "active"}, + }, + }], + ) + failed = create_resp.get("failedRecords", []) + if failed: + raise RuntimeError(f"batch_create failed: {failed!r}") + real_id = create_resp["successfulRecords"][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(">> 6. BatchDeleteMemoryRecords — cleanup") + del_resp = data.batch_delete_memory_records( + memoryId=cfg.semantic.memory_id, + records=[{"memoryRecordId": real_id}], + ) + if del_resp.get("failedRecords"): + raise RuntimeError( + f"batch_delete failed: {del_resp['failedRecords']!r}" + ) + print(" ok") + + print() + print("AgentCore smoke PASSED") + return 0 + except Exception as exc: + print(f"AgentCore smoke FAILED: {exc!r}", file=sys.stderr) + return 1 def _handle_migrate(args: argparse.Namespace) -> int: diff --git a/tests/cli/test_agentcore_smoke.py b/tests/cli/test_agentcore_smoke.py new file mode 100644 index 0000000..b6c996d --- /dev/null +++ b/tests/cli/test_agentcore_smoke.py @@ -0,0 +1,110 @@ +"""Tests for `better-memory agentcore smoke`.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from unittest.mock import MagicMock + +from better_memory.cli.agentcore import _handle_smoke + + +def _make_args(home: Path, region: str | None = None) -> argparse.Namespace: + """Build an argparse.Namespace the handler accepts.""" + return argparse.Namespace( + home=str(home), + region=region, + subcommand="smoke", + ) + + +def _write_config(home: Path) -> None: + cfg = { + "schema_version": 1, + "region": "eu-west-2", + "episodic": { + "memory_id": "epi-X", + "memory_arn": "arn:aws:bedrock-agentcore:eu-west-2:123:memory/epi-X", + "memory_name": "better_memory_episodic", + "strategy_id": "epi-strat", + "strategy_name": "episodicReflections", + "event_expiry_duration_days": 90, + }, + "semantic": { + "memory_id": "sem-X", + "memory_arn": "arn:aws:bedrock-agentcore:eu-west-2:123:memory/sem-X", + "memory_name": "better_memory_semantic", + "strategy_id": "sem-strat", + "strategy_name": "userPreference", + "event_expiry_duration_days": 365, + }, + } + (home / "agentcore.json").write_text(json.dumps(cfg)) + + +def test_smoke_exits_1_when_config_missing(tmp_path) -> None: + rc = _handle_smoke(_make_args(tmp_path)) + assert rc == 1 + + +def test_smoke_runs_full_cycle_against_existing_memories( + tmp_path, monkeypatch +) -> None: + _write_config(tmp_path) + data = MagicMock(name="bedrock-agentcore") + # CreateEvent (observation) + CreateEvent (closure) + data.create_event.side_effect = [ + {"event": {"eventId": "evt-1"}}, + {"event": {"eventId": "evt-2"}}, + ] + # ListEvents returns the two events + data.list_events.return_value = { + "events": [ + {"eventId": "evt-1", "sessionId": "smoke-sess"}, + {"eventId": "evt-2", "sessionId": "smoke-sess"}, + ] + } + # BatchCreateMemoryRecords for a semantic write + data.batch_create_memory_records.return_value = { + "successfulRecords": [{"memoryRecordId": "mem-rec-1"}], + "failedRecords": [], + } + # ListMemoryRecords returns the record + data.list_memory_records.return_value = { + "memoryRecordSummaries": [ + {"memoryRecordId": "mem-rec-1", "content": {"text": "hi"}} + ] + } + # BatchDeleteMemoryRecords cleans up + data.batch_delete_memory_records.return_value = { + "successfulRecords": [{"memoryRecordId": "mem-rec-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.create_event.call_count == 2 + assert data.list_events.call_count >= 1 + assert data.batch_create_memory_records.call_count == 1 + assert data.batch_delete_memory_records.call_count == 1 + + +def test_smoke_exits_1_when_any_step_fails(tmp_path, monkeypatch) -> None: + _write_config(tmp_path) + data = MagicMock() + data.create_event.side_effect = RuntimeError("simulated AWS failure") + 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 == 1 From 9f364e8bc2ef61ee28ec475385cfc826a88ff0b5 Mon Sep 17 00:00:00 2001 From: gethin Date: Wed, 27 May 2026 08:59:06 +0100 Subject: [PATCH 06/18] fix(cli): move _build_data_client into _handle_smoke try block --- better_memory/cli/agentcore.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/better_memory/cli/agentcore.py b/better_memory/cli/agentcore.py index 46187cc..7bc883c 100644 --- a/better_memory/cli/agentcore.py +++ b/better_memory/cli/agentcore.py @@ -352,12 +352,15 @@ def _handle_smoke(args: argparse.Namespace) -> int: return 1 region = args.region or cfg.region - data = _build_data_client(region) actor_id = "smoke" session_id = f"smoke-{int(time.time())}" from datetime import UTC, datetime try: + # Build client INSIDE try so import / region / credential failures + # land in the same "smoke FAILED -> rc=1" path as wire errors, + # rather than escaping as an unhandled traceback. + data = _build_data_client(region) print(">> 1. CreateEvent — observation") data.create_event( memoryId=cfg.episodic.memory_id, From 2f4e5f71dce2292c42ea7de5abd4c904508fe3e8 Mon Sep 17 00:00:00 2001 From: gethin Date: Wed, 27 May 2026 09:01:14 +0100 Subject: [PATCH 07/18] feat(cli): stub agentcore migrate-from-sqlite with deferred-spec pointer --- better_memory/cli/agentcore.py | 8 +++++++- tests/cli/test_agentcore_migrate.py | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 tests/cli/test_agentcore_migrate.py diff --git a/better_memory/cli/agentcore.py b/better_memory/cli/agentcore.py index 7bc883c..65c72d8 100644 --- a/better_memory/cli/agentcore.py +++ b/better_memory/cli/agentcore.py @@ -454,4 +454,10 @@ def _handle_smoke(args: argparse.Namespace) -> int: def _handle_migrate(args: argparse.Namespace) -> int: - raise NotImplementedError("migrate-from-sqlite lands in Task 5") + raise NotImplementedError( + "Bulk migration of sqlite data to AgentCore is deferred to a future " + "spec. See docs/superpowers/specs/2026-05-24-agentcore-storage-backend-" + "design.md § 'Open questions (deferred to implementation)' item 5. " + "Workaround for now: start fresh in agentcore mode; observations from " + "sqlite-mode sessions remain queryable in sqlite mode." + ) diff --git a/tests/cli/test_agentcore_migrate.py b/tests/cli/test_agentcore_migrate.py new file mode 100644 index 0000000..3d8347f --- /dev/null +++ b/tests/cli/test_agentcore_migrate.py @@ -0,0 +1,18 @@ +"""Tests for `better-memory agentcore migrate-from-sqlite` (stubbed).""" + +from __future__ import annotations + +import argparse + +import pytest + +from better_memory.cli.agentcore import _handle_migrate + + +def test_migrate_raises_not_implemented_with_pointer() -> None: + args = argparse.Namespace(subcommand="migrate-from-sqlite") + with pytest.raises(NotImplementedError) as excinfo: + _handle_migrate(args) + msg = str(excinfo.value) + # Pointer text must mention the deferred spec / future work + assert "future" in msg.lower() or "deferred" in msg.lower() From 4155a0cb45e9c49b9669de2bc7fb4d88f8feb52c Mon Sep 17 00:00:00 2001 From: gethin Date: Wed, 27 May 2026 09:09:02 +0100 Subject: [PATCH 08/18] feat(hooks): fire AgentCore closure event from Stop hook in agentcore mode --- better_memory/hooks/session_close.py | 90 ++++++++++++ tests/hooks/test_session_close_agentcore.py | 151 ++++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 tests/hooks/test_session_close_agentcore.py diff --git a/better_memory/hooks/session_close.py b/better_memory/hooks/session_close.py index e300ecf..62a18c1 100644 --- a/better_memory/hooks/session_close.py +++ b/better_memory/hooks/session_close.py @@ -58,6 +58,79 @@ def _synthesise_marker() -> dict[str, str]: } +def _build_agentcore_data_client(region: str): + """Construct the bedrock-agentcore (data plane) boto3 client. + + 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 + return boto3.client( + "bedrock-agentcore", + config=BotoConfig( + region_name=region, retries={"mode": "standard", "max_attempts": 5} + ), + ) + + +def _fire_agentcore_closure(*, session_id: str, project: 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). + + NEVER raises. AgentCore-side failure is logged via _error_log and + the spool-marker write proceeds anyway (idle-detection fallback). + + 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.""" + # Env-var guard BEFORE any import so sqlite-mode pays nothing. + if os.environ.get("BETTER_MEMORY_STORAGE_BACKEND", "sqlite") != "agentcore": + return False + + try: + # Lazy imports — sqlite mode short-circuited above and never reaches + # this block. + from datetime import UTC, datetime + + from better_memory.storage.agentcore_persistence import ( + load_agentcore_config, + ) + from better_memory.storage.session import ( + closure_event_payload, + resolve_actor_id, + ) + + home_env = os.environ.get("BETTER_MEMORY_HOME") + home = ( + Path(home_env).expanduser() + if home_env + else Path.home() / ".better-memory" + ) + cfg = load_agentcore_config(home) + if cfg is None: + return False + + client = _build_agentcore_data_client(cfg.region) + client.create_event( + memoryId=cfg.episodic.memory_id, + actorId=resolve_actor_id(project), + sessionId=session_id, + eventTimestamp=datetime.now(UTC), + payload=closure_event_payload(), + ) + return True + except BaseException as _exc: + try: + from better_memory.hooks._error_log import record_hook_error + record_hook_error(hook_name="session_close_agentcore", exc=_exc) + except BaseException: + pass + return False + + def _emit_rating_directive_if_unrated(session_id: str) -> bool: """Best-effort: if the current session has any unrated exposures, emit a decision:block directive on stdout asking the LLM to rate @@ -217,6 +290,23 @@ def main() -> None: # downstream synthesis runs AFTER ratings land. sys.exit(0) + # Agentcore mode: fire a closure-marker event so the episodic + # 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" + _fire_agentcore_closure( + session_id=str(session_id_str or ""), + project=str(project_for_closure), + ) + spool_dir = _default_spool_dir() spool_dir.mkdir(parents=True, exist_ok=True) diff --git a/tests/hooks/test_session_close_agentcore.py b/tests/hooks/test_session_close_agentcore.py new file mode 100644 index 0000000..194a3db --- /dev/null +++ b/tests/hooks/test_session_close_agentcore.py @@ -0,0 +1,151 @@ +"""Tests for Stop hook's agentcore-mode closure event.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + + +@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({ + "schema_version": 1, + "region": "eu-west-2", + "episodic": { + "memory_id": "epi-test", + "memory_arn": "arn:aws:bedrock-agentcore:eu-west-2:123:memory/epi-test", + "memory_name": "better_memory_episodic", + "strategy_id": "epi-strat", + "strategy_name": "episodicReflections", + "event_expiry_duration_days": 90, + }, + "semantic": { + "memory_id": "sem-test", + "memory_arn": "arn:aws:bedrock-agentcore:eu-west-2:123:memory/sem-test", + "memory_name": "better_memory_semantic", + "strategy_id": "sem-strat", + "strategy_name": "userPreference", + "event_expiry_duration_days": 365, + }, + })) + 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 + + +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") + fake_data_client.create_event.return_value = {"event": {"eventId": "evt-close"}} + + monkeypatch.setattr( + "better_memory.hooks.session_close._build_agentcore_data_client", + lambda region: fake_data_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_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" + payload = call["payload"][0]["conversational"] + assert payload["role"] == "OTHER" + + +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)) + monkeypatch.setenv("BETTER_MEMORY_STORAGE_BACKEND", "sqlite") + 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") + assert rc is False + + +def test_agentcore_failure_is_non_fatal(agentcore_config_present, monkeypatch): + """If the closure event raises, the hook must NOT propagate. Returns False.""" + fake_client = MagicMock() + fake_client.create_event.side_effect = RuntimeError("simulated AWS failure") + 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 + # Must NOT raise + rc = _fire_agentcore_closure(session_id="test-sess-abc", project="testproj") + assert rc is False + + +def test_spool_marker_written_even_when_closure_event_raises( + agentcore_config_present, monkeypatch +): + """Regression: closure-event failure MUST NOT block the spool marker. + Branch-order bug protection — if someone refactors main() and puts the + 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( + "better_memory.hooks.session_close._build_agentcore_data_client", + lambda region: fake_client, + ) + # Force the hook to read from agentcore_config_present's tmp_path + monkeypatch.setattr( + "better_memory.hooks.session_close._default_spool_dir", + 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: ""})()) + + from better_memory.hooks.session_close import main + + # main() exits 0 always — capture the SystemExit + with pytest.raises(SystemExit) as excinfo: + main() + assert excinfo.value.code == 0 + + # Spool marker FILE was written despite the closure event raising + spool_dir = Path(agentcore_config_present) / "spool" + markers = list(spool_dir.glob("*_session_end_*.json")) + 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.""" + 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 + + 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) + + from better_memory.hooks.session_close import _fire_agentcore_closure + rc = _fire_agentcore_closure(session_id="x", project="p") + assert rc is False + assert sentinel_raised == [] From e7f8496e8da7da02314bcfdb951193096ce42826 Mon Sep 17 00:00:00 2001 From: gethin Date: Wed, 27 May 2026 09:17:28 +0100 Subject: [PATCH 09/18] test(integration): real-AWS roundtrip for AgentCoreBackend (gated by env var) --- tests/integration/conftest.py | 233 ++++++++++++++++++ tests/integration/test_agentcore_roundtrip.py | 159 ++++++++++++ 2 files changed, 392 insertions(+) create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/test_agentcore_roundtrip.py diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..37a49f0 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,233 @@ +"""Fixtures for live-AWS AgentCore integration tests. + +Gated by ``BETTER_MEMORY_TEST_AGENTCORE=1``. CI does NOT set this. Local +runs need valid AWS credentials in the environment (boto3's default +discovery chain). + +**Setup tax: ~3 minutes** per pytest session. Two memories created +sequentially, each ~90-115s. Tests are session-scoped so this cost is +paid once. + +Teardown strategy: +- pytest fixture teardown deletes both memories on clean exit. +- atexit handler deletes them on Ctrl-C / SIGTERM / hard kill — same + cleanup work, just registered on the interpreter's exit path so any + abnormal termination still cleans up. +- ``BETTER_MEMORY_TEST_AGENTCORE_KEEP=1`` skips teardown for debugging. + +Before fixture setup runs, ``_sweep_stale_memories`` lists every +``bm_int_*`` memory and deletes any older than 1h — catches leaked state +from previously interrupted runs (network drop, OS reboot, etc.) so +re-runs don't accumulate orphans in the AWS account. +""" + +from __future__ import annotations + +import atexit +import os +import time +import uuid +from datetime import UTC, datetime, timedelta +from typing import Any + +import pytest + +_STALE_MEMORY_PREFIX = "bm_int_" +_STALE_MEMORY_AGE = timedelta(hours=1) + + +def _agentcore_enabled() -> bool: + return os.environ.get("BETTER_MEMORY_TEST_AGENTCORE") == "1" + + +def _keep_after_test() -> bool: + return os.environ.get("BETTER_MEMORY_TEST_AGENTCORE_KEEP") == "1" + + +def _sweep_stale_memories(control: Any) -> None: + """Delete any ``bm_int_*`` memories older than 1h. Catches leaked state + from previously interrupted test runs.""" + cutoff = datetime.now(UTC) - _STALE_MEMORY_AGE + paginator = control.get_paginator("list_memories") + for page in paginator.paginate(): + for summary in page.get("memories", []): + if summary.get("status") == "DELETING": + continue + try: + memory = control.get_memory(memoryId=summary["id"])["memory"] + except Exception: + continue + name = memory.get("name", "") + if not name.startswith(_STALE_MEMORY_PREFIX): + continue + created = memory.get("createdAt") + if not isinstance(created, datetime): + continue + if created.tzinfo is None: + created = created.replace(tzinfo=UTC) + if created < cutoff: + try: + print(f" sweeping stale memory {name} (id={memory['id']})") + control.delete_memory(memoryId=memory["id"]) + except Exception as exc: + print(f" WARN: failed to sweep {memory['id']}: {exc!r}") + + +@pytest.fixture(scope="session") +def agentcore_region() -> str: + return os.environ.get("BETTER_MEMORY_TEST_AGENTCORE_REGION", "eu-west-2") + + +@pytest.fixture(scope="session") +def agentcore_throwaway_memories(agentcore_region: str): + """Provision (semantic + episodic) memories; yield (semantic, episodic) + AgentCoreConfig.MemoryRecord pair; delete on teardown.""" + if not _agentcore_enabled(): + pytest.skip("Set BETTER_MEMORY_TEST_AGENTCORE=1 to run real-AWS tests.") + + import boto3 + from botocore.config import Config as BotoConfig + + from better_memory.cli._agentcore_strategies import ( + DEFAULT_EPISODIC_EVENT_EXPIRY_DAYS, + DEFAULT_SEMANTIC_EVENT_EXPIRY_DAYS, + INDEXED_KEYS, + episodic_strategy_block, + semantic_strategy_block, + ) + from better_memory.storage.agentcore_persistence import MemoryRecord + + suffix = uuid.uuid4().hex[:8] + epi_name = f"bm_int_epi_{suffix}" + sem_name = f"bm_int_sem_{suffix}" + + control = boto3.client( + "bedrock-agentcore-control", + config=BotoConfig( + region_name=agentcore_region, + retries={"mode": "standard", "max_attempts": 5}, + ), + ) + + # Sweep before creating so stale leaks from previously interrupted runs + # are cleaned up — best-effort, errors logged not raised. + try: + _sweep_stale_memories(control) + except Exception as exc: + print(f" WARN: stale-memory sweep failed: {exc!r}") + + def _create(name: str, strategy_block: dict, expiry_days: int) -> dict: + response = control.create_memory( + name=name, + eventExpiryDuration=expiry_days, + memoryStrategies=[strategy_block], + indexedKeys=INDEXED_KEYS, + ) + return response["memory"] + + def _poll_active(memory_id: str, *, timeout: int = 240, interval: int = 5) -> dict: + """Poll until ACTIVE — slow path, ~90-115s typical, allow 4min.""" + start = time.monotonic() + while time.monotonic() - start < timeout: + response = control.get_memory(memoryId=memory_id) + memory = response["memory"] + status = memory.get("status") + strategies = memory.get("strategies") or [] + if ( + status == "ACTIVE" + and strategies + and all(s.get("status") == "ACTIVE" for s in strategies) + ): + return memory + time.sleep(interval) + raise TimeoutError(f"memory {memory_id} did not become ACTIVE in {timeout}s") + + epi_initial = _create( + epi_name, episodic_strategy_block(), DEFAULT_EPISODIC_EVENT_EXPIRY_DAYS + ) + sem_initial = _create( + sem_name, semantic_strategy_block(), DEFAULT_SEMANTIC_EVENT_EXPIRY_DAYS + ) + + # Register atexit teardown IMMEDIATELY after create — so any abnormal + # termination (Ctrl-C, SIGTERM, pytest hard-kill) still triggers cleanup. + # `_cleanup` is idempotent — pytest fixture teardown runs it too on the + # happy path; the second call is a no-op since the memories are gone. + cleaned_up: list[bool] = [] + + def _cleanup() -> None: + if cleaned_up or _keep_after_test(): + return + cleaned_up.append(True) + for mid in (epi_initial["id"], sem_initial["id"]): + try: + control.delete_memory(memoryId=mid) + except Exception as exc: + print(f"WARN: atexit failed to delete {mid}: {exc!r}") + + atexit.register(_cleanup) + + epi_active = _poll_active(epi_initial["id"]) + sem_active = _poll_active(sem_initial["id"]) + + def _to_record( + active: dict, expiry_days: int, default_strategy_name: str + ) -> MemoryRecord: + strategies = active.get("strategies") or [] + if not strategies: + raise RuntimeError( + f"memory {active.get('id')} ACTIVE but reports no strategies" + ) + return MemoryRecord( + memory_id=active["id"], + memory_arn=active["arn"], + memory_name=active.get("name", ""), + strategy_id=strategies[0]["strategyId"], + strategy_name=strategies[0].get("name", default_strategy_name), + event_expiry_duration_days=expiry_days, + ) + + epi_record = _to_record( + epi_active, DEFAULT_EPISODIC_EVENT_EXPIRY_DAYS, "episodicReflections" + ) + sem_record = _to_record( + sem_active, DEFAULT_SEMANTIC_EVENT_EXPIRY_DAYS, "userPreference" + ) + + yield sem_record, epi_record + + # Clean teardown — same code as atexit (idempotent). + _cleanup() + + +@pytest.fixture +def agentcore_backend(agentcore_throwaway_memories, agentcore_region: str): + """Construct an AgentCoreBackend pointing at the throwaway memories.""" + import boto3 + from botocore.config import Config as BotoConfig + + from better_memory.storage.agentcore import AgentCoreBackend + from better_memory.storage.agentcore_persistence import AgentCoreConfig + + sem_record, epi_record = agentcore_throwaway_memories + cfg = AgentCoreConfig( + schema_version=1, + region=agentcore_region, + semantic=sem_record, + episodic=epi_record, + ) + + boto_config = BotoConfig( + region_name=agentcore_region, + retries={"mode": "standard", "max_attempts": 5}, + ) + data_client = boto3.client("bedrock-agentcore", config=boto_config) + control_client = boto3.client("bedrock-agentcore-control", config=boto_config) + + return AgentCoreBackend( + config=cfg, + data_client=data_client, + control_client=control_client, + session_id=f"int-test-{uuid.uuid4().hex[:8]}", + project="integration", + ) diff --git a/tests/integration/test_agentcore_roundtrip.py b/tests/integration/test_agentcore_roundtrip.py new file mode 100644 index 0000000..4cbdfff --- /dev/null +++ b/tests/integration/test_agentcore_roundtrip.py @@ -0,0 +1,159 @@ +"""End-to-end roundtrip against real AWS Bedrock AgentCore. + +Gated by ``BETTER_MEMORY_TEST_AGENTCORE=1``. Each scenario exercises 3+ +backend methods in sequence so contract drift between methods (which +mocked unit tests can't see) surfaces here. + +Async vs sync notes +------------------- +``AgentCoreBackend`` mixes async I/O wrappers and synchronous methods: + +- ``async``: ``observe``, ``list_observations`` — wrap boto3 in + ``loop.run_in_executor`` so the MCP event loop is not blocked. Tests + drive these via ``asyncio.run(...)``. +- ``sync``: ``retrieve``, ``record_use``, ``semantic_observe``, + ``semantic_update_text``, ``semantic_set_scope``, ``semantic_delete``, + ``credit_one`` — called directly (no coroutine). + +This split is verified against ``better_memory/storage/agentcore.py``. +""" + +from __future__ import annotations + +import asyncio +import os +import time + +import pytest + +pytestmark = [pytest.mark.integration] + + +def test_observe_then_list_observations_returns_event(agentcore_backend) -> None: + """observe writes an event; list_observations reads it back.""" + event_id = asyncio.run( + agentcore_backend.observe( + content="integration test observation", + component="testpkg", + theme="bug", + outcome="failure", + ) + ) + assert event_id + + events = asyncio.run(agentcore_backend.list_observations(limit=10)) + assert any(e["id"] == event_id for e in events), ( + f"observed event {event_id} not in list_observations({len(events)} events)" + ) + + +def test_semantic_observe_update_delete_full_cycle(agentcore_backend) -> None: + """Write a semantic record, update its text, then delete it.""" + record_id = agentcore_backend.semantic_observe( + content="integration semantic write", + scope="project", + ) + assert record_id + + # AgentCore has ~10s lag between create and the record being mutable. + # AgentCoreBackend._retry_on_transient_404 should handle it. + agentcore_backend.semantic_update_text( + id=record_id, content="integration semantic update" + ) + + agentcore_backend.semantic_delete(id=record_id) + + +def test_semantic_credit_one_bumps_useful_count(agentcore_backend) -> None: + """Fast end-to-end credit test against semantic memory (no extraction + wait — semantic records are created directly via BatchCreate, no LLM + pipeline). Locks down the kind/class contract that round-3 BugBot + fixed: credit_one(kind='semantic', classification='cited') must route + to the semantic memory and bump useful_count.""" + record_id = agentcore_backend.semantic_observe( + content="credit test record", + scope="project", + ) + + result = agentcore_backend.credit_one( + session_id="int-test-credit", + kind="semantic", + id=record_id, + classification="cited", + ) + assert result["applied"] == record_id + assert result["skipped"] is None + + # Cleanup + agentcore_backend.semantic_delete(id=record_id) + + +def test_no_leaked_memories_after_session(agentcore_throwaway_memories) -> None: + """Sanity check: after fixture teardown, the throwaway memories should + be deleted. Runs LAST (after all other tests in this module) — pytest + doesn't guarantee order across modules but does within a single one. + + The list_memories call here happens BEFORE teardown (fixture still + active), so this primarily exercises that the fixture WAS used and that + the names were unique-suffixed (no accidental collision with another + test run).""" + sem_record, epi_record = agentcore_throwaway_memories + assert sem_record.memory_name.startswith("bm_int_sem_") + assert epi_record.memory_name.startswith("bm_int_epi_") + assert sem_record.memory_id != epi_record.memory_id + + +def test_observe_credit_uses_correct_counter_via_extraction(agentcore_backend) -> None: + """SLOW: observe -> closure -> wait for AgentCore extraction -> credit. + + SKIPPED unless BETTER_MEMORY_TEST_AGENTCORE_SLOW=1 because episodic + extraction takes 1-3 min with closure event (15-20 min without). The + test locks down the credit -> useful_count contract for EXTRACTED + reflections (vs the directly-written semantic records covered by the + fast credit test above).""" + if os.environ.get("BETTER_MEMORY_TEST_AGENTCORE_SLOW") != "1": + pytest.skip("Set BETTER_MEMORY_TEST_AGENTCORE_SLOW=1 to run.") + + # Write 3 observations to give the strategy enough signal to extract + for i in range(3): + asyncio.run( + agentcore_backend.observe( + content=f"slow integration test observation {i}", + theme="bug", + outcome="failure", + ) + ) + + # Fire closure event so extraction triggers within minutes + # (the AgentCoreBackend.session_bootstrap path doesn't fire this; we + # call create_event directly via the data client for the slow test) + from datetime import UTC, datetime + + agentcore_backend._data.create_event( + memoryId=agentcore_backend._cfg.episodic.memory_id, + actorId="integration", + sessionId=agentcore_backend._session_id, + eventTimestamp=datetime.now(UTC), + payload=[{"conversational": {"role": "OTHER", "content": {"text": "closed"}}}], + ) + + # Poll for extracted reflections — ~1-3 min after closure + deadline = time.monotonic() + 360 # 6min upper bound + reflections: list[dict] = [] + while time.monotonic() < deadline: + result = agentcore_backend.retrieve(project="integration", limit_per_bucket=10) + reflections = [r for bucket in result.values() for r in bucket] + if reflections: + break + time.sleep(20) + assert reflections, "AgentCore did not extract any reflections within 6min" + + # Credit the first reflection + first = reflections[0] + result = agentcore_backend.credit_one( + session_id="int-test-credit-slow", + kind="reflection", + id=first["id"], + classification="cited", + ) + assert result["applied"] == first["id"] From 5d995507dbc6f94cad06243c0b8b14c407d3000e Mon Sep 17 00:00:00 2001 From: gethin Date: Wed, 27 May 2026 09:25:25 +0100 Subject: [PATCH 10/18] fix(integration): register atexit BEFORE first create so orphans clean up on partial failure --- tests/integration/conftest.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 37a49f0..d8f8457 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -142,24 +142,18 @@ def _poll_active(memory_id: str, *, timeout: int = 240, interval: int = 5) -> di time.sleep(interval) raise TimeoutError(f"memory {memory_id} did not become ACTIVE in {timeout}s") - epi_initial = _create( - epi_name, episodic_strategy_block(), DEFAULT_EPISODIC_EVENT_EXPIRY_DAYS - ) - sem_initial = _create( - sem_name, semantic_strategy_block(), DEFAULT_SEMANTIC_EVENT_EXPIRY_DAYS - ) - - # Register atexit teardown IMMEDIATELY after create — so any abnormal - # termination (Ctrl-C, SIGTERM, pytest hard-kill) still triggers cleanup. - # `_cleanup` is idempotent — pytest fixture teardown runs it too on the - # happy path; the second call is a no-op since the memories are gone. + # Track memory IDs as we create them and register atexit BEFORE the + # second create — so if create #2 raises, create #1's memory still + # gets cleaned up. Without this, a mid-flow AWS throttle leaves one + # orphan memory per failed run. + created_ids: list[str] = [] cleaned_up: list[bool] = [] def _cleanup() -> None: if cleaned_up or _keep_after_test(): return cleaned_up.append(True) - for mid in (epi_initial["id"], sem_initial["id"]): + for mid in created_ids: try: control.delete_memory(memoryId=mid) except Exception as exc: @@ -167,6 +161,16 @@ def _cleanup() -> None: atexit.register(_cleanup) + epi_initial = _create( + epi_name, episodic_strategy_block(), DEFAULT_EPISODIC_EVENT_EXPIRY_DAYS + ) + created_ids.append(epi_initial["id"]) + + sem_initial = _create( + sem_name, semantic_strategy_block(), DEFAULT_SEMANTIC_EVENT_EXPIRY_DAYS + ) + created_ids.append(sem_initial["id"]) + epi_active = _poll_active(epi_initial["id"]) sem_active = _poll_active(sem_initial["id"]) From e843df3d233897483410a89a88b765f1e5c5fc74 Mon Sep 17 00:00:00 2001 From: gethin Date: Wed, 27 May 2026 09:27:33 +0100 Subject: [PATCH 11/18] docs(readme): add storage-backends section pointing to agentcore setup --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index cd7ce17..229812f 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,19 @@ It's a memory layer for Claude Code that runs entirely on your machine. Your obs SQLite ships with Python; `sqlite-vec` is installed as a pip dependency — nothing else to set up. +## Storage backends + +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). | + +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). + +`agentcore` mode needs the optional dependency group: `pip install 'better-memory[agentcore]'` (or `uv pip install '.[agentcore]'`). Sqlite-only installs skip boto3 entirely. + ## Quick start ```bash From 6a720d6328de164d511f9527919b61e6c223af24 Mon Sep 17 00:00:00 2001 From: gethin Date: Wed, 27 May 2026 09:28:12 +0100 Subject: [PATCH 12/18] docs(configuration): document agentcore env vars --- website/configuration.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/website/configuration.md b/website/configuration.md index 16f4242..4588e34 100644 --- a/website/configuration.md +++ b/website/configuration.md @@ -12,6 +12,10 @@ One environment variable roots the runtime filesystem layout. Everything else ha | `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. 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_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_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. | ## Project-name override From 621abc58b0057af471e02d0ac9f84324c18fb764 Mon Sep 17 00:00:00 2001 From: gethin Date: Wed, 27 May 2026 09:29:00 +0100 Subject: [PATCH 13/18] docs(agentcore): publish setup walkthrough --- mkdocs.yml | 1 + website/agentcore-setup.md | 80 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 website/agentcore-setup.md diff --git a/mkdocs.yml b/mkdocs.yml index 4f5803d..2369b0d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -79,6 +79,7 @@ plugins: nav: - Home: index.md - Configuration: configuration.md + - AgentCore setup: agentcore-setup.md - Architecture: architecture.md - MCP tools: mcp-tools.md - Observation lifecycle: observation-lifecycle.md diff --git a/website/agentcore-setup.md b/website/agentcore-setup.md new file mode 100644 index 0000000..0c522fd --- /dev/null +++ b/website/agentcore-setup.md @@ -0,0 +1,80 @@ +# AgentCore setup + +`agentcore` is better-memory's optional cloud-managed storage backend. It uses AWS Bedrock AgentCore Memory (currently GA in `eu-west-2`) for storage and built-in LLM extraction. Pick it if you want team-shared memory or managed extraction; the [sqlite](configuration.md) backend remains the default and the recommended choice for single-machine usage. + +## Prerequisites + +- AWS account with Bedrock AgentCore Memory enabled in `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]'`. + +## IAM policy + +Narrow policy — `bedrock-agentcore` (data plane) + `bedrock-agentcore-control` (control plane). No Bedrock model access needed (built-in strategies have their own infrastructure). + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "bedrock-agentcore-control:CreateMemory", + "bedrock-agentcore-control:GetMemory", + "bedrock-agentcore-control:ListMemories", + "bedrock-agentcore-control:DeleteMemory", + "bedrock-agentcore:CreateEvent", + "bedrock-agentcore:ListEvents", + "bedrock-agentcore:BatchCreateMemoryRecords", + "bedrock-agentcore:BatchUpdateMemoryRecords", + "bedrock-agentcore:BatchDeleteMemoryRecords", + "bedrock-agentcore:ListMemoryRecords", + "bedrock-agentcore:GetMemoryRecord" + ], + "Resource": "*" + } + ] +} +``` + +For tighter scoping, restrict `Resource` to the two memory ARNs after `init` writes them. + +## Initialise + +```bash +export BETTER_MEMORY_STORAGE_BACKEND=agentcore +better-memory agentcore init +``` + +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. + +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`. + +## Verify + +```bash +better-memory agentcore status +``` + +Should print `ACTIVE` for both memories. If you see `CREATING`, wait a minute and re-run. + +```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. + +## 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. + +See [Architecture > Storage backends](architecture.md#storage-backends) for the data-flow diagram. + +## Troubleshooting + +See [AgentCore troubleshooting](troubleshooting/agentcore.md) for common errors (name regex, ~10s lag, system-key handling, credential discovery, region mismatches). From ffd01f20eaf2c57b3f084667d877f26e631d32e9 Mon Sep 17 00:00:00 2001 From: gethin Date: Wed, 27 May 2026 09:29:51 +0100 Subject: [PATCH 14/18] docs(troubleshooting): publish agentcore common-error guide --- mkdocs.yml | 2 ++ website/troubleshooting/agentcore.md | 51 ++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 website/troubleshooting/agentcore.md diff --git a/mkdocs.yml b/mkdocs.yml index 2369b0d..e6498bb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -83,6 +83,8 @@ nav: - Architecture: architecture.md - MCP tools: mcp-tools.md - Observation lifecycle: observation-lifecycle.md + - Troubleshooting: + - AgentCore: troubleshooting/agentcore.md - Contributing: contributing.md extra_css: diff --git a/website/troubleshooting/agentcore.md b/website/troubleshooting/agentcore.md new file mode 100644 index 0000000..fa93a42 --- /dev/null +++ b/website/troubleshooting/agentcore.md @@ -0,0 +1,51 @@ +# AgentCore troubleshooting + +Errors you'll likely hit when running better-memory in `agentcore` mode, and what to do about them. + +## `ValidationException: Memory name does not match required pattern` + +AgentCore memory names must match `[a-zA-Z][a-zA-Z0-9_]{0,47}` — letters, digits, and underscores only, **no dashes**. `better-memory agentcore init` uses safe defaults (`better_memory_episodic` and `better_memory_semantic`); if you've edited `agentcore.json` by hand, check both names match the regex. + +## `ResourceNotFoundException` on a fresh write/update + +AgentCore has roughly 10 seconds of indexing lag between `BatchCreateMemoryRecords` and the new record being mutable. The backend retries `batch_update_memory_records` calls automatically (`_retry_on_transient_404`, 3 attempts, 10s backoff) — if you see this error escaping into MCP tool responses, the record genuinely doesn't exist (wrong memory ID, deleted, or namespace mismatch). + +## `400 — Metadata keys cannot use reserved names or prefixes` + +AgentCore reserves the `x-amz-agentcore-memory-*` metadata namespace. The backend strips system-managed keys from update payloads before sending (`_full_metadata_snapshot`), but if you're constructing a record by hand via the smoke or a test, do the same: skip any key whose name starts with `x-amz-agentcore-memory-`. + +## `NoCredentialsError` or `Unable to locate credentials` + +boto3 didn't find AWS credentials. Standard discovery order: + +1. `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` env vars +2. `~/.aws/credentials` (named profile via `AWS_PROFILE`) +3. EC2/EKS instance role +4. ECS task role + +If you're on a laptop, `aws configure` is the fastest fix. If you're in a container, mount credentials or attach a task role. + +## `ResourceConflictException: A memory with this name already exists` + +`init` refuses to create a duplicate memory. Either: + +- Delete the existing memory via the AWS console and re-run, **or** +- Manually populate `agentcore.json` with the existing memory's IDs (see the file format in `better_memory/storage/agentcore_persistence.py`). + +## `MEMORY_FAILED` after `init` + +Memory creation entered a terminal `FAILED` state. Check the AWS console for the actual failure reason (strategy-config errors, Bedrock region availability). Common cause: requesting a region where AgentCore Memory isn't GA yet. Stick to `eu-west-2` unless you've verified GA elsewhere. + +## Closure events not firing + +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 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 + +`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. From ac0ae292119542b6e759cec31acad4c4f7f17677 Mon Sep 17 00:00:00 2001 From: gethin Date: Wed, 27 May 2026 09:30:41 +0100 Subject: [PATCH 15/18] docs(mcp-tools): annotate per-tool agentcore-mode behaviour --- website/mcp-tools.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/website/mcp-tools.md b/website/mcp-tools.md index 262e65e..030ca2e 100644 --- a/website/mcp-tools.md +++ b/website/mcp-tools.md @@ -69,6 +69,12 @@ 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. + ### `memory.close_episode` Close the active episode. @@ -82,14 +88,23 @@ 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. + ### `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. + ### `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. + ### `memory.run_retention` Manually trigger the retention service. (Auto-fires on `memory.retrieve` once per 24h.) @@ -158,6 +173,9 @@ 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. + ### `memory.synthesize_next_apply` Apply a synthesis decision for one episode. Atomically creates new reflections, augments existing ones, merges near-duplicates (combining their evidence and rating counters onto the survivor), marks observations consumed (or ignored), and stamps the episode synthesized. @@ -170,6 +188,9 @@ 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. + ## Rating tools Memories prove their worth by being used. The rating loop closes the From 703e40d30a8e53438e7e510bfb4ebe52c50252a9 Mon Sep 17 00:00:00 2001 From: gethin Date: Wed, 27 May 2026 09:31:22 +0100 Subject: [PATCH 16/18] docs(architecture): add storage-backends section with sqlite-vs-agentcore comparison --- website/architecture.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/website/architecture.md b/website/architecture.md index e0f5ed5..ed78de2 100644 --- a/website/architecture.md +++ b/website/architecture.md @@ -17,6 +17,31 @@ better-memory is a four-layer epistemic hierarchy backed by a single SQLite data - **`knowledge.db`** — FTS5 index over the contents of `~/.better-memory/knowledge-base/`. Rebuilt on mtime change. - **`spool/`** — JSON payloads written by Claude Code hooks, drained lazily by the next `memory.retrieve` call. Bad files quarantine to `spool/.quarantine/` rather than blocking the drain. +## 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`: + +```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)"] + SQLITE -->|sync I/O| DB[("memory.db")] + AGENTCORE -->|boto3| AWS[("eu-west-2
bedrock-agentcore")] +``` + +| Aspect | `sqlite` | `agentcore` | +|---|---|---| +| Data location | Local file (`memory.db`) | AWS-managed (`eu-west-2`) | +| 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) | + +See [Configuration](configuration.md) for env vars and [AgentCore setup](agentcore-setup.md) for the agentcore path. + ## Retrieval `memory.retrieve` returns three buckets — `do`, `dont`, `neutral` — built from a hybrid search: From 672dc80fe0b3e68187429ae5c9da92717c42f93f Mon Sep 17 00:00:00 2001 From: gethin Date: Wed, 27 May 2026 09:40:43 +0100 Subject: [PATCH 17/18] style(cli): use collections.abc.Sequence instead of typing.Sequence --- better_memory/cli/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/better_memory/cli/main.py b/better_memory/cli/main.py index bd97864..b26584d 100644 --- a/better_memory/cli/main.py +++ b/better_memory/cli/main.py @@ -8,7 +8,7 @@ import argparse import sys -from typing import Sequence +from collections.abc import Sequence def _build_parser() -> argparse.ArgumentParser: From 0e1cd88d23880b59dc5f4662dda9bf5bc282773b Mon Sep 17 00:00:00 2001 From: gethin Date: Sat, 30 May 2026 21:38:33 +0100 Subject: [PATCH 18/18] fix(cli): track raw memory_ids for orphan cleanup + guard smoke[0] index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-1 BugBot findings on PR #70: - MEDIUM: _create_one_memory leaked AWS resource if _poll_until_active raised after create_memory succeeded (FAILED state OR 240s timeout). Refactor: pass a mutable created_ids: list[str] into _create_one_memory; helper appends raw memory_id immediately after create_memory returns, BEFORE polling. Caller iterates the list on exception and deletes every id — covers both "create #2 raised" AND "create #1 succeeded but polling raised". Add test_init_deletes_orphan_when_poll_raises_after_create. - LOW: _handle_smoke indexed create_resp["successfulRecords"][0] after only checking failedRecords. Empty successfulRecords (legal AWS edge) would raise IndexError which the outer try/except converted to rc=1 with the unhelpful message "list index out of range". Add explicit guard with descriptive RuntimeError before the index access. 8 init tests + 3 smoke tests pass. --- better_memory/cli/agentcore.py | 60 ++++++++++++++++++++++---------- tests/cli/test_agentcore_init.py | 43 +++++++++++++++++++++++ 2 files changed, 85 insertions(+), 18 deletions(-) diff --git a/better_memory/cli/agentcore.py b/better_memory/cli/agentcore.py index 65c72d8..497ee91 100644 --- a/better_memory/cli/agentcore.py +++ b/better_memory/cli/agentcore.py @@ -159,7 +159,15 @@ def _create_one_memory( strategy_name: str, event_expiry_days: int, label: str, + created_ids: list[str], ) -> MemoryRecord: + """Create one AgentCore memory and wait for ACTIVE. + + Appends the raw memory_id to ``created_ids`` immediately after + CreateMemory returns — BEFORE the polling loop — so the caller can + clean up even if `_poll_until_active` raises (FAILED state, 240s + timeout, network blip). Without this, polling failures would leak + the AWS resource because the helper never returns a MemoryRecord.""" print(f">> Creating {label} memory ({name!r})...") response = control.create_memory( name=name, @@ -169,6 +177,7 @@ def _create_one_memory( ) initial = response["memory"] memory_id = initial["id"] + created_ids.append(memory_id) print(f" created: memory_id={memory_id}") final = _poll_until_active(control, memory_id, label=label) @@ -215,7 +224,13 @@ def _handle_init(args: argparse.Namespace) -> int: ) return 1 - episodic: MemoryRecord | None = None + # Mutable list of raw memory_ids appended by _create_one_memory + # immediately after each CreateMemory returns. On exception we iterate + # this list and delete every memory we created — covers both the + # "create #2 raised" case AND the "create #1 succeeded but its + # _poll_until_active timed out / hit FAILED" case (which a return-value- + # based cleanup would miss). + created_ids: list[str] = [] try: episodic = _create_one_memory( control, @@ -224,6 +239,7 @@ def _handle_init(args: argparse.Namespace) -> int: strategy_name=DEFAULT_EPISODIC_STRATEGY_NAME, event_expiry_days=DEFAULT_EPISODIC_EVENT_EXPIRY_DAYS, label="episodic", + created_ids=created_ids, ) semantic = _create_one_memory( @@ -233,6 +249,7 @@ def _handle_init(args: argparse.Namespace) -> int: strategy_name=DEFAULT_SEMANTIC_STRATEGY_NAME, event_expiry_days=DEFAULT_SEMANTIC_EVENT_EXPIRY_DAYS, label="semantic", + created_ids=created_ids, ) except Exception as exc: # ValidationException on the name regex is the most common @@ -246,25 +263,27 @@ def _handle_init(args: argparse.Namespace) -> int: except Exception: pass - # Orphan cleanup: if episodic was created but semantic failed, - # delete the episodic memory so a re-run of `init` starts clean. - if episodic is not None: + # Orphan cleanup: every memory_id appended to created_ids gets + # deleted, including any whose _poll_until_active raised. + if created_ids: print( - f"\n!! Second memory create failed ({exc!r}). " - f"Deleting orphan episodic memory {episodic.memory_id} " - f"so a re-run starts clean...", + f"\n!! Memory create / poll failed ({exc!r}). " + f"Deleting {len(created_ids)} orphan " + f"memor{'y' if len(created_ids) == 1 else 'ies'} " + f"so a re-run of init starts clean...", file=sys.stderr, ) - try: - control.delete_memory(memoryId=episodic.memory_id) - print(f" deleted {episodic.memory_id}", file=sys.stderr) - except Exception as del_exc: - print( - f" WARN: failed to delete orphan {episodic.memory_id}: " - f"{del_exc!r}. Delete it manually via the AWS console " - f"before re-running init.", - file=sys.stderr, - ) + for orphan_id in created_ids: + try: + control.delete_memory(memoryId=orphan_id) + print(f" deleted {orphan_id}", file=sys.stderr) + except Exception as del_exc: + print( + f" WARN: failed to delete orphan {orphan_id}: " + f"{del_exc!r}. Delete it manually via the AWS console " + f"before re-running init.", + file=sys.stderr, + ) if code == "ValidationException": print( @@ -420,7 +439,12 @@ def _handle_smoke(args: argparse.Namespace) -> int: failed = create_resp.get("failedRecords", []) if failed: raise RuntimeError(f"batch_create failed: {failed!r}") - real_id = create_resp["successfulRecords"][0]["memoryRecordId"] + successful = create_resp.get("successfulRecords") or [] + if not successful: + raise RuntimeError( + f"batch_create returned no successful records: {create_resp!r}" + ) + real_id = successful[0]["memoryRecordId"] print(f" ok (id={real_id})") print(">> 5. ListMemoryRecords — readback") diff --git a/tests/cli/test_agentcore_init.py b/tests/cli/test_agentcore_init.py index 23c84e6..d03f31c 100644 --- a/tests/cli/test_agentcore_init.py +++ b/tests/cli/test_agentcore_init.py @@ -192,6 +192,49 @@ def test_init_polls_until_active(tmp_path, monkeypatch) -> None: assert control.get_memory.call_count == 5 +def test_init_deletes_orphan_when_poll_raises_after_create( + tmp_path, monkeypatch +) -> None: + """CreateMemory succeeds, _poll_until_active hits FAILED state -> + init must still delete the AWS resource even though no MemoryRecord + was ever returned. Without the created_ids list this leak would slip + past the orphan cleanup (which previously only ran when episodic was + not None).""" + 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-poll-fail", "epi-strat"), + ] + # First poll returns FAILED -> _poll_until_active raises RuntimeError + control.get_memory.side_effect = [{ + "memory": { + "id": "epi-poll-fail", + "arn": "arn:aws:bedrock-agentcore:eu-west-2:123:memory/epi-poll-fail", + "name": "better_memory_episodic", + "status": "FAILED", + "strategies": [ + {"strategyId": "epi-strat", "status": "FAILED", "name": "x"} + ], + } + }] + + monkeypatch.setattr( + "better_memory.cli.agentcore._build_control_client", + lambda region: control, + ) + monkeypatch.setattr("better_memory.cli.agentcore.time.sleep", lambda _s: None) + + with pytest.raises(RuntimeError, match="FAILED state"): + _handle_init(_make_args(tmp_path)) + + # Even though episodic MemoryRecord was never returned, the raw id + # tracked via created_ids must be deleted. + control.delete_memory.assert_called_once_with(memoryId="epi-poll-fail") + + def test_init_deletes_orphan_when_second_create_fails( tmp_path, monkeypatch ) -> None: