Merge pull request #2890 from Molecule-AI/refactor/a2a-tools-memory-extract-rfc2873-iter4c
refactor(workspace): extract memory tools from a2a_tools.py (RFC #2873 iter 4c)
This commit is contained in:
commit
23d3f057d3
@ -56,6 +56,7 @@ TOP_LEVEL_MODULES = {
|
|||||||
"a2a_mcp_server",
|
"a2a_mcp_server",
|
||||||
"a2a_tools",
|
"a2a_tools",
|
||||||
"a2a_tools_delegation",
|
"a2a_tools_delegation",
|
||||||
|
"a2a_tools_memory",
|
||||||
"a2a_tools_rbac",
|
"a2a_tools_rbac",
|
||||||
"adapter_base",
|
"adapter_base",
|
||||||
"agent",
|
"agent",
|
||||||
|
|||||||
@ -325,115 +325,14 @@ async def tool_get_workspace_info(source_workspace_id: str | None = None) -> str
|
|||||||
return json.dumps(info, indent=2)
|
return json.dumps(info, indent=2)
|
||||||
|
|
||||||
|
|
||||||
async def tool_commit_memory(
|
# Memory tool handlers — extracted to a2a_tools_memory (RFC #2873 iter 4c).
|
||||||
content: str,
|
# Re-imported here so call sites + tests that reference
|
||||||
scope: str = "LOCAL",
|
# ``a2a_tools.tool_commit_memory`` / ``tool_recall_memory`` keep
|
||||||
source_workspace_id: str | None = None,
|
# resolving identically.
|
||||||
) -> str:
|
from a2a_tools_memory import ( # noqa: E402 (import after the top-of-module imports)
|
||||||
"""Save important information to persistent memory.
|
tool_commit_memory,
|
||||||
|
tool_recall_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}"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
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
|
import a2a_tools
|
||||||
|
|
||||||
mc = _make_http_mock(post_resp=_resp(201, {"id": "mem-1"}))
|
mc = _make_http_mock(post_resp=_resp(201, {"id": "mem-1"}))
|
||||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \
|
with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \
|
||||||
patch("a2a_tools._check_memory_write_permission", return_value=True), \
|
patch("a2a_tools_memory._check_memory_write_permission", return_value=True), \
|
||||||
patch("a2a_tools._is_root_workspace", return_value=False):
|
patch("a2a_tools_memory._is_root_workspace", return_value=False):
|
||||||
result = await a2a_tools.tool_commit_memory("Remember this", scope="local")
|
result = await a2a_tools.tool_commit_memory("Remember this", scope="local")
|
||||||
|
|
||||||
data = json.loads(result)
|
data = json.loads(result)
|
||||||
@ -716,9 +716,9 @@ class TestToolCommitMemory:
|
|||||||
import a2a_tools
|
import a2a_tools
|
||||||
|
|
||||||
mc = _make_http_mock(post_resp=_resp(200, {"id": "mem-2"}))
|
mc = _make_http_mock(post_resp=_resp(200, {"id": "mem-2"}))
|
||||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \
|
with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \
|
||||||
patch("a2a_tools._check_memory_write_permission", return_value=True), \
|
patch("a2a_tools_memory._check_memory_write_permission", return_value=True), \
|
||||||
patch("a2a_tools._is_root_workspace", return_value=False):
|
patch("a2a_tools_memory._is_root_workspace", return_value=False):
|
||||||
result = await a2a_tools.tool_commit_memory("Remember this", scope="INVALID")
|
result = await a2a_tools.tool_commit_memory("Remember this", scope="INVALID")
|
||||||
|
|
||||||
data = json.loads(result)
|
data = json.loads(result)
|
||||||
@ -728,9 +728,9 @@ class TestToolCommitMemory:
|
|||||||
import a2a_tools
|
import a2a_tools
|
||||||
|
|
||||||
mc = _make_http_mock(post_resp=_resp(200, {"id": "mem-3"}))
|
mc = _make_http_mock(post_resp=_resp(200, {"id": "mem-3"}))
|
||||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \
|
with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \
|
||||||
patch("a2a_tools._check_memory_write_permission", return_value=True), \
|
patch("a2a_tools_memory._check_memory_write_permission", return_value=True), \
|
||||||
patch("a2a_tools._is_root_workspace", return_value=False):
|
patch("a2a_tools_memory._is_root_workspace", return_value=False):
|
||||||
result = await a2a_tools.tool_commit_memory("Team info", scope="TEAM")
|
result = await a2a_tools.tool_commit_memory("Team info", scope="TEAM")
|
||||||
|
|
||||||
data = json.loads(result)
|
data = json.loads(result)
|
||||||
@ -741,9 +741,9 @@ class TestToolCommitMemory:
|
|||||||
import a2a_tools
|
import a2a_tools
|
||||||
|
|
||||||
mc = _make_http_mock(post_resp=_resp(201, {"id": "mem-4"}))
|
mc = _make_http_mock(post_resp=_resp(201, {"id": "mem-4"}))
|
||||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \
|
with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \
|
||||||
patch("a2a_tools._check_memory_write_permission", return_value=True), \
|
patch("a2a_tools_memory._check_memory_write_permission", return_value=True), \
|
||||||
patch("a2a_tools._is_root_workspace", return_value=True):
|
patch("a2a_tools_memory._is_root_workspace", return_value=True):
|
||||||
result = await a2a_tools.tool_commit_memory("Global info", scope="GLOBAL")
|
result = await a2a_tools.tool_commit_memory("Global info", scope="GLOBAL")
|
||||||
|
|
||||||
data = json.loads(result)
|
data = json.loads(result)
|
||||||
@ -753,9 +753,9 @@ class TestToolCommitMemory:
|
|||||||
import a2a_tools
|
import a2a_tools
|
||||||
|
|
||||||
mc = _make_http_mock(post_resp=_resp(200, {"id": "mem-5"}))
|
mc = _make_http_mock(post_resp=_resp(200, {"id": "mem-5"}))
|
||||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \
|
with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \
|
||||||
patch("a2a_tools._check_memory_write_permission", return_value=True), \
|
patch("a2a_tools_memory._check_memory_write_permission", return_value=True), \
|
||||||
patch("a2a_tools._is_root_workspace", return_value=False):
|
patch("a2a_tools_memory._is_root_workspace", return_value=False):
|
||||||
result = await a2a_tools.tool_commit_memory("info")
|
result = await a2a_tools.tool_commit_memory("info")
|
||||||
|
|
||||||
data = json.loads(result)
|
data = json.loads(result)
|
||||||
@ -766,9 +766,9 @@ class TestToolCommitMemory:
|
|||||||
import a2a_tools
|
import a2a_tools
|
||||||
|
|
||||||
mc = _make_http_mock(post_resp=_resp(201, {"id": "mem-6"}))
|
mc = _make_http_mock(post_resp=_resp(201, {"id": "mem-6"}))
|
||||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \
|
with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \
|
||||||
patch("a2a_tools._check_memory_write_permission", return_value=True), \
|
patch("a2a_tools_memory._check_memory_write_permission", return_value=True), \
|
||||||
patch("a2a_tools._is_root_workspace", return_value=False):
|
patch("a2a_tools_memory._is_root_workspace", return_value=False):
|
||||||
result = await a2a_tools.tool_commit_memory("info")
|
result = await a2a_tools.tool_commit_memory("info")
|
||||||
|
|
||||||
data = json.loads(result)
|
data = json.loads(result)
|
||||||
@ -779,9 +779,9 @@ class TestToolCommitMemory:
|
|||||||
import a2a_tools
|
import a2a_tools
|
||||||
|
|
||||||
mc = _make_http_mock(post_resp=_resp(400, {"error": "bad request payload"}))
|
mc = _make_http_mock(post_resp=_resp(400, {"error": "bad request payload"}))
|
||||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \
|
with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \
|
||||||
patch("a2a_tools._check_memory_write_permission", return_value=True), \
|
patch("a2a_tools_memory._check_memory_write_permission", return_value=True), \
|
||||||
patch("a2a_tools._is_root_workspace", return_value=False):
|
patch("a2a_tools_memory._is_root_workspace", return_value=False):
|
||||||
result = await a2a_tools.tool_commit_memory("info")
|
result = await a2a_tools.tool_commit_memory("info")
|
||||||
|
|
||||||
assert "Error" in result
|
assert "Error" in result
|
||||||
@ -791,9 +791,9 @@ class TestToolCommitMemory:
|
|||||||
import a2a_tools
|
import a2a_tools
|
||||||
|
|
||||||
mc = _make_http_mock(post_exc=RuntimeError("storage failure"))
|
mc = _make_http_mock(post_exc=RuntimeError("storage failure"))
|
||||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \
|
with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \
|
||||||
patch("a2a_tools._check_memory_write_permission", return_value=True), \
|
patch("a2a_tools_memory._check_memory_write_permission", return_value=True), \
|
||||||
patch("a2a_tools._is_root_workspace", return_value=False):
|
patch("a2a_tools_memory._is_root_workspace", return_value=False):
|
||||||
result = await a2a_tools.tool_commit_memory("info")
|
result = await a2a_tools.tool_commit_memory("info")
|
||||||
|
|
||||||
assert "Error saving memory" in result
|
assert "Error saving memory" in result
|
||||||
@ -808,9 +808,9 @@ class TestToolCommitMemory:
|
|||||||
import a2a_tools
|
import a2a_tools
|
||||||
|
|
||||||
mc = _make_http_mock(post_resp=_resp(201, {"id": "mem-poison"}))
|
mc = _make_http_mock(post_resp=_resp(201, {"id": "mem-poison"}))
|
||||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \
|
with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \
|
||||||
patch("a2a_tools._check_memory_write_permission", return_value=True), \
|
patch("a2a_tools_memory._check_memory_write_permission", return_value=True), \
|
||||||
patch("a2a_tools._is_root_workspace", return_value=False):
|
patch("a2a_tools_memory._is_root_workspace", return_value=False):
|
||||||
result = await a2a_tools.tool_commit_memory("poisoned GLOBAL memory", scope="GLOBAL")
|
result = await a2a_tools.tool_commit_memory("poisoned GLOBAL memory", scope="GLOBAL")
|
||||||
|
|
||||||
# Must NOT have called the platform — early rejection
|
# Must NOT have called the platform — early rejection
|
||||||
@ -824,9 +824,9 @@ class TestToolCommitMemory:
|
|||||||
import a2a_tools
|
import a2a_tools
|
||||||
|
|
||||||
mc = _make_http_mock(post_resp=_resp(201, {"id": "mem-7"}))
|
mc = _make_http_mock(post_resp=_resp(201, {"id": "mem-7"}))
|
||||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \
|
with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \
|
||||||
patch("a2a_tools._check_memory_write_permission", return_value=False), \
|
patch("a2a_tools_memory._check_memory_write_permission", return_value=False), \
|
||||||
patch("a2a_tools._is_root_workspace", 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")
|
result = await a2a_tools.tool_commit_memory("should be denied", scope="LOCAL")
|
||||||
|
|
||||||
mc.post.assert_not_called()
|
mc.post.assert_not_called()
|
||||||
@ -838,9 +838,9 @@ class TestToolCommitMemory:
|
|||||||
import a2a_tools
|
import a2a_tools
|
||||||
|
|
||||||
mc = _make_http_mock(post_resp=_resp(201, {"id": "mem-8"}))
|
mc = _make_http_mock(post_resp=_resp(201, {"id": "mem-8"}))
|
||||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \
|
with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \
|
||||||
patch("a2a_tools._check_memory_write_permission", return_value=True), \
|
patch("a2a_tools_memory._check_memory_write_permission", return_value=True), \
|
||||||
patch("a2a_tools._is_root_workspace", return_value=False):
|
patch("a2a_tools_memory._is_root_workspace", return_value=False):
|
||||||
await a2a_tools.tool_commit_memory("test content", scope="LOCAL")
|
await a2a_tools.tool_commit_memory("test content", scope="LOCAL")
|
||||||
|
|
||||||
call_kwargs = mc.post.call_args.kwargs
|
call_kwargs = mc.post.call_args.kwargs
|
||||||
@ -865,8 +865,8 @@ class TestToolRecallMemory:
|
|||||||
{"scope": "TEAM", "content": "We use Python 3.11"},
|
{"scope": "TEAM", "content": "We use Python 3.11"},
|
||||||
]
|
]
|
||||||
mc = _make_http_mock(get_resp=_resp(200, memories))
|
mc = _make_http_mock(get_resp=_resp(200, memories))
|
||||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \
|
with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \
|
||||||
patch("a2a_tools._check_memory_read_permission", return_value=True):
|
patch("a2a_tools_memory._check_memory_read_permission", return_value=True):
|
||||||
result = await a2a_tools.tool_recall_memory(query="capital")
|
result = await a2a_tools.tool_recall_memory(query="capital")
|
||||||
|
|
||||||
assert "[LOCAL]" in result
|
assert "[LOCAL]" in result
|
||||||
@ -878,8 +878,8 @@ class TestToolRecallMemory:
|
|||||||
import a2a_tools
|
import a2a_tools
|
||||||
|
|
||||||
mc = _make_http_mock(get_resp=_resp(200, []))
|
mc = _make_http_mock(get_resp=_resp(200, []))
|
||||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \
|
with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \
|
||||||
patch("a2a_tools._check_memory_read_permission", return_value=True):
|
patch("a2a_tools_memory._check_memory_read_permission", return_value=True):
|
||||||
result = await a2a_tools.tool_recall_memory(query="anything")
|
result = await a2a_tools.tool_recall_memory(query="anything")
|
||||||
|
|
||||||
assert result == "No memories found."
|
assert result == "No memories found."
|
||||||
@ -890,8 +890,8 @@ class TestToolRecallMemory:
|
|||||||
|
|
||||||
payload = {"error": "search unavailable"}
|
payload = {"error": "search unavailable"}
|
||||||
mc = _make_http_mock(get_resp=_resp(200, payload))
|
mc = _make_http_mock(get_resp=_resp(200, payload))
|
||||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \
|
with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \
|
||||||
patch("a2a_tools._check_memory_read_permission", return_value=True):
|
patch("a2a_tools_memory._check_memory_read_permission", return_value=True):
|
||||||
result = await a2a_tools.tool_recall_memory()
|
result = await a2a_tools.tool_recall_memory()
|
||||||
|
|
||||||
parsed = json.loads(result)
|
parsed = json.loads(result)
|
||||||
@ -901,8 +901,8 @@ class TestToolRecallMemory:
|
|||||||
import a2a_tools
|
import a2a_tools
|
||||||
|
|
||||||
mc = _make_http_mock(get_exc=RuntimeError("search service down"))
|
mc = _make_http_mock(get_exc=RuntimeError("search service down"))
|
||||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \
|
with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \
|
||||||
patch("a2a_tools._check_memory_read_permission", return_value=True):
|
patch("a2a_tools_memory._check_memory_read_permission", return_value=True):
|
||||||
result = await a2a_tools.tool_recall_memory(query="test")
|
result = await a2a_tools.tool_recall_memory(query="test")
|
||||||
|
|
||||||
assert "Error recalling memory" in result
|
assert "Error recalling memory" in result
|
||||||
@ -913,8 +913,8 @@ class TestToolRecallMemory:
|
|||||||
import a2a_tools
|
import a2a_tools
|
||||||
|
|
||||||
mc = _make_http_mock(get_resp=_resp(200, []))
|
mc = _make_http_mock(get_resp=_resp(200, []))
|
||||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \
|
with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \
|
||||||
patch("a2a_tools._check_memory_read_permission", return_value=True):
|
patch("a2a_tools_memory._check_memory_read_permission", return_value=True):
|
||||||
await a2a_tools.tool_recall_memory(query="paris", scope="local")
|
await a2a_tools.tool_recall_memory(query="paris", scope="local")
|
||||||
|
|
||||||
call_kwargs = mc.get.call_args.kwargs
|
call_kwargs = mc.get.call_args.kwargs
|
||||||
@ -928,8 +928,8 @@ class TestToolRecallMemory:
|
|||||||
import a2a_tools
|
import a2a_tools
|
||||||
|
|
||||||
mc = _make_http_mock(get_resp=_resp(200, []))
|
mc = _make_http_mock(get_resp=_resp(200, []))
|
||||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \
|
with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \
|
||||||
patch("a2a_tools._check_memory_read_permission", return_value=True):
|
patch("a2a_tools_memory._check_memory_read_permission", return_value=True):
|
||||||
await a2a_tools.tool_recall_memory()
|
await a2a_tools.tool_recall_memory()
|
||||||
|
|
||||||
call_kwargs = mc.get.call_args.kwargs
|
call_kwargs = mc.get.call_args.kwargs
|
||||||
@ -942,8 +942,8 @@ class TestToolRecallMemory:
|
|||||||
import a2a_tools
|
import a2a_tools
|
||||||
|
|
||||||
mc = _make_http_mock(get_resp=_resp(200, []))
|
mc = _make_http_mock(get_resp=_resp(200, []))
|
||||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \
|
with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \
|
||||||
patch("a2a_tools._check_memory_read_permission", return_value=True):
|
patch("a2a_tools_memory._check_memory_read_permission", return_value=True):
|
||||||
await a2a_tools.tool_recall_memory(scope="team")
|
await a2a_tools.tool_recall_memory(scope="team")
|
||||||
|
|
||||||
call_kwargs = mc.get.call_args.kwargs
|
call_kwargs = mc.get.call_args.kwargs
|
||||||
@ -960,8 +960,8 @@ class TestToolRecallMemory:
|
|||||||
import a2a_tools
|
import a2a_tools
|
||||||
|
|
||||||
mc = _make_http_mock(get_resp=_resp(200, [{"scope": "GLOBAL", "content": "secret"}]))
|
mc = _make_http_mock(get_resp=_resp(200, [{"scope": "GLOBAL", "content": "secret"}]))
|
||||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc), \
|
with patch("a2a_tools_memory.httpx.AsyncClient", return_value=mc), \
|
||||||
patch("a2a_tools._check_memory_read_permission", return_value=False):
|
patch("a2a_tools_memory._check_memory_read_permission", return_value=False):
|
||||||
result = await a2a_tools.tool_recall_memory(query="secret")
|
result = await a2a_tools.tool_recall_memory(query="secret")
|
||||||
|
|
||||||
mc.get.assert_not_called()
|
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")
|
||||||
@ -63,7 +63,7 @@ async def test_commit_memory_success(monkeypatch):
|
|||||||
mcp = _load_mcp()
|
mcp = _load_mcp()
|
||||||
|
|
||||||
client = FakeClient()
|
client = FakeClient()
|
||||||
monkeypatch.setattr("a2a_tools.httpx.AsyncClient", lambda **kw: client)
|
monkeypatch.setattr("a2a_tools_memory.httpx.AsyncClient", lambda **kw: client)
|
||||||
|
|
||||||
result = await mcp.handle_tool_call("commit_memory", {
|
result = await mcp.handle_tool_call("commit_memory", {
|
||||||
"content": "Architecture decision: use Go for backend",
|
"content": "Architecture decision: use Go for backend",
|
||||||
@ -92,7 +92,7 @@ async def test_commit_memory_default_scope(monkeypatch):
|
|||||||
mcp = _load_mcp()
|
mcp = _load_mcp()
|
||||||
|
|
||||||
client = FakeClient()
|
client = FakeClient()
|
||||||
monkeypatch.setattr("a2a_tools.httpx.AsyncClient", lambda **kw: client)
|
monkeypatch.setattr("a2a_tools_memory.httpx.AsyncClient", lambda **kw: client)
|
||||||
|
|
||||||
result = await mcp.handle_tool_call("commit_memory", {
|
result = await mcp.handle_tool_call("commit_memory", {
|
||||||
"content": "Some note",
|
"content": "Some note",
|
||||||
@ -108,7 +108,7 @@ async def test_recall_memory_success(monkeypatch):
|
|||||||
mcp = _load_mcp()
|
mcp = _load_mcp()
|
||||||
|
|
||||||
client = FakeClient()
|
client = FakeClient()
|
||||||
monkeypatch.setattr("a2a_tools.httpx.AsyncClient", lambda **kw: client)
|
monkeypatch.setattr("a2a_tools_memory.httpx.AsyncClient", lambda **kw: client)
|
||||||
|
|
||||||
result = await mcp.handle_tool_call("recall_memory", {"query": "architecture"})
|
result = await mcp.handle_tool_call("recall_memory", {"query": "architecture"})
|
||||||
|
|
||||||
@ -127,7 +127,7 @@ async def test_recall_memory_empty(monkeypatch):
|
|||||||
async def get(self, url, params=None, headers=None, **kwargs):
|
async def get(self, url, params=None, headers=None, **kwargs):
|
||||||
return FakeResponse(200, [])
|
return FakeResponse(200, [])
|
||||||
|
|
||||||
monkeypatch.setattr("a2a_tools.httpx.AsyncClient", lambda **kw: EmptyClient())
|
monkeypatch.setattr("a2a_tools_memory.httpx.AsyncClient", lambda **kw: EmptyClient())
|
||||||
|
|
||||||
result = await mcp.handle_tool_call("recall_memory", {})
|
result = await mcp.handle_tool_call("recall_memory", {})
|
||||||
assert "No memories found" in result
|
assert "No memories found" in result
|
||||||
@ -139,7 +139,7 @@ async def test_recall_memory_with_scope_filter(monkeypatch):
|
|||||||
mcp = _load_mcp()
|
mcp = _load_mcp()
|
||||||
|
|
||||||
client = FakeClient()
|
client = FakeClient()
|
||||||
monkeypatch.setattr("a2a_tools.httpx.AsyncClient", lambda **kw: client)
|
monkeypatch.setattr("a2a_tools_memory.httpx.AsyncClient", lambda **kw: client)
|
||||||
|
|
||||||
await mcp.handle_tool_call("recall_memory", {"scope": "TEAM"})
|
await mcp.handle_tool_call("recall_memory", {"scope": "TEAM"})
|
||||||
|
|
||||||
|
|||||||
@ -357,7 +357,7 @@ class TestA2AToolCommitMemoryRedactsSecrets:
|
|||||||
|
|
||||||
fake_client.post = _capture
|
fake_client.post = _capture
|
||||||
|
|
||||||
with patch("a2a_tools.httpx.AsyncClient", return_value=fake_client):
|
with patch("a2a_tools_memory.httpx.AsyncClient", return_value=fake_client):
|
||||||
await a2a_tools.tool_commit_memory(content_with_secret)
|
await a2a_tools.tool_commit_memory(content_with_secret)
|
||||||
|
|
||||||
stored = captured.get("content", "")
|
stored = captured.get("content", "")
|
||||||
@ -385,7 +385,7 @@ class TestA2AToolCommitMemoryRedactsSecrets:
|
|||||||
|
|
||||||
fake_client.post = _capture
|
fake_client.post = _capture
|
||||||
|
|
||||||
with patch("a2a_tools.httpx.AsyncClient", return_value=fake_client):
|
with patch("a2a_tools_memory.httpx.AsyncClient", return_value=fake_client):
|
||||||
await a2a_tools.tool_commit_memory(f"key={key}")
|
await a2a_tools.tool_commit_memory(f"key={key}")
|
||||||
|
|
||||||
stored = captured.get("content", "")
|
stored = captured.get("content", "")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user