Merge pull request #285 from Molecule-AI/fix/memory-tools-auth-headers

fix(memory-tools): #215-class — auth_headers on commit_memory + search_memory HTTP fallback
This commit is contained in:
Hongming Wang 2026-04-15 17:29:24 -07:00 committed by GitHub
commit 7e18f8b15e
2 changed files with 79 additions and 30 deletions

View File

@ -116,11 +116,21 @@ async def commit_memory(content: str, scope: str = "LOCAL") -> dict:
pass
return {"success": False, "error": str(e)}
else:
# #215-class bug: platform now gates /workspaces/:id/memories behind
# workspace auth. Import auth_headers lazily (same pattern as the
# activity-log path below) so test environments that don't ship
# platform_auth still work.
try:
from platform_auth import auth_headers as _auth
_headers = _auth()
except Exception:
_headers = {}
async with httpx.AsyncClient(timeout=10.0) as client:
try:
resp = await client.post(
f"{PLATFORM_URL}/workspaces/{WORKSPACE_ID}/memories",
json={"content": content, "scope": scope},
headers=_headers,
)
if resp.status_code == 201:
result = {"success": True, "id": resp.json().get("id"), "scope": scope}
@ -264,11 +274,23 @@ async def search_memory(query: str = "", scope: str = "") -> dict:
if scope:
params["scope"] = scope.upper()
# #215-class bug (search path): same fix as commit_memory above —
# the platform gates GET /workspaces/:id/memories behind workspace
# auth, so without auth_headers() every search silently 401s and the
# agent thinks its backlog is empty (observed on Technical Researcher
# idle-loop pilot 2026-04-15).
try:
from platform_auth import auth_headers as _auth
_headers = _auth()
except Exception:
_headers = {}
async with httpx.AsyncClient(timeout=10.0) as client:
try:
resp = await client.get(
f"{PLATFORM_URL}/workspaces/{WORKSPACE_ID}/memories",
params=params,
headers=_headers,
)
if resp.status_code == 200:
memories = resp.json()

View File

