forked from molecule-ai/molecule-core
Renames: - platform/ → workspace-server/ (Go module path stays as "platform" for external dep compat — will update after plugin module republish) - workspace-template/ → workspace/ Removed (moved to separate repos or deleted): - PLAN.md — internal roadmap (move to private project board) - HANDOFF.md, AGENTS.md — one-time internal session docs - .claude/ — gitignored entirely (local agent config) - infra/cloudflare-worker/ → Molecule-AI/molecule-tenant-proxy - org-templates/molecule-dev/ → standalone template repo - .mcp-eval/ → molecule-mcp-server repo - test-results/ — ephemeral, gitignored Security scrubbing: - Cloudflare account/zone/KV IDs → placeholders - Real EC2 IPs → <EC2_IP> in all docs - CF token prefix, Neon project ID, Fly app names → redacted - Langfuse dev credentials → parameterized - Personal runner username/machine name → generic Community files: - CONTRIBUTING.md — build, test, branch conventions - CODE_OF_CONDUCT.md — Contributor Covenant 2.1 All Dockerfiles, CI workflows, docker-compose, railway.toml, render.yaml, README, CLAUDE.md updated for new directory names. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
471 lines
17 KiB
Python
471 lines
17 KiB
Python
"""HMA memory tools for agents.
|
|
|
|
Hierarchical Memory Architecture:
|
|
- LOCAL: private to this workspace, invisible to others
|
|
- TEAM: shared with parent + siblings (same team)
|
|
- GLOBAL: readable by all, writable by root workspaces only
|
|
|
|
RBAC enforcement
|
|
----------------
|
|
``commit_memory`` requires the ``"memory.write"`` action.
|
|
``search_memory`` requires the ``"memory.read"`` action.
|
|
Roles are read from ``config.yaml`` under ``rbac.roles`` (default: operator).
|
|
|
|
Audit trail
|
|
-----------
|
|
Every memory operation appends a JSON Lines record to the audit log:
|
|
|
|
memory / memory.write / allowed — write permitted by RBAC
|
|
memory / memory.write / success — write committed successfully
|
|
memory / memory.write / failure — write failed (platform error)
|
|
memory / memory.read / allowed — read permitted by RBAC
|
|
memory / memory.read / success — search returned results
|
|
memory / memory.read / failure — search failed (platform error)
|
|
|
|
RBAC denials emit ``rbac / rbac.deny / denied`` events instead.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import uuid
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
from langchain_core.tools import tool
|
|
from builtin_tools.awareness_client import build_awareness_client
|
|
from builtin_tools.audit import check_permission, get_workspace_roles, log_event
|
|
from builtin_tools.security import _redact_secrets
|
|
from builtin_tools.telemetry import MEMORY_QUERY, MEMORY_SCOPE, WORKSPACE_ID_ATTR, get_tracer
|
|
|
|
try: # pragma: no cover - optional runtime dependency in lightweight test envs
|
|
import httpx # type: ignore
|
|
except ImportError: # pragma: no cover
|
|
httpx = SimpleNamespace(AsyncClient=None)
|
|
|
|
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://platform:8080")
|
|
WORKSPACE_ID = os.environ.get("WORKSPACE_ID", "")
|
|
|
|
|
|
@tool
|
|
async def commit_memory(content: str, scope: str = "LOCAL") -> dict:
|
|
"""Store a fact in memory with a specific scope.
|
|
|
|
Args:
|
|
content: The fact or knowledge to remember.
|
|
scope: Memory scope — LOCAL (private), TEAM (shared with team), or GLOBAL (company-wide, root only).
|
|
"""
|
|
content = _redact_secrets(content)
|
|
trace_id = str(uuid.uuid4())
|
|
scope = scope.upper()
|
|
if scope not in ("LOCAL", "TEAM", "GLOBAL"):
|
|
return {"error": "scope must be LOCAL, TEAM, or GLOBAL"}
|
|
|
|
# --- RBAC check -----------------------------------------------------------
|
|
roles, custom_perms = get_workspace_roles()
|
|
if not check_permission("memory.write", roles, custom_perms):
|
|
log_event(
|
|
event_type="rbac",
|
|
action="rbac.deny",
|
|
resource=scope,
|
|
outcome="denied",
|
|
trace_id=trace_id,
|
|
attempted_action="memory.write",
|
|
roles=roles,
|
|
)
|
|
return {
|
|
"success": False,
|
|
"error": (
|
|
"RBAC: this workspace does not have the 'memory.write' permission. "
|
|
f"Current roles: {roles}"
|
|
),
|
|
}
|
|
|
|
log_event(
|
|
event_type="memory",
|
|
action="memory.write",
|
|
resource=scope,
|
|
outcome="allowed",
|
|
trace_id=trace_id,
|
|
memory_scope=scope,
|
|
content_length=len(content),
|
|
)
|
|
|
|
# ── OTEL: memory_write span ──────────────────────────────────────────────
|
|
tracer = get_tracer()
|
|
|
|
with tracer.start_as_current_span("memory_write") as mem_span:
|
|
mem_span.set_attribute(WORKSPACE_ID_ATTR, WORKSPACE_ID)
|
|
mem_span.set_attribute(MEMORY_SCOPE, scope)
|
|
mem_span.set_attribute("memory.content_length", len(content))
|
|
|
|
awareness_client = build_awareness_client()
|
|
if awareness_client is not None:
|
|
try:
|
|
result = await awareness_client.commit(content, scope)
|
|
except Exception as e:
|
|
log_event(
|
|
event_type="memory",
|
|
action="memory.write",
|
|
resource=scope,
|
|
outcome="failure",
|
|
trace_id=trace_id,
|
|
memory_scope=scope,
|
|
error=str(e),
|
|
)
|
|
try:
|
|
mem_span.record_exception(e)
|
|
except Exception:
|
|
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}
|
|
else:
|
|
result = {"success": False, "error": resp.json().get("error", resp.text)}
|
|
except Exception as e:
|
|
log_event(
|
|
event_type="memory",
|
|
action="memory.write",
|
|
resource=scope,
|
|
outcome="failure",
|
|
trace_id=trace_id,
|
|
memory_scope=scope,
|
|
error=str(e),
|
|
)
|
|
try:
|
|
mem_span.record_exception(e)
|
|
except Exception:
|
|
pass
|
|
return {"success": False, "error": str(e)}
|
|
|
|
if result.get("success"):
|
|
mem_span.set_attribute("memory.id", result.get("id") or "")
|
|
mem_span.set_attribute("memory.success", True)
|
|
log_event(
|
|
event_type="memory",
|
|
action="memory.write",
|
|
resource=scope,
|
|
outcome="success",
|
|
trace_id=trace_id,
|
|
memory_scope=scope,
|
|
memory_id=result.get("id"),
|
|
)
|
|
# #125: surface memory writes in /activity so the Canvas
|
|
# "Agent Comms" tab shows what an agent chose to remember.
|
|
# Fire-and-forget — failure here must not poison the tool
|
|
# response since the memory write itself already succeeded.
|
|
await _record_memory_activity(scope, content, result.get("id"))
|
|
await _maybe_log_skill_promotion(content, scope, result)
|
|
else:
|
|
mem_span.set_attribute("memory.success", False)
|
|
log_event(
|
|
event_type="memory",
|
|
action="memory.write",
|
|
resource=scope,
|
|
outcome="failure",
|
|
trace_id=trace_id,
|
|
memory_scope=scope,
|
|
error=result.get("error"),
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
@tool
|
|
async def search_memory(query: str = "", scope: str = "") -> dict:
|
|
"""Search stored memories.
|
|
|
|
Args:
|
|
query: Text to search for (empty returns all).
|
|
scope: Filter by scope — LOCAL, TEAM, GLOBAL, or empty for all accessible.
|
|
"""
|
|
trace_id = str(uuid.uuid4())
|
|
scope = scope.upper()
|
|
if scope and scope not in ("LOCAL", "TEAM", "GLOBAL"):
|
|
return {"error": "scope must be LOCAL, TEAM, GLOBAL, or empty"}
|
|
|
|
# --- RBAC check -----------------------------------------------------------
|
|
roles, custom_perms = get_workspace_roles()
|
|
if not check_permission("memory.read", roles, custom_perms):
|
|
log_event(
|
|
event_type="rbac",
|
|
action="rbac.deny",
|
|
resource=scope or "all",
|
|
outcome="denied",
|
|
trace_id=trace_id,
|
|
attempted_action="memory.read",
|
|
roles=roles,
|
|
)
|
|
return {
|
|
"success": False,
|
|
"error": (
|
|
"RBAC: this workspace does not have the 'memory.read' permission. "
|
|
f"Current roles: {roles}"
|
|
),
|
|
}
|
|
|
|
log_event(
|
|
event_type="memory",
|
|
action="memory.read",
|
|
resource=scope or "all",
|
|
outcome="allowed",
|
|
trace_id=trace_id,
|
|
memory_scope=scope or "all",
|
|
query_length=len(query),
|
|
)
|
|
|
|
# ── OTEL: memory_read span ───────────────────────────────────────────────
|
|
tracer = get_tracer()
|
|
|
|
with tracer.start_as_current_span("memory_read") as mem_span:
|
|
mem_span.set_attribute(WORKSPACE_ID_ATTR, WORKSPACE_ID)
|
|
mem_span.set_attribute(MEMORY_SCOPE, scope or "all")
|
|
mem_span.set_attribute(MEMORY_QUERY, query[:256] if query else "")
|
|
|
|
awareness_client = build_awareness_client()
|
|
if awareness_client is not None:
|
|
try:
|
|
result = await awareness_client.search(query, scope)
|
|
mem_span.set_attribute("memory.result_count", result.get("count", 0))
|
|
mem_span.set_attribute("memory.success", result.get("success", False))
|
|
log_event(
|
|
event_type="memory",
|
|
action="memory.read",
|
|
resource=scope or "all",
|
|
outcome="success" if result.get("success") else "failure",
|
|
trace_id=trace_id,
|
|
memory_scope=scope or "all",
|
|
result_count=result.get("count", 0),
|
|
)
|
|
return result
|
|
except Exception as e:
|
|
log_event(
|
|
event_type="memory",
|
|
action="memory.read",
|
|
resource=scope or "all",
|
|
outcome="failure",
|
|
trace_id=trace_id,
|
|
memory_scope=scope or "all",
|
|
error=str(e),
|
|
)
|
|
try:
|
|
mem_span.record_exception(e)
|
|
except Exception:
|
|
pass
|
|
return {"success": False, "error": str(e)}
|
|
|
|
params = {}
|
|
if query:
|
|
params["q"] = query
|
|
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()
|
|
mem_span.set_attribute("memory.result_count", len(memories))
|
|
mem_span.set_attribute("memory.success", True)
|
|
log_event(
|
|
event_type="memory",
|
|
action="memory.read",
|
|
resource=scope or "all",
|
|
outcome="success",
|
|
trace_id=trace_id,
|
|
memory_scope=scope or "all",
|
|
result_count=len(memories),
|
|
)
|
|
return {
|
|
"success": True,
|
|
"count": len(memories),
|
|
"memories": memories,
|
|
}
|
|
mem_span.set_attribute("memory.success", False)
|
|
log_event(
|
|
event_type="memory",
|
|
action="memory.read",
|
|
resource=scope or "all",
|
|
outcome="failure",
|
|
trace_id=trace_id,
|
|
memory_scope=scope or "all",
|
|
http_status=resp.status_code,
|
|
)
|
|
return {"success": False, "error": resp.json().get("error", resp.text)}
|
|
except Exception as e:
|
|
log_event(
|
|
event_type="memory",
|
|
action="memory.read",
|
|
resource=scope or "all",
|
|
outcome="failure",
|
|
trace_id=trace_id,
|
|
memory_scope=scope or "all",
|
|
error=str(e),
|
|
)
|
|
try:
|
|
mem_span.record_exception(e)
|
|
except Exception:
|
|
pass
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
def _parse_promotion_packet(content: str) -> dict[str, Any] | None:
|
|
"""Return a structured memory packet when content looks like promotion metadata."""
|
|
text = content.strip()
|
|
if not text.startswith("{"):
|
|
return None
|
|
|
|
try:
|
|
payload = json.loads(text)
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
if not isinstance(payload, dict): # pragma: no cover
|
|
return None
|
|
if not payload.get("promote_to_skill"):
|
|
return None
|
|
|
|
return payload
|
|
|
|
|
|
async def _record_memory_activity(scope: str, content: str, memory_id: str | None) -> None:
|
|
"""Surface a successful memory write as an activity row so the Canvas
|
|
"Agent Comms" tab can display what an agent chose to remember.
|
|
Fire-and-forget — never raises. #125.
|
|
|
|
The summary is intentionally short (scope tag + first 80 chars of
|
|
content with a ``…`` ellipsis when truncated) so the activity table
|
|
stays readable; full content lives in ``agent_memories``.
|
|
"""
|
|
workspace_id = WORKSPACE_ID.strip()
|
|
platform_url = PLATFORM_URL.strip().rstrip("/")
|
|
if not workspace_id or not platform_url:
|
|
return
|
|
|
|
preview = content.strip().replace("\n", " ")
|
|
if len(preview) > 80:
|
|
preview = preview[:80] + "…"
|
|
summary = f"[{scope}] {preview}"
|
|
|
|
# NOTE: target_id is a UUID column scoped to workspace_id references —
|
|
# cannot hold awareness/memory IDs (which are arbitrary strings).
|
|
# We embed the memory_id in the summary instead so it's still searchable.
|
|
if memory_id:
|
|
summary = f"{summary} (id={memory_id[:24]})"
|
|
payload: dict[str, Any] = {
|
|
"workspace_id": workspace_id,
|
|
"activity_type": "memory_write",
|
|
"summary": summary,
|
|
"status": "ok",
|
|
}
|
|
|
|
try:
|
|
try:
|
|
from platform_auth import auth_headers as _auth
|
|
_headers = _auth()
|
|
except Exception:
|
|
_headers = {}
|
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
await client.post(
|
|
f"{platform_url}/workspaces/{workspace_id}/activity",
|
|
json=payload,
|
|
headers=_headers,
|
|
)
|
|
except Exception:
|
|
# Activity logging is purely observability — never poison the
|
|
# tool response on a failure here. We don't even log_event the
|
|
# failure since the memory write itself succeeded and that's
|
|
# what matters to the caller.
|
|
pass
|
|
|
|
|
|
async def _maybe_log_skill_promotion(content: str, scope: str, memory_result: dict) -> None:
|
|
"""Best-effort activity log for durable memory entries that should become skills."""
|
|
packet = _parse_promotion_packet(content)
|
|
if packet is None:
|
|
return
|
|
|
|
workspace_id = WORKSPACE_ID.strip()
|
|
platform_url = PLATFORM_URL.strip().rstrip("/")
|
|
if not workspace_id or not platform_url:
|
|
return
|
|
|
|
repetition_signal = packet.get("repetition_signal")
|
|
summary = (
|
|
packet.get("summary")
|
|
or packet.get("title")
|
|
or packet.get("what changed")
|
|
or "Repeatable workflow promoted to skill candidate"
|
|
)
|
|
metadata: dict[str, Any] = {
|
|
"source": "memory-curation",
|
|
"scope": scope,
|
|
"memory_id": memory_result.get("id"),
|
|
"promote_to_skill": True,
|
|
"repetition_signal": repetition_signal,
|
|
"memory_packet": packet,
|
|
}
|
|
|
|
payload = {
|
|
"activity_type": "skill_promotion",
|
|
"method": "memory/skill-promotion",
|
|
"summary": summary,
|
|
"status": "ok",
|
|
"source_id": workspace_id,
|
|
"request_body": packet,
|
|
"metadata": metadata,
|
|
}
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
await client.post(
|
|
f"{platform_url}/workspaces/{workspace_id}/activity",
|
|
json=payload,
|
|
)
|
|
await client.post(
|
|
f"{platform_url}/registry/heartbeat",
|
|
json={
|
|
"workspace_id": workspace_id,
|
|
"error_rate": 0,
|
|
"sample_error": "",
|
|
"active_tasks": 1,
|
|
"uptime_seconds": 0,
|
|
"current_task": f"Skill promotion: {summary}",
|
|
},
|
|
)
|
|
except Exception:
|
|
# Best-effort observability only. Memory commits must never fail because
|
|
# the promotion log could not be written.
|
|
return
|