From 210a26d31ab2f8fcb8cefd5439fa4dcc8cc7f1f1 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Tue, 5 May 2026 05:08:33 -0700 Subject: [PATCH] refactor(workspace): extract memory tools from a2a_tools.py to a2a_tools_memory.py (RFC #2873 iter 4c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third slice of the a2a_tools.py split (stacked on iter 4b). Owns the two persistent-memory MCP tools: * tool_commit_memory — write to /workspaces/:id/memories with RBAC + GLOBAL-scope tier-zero enforcement * tool_recall_memory — search /workspaces/:id/memories with RBAC a2a_tools.py shrinks from 609 → 508 LOC (−101). Both handlers depend ONLY on a2a_tools_rbac (iter 4a), a2a_client, and the platform's /memories endpoint — no entanglement with delegation or messaging. Side-effects of the layered architecture: a2a_tools_memory's import contract is "depends on a2a_tools_rbac, never on a2a_tools" — the kitchen-sink module is for back-compat re-exports only. A test pins this so a future refactor that re-introduces `from a2a_tools import …` fails in CI. Tests: * 49 patch sites in TestToolCommitMemory + TestToolRecallMemory retargeted from `a2a_tools.{_check_memory_*, _is_root_workspace, httpx.AsyncClient}` to `a2a_tools_memory.…` because the call sites moved. * test_a2a_tools_memory.py adds 4 new tests (alias drift gate + import-contract + a2a_tools-side re-export). 117 tests total (77 impl + 28 rbac + 8 delegation + 4 memory), all green. Refs RFC #2873. --- scripts/build_runtime_package.py | 1 + workspace/a2a_tools.py | 117 ++----------------- workspace/a2a_tools_memory.py | 141 +++++++++++++++++++++++ workspace/tests/test_a2a_tools_impl.py | 98 ++++++++-------- workspace/tests/test_a2a_tools_memory.py | 69 +++++++++++ 5 files changed, 268 insertions(+), 158 deletions(-) create mode 100644 workspace/a2a_tools_memory.py create mode 100644 workspace/tests/test_a2a_tools_memory.py diff --git a/scripts/build_runtime_package.py b/scripts/build_runtime_package.py index dcb25d05..3f45acdf 100755 --- a/scripts/build_runtime_package.py +++ b/scripts/build_runtime_package.py @@ -56,6 +56,7 @@ TOP_LEVEL_MODULES = { "a2a_mcp_server", "a2a_tools", "a2a_tools_delegation", + "a2a_tools_memory", "a2a_tools_rbac", "adapter_base", "agent", diff --git a/workspace/a2a_tools.py b/workspace/a2a_tools.py index b482a3be..2a664021 100644 --- a/workspace/a2a_tools.py +++ b/workspace/a2a_tools.py @@ -325,115 +325,14 @@ async def tool_get_workspace_info(source_workspace_id: str | None = None) -> str return json.dumps(info, indent=2) -async def tool_commit_memory( - content: str, - scope: str = "LOCAL", - source_workspace_id: str | None = None, -) -> str: - """Save important information to persistent memory. - - GLOBAL scope is writable only by root workspaces (tier == 0). - RBAC memory.write permission is required for all scope levels. - The source workspace_id is embedded in every record so the platform - can enforce cross-workspace isolation and audit trail. - - ``source_workspace_id`` selects which registered workspace this - memory belongs to when the agent is registered into multiple - workspaces (PR-1 / multi-workspace mode). When unset, falls back - to the module-level WORKSPACE_ID — single-workspace operators see - no behaviour change. - """ - if not content: - return "Error: content is required" - content = _redact_secrets(content) - scope = scope.upper() - if scope not in ("LOCAL", "TEAM", "GLOBAL"): - scope = "LOCAL" - - # RBAC: require memory.write permission (mirrors builtin_tools/memory.py) - if not _check_memory_write_permission(): - return ( - "Error: RBAC — this workspace does not have the 'memory.write' " - "permission for this operation." - ) - - # Scope enforcement: only root workspaces (tier 0) can write GLOBAL memory. - # This prevents tenant workspaces from poisoning org-wide memory (GH#1610). - if scope == "GLOBAL" and not _is_root_workspace(): - return ( - "Error: RBAC — only root workspaces (tier 0) can write to GLOBAL scope. " - "Non-root workspaces may use LOCAL or TEAM scope." - ) - - src = source_workspace_id or WORKSPACE_ID - try: - async with httpx.AsyncClient(timeout=10.0) as client: - resp = await client.post( - f"{PLATFORM_URL}/workspaces/{src}/memories", - json={ - "content": content, - "scope": scope, - # Embed source workspace so the platform can namespace-isolate - # and audit cross-workspace writes (GH#1610 fix). - "workspace_id": src, - }, - headers=_auth_headers_for_heartbeat(src), - ) - data = resp.json() - if resp.status_code in (200, 201): - return json.dumps({"success": True, "id": data.get("id"), "scope": scope}) - return f"Error: {data.get('error', resp.text)}" - except Exception as e: - return f"Error saving memory: {e}" - - -async def tool_recall_memory( - query: str = "", - scope: str = "", - source_workspace_id: str | None = None, -) -> str: - """Search persistent memory for previously saved information. - - RBAC memory.read permission is required (mirrors builtin_tools/memory.py). - The workspace_id is sent as a query parameter so the platform can - cross-validate it against the auth token and defend against any future - path traversal / cross-tenant read bugs in the platform itself. - - ``source_workspace_id`` selects which registered workspace's memories - to search when the agent is registered into multiple workspaces. - Unset → defaults to the module-level WORKSPACE_ID. - """ - # RBAC: require memory.read permission (mirrors builtin_tools/memory.py) - if not _check_memory_read_permission(): - return ( - "Error: RBAC — this workspace does not have the 'memory.read' " - "permission for this operation." - ) - - src = source_workspace_id or WORKSPACE_ID - params: dict[str, str] = {"workspace_id": src} - if query: - params["q"] = query - if scope: - params["scope"] = scope.upper() - try: - async with httpx.AsyncClient(timeout=10.0) as client: - resp = await client.get( - f"{PLATFORM_URL}/workspaces/{src}/memories", - params=params, - headers=_auth_headers_for_heartbeat(src), - ) - data = resp.json() - if isinstance(data, list): - if not data: - return "No memories found." - lines = [] - for m in data: - lines.append(f"[{m.get('scope', '?')}] {m.get('content', '')}") - return "\n".join(lines) - return json.dumps(data) - except Exception as e: - return f"Error recalling memory: {e}" +# Memory tool handlers — extracted to a2a_tools_memory (RFC #2873 iter 4c). +# Re-imported here so call sites + tests that reference +# ``a2a_tools.tool_commit_memory`` / ``tool_recall_memory`` keep +# resolving identically. +from a2a_tools_memory import ( # noqa: E402 (import after the top-of-module imports) + tool_commit_memory, + tool_recall_memory, +) # --------------------------------------------------------------------------- diff --git a/workspace/a2a_tools_memory.py b/workspace/a2a_tools_memory.py new file mode 100644 index 00000000..3e2cff4b --- /dev/null +++ b/workspace/a2a_tools_memory.py @@ -0,0 +1,141 @@ +"""Memory tool handlers — single-concern slice of the a2a_tools surface. + +Extracted from ``a2a_tools.py`` (RFC #2873 iter 4c). Owns the two +agent-memory MCP tools: + + * ``tool_commit_memory`` — write to the workspace's persistent memory. + * ``tool_recall_memory`` — search the workspace's persistent memory. + +Both go through the platform's ``/workspaces/:id/memories`` endpoint; +the platform is the source of truth for namespace isolation + audit +trail. Local responsibility here is RBAC enforcement BEFORE hitting +the network so a denied operation surfaces a clear in-band error +instead of an opaque platform 403. + +Imports the RBAC primitives from ``a2a_tools_rbac`` (iter 4a). +""" +from __future__ import annotations + +import json + +import httpx + +from a2a_client import PLATFORM_URL, WORKSPACE_ID +from a2a_tools_rbac import ( + auth_headers_for_heartbeat as _auth_headers_for_heartbeat, + check_memory_read_permission as _check_memory_read_permission, + check_memory_write_permission as _check_memory_write_permission, + is_root_workspace as _is_root_workspace, +) +from builtin_tools.security import _redact_secrets + + +async def tool_commit_memory( + content: str, + scope: str = "LOCAL", + source_workspace_id: str | None = None, +) -> str: + """Save important information to persistent memory. + + GLOBAL scope is writable only by root workspaces (tier == 0). + RBAC memory.write permission is required for all scope levels. + The source workspace_id is embedded in every record so the platform + can enforce cross-workspace isolation and audit trail. + + ``source_workspace_id`` selects which registered workspace this + memory belongs to when the agent is registered into multiple + workspaces (PR-1 / multi-workspace mode). When unset, falls back + to the module-level WORKSPACE_ID — single-workspace operators see + no behaviour change. + """ + if not content: + return "Error: content is required" + content = _redact_secrets(content) + scope = scope.upper() + if scope not in ("LOCAL", "TEAM", "GLOBAL"): + scope = "LOCAL" + + # RBAC: require memory.write permission (mirrors builtin_tools/memory.py) + if not _check_memory_write_permission(): + return ( + "Error: RBAC — this workspace does not have the 'memory.write' " + "permission for this operation." + ) + + # Scope enforcement: only root workspaces (tier 0) can write GLOBAL memory. + # This prevents tenant workspaces from poisoning org-wide memory (GH#1610). + if scope == "GLOBAL" and not _is_root_workspace(): + return ( + "Error: RBAC — only root workspaces (tier 0) can write to GLOBAL scope. " + "Non-root workspaces may use LOCAL or TEAM scope." + ) + + src = source_workspace_id or WORKSPACE_ID + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.post( + f"{PLATFORM_URL}/workspaces/{src}/memories", + json={ + "content": content, + "scope": scope, + # Embed source workspace so the platform can namespace-isolate + # and audit cross-workspace writes (GH#1610 fix). + "workspace_id": src, + }, + headers=_auth_headers_for_heartbeat(src), + ) + data = resp.json() + if resp.status_code in (200, 201): + return json.dumps({"success": True, "id": data.get("id"), "scope": scope}) + return f"Error: {data.get('error', resp.text)}" + except Exception as e: + return f"Error saving memory: {e}" + + +async def tool_recall_memory( + query: str = "", + scope: str = "", + source_workspace_id: str | None = None, +) -> str: + """Search persistent memory for previously saved information. + + RBAC memory.read permission is required (mirrors builtin_tools/memory.py). + The workspace_id is sent as a query parameter so the platform can + cross-validate it against the auth token and defend against any future + path traversal / cross-tenant read bugs in the platform itself. + + ``source_workspace_id`` selects which registered workspace's memories + to search when the agent is registered into multiple workspaces. + Unset → defaults to the module-level WORKSPACE_ID. + """ + # RBAC: require memory.read permission (mirrors builtin_tools/memory.py) + if not _check_memory_read_permission(): + return ( + "Error: RBAC — this workspace does not have the 'memory.read' " + "permission for this operation." + ) + + src = source_workspace_id or WORKSPACE_ID + params: dict[str, str] = {"workspace_id": src} + if query: + params["q"] = query + if scope: + params["scope"] = scope.upper() + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.get( + f"{PLATFORM_URL}/workspaces/{src}/memories", + params=params, + headers=_auth_headers_for_heartbeat(src), + ) + data = resp.json() + if isinstance(data, list): + if not data: + return "No memories found." + lines = [] + for m in data: + lines.append(f"[{m.get('scope', '?')}] {m.get('content', '')}") + return "\n".join(lines) + return json.dumps(data) + except Exception as e: + return f"Error recalling memory: {e}" diff --git a/workspace/tests/test_a2a_tools_impl.py b/workspace/tests/test_a2a_tools_impl.py index 43f149cb..b53bb8f3 100644 --- a/workspace/tests/test_a2a_tools_impl.py +++ b/workspace/tests/test_a2a_tools_impl.py @@ -702,9 +702,9 @@ class TestToolCommitMemory: import a2a_tools mc = _make_http_mock(post_resp=_resp(201, {"id": "mem-1"})) - with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \ - patch("a2a_tools._check_memory_write_permission", return_value=True), \ - patch("a2a_tools._is_root_workspace", return_value=False): + with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \ + patch("a2a_tools_memory._check_memory_write_permission", return_value=True), \ + patch("a2a_tools_memory._is_root_workspace", return_value=False): result = await a2a_tools.tool_commit_memory("Remember this", scope="local") data = json.loads(result) @@ -716,9 +716,9 @@ class TestToolCommitMemory: import a2a_tools mc = _make_http_mock(post_resp=_resp(200, {"id": "mem-2"})) - with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \ - patch("a2a_tools._check_memory_write_permission", return_value=True), \ - patch("a2a_tools._is_root_workspace", return_value=False): + with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \ + patch("a2a_tools_memory._check_memory_write_permission", return_value=True), \ + patch("a2a_tools_memory._is_root_workspace", return_value=False): result = await a2a_tools.tool_commit_memory("Remember this", scope="INVALID") data = json.loads(result) @@ -728,9 +728,9 @@ class TestToolCommitMemory: import a2a_tools mc = _make_http_mock(post_resp=_resp(200, {"id": "mem-3"})) - with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \ - patch("a2a_tools._check_memory_write_permission", return_value=True), \ - patch("a2a_tools._is_root_workspace", return_value=False): + with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \ + patch("a2a_tools_memory._check_memory_write_permission", return_value=True), \ + patch("a2a_tools_memory._is_root_workspace", return_value=False): result = await a2a_tools.tool_commit_memory("Team info", scope="TEAM") data = json.loads(result) @@ -741,9 +741,9 @@ class TestToolCommitMemory: import a2a_tools mc = _make_http_mock(post_resp=_resp(201, {"id": "mem-4"})) - with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \ - patch("a2a_tools._check_memory_write_permission", return_value=True), \ - patch("a2a_tools._is_root_workspace", return_value=True): + with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \ + patch("a2a_tools_memory._check_memory_write_permission", return_value=True), \ + patch("a2a_tools_memory._is_root_workspace", return_value=True): result = await a2a_tools.tool_commit_memory("Global info", scope="GLOBAL") data = json.loads(result) @@ -753,9 +753,9 @@ class TestToolCommitMemory: import a2a_tools mc = _make_http_mock(post_resp=_resp(200, {"id": "mem-5"})) - with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \ - patch("a2a_tools._check_memory_write_permission", return_value=True), \ - patch("a2a_tools._is_root_workspace", return_value=False): + with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \ + patch("a2a_tools_memory._check_memory_write_permission", return_value=True), \ + patch("a2a_tools_memory._is_root_workspace", return_value=False): result = await a2a_tools.tool_commit_memory("info") data = json.loads(result) @@ -766,9 +766,9 @@ class TestToolCommitMemory: import a2a_tools mc = _make_http_mock(post_resp=_resp(201, {"id": "mem-6"})) - with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \ - patch("a2a_tools._check_memory_write_permission", return_value=True), \ - patch("a2a_tools._is_root_workspace", return_value=False): + with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \ + patch("a2a_tools_memory._check_memory_write_permission", return_value=True), \ + patch("a2a_tools_memory._is_root_workspace", return_value=False): result = await a2a_tools.tool_commit_memory("info") data = json.loads(result) @@ -779,9 +779,9 @@ class TestToolCommitMemory: import a2a_tools mc = _make_http_mock(post_resp=_resp(400, {"error": "bad request payload"})) - with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \ - patch("a2a_tools._check_memory_write_permission", return_value=True), \ - patch("a2a_tools._is_root_workspace", return_value=False): + with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \ + patch("a2a_tools_memory._check_memory_write_permission", return_value=True), \ + patch("a2a_tools_memory._is_root_workspace", return_value=False): result = await a2a_tools.tool_commit_memory("info") assert "Error" in result @@ -791,9 +791,9 @@ class TestToolCommitMemory: import a2a_tools mc = _make_http_mock(post_exc=RuntimeError("storage failure")) - with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \ - patch("a2a_tools._check_memory_write_permission", return_value=True), \ - patch("a2a_tools._is_root_workspace", return_value=False): + with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \ + patch("a2a_tools_memory._check_memory_write_permission", return_value=True), \ + patch("a2a_tools_memory._is_root_workspace", return_value=False): result = await a2a_tools.tool_commit_memory("info") assert "Error saving memory" in result @@ -808,9 +808,9 @@ class TestToolCommitMemory: import a2a_tools mc = _make_http_mock(post_resp=_resp(201, {"id": "mem-poison"})) - with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \ - patch("a2a_tools._check_memory_write_permission", return_value=True), \ - patch("a2a_tools._is_root_workspace", return_value=False): + with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \ + patch("a2a_tools_memory._check_memory_write_permission", return_value=True), \ + patch("a2a_tools_memory._is_root_workspace", return_value=False): result = await a2a_tools.tool_commit_memory("poisoned GLOBAL memory", scope="GLOBAL") # Must NOT have called the platform — early rejection @@ -824,9 +824,9 @@ class TestToolCommitMemory: import a2a_tools mc = _make_http_mock(post_resp=_resp(201, {"id": "mem-7"})) - with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \ - patch("a2a_tools._check_memory_write_permission", return_value=False), \ - patch("a2a_tools._is_root_workspace", return_value=False): + with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \ + patch("a2a_tools_memory._check_memory_write_permission", return_value=False), \ + patch("a2a_tools_memory._is_root_workspace", return_value=False): result = await a2a_tools.tool_commit_memory("should be denied", scope="LOCAL") mc.post.assert_not_called() @@ -838,9 +838,9 @@ class TestToolCommitMemory: import a2a_tools mc = _make_http_mock(post_resp=_resp(201, {"id": "mem-8"})) - with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \ - patch("a2a_tools._check_memory_write_permission", return_value=True), \ - patch("a2a_tools._is_root_workspace", return_value=False): + with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \ + patch("a2a_tools_memory._check_memory_write_permission", return_value=True), \ + patch("a2a_tools_memory._is_root_workspace", return_value=False): await a2a_tools.tool_commit_memory("test content", scope="LOCAL") call_kwargs = mc.post.call_args.kwargs @@ -865,8 +865,8 @@ class TestToolRecallMemory: {"scope": "TEAM", "content": "We use Python 3.11"}, ] mc = _make_http_mock(get_resp=_resp(200, memories)) - with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \ - patch("a2a_tools._check_memory_read_permission", return_value=True): + with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \ + patch("a2a_tools_memory._check_memory_read_permission", return_value=True): result = await a2a_tools.tool_recall_memory(query="capital") assert "[LOCAL]" in result @@ -878,8 +878,8 @@ class TestToolRecallMemory: import a2a_tools mc = _make_http_mock(get_resp=_resp(200, [])) - with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \ - patch("a2a_tools._check_memory_read_permission", return_value=True): + with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \ + patch("a2a_tools_memory._check_memory_read_permission", return_value=True): result = await a2a_tools.tool_recall_memory(query="anything") assert result == "No memories found." @@ -890,8 +890,8 @@ class TestToolRecallMemory: payload = {"error": "search unavailable"} mc = _make_http_mock(get_resp=_resp(200, payload)) - with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \ - patch("a2a_tools._check_memory_read_permission", return_value=True): + with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \ + patch("a2a_tools_memory._check_memory_read_permission", return_value=True): result = await a2a_tools.tool_recall_memory() parsed = json.loads(result) @@ -901,8 +901,8 @@ class TestToolRecallMemory: import a2a_tools mc = _make_http_mock(get_exc=RuntimeError("search service down")) - with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \ - patch("a2a_tools._check_memory_read_permission", return_value=True): + with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \ + patch("a2a_tools_memory._check_memory_read_permission", return_value=True): result = await a2a_tools.tool_recall_memory(query="test") assert "Error recalling memory" in result @@ -913,8 +913,8 @@ class TestToolRecallMemory: import a2a_tools mc = _make_http_mock(get_resp=_resp(200, [])) - with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \ - patch("a2a_tools._check_memory_read_permission", return_value=True): + with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \ + patch("a2a_tools_memory._check_memory_read_permission", return_value=True): await a2a_tools.tool_recall_memory(query="paris", scope="local") call_kwargs = mc.get.call_args.kwargs @@ -928,8 +928,8 @@ class TestToolRecallMemory: import a2a_tools mc = _make_http_mock(get_resp=_resp(200, [])) - with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \ - patch("a2a_tools._check_memory_read_permission", return_value=True): + with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \ + patch("a2a_tools_memory._check_memory_read_permission", return_value=True): await a2a_tools.tool_recall_memory() call_kwargs = mc.get.call_args.kwargs @@ -942,8 +942,8 @@ class TestToolRecallMemory: import a2a_tools mc = _make_http_mock(get_resp=_resp(200, [])) - with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \ - patch("a2a_tools._check_memory_read_permission", return_value=True): + with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \ + patch("a2a_tools_memory._check_memory_read_permission", return_value=True): await a2a_tools.tool_recall_memory(scope="team") call_kwargs = mc.get.call_args.kwargs @@ -960,8 +960,8 @@ class TestToolRecallMemory: import a2a_tools mc = _make_http_mock(get_resp=_resp(200, [{"scope": "GLOBAL", "content": "secret"}])) - with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \ - patch("a2a_tools._check_memory_read_permission", return_value=False): + with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \ + patch("a2a_tools_memory._check_memory_read_permission", return_value=False): result = await a2a_tools.tool_recall_memory(query="secret") mc.get.assert_not_called() diff --git a/workspace/tests/test_a2a_tools_memory.py b/workspace/tests/test_a2a_tools_memory.py new file mode 100644 index 00000000..fb2ff027 --- /dev/null +++ b/workspace/tests/test_a2a_tools_memory.py @@ -0,0 +1,69 @@ +"""Drift gate + smoke tests for ``a2a_tools_memory`` (RFC #2873 iter 4c). + +The full behavior matrix (RBAC denies, scope enforcement, platform +HTTP error paths) lives in ``test_a2a_tools_impl.py`` (TestToolCommitMemory ++ TestToolRecallMemory) which patches `a2a_tools_memory.foo` after the +iter 4c retarget. + +This file pins: + + 1. **Drift gate** — every previously-public symbol on ``a2a_tools`` + (``tool_commit_memory``, ``tool_recall_memory``) is the EXACT same + callable as ``a2a_tools_memory.foo``. Refactor wrapping silently + loses the existing test coverage; this gate makes that drift fail + fast. + 2. **Import contract** — ``a2a_tools_memory`` does NOT pull in + ``a2a_tools`` at module-load time. The handlers depend on + ``a2a_tools_rbac`` (the layered architecture) and ``a2a_client``, + not on the kitchen-sink module that re-exports them. +""" +from __future__ import annotations + +import sys + +import pytest + + +@pytest.fixture(autouse=True) +def _require_workspace_id(monkeypatch): + monkeypatch.setenv("WORKSPACE_ID", "00000000-0000-0000-0000-000000000000") + monkeypatch.setenv("PLATFORM_URL", "http://test.invalid") + yield + + +# ============== Drift gate ============== + +class TestBackCompatAliases: + def test_tool_commit_memory_alias(self): + import a2a_tools + import a2a_tools_memory + assert a2a_tools.tool_commit_memory is a2a_tools_memory.tool_commit_memory + + def test_tool_recall_memory_alias(self): + import a2a_tools + import a2a_tools_memory + assert a2a_tools.tool_recall_memory is a2a_tools_memory.tool_recall_memory + + +# ============== Import contract ============== + +class TestImportContract: + def test_memory_module_does_not_load_a2a_tools(self, monkeypatch): + """`a2a_tools_memory` must depend on `a2a_tools_rbac` (the layered + architecture) and `a2a_client`, NEVER on the kitchen-sink + `a2a_tools`. Top-level `from a2a_tools import …` would defeat + the modularization goal and risk a circular-import.""" + # Drop both modules to control import order + for m in ("a2a_tools", "a2a_tools_memory"): + sys.modules.pop(m, None) + + # Import memory module. Should succeed without a2a_tools loaded. + import a2a_tools_memory # noqa: F401 + assert "a2a_tools_memory" in sys.modules + + def test_a2a_tools_re_exports_memory_handlers(self): + """The opposite direction: a2a_tools must surface every memory + symbol so existing call sites + tests work unchanged.""" + import a2a_tools + assert hasattr(a2a_tools, "tool_commit_memory") + assert hasattr(a2a_tools, "tool_recall_memory")