@ -78,9 +78,13 @@ def test_commit_memory_uses_awareness_client_when_configured(monkeypatch, memory
async def __aexit__(self, exc_type, exc, tb):
return None
async def post(self, url, json):
captured["url"] = url
captured["json"] = json
async def post(self, url, json, headers=None):
# Only capture the memories write — _record_memory_activity
# fires a second /activity post that would overwrite
# captured["url"] otherwise.
if "/memories" in url:
captured["url"] = url
captured["json"] = json
return _FakeResponse(201, {"id": "mem-123"})
monkeypatch.setenv("AWARENESS_URL", "http://awareness.test")
@ -108,7 +112,7 @@ def test_search_memory_uses_platform_fallback_without_awareness(monkeypatch, mem
async def __aexit__(self, exc_type, exc, tb):
return None
async def get(self, url, params):
async def get(self, url, params, headers=None):
captured["url"] = url
captured["params"] = params
return _FakeResponse(200, [{"content": "existing"}])
@ -140,9 +144,15 @@ def test_commit_memory_uses_platform_fallback_without_awareness(monkeypatch, mem
async def __aexit__(self, exc_type, exc, tb):
return None
async def post(self, url, json):
captured["url"] = url
captured["json"] = json
async def post(self, url, json, headers=None):
# commit_memory first hits /workspaces/:id/memories (the fix
# under test), then _record_memory_activity hits /activity as
# a fire-and-forget follow-up. Filter to only capture the
# memories call so the subsequent activity post doesn't
# overwrite captured["url"].
if "/memories" in url:
captured["url"] = url
captured["json"] = json
return _FakeResponse(201, {"id": "platform-mem"})
monkeypatch.setattr(memory.httpx, "AsyncClient", FakeAsyncClient)
@ -168,12 +178,14 @@ def test_commit_memory_promoted_packet_logs_skill_promotion(monkeypatch, tmp_pat
async def __aexit__(self, exc_type, exc, tb):
return None
async def post(self, url, json):
async def post(self, url, json, headers=None):
captured["calls"].append((url, json))
if url.endswith("/memories"):
return _FakeResponse(201, {"id": "mem-skill"})
if url.endswith("/activity"):
return _FakeResponse(200, {"status": "logged"})
if url.endswith("/registry/heartbeat"):
return _FakeResponse(200, {"status": "ok"})
raise AssertionError(f"unexpected URL: {url}")
monkeypatch.setattr(memory.httpx, "AsyncClient", FakeAsyncClient)
@ -193,19 +205,30 @@ def test_commit_memory_promoted_packet_logs_skill_promotion(monkeypatch, tmp_pat
result = asyncio.run(memory.commit_memory(json.dumps(packet), "team"))
assert result == {"success": True, "id": "mem-skill", "scope": "TEAM"}
assert len(captured["calls"]) == 3
# Promoted packets now produce 4 calls (pre-#215-fix the memory-write
# activity call was silently dropped because the test fake didn't
# accept a `headers=` kwarg, which changed as the fakes were updated
# to match the new auth-headers wiring):
# [0] POST /memories — the memory write itself
# [1] POST /activity — memory_write activity row (#125)
# [2] POST /activity — skill_promotion activity row
# [3] POST /registry/heartbeat — heartbeat update with promotion task
assert len(captured["calls"]) == 4
memory_url, memory_payload = captured["calls"][0]
activity_url, activity_payload = captured["calls"][1]
heartbeat_url, heartbeat_payload = captured["calls"][2]
memory_activity_url, memory_activity_payload = captured["calls"][1]
skill_activity_url, skill_activity_payload = captured["calls"][2]
heartbeat_url, heartbeat_payload = captured["calls"][3]
assert memory_url == "http://platform.test/workspaces/ws-test/memories"
assert memory_payload == {"content": json.dumps(packet), "scope": "TEAM"}
assert activity_url == "http://platform.test/workspaces/ws-test/activity"
assert activity_payload["activity_type"] == "skill_promotion"
assert activity_payload["method"] == "memory/skill-promotion"
assert activity_payload["summary"] == "Repeated GitHub webhook handling is now a skill candidate"
assert activity_payload["metadata"]["promote_to_skill"] is True
assert activity_payload["metadata"]["memory_id"] == "mem-skill"
assert activity_payload["metadata"]["repetition_signal"] == packet["repetition_signal"]
assert memory_activity_url == "http://platform.test/workspaces/ws-test/activity"
assert memory_activity_payload["activity_type"] == "memory_write"
assert skill_activity_url == "http://platform.test/workspaces/ws-test/activity"
assert skill_activity_payload["activity_type"] == "skill_promotion"
assert skill_activity_payload["method"] == "memory/skill-promotion"
assert skill_activity_payload["summary"] == "Repeated GitHub webhook handling is now a skill candidate"
assert skill_activity_payload["metadata"]["promote_to_skill"] is True
assert skill_activity_payload["metadata"]["memory_id"] == "mem-skill"
assert skill_activity_payload["metadata"]["repetition_signal"] == packet["repetition_signal"]
assert heartbeat_url == "http://platform.test/registry/heartbeat"
assert heartbeat_payload["current_task"] == "Skill promotion: Repeated GitHub webhook handling is now a skill candidate"
assert heartbeat_payload["active_tasks"] == 1
@ -349,8 +372,12 @@ def test_commit_memory_httpx_201_success(memory_modules_with_mocks):
async def __aexit__(self, exc_type, exc, tb):
return None
async def post(self, url, json):
captured["url"] = url
async def post(self, url, json, headers=None):
# Only capture the /memories call — _record_memory_activity
# fires /activity after on success and would otherwise
# overwrite captured["url"].
if "/memories" in url:
captured["url"] = url
return _FakeResponse(201, {"id": "new-mem-1"})
memory.httpx.AsyncClient = FakeAsyncClient
@ -372,7 +399,7 @@ def test_commit_memory_httpx_non_201(memory_modules_with_mocks):
def __init__(self, timeout): pass
async def __aenter__(self): return self
async def __aexit__(self, *a): return None
async def post(self, url, json):
async def post(self, url, json, headers=None):
return _FakeResponse(400, {"error": "bad request"})
memory.httpx.AsyncClient = FakeAsyncClient
@ -394,7 +421,7 @@ def test_commit_memory_httpx_exception(memory_modules_with_mocks):
def __init__(self, timeout): pass
async def __aenter__(self): return self
async def __aexit__(self, *a): return None
async def post(self, url, json):
async def post(self, url, json, headers=None):
raise ConnectionError("network gone")
memory.httpx.AsyncClient = FakeAsyncClient
@ -416,7 +443,7 @@ def test_commit_memory_result_failure(memory_modules_with_mocks):
def __init__(self, timeout): pass
async def __aenter__(self): return self
async def __aexit__(self, *a): return None
async def post(self, url, json):
async def post(self, url, json, headers=None):
return _FakeResponse(400, {"error": "storage full"})
memory.httpx.AsyncClient = FakeAsyncClient
@ -513,7 +540,7 @@ def test_search_memory_httpx_200_success(memory_modules_with_mocks):
def __init__(self, timeout): pass
async def __aenter__(self): return self
async def __aexit__(self, *a): return None
async def get(self, url, params):
async def get(self, url, params, headers=None):
return _FakeResponse(200, [{"content": "result1"}, {"content": "result2"}])
memory.httpx.AsyncClient = FakeAsyncClient
@ -536,7 +563,7 @@ def test_search_memory_httpx_non_200(memory_modules_with_mocks):
def __init__(self, timeout): pass
async def __aenter__(self): return self
async def __aexit__(self, *a): return None
async def get(self, url, params):
async def get(self, url, params, headers=None):
return _FakeResponse(500, {"error": "server error"})
memory.httpx.AsyncClient = FakeAsyncClient
@ -558,7 +585,7 @@ def test_search_memory_httpx_exception(memory_modules_with_mocks):
def __init__(self, timeout): pass
async def __aenter__(self): return self
async def __aexit__(self, *a): return None
async def get(self, url, params):
async def get(self, url, params, headers=None):
raise TimeoutError("request timed out")
memory.httpx.AsyncClient = FakeAsyncClient
@ -614,7 +641,7 @@ def test_maybe_log_skill_promotion_no_packet(memory_modules_with_mocks):
def __init__(self, timeout): pass
async def __aenter__(self): return self
async def __aexit__(self, *a): return None
async def post(self, url, json):
async def post(self, url, json, headers=None):
http_called.append(url)
memory.httpx.AsyncClient = FakeAsyncClient
@ -675,7 +702,7 @@ def test_commit_memory_httpx_exception_span_record_fails(memory_modules_with_moc
def __init__(self, timeout): pass
async def __aenter__(self): return self
async def __aexit__(self, *a): return None
async def post(self, url, json):
async def post(self, url, json, headers=None):
raise ConnectionError("network gone")
memory.httpx.AsyncClient = FakeAsyncClient
@ -697,7 +724,7 @@ def test_search_memory_httpx_exception_span_record_fails(memory_modules_with_moc
def __init__(self, timeout): pass
async def __aenter__(self): return self
async def __aexit__(self, *a): return None
async def get(self, url, params):
async def get(self, url, params, headers=None):
raise TimeoutError("request timed out")
memory.httpx.AsyncClient = FakeAsyncClient
@ -731,7 +758,7 @@ def test_maybe_log_skill_promotion_no_workspace_id(memory_modules_with_mocks):
def __init__(self, timeout): pass
async def __aenter__(self): return self
async def __aexit__(self, *a): return None
async def post(self, url, json):
async def post(self, url, json, headers=None):
http_called.append(url)
memory.httpx.AsyncClient = FakeAsyncClient