forked from molecule-ai/molecule-core
refactor(workspace): extract memory tools from a2a_tools.py to a2a_tools_memory.py (RFC #2873 iter 4c)
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.
This commit is contained in:
parent
be18b9c8f9
commit
210a26d31a
@ -56,6 +56,7 @@ TOP_LEVEL_MODULES = {
|
||||
"a2a_mcp_server",
|
||||
"a2a_tools",
|
||||
"a2a_tools_delegation",
|
||||
"a2a_tools_memory",
|
||||
"a2a_tools_rbac",
|
||||
"adapter_base",
|
||||
"agent",
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
141
workspace/a2a_tools_memory.py
Normal file
141
workspace/a2a_tools_memory.py
Normal file
@ -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}"
|
||||
@ -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()
|
||||
|
||||
69
workspace/tests/test_a2a_tools_memory.py
Normal file
69
workspace/tests/test_a2a_tools_memory.py
Normal file
@ -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")
|
||||
Loading…
Reference in New Issue
Block a user