feat(builtin_tools/memory): add optional namespace param to commit_memory and search_memory

Adds optional namespace parameter so agents can organize memories into named
buckets (e.g. "facts", "procedures", "blockers"). Defaults to "general".

- commit_memory(content, scope, *, namespace=None): namespace normalised to
  "general" when None or whitespace-only, forwarded to awareness client and
  included in httpx POST body.
- search_memory(query, scope, *, namespace=None): namespace forwarded as
  ?namespace= query param (omitted when None), matching the existing behaviour
  for the scope param.
- AwarenessClient.commit() and .search() updated to accept namespace kwarg.

Fixes #908.
This commit is contained in:
Molecule AI · infra-sre 2026-04-20 23:12:32 +00:00
parent 2bb0f97085
commit ecc0a231bf
2 changed files with 30 additions and 9 deletions

View File

@ -51,21 +51,24 @@ class AwarenessClient:
# be adjusted later without touching the agent-facing tools.
return f"{self.base_url}/api/v1/namespaces/{self.namespace}/memories"
async def commit(self, content: str, scope: str) -> dict[str, Any]:
async def commit(self, content: str, scope: str, *, namespace: str | None = None) -> dict[str, Any]:
_ns = _normalise_namespace(namespace)
client_cls = _resolve_async_client()
async with client_cls(timeout=self.timeout) as client:
resp = await client.post(
self._memories_url(),
json={"content": content, "scope": scope},
json={"content": content, "scope": scope, "namespace": _ns},
)
return _parse_commit_response(resp, scope)
async def search(self, query: str = "", scope: str = "") -> dict[str, Any]:
async def search(self, query: str = "", scope: str = "", *, namespace: str | None = None) -> dict[str, Any]:
params: dict[str, str] = {}
if query:
params["q"] = query
if scope:
params["scope"] = scope
if namespace is not None:
params["namespace"] = namespace.strip()
client_cls = _resolve_async_client()
async with client_cls(timeout=self.timeout) as client:
@ -120,3 +123,17 @@ def _resolve_async_client():
return client_cls
raise RuntimeError("httpx.AsyncClient is unavailable")
def _normalise_namespace(namespace: str | None) -> str:
"""Normalise a namespace value to 'general' when None or empty.
Whitespace is stripped before the empty check so that strings
containing only spaces are also treated as 'general'.
"""
if namespace is None:
return "general"
stripped = namespace.strip()
if not stripped:
return "general"
return stripped

View File

@ -32,7 +32,7 @@ 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.awareness_client import _normalise_namespace, build_awareness_client
from builtin_tools.audit import check_permission, get_workspace_roles, log_event
from builtin_tools.telemetry import MEMORY_QUERY, MEMORY_SCOPE, WORKSPACE_ID_ATTR, get_tracer
@ -46,12 +46,13 @@ WORKSPACE_ID = os.environ.get("WORKSPACE_ID", "")
@tool
async def commit_memory(content: str, scope: str = "LOCAL") -> dict:
async def commit_memory(content: str, scope: str = "LOCAL", *, namespace: str | None = None) -> 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).
namespace: Optional namespace bucket (e.g. "facts", "procedures", "blockers"). Defaults to "general".
"""
trace_id = str(uuid.uuid4())
scope = scope.upper()
@ -99,7 +100,7 @@ async def commit_memory(content: str, scope: str = "LOCAL") -> dict:
awareness_client = build_awareness_client()
if awareness_client is not None:
try:
result = await awareness_client.commit(content, scope)
result = await awareness_client.commit(content, scope, namespace=namespace)
except Exception as e:
log_event(
event_type="memory",
@ -129,7 +130,7 @@ async def commit_memory(content: str, scope: str = "LOCAL") -> dict:
try:
resp = await client.post(
f"{PLATFORM_URL}/workspaces/{WORKSPACE_ID}/memories",
json={"content": content, "scope": scope},
json={"content": content, "scope": scope, "namespace": _normalise_namespace(namespace)},
headers=_headers,
)
if resp.status_code == 201:
@ -186,12 +187,13 @@ async def commit_memory(content: str, scope: str = "LOCAL") -> dict:
@tool
async def search_memory(query: str = "", scope: str = "") -> dict:
async def search_memory(query: str = "", scope: str = "", *, namespace: str | None = None) -> 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.
namespace: Optional namespace bucket to search within. When None, searches all namespaces.
"""
trace_id = str(uuid.uuid4())
scope = scope.upper()
@ -239,7 +241,7 @@ async def search_memory(query: str = "", scope: str = "") -> dict:
awareness_client = build_awareness_client()
if awareness_client is not None:
try:
result = await awareness_client.search(query, scope)
result = await awareness_client.search(query, scope, namespace=namespace)
mem_span.set_attribute("memory.result_count", result.get("count", 0))
mem_span.set_attribute("memory.success", result.get("success", False))
log_event(
@ -273,6 +275,8 @@ async def search_memory(query: str = "", scope: str = "") -> dict:
params["q"] = query
if scope:
params["scope"] = scope.upper()
if namespace is not None:
params["namespace"] = namespace.strip()
# #215-class bug (search path): same fix as commit_memory above —
# the platform gates GET /workspaces/:id/memories behind workspace