Compare commits

...

5 Commits

12 changed files with 2748 additions and 1056 deletions

958
README.md

File diff suppressed because it is too large Load Diff

View File

@ -7,15 +7,23 @@ from datetime import datetime, timezone
from typing import Any, Literal from typing import Any, Literal
from urllib.parse import parse_qsl, quote, urlsplit, urlunsplit from urllib.parse import parse_qsl, quote, urlsplit, urlunsplit
import httpx
from fastapi import APIRouter, FastAPI, File, Form, HTTPException, Request, UploadFile from fastapi import APIRouter, FastAPI, File, Form, HTTPException, Request, UploadFile
from pydantic import BaseModel, Field from pydantic import ValidationError
from pydantic import BaseModel, Field, field_validator
from starlette.datastructures import UploadFile as StarletteUploadFile
from starlette.responses import Response from starlette.responses import Response
from .config import GatewayConfig from .config import GatewayConfig
from .db import init_db from .db import init_db
from .backend_client import BackendClient from .backend_client import BackendClient
from .repository import MemoryRepository from .repository import MemoryRepository
from .service import MemoryGatewayService, UnsupportedContentType, UploadTooLarge from .service import (
InvalidAttachment,
MemoryGatewayService,
UnsupportedContentType,
UploadTooLarge,
)
API_LOGGER = logging.getLogger("memory_gateway.api") API_LOGGER = logging.getLogger("memory_gateway.api")
@ -34,15 +42,28 @@ SENSITIVE_FIELD_NAMES = {
class SearchMemoriesRequest(BaseModel): class SearchMemoriesRequest(BaseModel):
user_id: str = Field(min_length=1) user_id: str = Field(min_length=1)
user_key: str = Field(min_length=1) user_key: str = Field(min_length=1)
agent_id: str | None = Field(default=None, min_length=1)
conversation_id: str | None = None conversation_id: str | None = None
query: str = Field(min_length=1) query: str = Field(min_length=1)
scope: list[Literal["current_chat", "resources", "all_user_memory"]] = Field( scope: list[Literal["current_chat", "resources", "all_user_memory"]] = Field(
default_factory=lambda: ["current_chat", "resources"] default_factory=lambda: ["current_chat", "resources"]
) )
top_k: int = Field(default=8, ge=1, le=100) method: Literal["keyword", "vector", "hybrid", "agentic"] = "hybrid"
top_k: int = 8
radius: float | None = Field(default=None, ge=0, le=1)
include_profile: bool = True
enable_llm_rerank: bool = True
filters: dict[str, Any] | None = None
app_id: str = "default" app_id: str = "default"
project_id: str = "default" project_id: str = "default"
@field_validator("top_k")
@classmethod
def validate_top_k(cls, value: int) -> int:
if value != -1 and not 1 <= value <= 100:
raise ValueError("top_k must be -1 or in 1..100")
return value
class AddMemoryMessage(BaseModel): class AddMemoryMessage(BaseModel):
sender_id: str = Field(min_length=1) sender_id: str = Field(min_length=1)
@ -68,6 +89,22 @@ class FlushMemoryRequest(BaseModel):
project_id: str = "default" project_id: str = "default"
class ExternalResourceRequest(BaseModel):
user_id: str = Field(min_length=1)
user_key: str = Field(min_length=1)
app_id: str = "default"
project_id: str = "default"
filename: str = Field(min_length=1)
mime_type: str | None = None
content_type: str | None = None
size_bytes: int | None = Field(default=None, ge=0)
sha256: str | None = None
source_uri: str = Field(min_length=1)
ingest_uri: str | None = None
title: str | None = None
description: str | None = None
class MemoryOverrideRequest(BaseModel): class MemoryOverrideRequest(BaseModel):
user_id: str = Field(min_length=1) user_id: str = Field(min_length=1)
user_key: str = Field(min_length=1) user_key: str = Field(min_length=1)
@ -178,6 +215,67 @@ def _body_for_log(body: bytes, content_type: str | None) -> Any:
return {"content_type": content_type, "size_bytes": len(body)} return {"content_type": content_type, "size_bytes": len(body)}
def _backend_http_error_detail(exc: httpx.HTTPStatusError) -> Any:
try:
return exc.response.json()
except ValueError:
return exc.response.text
def _form_text(form: Any, field: str, default: str | None = None) -> str:
value = form.get(field)
if value is None:
if default is not None:
return default
raise HTTPException(status_code=422, detail=f"missing form field: {field}")
if isinstance(value, StarletteUploadFile):
raise HTTPException(status_code=422, detail=f"form field must be text: {field}")
return str(value)
async def _form_json_text(form: Any, field: str) -> str:
value = form.get(field)
if value is None:
raise HTTPException(status_code=422, detail=f"missing form field: {field}")
if isinstance(value, StarletteUploadFile):
raw = await value.read()
return raw.decode("utf-8")
return str(value)
def _upload_files_from_form(form: Any) -> dict[str, UploadFile]:
files: dict[str, UploadFile] = {}
for key, value in form.multi_items():
if not isinstance(value, StarletteUploadFile):
continue
if key == "messages":
continue
if key in files:
raise HTTPException(
status_code=422,
detail=f"duplicate upload file field: {key}",
)
files[key] = value
return files
async def _multipart_messages(form: Any) -> list[dict[str, Any]]:
raw = await _form_json_text(form, "messages")
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
raise HTTPException(
status_code=400,
detail=f"invalid messages JSON: {exc.msg}",
) from exc
if not isinstance(parsed, list):
raise HTTPException(status_code=400, detail="messages must be a JSON array")
try:
return [AddMemoryMessage.model_validate(item).model_dump() for item in parsed]
except ValidationError as exc:
raise HTTPException(status_code=422, detail=exc.errors()) from exc
def create_app( def create_app(
*, *,
config: GatewayConfig | None = None, config: GatewayConfig | None = None,
@ -348,6 +446,26 @@ def create_app(
return {"resources": []} return {"resources": []}
return {"resources": [resource]} return {"resources": [resource]}
@router.post("/resources/external")
async def register_external_resource(
request: ExternalResourceRequest,
) -> dict[str, Any]:
require_user(request.user_id, request.user_key)
return await service.register_external_resource(
user_id=request.user_id,
app_id=request.app_id,
project_id=request.project_id,
filename=request.filename,
mime_type=request.mime_type,
content_type=request.content_type,
size_bytes=request.size_bytes,
sha256=request.sha256,
source_uri=request.source_uri,
ingest_uri=request.ingest_uri,
title=request.title,
description=request.description,
)
@router.delete("/resources/{resource_id}") @router.delete("/resources/{resource_id}")
async def delete_resource( async def delete_resource(
resource_id: str, resource_id: str,
@ -367,10 +485,16 @@ def create_app(
require_user(request.user_id, request.user_key) require_user(request.user_id, request.user_key)
return await service.search_memories( return await service.search_memories(
user_id=request.user_id, user_id=request.user_id,
agent_id=request.agent_id,
query=request.query, query=request.query,
conversation_id=request.conversation_id, conversation_id=request.conversation_id,
scope=request.scope, scope=request.scope,
method=request.method,
top_k=request.top_k, top_k=request.top_k,
radius=request.radius,
include_profile=request.include_profile,
enable_llm_rerank=request.enable_llm_rerank,
filters=request.filters,
app_id=request.app_id, app_id=request.app_id,
project_id=request.project_id, project_id=request.project_id,
) )
@ -380,12 +504,50 @@ def create_app(
request: AddMemoryRequest, request: AddMemoryRequest,
) -> dict[str, Any]: ) -> dict[str, Any]:
require_user(request.user_id, request.user_key) require_user(request.user_id, request.user_key)
try:
return await service.add_memory( return await service.add_memory(
user_id=request.user_id,
session_id=request.session_id, session_id=request.session_id,
app_id=request.app_id, app_id=request.app_id,
project_id=request.project_id, project_id=request.project_id,
messages=[message.model_dump() for message in request.messages], messages=[message.model_dump() for message in request.messages],
) )
except httpx.HTTPStatusError as exc:
raise HTTPException(
status_code=exc.response.status_code,
detail=_backend_http_error_detail(exc),
) from exc
except UploadTooLarge as exc:
raise HTTPException(status_code=413, detail=str(exc)) from exc
except InvalidAttachment as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@router.post("/memories/add/multipart")
async def add_memory_multipart(request: Request) -> dict[str, Any]:
form = await request.form()
user_id = _form_text(form, "user_id")
user_key = _form_text(form, "user_key")
require_user(user_id, user_key)
try:
return await service.add_memory_with_uploads(
user_id=user_id,
session_id=_form_text(form, "session_id"),
app_id=_form_text(form, "app_id", "default"),
project_id=_form_text(form, "project_id", "default"),
messages=await _multipart_messages(form),
upload_files=_upload_files_from_form(form),
)
except httpx.HTTPStatusError as exc:
raise HTTPException(
status_code=exc.response.status_code,
detail=_backend_http_error_detail(exc),
) from exc
except UploadTooLarge as exc:
raise HTTPException(status_code=413, detail=str(exc)) from exc
except UnsupportedContentType as exc:
raise HTTPException(status_code=415, detail=str(exc)) from exc
except InvalidAttachment as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@router.post("/memories/flush") @router.post("/memories/flush")
async def flush_memory( async def flush_memory(

View File

@ -43,6 +43,62 @@ ON user_resources (session_id);
CREATE INDEX IF NOT EXISTS idx_user_resources_user_id CREATE INDEX IF NOT EXISTS idx_user_resources_user_id
ON user_resources (user_id); ON user_resources (user_id);
CREATE TABLE IF NOT EXISTS memory_attachments (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
app_id TEXT NOT NULL DEFAULT 'default',
project_id TEXT NOT NULL DEFAULT 'default',
session_id TEXT NOT NULL,
resource_id TEXT,
content_type TEXT NOT NULL,
name TEXT NOT NULL,
internal_uri TEXT NOT NULL,
source TEXT NOT NULL,
sha256 TEXT,
created_at TIMESTAMP NOT NULL,
deleted_at TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_memory_attachments_unique_uri
ON memory_attachments (user_id, session_id, internal_uri);
CREATE INDEX IF NOT EXISTS idx_memory_attachments_user_session
ON memory_attachments (user_id, session_id, deleted_at);
CREATE INDEX IF NOT EXISTS idx_memory_attachments_resource
ON memory_attachments (resource_id, deleted_at);
INSERT OR IGNORE INTO memory_attachments (
id,
user_id,
app_id,
project_id,
session_id,
resource_id,
content_type,
name,
internal_uri,
source,
sha256,
created_at,
deleted_at
)
SELECT
'a_resource_' || id,
user_id,
app_id,
project_id,
session_id,
id,
content_type,
COALESCE(original_filename, id),
uri,
'resource_upload',
sha256,
created_at,
deleted_at
FROM user_resources;
CREATE TABLE IF NOT EXISTS memory_tombstones ( CREATE TABLE IF NOT EXISTS memory_tombstones (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,

View File

@ -96,9 +96,13 @@ class MemoryRepository:
now = utc_now() now = utc_now()
where = "id = ? AND deleted_at IS NULL" where = "id = ? AND deleted_at IS NULL"
params: tuple[Any, ...] = (now, now, resource_id) params: tuple[Any, ...] = (now, now, resource_id)
attachment_where = "resource_id = ? AND deleted_at IS NULL"
attachment_params: tuple[Any, ...] = (now, resource_id)
if user_id is not None: if user_id is not None:
where += " AND user_id = ?" where += " AND user_id = ?"
params = (now, now, resource_id, user_id) params = (now, now, resource_id, user_id)
attachment_where += " AND user_id = ?"
attachment_params = (now, resource_id, user_id)
with connect(self.db_path) as conn: with connect(self.db_path) as conn:
conn.execute( conn.execute(
f""" f"""
@ -108,6 +112,14 @@ class MemoryRepository:
""", """,
params, params,
) )
conn.execute(
f"""
UPDATE memory_attachments
SET deleted_at = ?
WHERE {attachment_where}
""",
attachment_params,
)
conn.commit() conn.commit()
return self.get_resource(resource_id) return self.get_resource(resource_id)
@ -215,6 +227,62 @@ class MemoryRepository:
).fetchall() ).fetchall()
return [dict(row) for row in rows] return [dict(row) for row in rows]
def create_attachment(self, **values: Any) -> dict[str, Any]:
attachment_id = str(values.get("id") or f"a_{uuid.uuid4().hex}")
payload = {
"id": attachment_id,
"created_at": utc_now(),
"deleted_at": None,
**values,
}
with connect(self.db_path) as conn:
conn.execute(
"""
INSERT OR IGNORE INTO memory_attachments (
id, user_id, app_id, project_id, session_id, resource_id,
content_type, name, internal_uri, source, sha256,
created_at, deleted_at
) VALUES (
:id, :user_id, :app_id, :project_id, :session_id, :resource_id,
:content_type, :name, :internal_uri, :source, :sha256,
:created_at, :deleted_at
)
""",
payload,
)
row = conn.execute(
"""
SELECT * FROM memory_attachments
WHERE user_id = ? AND session_id = ? AND internal_uri = ?
""",
(
payload["user_id"],
payload["session_id"],
payload["internal_uri"],
),
).fetchone()
conn.commit()
attachment = _row_to_dict(row)
if attachment is None:
raise RuntimeError("created attachment could not be read back")
return attachment
def list_attachments_for_session(
self,
user_id: str,
session_id: str,
) -> list[dict[str, Any]]:
with connect(self.db_path) as conn:
rows = conn.execute(
"""
SELECT * FROM memory_attachments
WHERE user_id = ? AND session_id = ? AND deleted_at IS NULL
ORDER BY created_at ASC, id ASC
""",
(user_id, session_id),
).fetchall()
return [dict(row) for row in rows]
def add_tombstone( def add_tombstone(
self, self,
user_id: str, user_id: str,

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
import base64 import base64
import binascii
import hashlib import hashlib
import mimetypes import mimetypes
import secrets import secrets
@ -50,6 +51,17 @@ def infer_content_type(filename: str | None, mime_type: str | None) -> str:
return "doc" return "doc"
def normalize_content_type(
filename: str | None,
mime_type: str | None,
content_type: str | None,
) -> str:
requested = (content_type or "").strip().lower()
if requested in {"image", "audio", "pdf", "html", "text", "doc"}:
return requested
return infer_content_type(filename, mime_type or requested)
def _safe_filename(filename: str | None) -> str: def _safe_filename(filename: str | None) -> str:
name = Path(filename or "upload.bin").name name = Path(filename or "upload.bin").name
return name or "upload.bin" return name or "upload.bin"
@ -63,6 +75,10 @@ class UnsupportedContentType(ValueError):
pass pass
class InvalidAttachment(ValueError):
pass
def _copy_upload( def _copy_upload(
file: UploadFile, file: UploadFile,
destination: Path, destination: Path,
@ -122,6 +138,25 @@ def _remove_empty_parents(path: Path, stop_at: Path | None = None) -> None:
current = parent current = parent
def _read_upload_bytes(
file: UploadFile,
max_upload_bytes: int,
) -> tuple[bytes, str, int]:
sha256 = hashlib.sha256()
size = 0
chunks: list[bytes] = []
while True:
chunk = file.file.read(1024 * 1024)
if not chunk:
break
size += len(chunk)
if size > max_upload_bytes:
raise UploadTooLarge(f"upload exceeds max size of {max_upload_bytes} bytes")
sha256.update(chunk)
chunks.append(chunk)
return b"".join(chunks), sha256.hexdigest(), size
class MemoryGatewayService: class MemoryGatewayService:
def __init__( def __init__(
self, self,
@ -180,6 +215,7 @@ class MemoryGatewayService:
) )
if existing is not None: if existing is not None:
shutil.rmtree(stored_path.parent, ignore_errors=True) shutil.rmtree(stored_path.parent, ignore_errors=True)
self._register_resource_attachment(existing)
return self._resource_summary(existing) return self._resource_summary(existing)
internal_uri = stored_path.resolve().as_uri() internal_uri = stored_path.resolve().as_uri()
@ -202,6 +238,7 @@ class MemoryGatewayService:
status="ingesting", status="ingesting",
error_message=None, error_message=None,
) )
self._register_resource_attachment(resource)
try: try:
await self._retry_backend_call( await self._retry_backend_call(
@ -294,6 +331,123 @@ class MemoryGatewayService:
item["base64"] = base64.b64encode(content).decode("ascii") item["base64"] = base64.b64encode(content).decode("ascii")
return item return item
async def register_external_resource(
self,
*,
user_id: str,
app_id: str,
project_id: str,
filename: str,
mime_type: str | None,
content_type: str | None,
size_bytes: int | None,
sha256: str | None,
source_uri: str,
ingest_uri: str | None,
title: str | None,
description: str | None,
) -> dict[str, Any]:
resource_id = new_resource_id()
session_id = resource_session_id(user_id, resource_id)
original_filename = _safe_filename(filename)
normalized_content_type = normalize_content_type(
original_filename,
mime_type,
content_type,
)
existing = None
if sha256:
existing = self.repository.find_active_resource_by_sha256(
user_id=user_id,
app_id=app_id,
project_id=project_id,
sha256=sha256,
)
if existing is not None:
self._register_resource_attachment(existing, source="external_resource")
return self._resource_summary(existing)
resource = self.repository.create_resource(
id=resource_id,
user_id=user_id,
app_id=app_id,
project_id=project_id,
session_id=session_id,
original_filename=original_filename,
mime_type=mime_type,
content_type=normalized_content_type,
uri=source_uri,
uri_public=False,
sha256=sha256,
size_bytes=size_bytes,
title=title,
description=description,
status="ingesting",
error_message=None,
)
self._register_resource_attachment(resource, source="external_resource")
try:
await self._retry_backend_call(
lambda: self.backend_client.add_memory(
self._build_external_add_payload(
resource=resource,
user_id=user_id,
app_id=app_id,
project_id=project_id,
filename=original_filename,
ingest_uri=ingest_uri or source_uri,
)
)
)
await self._retry_backend_call(
lambda: self.backend_client.flush_memory(session_id, app_id, project_id)
)
except Exception as exc:
failed = self.repository.update_resource_status(
resource_id,
"failed",
str(exc),
)
return self._resource_summary(failed or resource)
extracted = self.repository.update_resource_status(resource_id, "extracted")
return self._resource_summary(extracted or resource)
def _build_external_add_payload(
self,
*,
resource: dict[str, Any],
user_id: str,
app_id: str,
project_id: str,
filename: str,
ingest_uri: str,
) -> dict[str, Any]:
content_item = {
"type": str(resource["content_type"]),
"name": filename,
"ext": Path(filename).suffix.lstrip(".") or None,
"uri": ingest_uri,
"extras": {
"resource_id": resource["id"],
"source": "external_resource",
},
}
return {
"session_id": resource["session_id"],
"app_id": app_id,
"project_id": project_id,
"messages": [
{
"sender_id": user_id,
"role": "user",
"timestamp": current_timestamp_ms(),
"content": [content_item],
}
],
}
def _resource_file_path(self, resource: dict[str, Any]) -> Path: def _resource_file_path(self, resource: dict[str, Any]) -> Path:
uri = str(resource["uri"]) uri = str(resource["uri"])
parsed = urlparse(uri) parsed = urlparse(uri)
@ -346,10 +500,16 @@ class MemoryGatewayService:
self, self,
*, *,
user_id: str, user_id: str,
agent_id: str | None,
query: str, query: str,
conversation_id: str | None, conversation_id: str | None,
scope: list[str], scope: list[str],
method: str,
top_k: int, top_k: int,
radius: float | None,
include_profile: bool,
enable_llm_rerank: bool,
filters: dict[str, Any] | None,
app_id: str, app_id: str,
project_id: str, project_id: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
@ -359,11 +519,19 @@ class MemoryGatewayService:
if "current_chat" in scope and conversation_id: if "current_chat" in scope and conversation_id:
payload = self._search_payload( payload = self._search_payload(
user_id=user_id, user_id=user_id,
agent_id=agent_id,
query=query, query=query,
method=method,
top_k=top_k, top_k=top_k,
radius=radius,
include_profile=include_profile,
enable_llm_rerank=enable_llm_rerank,
app_id=app_id, app_id=app_id,
project_id=project_id, project_id=project_id,
filters={"session_id": f"chat:{conversation_id}"}, filters=_combine_filters(
filters,
{"session_id": f"chat:{conversation_id}"},
),
) )
results.extend( results.extend(
self._extract_results( self._extract_results(
@ -385,11 +553,19 @@ class MemoryGatewayService:
for batch in _chunks(session_ids, self.config.resource_search_batch_size): for batch in _chunks(session_ids, self.config.resource_search_batch_size):
payload = self._search_payload( payload = self._search_payload(
user_id=user_id, user_id=user_id,
agent_id=agent_id,
query=query, query=query,
method=method,
top_k=top_k, top_k=top_k,
radius=radius,
include_profile=include_profile,
enable_llm_rerank=enable_llm_rerank,
app_id=app_id, app_id=app_id,
project_id=project_id, project_id=project_id,
filters={"session_id": {"in": batch}}, filters=_combine_filters(
filters,
{"session_id": {"in": batch}},
),
) )
results.extend( results.extend(
self._extract_results( self._extract_results(
@ -403,11 +579,16 @@ class MemoryGatewayService:
if "all_user_memory" in scope: if "all_user_memory" in scope:
payload = self._search_payload( payload = self._search_payload(
user_id=user_id, user_id=user_id,
agent_id=agent_id,
query=query, query=query,
method=method,
top_k=top_k, top_k=top_k,
radius=radius,
include_profile=include_profile,
enable_llm_rerank=enable_llm_rerank,
app_id=app_id, app_id=app_id,
project_id=project_id, project_id=project_id,
filters=None, filters=filters,
) )
results.extend( results.extend(
self._extract_results( self._extract_results(
@ -425,21 +606,279 @@ class MemoryGatewayService:
async def add_memory( async def add_memory(
self, self,
*, *,
user_id: str,
session_id: str, session_id: str,
app_id: str, app_id: str,
project_id: str, project_id: str,
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
) -> dict[str, Any]: ) -> dict[str, Any]:
attachments, generated_paths = self._prepare_memory_attachments(
user_id=user_id,
session_id=session_id,
app_id=app_id,
project_id=project_id,
messages=messages,
)
payload = { payload = {
"session_id": session_id, "session_id": session_id,
"app_id": app_id, "app_id": app_id,
"project_id": project_id, "project_id": project_id,
"messages": messages, "messages": messages,
} }
return { try:
backend = await self.backend_client.add_memory(payload)
for attachment in attachments:
self.repository.create_attachment(**attachment)
except Exception:
for path in generated_paths:
path.unlink(missing_ok=True)
_remove_empty_parents(path.parent, stop_at=self.config.storage_dir)
raise
return {"session_id": session_id, "backend": backend}
async def add_memory_with_uploads(
self,
*,
user_id: str,
session_id: str,
app_id: str,
project_id: str,
messages: list[dict[str, Any]],
upload_files: dict[str, UploadFile],
) -> dict[str, Any]:
messages, attachments, generated_paths = self._prepare_uploaded_memory_files(
user_id=user_id,
session_id=session_id,
app_id=app_id,
project_id=project_id,
messages=messages,
upload_files=upload_files,
)
payload = {
"session_id": session_id, "session_id": session_id,
"backend": await self.backend_client.add_memory(payload), "app_id": app_id,
"project_id": project_id,
"messages": messages,
} }
try:
backend = await self.backend_client.add_memory(payload)
for attachment in attachments:
self.repository.create_attachment(**attachment)
except Exception:
for path in generated_paths:
path.unlink(missing_ok=True)
_remove_empty_parents(path.parent, stop_at=self.config.storage_dir)
raise
return {"session_id": session_id, "backend": backend}
def _register_resource_attachment(
self,
resource: dict[str, Any],
*,
source: str = "resource_upload",
) -> None:
self.repository.create_attachment(
user_id=resource["user_id"],
app_id=resource["app_id"],
project_id=resource["project_id"],
session_id=resource["session_id"],
resource_id=resource["id"],
content_type=resource["content_type"],
name=resource["original_filename"] or resource["id"],
internal_uri=resource["uri"],
source=source,
sha256=resource["sha256"],
)
def _prepare_memory_attachments(
self,
*,
user_id: str,
session_id: str,
app_id: str,
project_id: str,
messages: list[dict[str, Any]],
) -> tuple[list[dict[str, Any]], list[Path]]:
attachments: list[dict[str, Any]] = []
generated_paths: list[Path] = []
try:
for message in messages:
content = message.get("content")
if not isinstance(content, list):
continue
for item in content:
if not isinstance(item, dict):
continue
uri = item.get("uri")
encoded = item.get("base64")
if not uri and not encoded:
continue
attachment_id = f"a_{uuid.uuid4().hex}"
name = _attachment_name(item, str(uri) if uri else None)
sha256: str | None = None
if uri:
internal_uri = str(uri)
source = "memory_add_uri"
else:
try:
data = base64.b64decode(str(encoded), validate=True)
except (binascii.Error, ValueError) as exc:
raise InvalidAttachment(
f"invalid base64 attachment: {name}"
) from exc
if len(data) > self.config.max_upload_bytes:
raise UploadTooLarge(
f"attachment exceeds max size of "
f"{self.config.max_upload_bytes} bytes"
)
sha256 = hashlib.sha256(data).hexdigest()
path = (
self.config.storage_dir
/ user_id
/ "memory_attachments"
/ sha256
/ name
)
if not path.exists():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
generated_paths.append(path)
internal_uri = path.resolve().as_uri()
source = "memory_add_base64"
attachments.append(
{
"id": attachment_id,
"user_id": user_id,
"app_id": app_id,
"project_id": project_id,
"session_id": session_id,
"resource_id": None,
"content_type": str(item.get("type") or "doc"),
"name": name,
"internal_uri": internal_uri,
"source": source,
"sha256": sha256,
}
)
except Exception:
for path in generated_paths:
path.unlink(missing_ok=True)
_remove_empty_parents(path.parent, stop_at=self.config.storage_dir)
raise
return attachments, generated_paths
def _prepare_uploaded_memory_files(
self,
*,
user_id: str,
session_id: str,
app_id: str,
project_id: str,
messages: list[dict[str, Any]],
upload_files: dict[str, UploadFile],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[Path]]:
attachments: list[dict[str, Any]] = []
generated_paths: list[Path] = []
used_upload_ids: set[str] = set()
try:
for message in messages:
content = message.get("content")
if not isinstance(content, list):
continue
for index, item in enumerate(content):
if not isinstance(item, dict) or "upload_id" not in item:
continue
upload_id = str(item.get("upload_id") or "").strip()
if not upload_id:
raise InvalidAttachment("upload_id must not be empty")
if upload_id in used_upload_ids:
raise InvalidAttachment(f"duplicate upload_id: {upload_id}")
file = upload_files.get(upload_id)
if file is None:
raise InvalidAttachment(
f"missing upload file for upload_id: {upload_id}"
)
used_upload_ids.add(upload_id)
content[index] = self._materialize_uploaded_content_item(
user_id=user_id,
session_id=session_id,
app_id=app_id,
project_id=project_id,
item=item,
file=file,
attachments=attachments,
generated_paths=generated_paths,
)
unused_upload_ids = sorted(set(upload_files) - used_upload_ids)
if unused_upload_ids:
raise InvalidAttachment(
f"unused upload file field: {unused_upload_ids[0]}"
)
except Exception:
for path in generated_paths:
path.unlink(missing_ok=True)
_remove_empty_parents(path.parent, stop_at=self.config.storage_dir)
raise
return messages, attachments, generated_paths
def _materialize_uploaded_content_item(
self,
*,
user_id: str,
session_id: str,
app_id: str,
project_id: str,
item: dict[str, Any],
file: UploadFile,
attachments: list[dict[str, Any]],
generated_paths: list[Path],
) -> dict[str, Any]:
name = _safe_filename(str(item.get("name") or file.filename or "upload.bin"))
mime_type = file.content_type or mimetypes.guess_type(name)[0]
if not _mime_allowed(mime_type, self.config.allowed_mime_types):
raise UnsupportedContentType(f"unsupported content type: {mime_type}")
content_type = normalize_content_type(
name,
mime_type,
str(item.get("type") or ""),
)
data, sha256, _size_bytes = _read_upload_bytes(
file,
self.config.max_upload_bytes,
)
path = self.config.storage_dir / user_id / "memory_attachments" / sha256 / name
if not path.exists():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
generated_paths.append(path)
content_item = {
key: value for key, value in item.items() if key not in {"upload_id", "uri"}
}
content_item["type"] = content_type
content_item["name"] = name
content_item["ext"] = Path(name).suffix.lstrip(".") or content_item.get("ext")
if content_type == "text":
content_item.pop("base64", None)
content_item["text"] = data.decode("utf-8", errors="replace")
else:
content_item.pop("text", None)
content_item["base64"] = base64.b64encode(data).decode("ascii")
attachments.append(
{
"id": f"a_{uuid.uuid4().hex}",
"user_id": user_id,
"app_id": app_id,
"project_id": project_id,
"session_id": session_id,
"resource_id": None,
"content_type": content_type,
"name": name,
"internal_uri": path.resolve().as_uri(),
"source": "memory_add_upload",
"sha256": sha256,
}
)
return content_item
async def flush_memory( async def flush_memory(
self, self,
@ -461,19 +900,29 @@ class MemoryGatewayService:
self, self,
*, *,
user_id: str, user_id: str,
agent_id: str | None,
query: str, query: str,
method: str,
top_k: int, top_k: int,
radius: float | None,
include_profile: bool,
enable_llm_rerank: bool,
app_id: str, app_id: str,
project_id: str, project_id: str,
filters: dict[str, Any] | None, filters: dict[str, Any] | None,
) -> dict[str, Any]: ) -> dict[str, Any]:
payload: dict[str, Any] = { payload: dict[str, Any] = {
"user_id": user_id,
"query": query, "query": query,
"method": method,
"top_k": top_k, "top_k": top_k,
"include_profile": include_profile,
"enable_llm_rerank": enable_llm_rerank,
"app_id": app_id, "app_id": app_id,
"project_id": project_id, "project_id": project_id,
} }
payload["agent_id" if agent_id else "user_id"] = agent_id or user_id
if radius is not None:
payload["radius"] = radius
if filters is not None: if filters is not None:
payload["filters"] = filters payload["filters"] = filters
return payload return payload
@ -487,18 +936,22 @@ class MemoryGatewayService:
user_id: str, user_id: str,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
data = response.get("data", {}) data = response.get("data", {})
raw_items: list[dict[str, Any]] = [] raw_items: list[tuple[str, dict[str, Any]]] = []
for key in ( memory_types = {
"episodes", "episodes": "episode",
"profiles", "profiles": "profile",
"agent_cases", "agent_cases": "agent_case",
"agent_skills", "agent_skills": "agent_skill",
"unprocessed_messages", "unprocessed_messages": "unprocessed_message",
): }
raw_items.extend(data.get(key, []) or []) for key, memory_type in memory_types.items():
raw_items.extend(
(memory_type, item) for item in (data.get(key, []) or [])
)
normalized = [] normalized = []
for raw in raw_items: attachment_cache: dict[str, list[dict[str, Any]]] = {}
for memory_type, raw in raw_items:
session_id = raw.get("session_id") session_id = raw.get("session_id")
resource = session_resource_map.get(session_id) resource = session_resource_map.get(session_id)
if resource is None and isinstance(session_id, str): if resource is None and isinstance(session_id, str):
@ -506,9 +959,21 @@ class MemoryGatewayService:
session_id, session_id,
user_id, user_id,
) )
attachments: list[dict[str, Any]] = []
if isinstance(session_id, str):
if session_id not in attachment_cache:
attachment_cache[session_id] = (
self.repository.list_attachments_for_session(
user_id,
session_id,
)
)
session_attachments = attachment_cache[session_id]
attachments = _matching_attachments(raw, session_attachments)
normalized.append( normalized.append(
{ {
"id": raw.get("id"), "id": raw.get("id"),
"memory_type": memory_type,
"session_id": session_id, "session_id": session_id,
"text": _display_text(raw), "text": _display_text(raw),
"score": raw.get("score"), "score": raw.get("score"),
@ -517,6 +982,7 @@ class MemoryGatewayService:
"resource_uri": ( "resource_uri": (
public_resource_uri(user_id, resource["id"]) if resource else None public_resource_uri(user_id, resource["id"]) if resource else None
), ),
"attachments": attachments,
"raw": raw, "raw": raw,
} }
) )
@ -623,6 +1089,72 @@ class MemoryGatewayService:
} }
def _combine_filters(
custom_filters: dict[str, Any] | None,
scope_filters: dict[str, Any] | None,
) -> dict[str, Any] | None:
if custom_filters is None:
return scope_filters
if scope_filters is None:
return custom_filters
return {"AND": [custom_filters, scope_filters]}
def _attachment_name(item: dict[str, Any], uri: str | None) -> str:
if item.get("name"):
return _safe_filename(str(item["name"]))
if uri:
parsed = urlparse(uri)
uri_name = Path(unquote(parsed.path)).name
if uri_name:
return _safe_filename(uri_name)
extension = str(item.get("ext") or "bin").lstrip(".") or "bin"
return f"attachment.{extension}"
def _matching_attachments(
raw: dict[str, Any],
attachments: list[dict[str, Any]],
) -> list[dict[str, Any]]:
strings = [value.casefold() for value in _raw_string_values(raw)]
matched: list[dict[str, Any]] = []
seen_uris: set[str] = set()
for attachment in attachments:
name = str(attachment["name"])
internal_uri = str(attachment["internal_uri"])
if internal_uri in seen_uris:
continue
if not any(name.casefold() in value for value in strings):
continue
seen_uris.add(internal_uri)
matched.append(
{
"type": attachment["content_type"],
"name": name,
"internal_uri": internal_uri,
}
)
return matched
def _raw_string_values(value: Any, key: str | None = None) -> list[str]:
if key is not None and key.casefold() == "base64":
return []
if isinstance(value, str):
return [value]
if isinstance(value, dict):
strings: list[str] = []
for item_key, item_value in value.items():
strings.extend(_raw_string_values(item_value, str(item_key)))
return strings
if isinstance(value, list):
strings = []
for item in value:
strings.extend(_raw_string_values(item))
return strings
return []
def _chunks(items: list[str], size: int) -> list[list[str]]: def _chunks(items: list[str], size: int) -> list[list[str]]:
if not items: if not items:
return [] return []

View File

@ -0,0 +1,57 @@
# Memory Attachment Path Mapping Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Persist attachment-to-session mappings for resource and direct memory ingestion, then return filename-matched real URIs from memory search results.
**Architecture:** Add one SQLite attachment table and repository methods. Register resource files directly, materialize base64 memory attachments under Gateway storage, and enrich normalized search results by matching attachment names against recursive raw string values.
**Tech Stack:** Python 3.10+, FastAPI, SQLite, Pydantic, pytest, httpx.
---
### Task 1: Attachment persistence
**Files:**
- Modify: `core/db.py`
- Modify: `core/repository.py`
- Modify: `tests/test_gateway.py`
- [x] Write failing tests proving attachment records can be created, listed by user/session, deduplicated, and soft-deleted with resources.
- [x] Run focused tests and verify failure because the table and methods do not exist.
- [x] Add `memory_attachments`, indexes, resource backfill SQL, and focused repository methods.
- [x] Run focused tests and verify they pass.
### Task 2: Register attachments during ingestion
**Files:**
- Modify: `core/api.py`
- Modify: `core/service.py`
- Modify: `tests/test_gateway.py`
- [x] Write failing tests for `/resources`, `/memories/add` URI items, and `/memories/add` base64 items.
- [x] Run focused tests and verify missing mappings and files.
- [x] Register resource mappings, pass authenticated `user_id` into add service, materialize base64 files, and persist successful add mappings.
- [x] Run focused tests and verify they pass.
### Task 3: Enrich search results
**Files:**
- Modify: `core/service.py`
- Modify: `tests/test_gateway.py`
- [x] Write failing tests for filename match, no match, base64-key exclusion, and cross-user isolation.
- [x] Run focused tests and verify `attachments` is absent.
- [x] Recursively collect raw strings excluding base64 and return deduplicated matching attachments.
- [x] Run focused tests and verify they pass.
### Task 4: Documentation and regression
**Files:**
- Modify: `README.md`
- Modify: `tests/test_command.md`
- [x] Document attachment persistence, historical backfill limits, matching behavior, and response shape.
- [x] Update the search response example with `attachments`.
- [x] Run `git diff --check`, compile checks, and the complete pytest suite.
- [x] Review the final diff for user isolation and unintended URI exposure outside search.

View File

@ -0,0 +1,118 @@
# Memory Search Upstream Options Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Extend `POST /memories/search` with all upstream search options while preserving Gateway authentication, scopes, resource isolation, tombstones, and overrides.
**Architecture:** Extend the existing Pydantic request model and pass the validated values through `MemoryGatewayService`. Keep scope orchestration intact, combine caller filters with scope-generated session filters using `AND`, and tag normalized results according to their upstream response array.
**Tech Stack:** Python 3.10+, FastAPI, Pydantic v2, pytest, pytest-asyncio, httpx ASGI transport.
---
### Task 1: Search request options and defaults
**Files:**
- Modify: `tests/test_gateway.py`
- Modify: `core/api.py`
- Modify: `core/service.py`
- [x] **Step 1: Write failing tests for defaults, custom options, and validation**
Add API tests that assert a default search sends `method="hybrid"`, `include_profile=true`, and `enable_llm_rerank=true`; a custom request forwards `agent_id`, `keyword`, `radius`, `top_k=-1`, and both false flags; and invalid `method`, `radius`, and `top_k=0` return HTTP 422.
- [x] **Step 2: Run tests and verify expected failures**
Run:
```bash
uv run pytest tests/test_gateway.py -k 'search_forwards_default_upstream_options or search_forwards_all_upstream_options or search_rejects_invalid_upstream_options' -q
```
Expected: assertions fail because the request model and service do not yet accept or forward the new fields.
- [x] **Step 3: Implement request fields and payload forwarding**
Extend `SearchMemoriesRequest` with:
```python
agent_id: str | None = Field(default=None, min_length=1)
method: Literal["keyword", "vector", "hybrid", "agentic"] = "hybrid"
radius: float | None = Field(default=None, ge=0, le=1)
include_profile: bool = True
enable_llm_rerank: bool = True
filters: dict[str, Any] | None = None
```
Validate `top_k` as `-1` or `1..100`, pass all values to the service, and make `_search_payload` select exactly one upstream owner key (`agent_id` when present, otherwise `user_id`).
- [x] **Step 4: Run focused tests and verify they pass**
Run the command from Step 2. Expected: all selected tests pass.
### Task 2: Filter composition and result memory types
**Files:**
- Modify: `tests/test_gateway.py`
- Modify: `core/service.py`
- [x] **Step 1: Write failing tests for filter composition and result types**
Add a resource-scope test asserting caller filters and `session_id in [...]` are combined as:
```python
{"AND": [caller_filters, {"session_id": {"in": [session_id]}}]}
```
Extend the fake backend to return all response arrays and assert normalized results have `memory_type` values `episode`, `profile`, `agent_case`, `agent_skill`, and `unprocessed_message`.
- [x] **Step 2: Run tests and verify expected failures**
Run:
```bash
uv run pytest tests/test_gateway.py -k 'search_combines_custom_and_scope_filters or search_labels_all_memory_types' -q
```
Expected: failures because caller filters are not composed and normalized results have no `memory_type`.
- [x] **Step 3: Implement composition and typed normalization**
Add a small `_combine_filters` helper that returns either condition directly, returns `None` when both are absent, or returns `{"AND": [custom, scope]}` when both exist. Iterate an explicit mapping from response array name to memory type in `_extract_results` and include the mapped value in every normalized result.
- [x] **Step 4: Run focused tests and verify they pass**
Run the command from Step 2. Expected: both tests pass.
### Task 3: Documentation and regression verification
**Files:**
- Modify: `README.md`
- Verify: `tests/test_gateway.py`
- Verify: `tests/test_memory_gateway_skill.py`
- [x] **Step 1: Update the Chinese API documentation**
Document `agent_id`, `method`, `radius`, `include_profile`, `enable_llm_rerank`, `filters`, the `top_k=-1` rule, filter composition, and the `memory_type` response field. Update the curl and JSON examples with the new defaults.
- [x] **Step 2: Run formatting and full tests**
Run:
```bash
git diff --check
uv run pytest -q
```
Expected: no whitespace errors and all tests pass.
- [x] **Step 3: Review the final diff**
Run:
```bash
git diff --stat
git diff -- core/api.py core/service.py tests/test_gateway.py README.md
```
Expected: changes are limited to the approved search compatibility scope and documentation.

View File

@ -0,0 +1,101 @@
# Memory 附件真实路径映射设计
## 目标
`/resources``/memories/add` 两种摄入方式都保存附件与 session 的映射。
`/memories/search` 返回结果时,根据结果 `session_id` 查询当前用户附件,并且只有
当附件完整文件名出现在结果 `raw` 的字符串字段中时,才返回该附件真实 URI。
## 数据模型
新增 SQLite 表 `memory_attachments`
- `id TEXT PRIMARY KEY`
- `user_id TEXT NOT NULL`
- `app_id TEXT NOT NULL DEFAULT 'default'`
- `project_id TEXT NOT NULL DEFAULT 'default'`
- `session_id TEXT NOT NULL`
- `resource_id TEXT`
- `content_type TEXT NOT NULL`
- `name TEXT NOT NULL`
- `internal_uri TEXT NOT NULL`
- `source TEXT NOT NULL`
- `sha256 TEXT`
- `created_at TIMESTAMP NOT NULL`
- `deleted_at TIMESTAMP`
`(user_id, session_id, internal_uri)` 建立唯一索引,避免幂等上传产生重复映射;
`(user_id, session_id, deleted_at)` 建立查询索引。
数据库初始化时,将现有未删除 `user_resources` 回填为附件映射。历史
`/memories/add` 请求没有保存在 Gateway 数据库中,因此无法自动回填。
## 摄入规则
### `/resources`
资源记录创建后,为保存的真实 `file://` URI 创建附件映射:
- `session_id` 使用 `resource:{user_id}:{resource_id}`
- `resource_id` 指向资源;
- `source``resource_upload`
- `content_type`、文件名、SHA256 复用资源元数据。
重复资源上传时确保已有资源对应的附件映射存在。
### `/memories/add`
API 将已鉴权的 `user_id` 一并传给 service。逐条检查 message 的 content item
- 只有字符串 content 或纯文本 item 时不创建附件;
-`uri` 时记录该 URI`source=memory_add_uri`
- 没有 `uri` 但有 `base64` 时,解码并保存到
`storage/{user_id}/memory_attachments/{attachment_id}/{safe_name}`,记录生成的
`file://` URI`source=memory_add_base64`
- 同时存在 `uri``base64` 时优先使用 `uri`,不重复落盘;
- 文件名优先使用 `name`,否则从 URI 路径或 `ext` 生成安全名称。
上游 add 调用失败时,删除本次 base64 生成的文件,不写入映射。调用成功后写入
附件映射。上游请求体保持原样,不修改现有 add 行为。
## 搜索匹配规则
对每条标准化搜索结果:
1. 根据已鉴权 `user_id` 和结果 `session_id` 查询未删除附件;
2. 递归遍历 `raw` 中 dict、list 的字符串值;
3. 跳过键名为 `base64` 的值,避免扫描大块编码数据;
4. 使用附件完整文件名做不区分大小写的子串匹配;
5. 仅命中的附件进入 `attachments`,按 `internal_uri` 去重;
6. 没有 session 或没有命中时返回 `attachments: []`
响应附件格式:
```json
{
"type": "image",
"name": "simple-multimodal-image.png",
"internal_uri": "file:///home/tom/memory-gateway/tests/simple-multimodal-image.png"
}
```
episode 是 session 级记忆,因此只能在同一 session 的附件中按文件名匹配,不能
证明具体附件是向量召回的直接来源。
## 删除与隔离
- 所有附件查询必须同时匹配 `user_id``session_id`
- 删除 `/resources` 时,对应附件映射设置 `deleted_at`
- 真实路径按用户明确要求直接出现在搜索结果中;
- 不改变资源列表和详情现有的 `resource://` 对外 URI。
## 测试
- 资源上传创建附件映射;
- 资源搜索仅在 raw 出现文件名时返回真实 URI
- raw 不含文件名时返回空附件数组;
- `/memories/add` 的 URI content 创建映射;
- `/memories/add` 的 base64 content 落盘并创建映射;
- 不扫描 raw 中的 base64 字段;
- 不返回其他用户同 session 的附件;
- 现有测试继续通过。

View File

@ -0,0 +1,145 @@
# Memory Search 上游参数增量兼容设计
## 目标
扩展 Memory Gateway 的 `POST /memories/search`,在保留现有用户鉴权、
`scope` 搜索编排、资源隔离、软删除和覆盖修改能力的前提下,支持上游
搜索接口的全部请求选项。
本次只修改 `/memories/search`,不新增 `/memories/get`,也不提供上游路径的
线协议兼容接口。
## 请求模型
保留现有字段:
- `user_id`:必填,始终用于 Gateway 用户鉴权和本地数据隔离。
- `user_key`:必填,用于 Gateway 用户鉴权。
- `conversation_id`:可选,供 `current_chat` scope 生成 session 过滤条件。
- `query`:必填。
- `scope`:保留 `current_chat``resources``all_user_memory`
- `app_id``project_id`:默认 `default`
新增或扩展字段:
- `agent_id`:可选。存在时,上游搜索使用 `agent_id`;不存在时使用
Gateway 鉴权用户的 `user_id`。请求中不会同时向上游发送两种 owner ID。
- `method`:支持 `keyword``vector``hybrid``agentic`,默认 `hybrid`
- `top_k`:支持 `-1``1..100`,保留 Gateway 默认值 `8`
- `radius`:可选,范围 `0..1`
- `include_profile`:布尔值,默认 `true`
- `enable_llm_rerank`:布尔值,默认 `true`
- `filters`:可选对象,支持上游开放字段过滤 DSL包括嵌套 `AND``OR`
`agent_id` 只改变上游记忆 owner不替代 Gateway 的 `user_id/user_key` 鉴权。
这可以防止调用者绕过 Gateway 用户体系,同时允许同一已认证用户查询被授权
使用的 agent memory。当前版本不新增 agent 权限表,因此仅校验 Gateway 用户
凭据,不声明 agent 的独立所有权关系。
## 搜索编排与过滤器合并
每一次上游 search 调用都必须透传:
- owner`agent_id``user_id`,二选一;
- `query`
- `method`
- `top_k`
- `radius`,仅在请求提供时发送;
- `include_profile`
- `enable_llm_rerank`
- `app_id`
- `project_id`
- 合并后的 `filters`
现有 scope 继续生成内部 session 条件:
- `current_chat``session_id = chat:{conversation_id}`
- `resources`:按批次生成 `session_id in [...]`
- `all_user_memory`:不生成 session 条件。
当请求同时提供自定义 `filters` 和 scope session 条件时,使用以下结构合并:
```json
{
"AND": [
{"自定义过滤条件": "..."},
{"session_id": "scope 生成的条件"}
]
}
```
仅存在其中一个条件时直接使用该条件;两者都不存在时不发送 `filters`
Gateway 不解析或重写自定义过滤 DSL 的内部字段,由上游执行完整校验。
`agent_id` 与所有 scope 均可组合。对于没有对应数据的组合,上游自然返回空数组;
Gateway 不额外禁止这些组合,以保持接口简单并完整透传搜索能力。
## 响应标准化
继续返回 Gateway 的统一结构:
```json
{
"results": []
}
```
每个结果新增 `memory_type`
| 上游数组 | `memory_type` |
|---|---|
| `episodes` | `episode` |
| `profiles` | `profile` |
| `agent_cases` | `agent_case` |
| `agent_skills` | `agent_skill` |
| `unprocessed_messages` | `unprocessed_message` |
其余字段保持现状:`id``session_id``text``score``source_scope`
`resource_id``resource_uri``raw`
profile 和 agent skill 等没有 `session_id` 的结果允许返回 `null`。资源映射只对
能匹配当前用户资源 session 的结果生效,不泄露其他用户的内部资源 URI。
所有类型的结果继续按现有顺序执行:
1. 合并各 scope 的结果;
2. 应用当前用户的 memory tombstone
3. 按 memory ID 应用当前用户的 active override
4. 返回统一结果。
## 错误处理
- 不合法的 `method``top_k``radius` 由 Gateway 请求模型返回 HTTP 422。
- 上游过滤 DSL 错误和其他 HTTP 错误继续由现有 client 行为向外传播。
- 不改变当前 `current_chat` 缺少 `conversation_id` 时跳过该 scope 的行为。
- 不为 `agent_id` 引入新的数据库表或权限模型。
## 代码改动
- `core/api.py`
- 扩展 `SearchMemoriesRequest`
- 将新增参数传给 service。
- `core/service.py`
- 扩展 `search_memories``_search_payload`
- 合并自定义 filters 与 scope filters。
- 标准化结果时增加 `memory_type`
- `tests/test_gateway.py`
- 验证默认参数透传。
- 验证全部自定义搜索选项透传。
- 验证 agent owner 与用户鉴权身份分离。
- 验证 filters 与 scope 条件使用 `AND` 合并。
- 验证五类结果的 `memory_type`
- `README.md`
- 更新 `/memories/search` 参数和响应说明。
## 验收标准
1. 未提供新字段时,上游收到 `method=hybrid``include_profile=true`
`enable_llm_rerank=true`
2. 所有上游搜索选项均能通过 Gateway 请求并原样传递。
3. `top_k=-1` 被接受,`top_k=0` 和范围外值被拒绝。
4. 自定义 filters 不会覆盖 scope 的资源或聊天 session 隔离条件。
5. 设置 `agent_id` 后,上游只收到 `agent_id`Gateway 仍使用
`user_id/user_key` 完成鉴权。
6. 每个搜索结果包含准确的 `memory_type`
7. 现有 tombstone、override、资源 URI 隔离测试继续通过。

View File

@ -136,6 +136,43 @@ $CLI flush-memory --session-id chat:c_456
`--messages` accepts either a JSON array string or a path to a JSON file. Always flush after all messages for the session have been added. `--messages` accepts either a JSON array string or a path to a JSON file. Always flush after all messages for the session have been added.
For local binary files that cannot be converted to base64 by the caller, use the
multipart API directly. Put an `upload_id` in the content item and send a file
field with the same name:
```bash
curl -X POST "$MEMORY_GATEWAY_BASE_URL/memories/add/multipart" \
-F user_id="$MEMORY_GATEWAY_USER_ID" \
-F user_key="$MEMORY_GATEWAY_USER_KEY" \
-F session_id=chat:c_456 \
-F app_id=default \
-F project_id=default \
-F 'messages=[
{
"sender_id": "u_123",
"role": "user",
"timestamp": 1781172177000,
"content": [
{"type": "text", "text": "Remember this image"},
{
"type": "image",
"upload_id": "image_1",
"name": "image.png",
"ext": "png"
}
]
}
]' \
-F 'image_1=@./image.png;type=image/png'
```
The multipart endpoint appends messages to the provided chat session. It stores
the uploaded file under Gateway storage, forwards text/base64 content to the
upstream memory service, and records an attachment mapping. Call `flush-memory`
afterward when the session should be extracted and indexed. This differs from
`upload-resource`, which creates an independent `resource:{user_id}:{resource_id}`
session and automatically performs add plus flush for resource searches.
### Override and Delete Memory ### Override and Delete Memory
Use IDs from a search result: Use IDs from a search result:
@ -188,6 +225,8 @@ Common content items:
``` ```
Prefer base64 for local binary files. A `file://` URI is only usable when upstream memory service can access the same filesystem path. Prefer base64 for local binary files. A `file://` URI is only usable when upstream memory service can access the same filesystem path.
If that shared path guarantee is not true, use `/memories/add/multipart`,
`upload-resource`, or `/resources/external`.
## Search Scopes ## Search Scopes

View File

@ -1,143 +1,126 @@
# Memory Gateway multimodal API test # Memory Gateway API curl examples
This file records a real end-to-end test through **Memory Gateway**, not direct upstream memory service calls. This file keeps only the concrete API curl shapes and short notes. Replace
`<USER_KEY>` with the key returned by `POST /users`.
Gateway URL used by curl: Base URL used in the live deployment test:
```text ```text
http://127.0.0.1:8010 http://127.0.0.1:8010
``` ```
Gateway upstream memory service: Test files:
```text ```text
http://10.6.80.123:1995 tests/simple-multimodal-image.png
tests/simple-tone.wav
``` ```
Test assets: ## 1. Health
```text
/home/tom/memory-gateway/tests/simple-multimodal-image.png
/home/tom/memory-gateway/tests/simple-tone.wav
```
Asset check:
```text
tests/simple-multimodal-image.png: PNG image data, 96 x 64, 8-bit/color RGB, non-interlaced
tests/simple-tone.wav: RIFF (little-endian) data, WAVE audio, Microsoft PCM, 16 bit, mono 8000 Hz
```
## Start Gateway
Command:
```bash ```bash
cd /home/tom/memory-gateway curl -sS http://127.0.0.1:8010/health
MEMORY_GATEWAY_BACKEND_BASE_URL=http://10.6.80.123:1995 \
MEMORY_GATEWAY_DB_PATH=/tmp/memory_gateway_curl.sqlite3 \
MEMORY_GATEWAY_STORAGE_DIR=/tmp/memory_gateway_curl_storage \
MEMORY_GATEWAY_HOST=127.0.0.1 \
MEMORY_GATEWAY_PORT=8010 \
.venv/bin/python main.py
``` ```
Observed startup: Expected shape:
```text
INFO: Started server process [771099]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8010 (Press CTRL+C to quit)
```
## 1. Create Gateway user
Request:
```bash
curl -sS --location 'http://127.0.0.1:8010/users' \
--header 'Content-Type: application/json' \
--data '{
"user_id": "gateway_user_20260611180257"
}'
```
Response:
```json ```json
{ {
"user_id": "gateway_user_20260611180257", "status": "ok",
"user_key": "uk_REDACTED", "api": {"status": "ok"},
"created_at": "2026-06-11T10:02:57.435437+00:00" "backend": {
"status": "ok",
"base_url": "http://0.0.0.0:1995",
"data": {"status": "ok"}
}
} }
``` ```
HTTP metadata: ## 2. Create user
```text ```bash
HTTP_STATUS:200 curl -sS -X POST http://127.0.0.1:8010/users \
TOTAL_TIME:0.022431 -H 'Content-Type: application/json' \
-d '{"user_id":"gateway_demo_user"}'
``` ```
## 2. Add text + audio(base64) + image(file) through Gateway Expected shape:
```json
{
"user_id": "gateway_demo_user",
"user_key": "uk_REDACTED",
"created_at": "2026-06-22T06:54:35.823262+00:00"
}
```
Use the returned `user_key` in later requests.
## 3. Add chat memory with multipart files
Use this when files belong to a chat/session message and the client should not
or cannot convert the files to base64.
`upload_id` rules:
- `upload_id` is defined by the caller.
- Gateway does not generate it.
- Gateway does not require a format such as `user_id_filetype_number`.
- It only needs to be non-empty, unique inside the request, and equal to the
multipart file field name.
- Good simple values are `image_1`, `image_2`, `audio_1`, `doc_1`.
In the `messages` JSON, `upload_id: "image_1"` points to this file field:
```bash
-F 'image_1=@tests/simple-multimodal-image.png;type=image/png'
```
Request: Request:
```bash ```bash
cd /home/tom/memory-gateway curl -sS -X POST http://127.0.0.1:8010/memories/add/multipart \
-F 'user_id=gateway_demo_user' \
USER_ID="gateway_user_20260611180257" -F 'user_key=<USER_KEY>' \
USER_KEY="uk_REDACTED" -F 'session_id=chat:gateway_demo_conversation' \
CONVERSATION_ID="gateway-multimodal-20260611180257" -F 'app_id=default' \
SESSION_ID="chat:${CONVERSATION_ID}" -F 'project_id=default' \
TIMESTAMP_MS="1781172177000" -F 'messages=[
AUDIO_BASE64="$(base64 -w0 tests/simple-tone.wav)"
curl -sS --location 'http://127.0.0.1:8010/memories/add' \
--header 'Content-Type: application/json' \
--data "{
\"user_id\": \"${USER_ID}\",
\"user_key\": \"${USER_KEY}\",
\"session_id\": \"${SESSION_ID}\",
\"app_id\": \"default\",
\"project_id\": \"default\",
\"messages\": [
{ {
\"sender_id\": \"${USER_ID}\", "sender_id": "gateway_demo_user",
\"role\": \"user\", "role": "user",
\"timestamp\": ${TIMESTAMP_MS}, "timestamp": 1782111275810,
\"content\": [ "content": [
{ {
\"type\": \"text\", "type": "text",
\"text\": \"请通过 Memory Gateway 同时记住这段文字、音频和图片:图片里有左红色方块、右蓝色圆形、底部绿色横条;音频是一段短促测试音。以后可能会问图片中各个物体的位置和颜色。\" "text": "请记住这次上传:图片里有左红色方块、右蓝色圆形、底部绿色横条;音频是一段短促测试音。"
}, },
{ {
\"type\": \"audio\", "type": "image",
\"base64\": \"${AUDIO_BASE64}\", "upload_id": "image_1",
\"ext\": \"wav\", "name": "simple-multimodal-image.png",
\"name\": \"simple-tone.wav\" "ext": "png"
}, },
{ {
\"type\": \"image\", "type": "audio",
\"uri\": \"file:///home/tom/memory-gateway/tests/simple-multimodal-image.png\", "upload_id": "audio_1",
\"ext\": \"png\", "name": "simple-tone.wav",
\"name\": \"simple-multimodal-image.png\" "ext": "wav"
} }
] ]
} }
] ]' \
}" -F 'image_1=@tests/simple-multimodal-image.png;type=image/png' \
-F 'audio_1=@tests/simple-tone.wav;type=audio/wav'
``` ```
Response: Expected shape:
```json ```json
{ {
"session_id": "chat:gateway-multimodal-20260611180257", "session_id": "chat:gateway_demo_conversation",
"backend": { "backend": {
"request_id": "c9e24b8d27ee4ad08a8df70273336637", "request_id": "0d6451f4077040e4af207cc6b034ea34",
"data": { "data": {
"message_count": 1, "message_count": 1,
"status": "accumulated" "status": "accumulated"
@ -146,66 +129,56 @@ Response:
} }
``` ```
HTTP metadata: Gateway stores the uploaded files and forwards upstream-compatible `base64` or
`text` content. The client does not send `file://` and does not send base64.
```text Common errors:
HTTP_STATUS:200
TOTAL_TIME:1.552665
```
## 3. Flush through Gateway - Missing file field for an `upload_id`: `422`
- Duplicate `upload_id`: `422`
- Extra uploaded file field not referenced by `messages`: `422`
- Unsupported MIME type: `415`
- File too large: `413`
Request: ## 4. Flush chat session
`/memories/add/multipart` only appends messages. Call flush when the session
should be extracted and indexed.
```bash ```bash
curl -sS --location 'http://127.0.0.1:8010/memories/flush' \ curl -sS -X POST http://127.0.0.1:8010/memories/flush \
--header 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
--data '{ -d '{
"user_id": "gateway_user_20260611180257", "user_id": "gateway_demo_user",
"user_key": "uk_REDACTED", "user_key": "<USER_KEY>",
"session_id": "chat:gateway-multimodal-20260611180257", "session_id": "chat:gateway_demo_conversation",
"app_id": "default", "app_id": "default",
"project_id": "default" "project_id": "default"
}' }'
``` ```
Response: Expected shape:
```json ```json
{ {
"session_id": "chat:gateway-multimodal-20260611180257", "session_id": "chat:gateway_demo_conversation",
"backend": { "backend": {
"request_id": "8eb7d5db2d3b43f4999f445aabb813b1", "request_id": "4df5415115a34f109c564abd2f9012c6",
"data": { "data": {"status": "extracted"}
"status": "extracted"
}
} }
} }
``` ```
HTTP metadata: ## 5. Search chat session
```text
HTTP_STATUS:200
TOTAL_TIME:2.135721
```
## 4. Search through Gateway
upstream memory service indexing can lag briefly after `flush`, so this test waited about 2 seconds before searching.
Request:
```bash ```bash
sleep 2 curl -sS -X POST http://127.0.0.1:8010/memories/search \
-H 'Content-Type: application/json' \
curl -sS --location 'http://127.0.0.1:8010/memories/search' \ -d '{
--header 'Content-Type: application/json' \ "user_id": "gateway_demo_user",
--data '{ "user_key": "<USER_KEY>",
"user_id": "gateway_user_20260611180257", "conversation_id": "gateway_demo_conversation",
"user_key": "uk_REDACTED", "query": "图片里的蓝色圆形在哪里?底部是什么颜色的横条?",
"conversation_id": "gateway-multimodal-20260611180257",
"query": "图片里的蓝色圆形在哪里?音频是什么?",
"scope": ["current_chat"], "scope": ["current_chat"],
"top_k": 5, "top_k": 5,
"app_id": "default", "app_id": "default",
@ -213,241 +186,96 @@ curl -sS --location 'http://127.0.0.1:8010/memories/search' \
}' }'
``` ```
Response: Expected result excerpt:
```json ```json
{ {
"results": [ "results": [
{ {
"id": "gateway_user_20260611180257_ep_20260611_00000001", "session_id": "chat:gateway_demo_conversation",
"session_id": "chat:gateway-multimodal-20260611180257",
"text": "On June 11, 2026 at 10:02 AM UTC, user gateway_user_20260611180257 uploaded a multimodal memory package via Memory Gateway. The package included an image file named simple-multimodal-image.png and a short test audio clip. The image displayed three geometric shapes on a light gray background: a solid red square in the upper-left, a solid blue circle in the upper-right (horizontally aligned with the square), and a long, thin green horizontal rectangle spanning the bottom below both shapes. The user instructed the system to retain these details, anticipating future queries regarding the objects' positions and colors.",
"score": 0.6069304347038269,
"source_scope": "current_chat", "source_scope": "current_chat",
"resource_id": null, "text": "The image contained a red square, a blue circle, and a green horizontal rectangle.",
"resource_uri": null, "attachments": [
"raw": {
"id": "gateway_user_20260611180257_ep_20260611_00000001",
"user_id": "gateway_user_20260611180257",
"app_id": "default",
"project_id": "default",
"session_id": "chat:gateway-multimodal-20260611180257",
"timestamp": "2026-06-11T10:02:57Z",
"sender_ids": [
"gateway_user_20260611180257"
],
"summary": "On June 11, 2026 at 10:02 AM UTC, user gateway_user_20260611180257 uploaded a multimodal memory package via Memory Gateway. The package included an image file named simple-multimodal-image.png and a s",
"subject": "gateway_user_20260611180257 Multimodal Memory Upload June 11, 2026",
"episode": "On June 11, 2026 at 10:02 AM UTC, user gateway_user_20260611180257 uploaded a multimodal memory package via Memory Gateway. The package included an image file named simple-multimodal-image.png and a short test audio clip. The image displayed three geometric shapes on a light gray background: a solid red square in the upper-left, a solid blue circle in the upper-right (horizontally aligned with the square), and a long, thin green horizontal rectangle spanning the bottom below both shapes. The user instructed the system to retain these details, anticipating future queries regarding the objects' positions and colors.",
"type": "Conversation",
"score": 0.6069304347038269,
"atomic_facts": [
{ {
"id": "gateway_user_20260611180257_af_20260611_00000004", "type": "image",
"content": "gateway_user_20260611180257 stated that questions about the positions and colors of the objects in the image might be asked in the future.", "name": "simple-multimodal-image.png",
"score": 0.6069304347038269 "internal_uri": "file:///home/tom/memory-gateway/data/storage/..."
},
{
"type": "audio",
"name": "simple-tone.wav",
"internal_uri": "file:///home/tom/memory-gateway/data/storage/..."
} }
] ]
} }
}
] ]
} }
``` ```
HTTP metadata: ## 6. Upload an independent resource
```text Use `/resources` when the file is an independent resource, not just an
HTTP_STATUS:200 attachment inside one chat message.
TOTAL_TIME:0.064128
```
# Other Memory Gateway API tests
The following calls used a temporary Gateway database and storage directory. All requests target Memory Gateway at `http://127.0.0.1:8010`.
## 5. Health
Request:
```bash ```bash
curl -sS --location 'http://127.0.0.1:8010/health' curl -sS -X POST http://127.0.0.1:8010/resources \
-F 'user_id=gateway_demo_user' \
-F 'user_key=<USER_KEY>' \
-F 'app_id=default' \
-F 'project_id=default' \
-F 'title=Gateway demo image resource' \
-F 'description=Demo upload for simple multimodal image' \
-F 'file=@tests/simple-multimodal-image.png;type=image/png'
``` ```
Response: Expected shape:
```json ```json
{ {
"status": "ok", "resource_id": "r_1678eacf3e8c49f9a8863454c5b35e68",
"api": {"status": "ok"}, "session_id": "resource:gateway_demo_user:r_1678eacf3e8c49f9a8863454c5b35e68",
"backend": { "uri": "resource://gateway_demo_user/r_1678eacf3e8c49f9a8863454c5b35e68",
"status": "ok",
"base_url": "http://10.6.80.123:1995",
"data": {"status": "ok"}
}
}
```
```text
HTTP_STATUS:200
TOTAL_TIME:0.034914
```
## 6. Invalid credentials
Request:
```bash
curl -sS --location \
'http://127.0.0.1:8010/resources?user_id=other_api_20260612095541&user_key=wrong-key'
```
Response:
```json
{"detail":"invalid user credentials"}
```
```text
HTTP_STATUS:401
TOTAL_TIME:0.001447
```
## 7. Upload resource
The temporary test user was created with:
```bash
curl -sS --location 'http://127.0.0.1:8010/users' \
--header 'Content-Type: application/json' \
--data '{"user_id":"other_api_20260612095541"}'
```
User response:
```json
{
"user_id": "other_api_20260612095541",
"user_key": "uk_REDACTED",
"created_at": "2026-06-12T01:55:41.448076+00:00"
}
```
Upload request:
```bash
cd /home/tom/memory-gateway
curl -sS --location 'http://127.0.0.1:8010/resources' \
--form 'user_id=other_api_20260612095541' \
--form 'user_key=uk_REDACTED' \
--form 'app_id=default' \
--form 'project_id=default' \
--form 'title=Gateway API image resource' \
--form 'description=Resource lifecycle test through Memory Gateway' \
--form 'file=@tests/simple-multimodal-image.png;type=image/png'
```
Response:
```json
{
"resource_id": "r_2700e435f72a49e6a7f736d17f8c7ac7",
"session_id": "resource:other_api_20260612095541:r_2700e435f72a49e6a7f736d17f8c7ac7",
"uri": "resource://other_api_20260612095541/r_2700e435f72a49e6a7f736d17f8c7ac7",
"status": "extracted" "status": "extracted"
} }
``` ```
```text Unlike `/memories/add/multipart`, `/resources` automatically calls upstream add
HTTP_STATUS:200 and flush.
TOTAL_TIME:4.700296
```
## 8. List resources ## 7. List resources
Request:
```bash ```bash
curl -sS --location \ curl -sS \
'http://127.0.0.1:8010/resources?user_id=other_api_20260612095541&user_key=uk_REDACTED' 'http://127.0.0.1:8010/resources?user_id=gateway_demo_user&user_key=<USER_KEY>'
``` ```
Response: Expected shape:
```json ```json
{ {
"resources": [ "resources": [
{ {
"resource_id": "r_2700e435f72a49e6a7f736d17f8c7ac7", "resource_id": "r_1678eacf3e8c49f9a8863454c5b35e68",
"user_id": "other_api_20260612095541",
"filename": "simple-multimodal-image.png", "filename": "simple-multimodal-image.png",
"content_type": "image", "content_type": "image",
"mime_type": "image/png", "mime_type": "image/png",
"uri": "resource://other_api_20260612095541/r_2700e435f72a49e6a7f736d17f8c7ac7", "uri": "resource://gateway_demo_user/r_1678eacf3e8c49f9a8863454c5b35e68",
"session_id": "resource:other_api_20260612095541:r_2700e435f72a49e6a7f736d17f8c7ac7", "session_id": "resource:gateway_demo_user:r_1678eacf3e8c49f9a8863454c5b35e68",
"status": "extracted", "status": "extracted"
"title": "Gateway API image resource",
"description": "Resource lifecycle test through Memory Gateway",
"created_at": "2026-06-12T01:55:41.527716+00:00",
"updated_at": "2026-06-12T01:55:46.204082+00:00"
} }
] ]
} }
``` ```
```text ## 8. Search resources
HTTP_STATUS:200
TOTAL_TIME:0.001785
```
## 9. Resource detail
Request:
```bash ```bash
curl -sS --location \ curl -sS -X POST http://127.0.0.1:8010/memories/search \
'http://127.0.0.1:8010/resources/r_2700e435f72a49e6a7f736d17f8c7ac7?user_id=other_api_20260612095541&user_key=uk_REDACTED' -H 'Content-Type: application/json' \
``` -d '{
"user_id": "gateway_demo_user",
Response: "user_key": "<USER_KEY>",
"query": "这张资源图片里有哪些几何图形和颜色?",
```json
{
"resources": [
{
"resource_id": "r_2700e435f72a49e6a7f736d17f8c7ac7",
"user_id": "other_api_20260612095541",
"filename": "simple-multimodal-image.png",
"content_type": "image",
"mime_type": "image/png",
"uri": "resource://other_api_20260612095541/r_2700e435f72a49e6a7f736d17f8c7ac7",
"session_id": "resource:other_api_20260612095541:r_2700e435f72a49e6a7f736d17f8c7ac7",
"status": "extracted",
"title": "Gateway API image resource",
"description": "Resource lifecycle test through Memory Gateway",
"created_at": "2026-06-12T01:55:41.527716+00:00",
"updated_at": "2026-06-12T01:55:46.204082+00:00"
}
]
}
```
```text
HTTP_STATUS:200
TOTAL_TIME:0.001634
```
## 10. Search resource memory
Request:
```bash
curl -sS --location 'http://127.0.0.1:8010/memories/search' \
--header 'Content-Type: application/json' \
--data '{
"user_id": "other_api_20260612095541",
"user_key": "uk_REDACTED",
"query": "图片中有哪些颜色和形状?",
"scope": ["resources"], "scope": ["resources"],
"top_k": 5, "top_k": 5,
"app_id": "default", "app_id": "default",
@ -455,251 +283,43 @@ curl -sS --location 'http://127.0.0.1:8010/memories/search' \
}' }'
``` ```
Response: Expected result excerpt:
```json ```json
{ {
"results": [ "results": [
{ {
"id": "other_api_20260612095541_ep_20260612_00000001",
"session_id": "resource:other_api_20260612095541:r_2700e435f72a49e6a7f736d17f8c7ac7",
"text": "On June 12, 2026 at 01:55 AM UTC, the user other_api_20260612095541 uploaded an image titled 'simple-multimodal-image.png' for visual analysis. The image displayed three distinct geometric shapes on a plain, light gray background. The composition included a solid red square in the upper-left portion, a solid blue circle in the upper-right portion, and a long, thin, horizontal green rectangle situated below both shapes. The red square and blue circle were roughly aligned horizontally, while the green rectangle spanned a width greater than either of the upper shapes.",
"score": 0.6418947577476501,
"source_scope": "resources", "source_scope": "resources",
"resource_id": "r_2700e435f72a49e6a7f736d17f8c7ac7", "resource_id": "r_1678eacf3e8c49f9a8863454c5b35e68",
"resource_uri": "resource://other_api_20260612095541/r_2700e435f72a49e6a7f736d17f8c7ac7", "resource_uri": "resource://gateway_demo_user/r_1678eacf3e8c49f9a8863454c5b35e68",
"raw": { "text": "The image displayed a red square, a blue circle, and a green rectangle.",
"id": "other_api_20260612095541_ep_20260612_00000001", "attachments": [
"user_id": "other_api_20260612095541",
"app_id": "default",
"project_id": "default",
"session_id": "resource:other_api_20260612095541:r_2700e435f72a49e6a7f736d17f8c7ac7",
"timestamp": "2026-06-12T01:55:41.541000Z",
"sender_ids": ["other_api_20260612095541"],
"summary": "On June 12, 2026 at 01:55 AM UTC, the user other_api_20260612095541 uploaded an image titled 'simple-multimodal-image.png' for visual analysis. The image displayed three distinct geometric shapes on a",
"subject": "Visual Analysis of Geometric Shapes Uploaded by other_api_20260612095541 on June 12, 2026",
"episode": "On June 12, 2026 at 01:55 AM UTC, the user other_api_20260612095541 uploaded an image titled 'simple-multimodal-image.png' for visual analysis. The image displayed three distinct geometric shapes on a plain, light gray background. The composition included a solid red square in the upper-left portion, a solid blue circle in the upper-right portion, and a long, thin, horizontal green rectangle situated below both shapes. The red square and blue circle were roughly aligned horizontally, while the green rectangle spanned a width greater than either of the upper shapes.",
"type": "Conversation",
"score": 0.6418947577476501,
"atomic_facts": [
{ {
"id": "other_api_20260612095541_af_20260612_00000001", "type": "image",
"content": "The image displays three distinct geometric shapes on a plain, light gray background.", "name": "simple-multimodal-image.png",
"score": 0.6418947577476501 "internal_uri": "file:///home/tom/memory-gateway/data/storage/..."
} }
] ]
} }
}
] ]
} }
``` ```
```text ## Multipart vs resources
HTTP_STATUS:200
TOTAL_TIME:0.176981
```
## 11. Override memory Use `/memories/add/multipart` when the upload belongs to a chat/session message:
Request: - caller supplies `session_id`, usually `chat:{conversation_id}`;
- caller defines `upload_id` values in `messages`;
- caller uploads files as form fields with names matching `upload_id`;
- Gateway only calls upstream add;
- caller should call `/memories/flush`;
- search normally uses `current_chat` or `all_user_memory`.
```bash Use `/resources` when the upload is an independent resource:
curl -sS --location --request PATCH \
'http://127.0.0.1:8010/memories/other_api_20260612095541_ep_20260612_00000001' \
--header 'Content-Type: application/json' \
--data '{
"user_id": "other_api_20260612095541",
"user_key": "uk_REDACTED",
"session_id": "resource:other_api_20260612095541:r_2700e435f72a49e6a7f736d17f8c7ac7",
"override_text": "OVERRIDE: 图片左侧是红色方块,右侧是蓝色圆形,底部是绿色横条。"
}'
```
Response: - Gateway creates `resource_id`;
- Gateway creates `session_id = resource:{user_id}:{resource_id}`;
```json - Gateway writes `user_resources`;
{ - Gateway automatically calls upstream add and flush;
"memory_id": "other_api_20260612095541_ep_20260612_00000001", - search normally uses `resources`.
"override_id": "o_328f03b40b164c4896640fd2567042cb",
"status": "active"
}
```
```text
HTTP_STATUS:200
TOTAL_TIME:0.007037
```
The next search returned the overridden text:
Request:
```bash
curl -sS --location 'http://127.0.0.1:8010/memories/search' \
--header 'Content-Type: application/json' \
--data '{
"user_id": "other_api_20260612095541",
"user_key": "uk_REDACTED",
"query": "图片中有哪些颜色和形状?",
"scope": ["resources"],
"top_k": 5,
"app_id": "default",
"project_id": "default"
}'
```
```json
{
"results": [
{
"id": "other_api_20260612095541_ep_20260612_00000001",
"session_id": "resource:other_api_20260612095541:r_2700e435f72a49e6a7f736d17f8c7ac7",
"text": "OVERRIDE: 图片左侧是红色方块,右侧是蓝色圆形,底部是绿色横条。",
"score": 0.6418947577476501,
"source_scope": "resources",
"resource_id": "r_2700e435f72a49e6a7f736d17f8c7ac7",
"resource_uri": "resource://other_api_20260612095541/r_2700e435f72a49e6a7f736d17f8c7ac7",
"raw": {
"id": "other_api_20260612095541_ep_20260612_00000001",
"user_id": "other_api_20260612095541",
"app_id": "default",
"project_id": "default",
"session_id": "resource:other_api_20260612095541:r_2700e435f72a49e6a7f736d17f8c7ac7",
"timestamp": "2026-06-12T01:55:41.541000Z",
"sender_ids": ["other_api_20260612095541"],
"summary": "On June 12, 2026 at 01:55 AM UTC, the user other_api_20260612095541 uploaded an image titled 'simple-multimodal-image.png' for visual analysis. The image displayed three distinct geometric shapes on a",
"subject": "Visual Analysis of Geometric Shapes Uploaded by other_api_20260612095541 on June 12, 2026",
"episode": "On June 12, 2026 at 01:55 AM UTC, the user other_api_20260612095541 uploaded an image titled 'simple-multimodal-image.png' for visual analysis. The image displayed three distinct geometric shapes on a plain, light gray background. The composition included a solid red square in the upper-left portion, a solid blue circle in the upper-right portion, and a long, thin, horizontal green rectangle situated below both shapes. The red square and blue circle were roughly aligned horizontally, while the green rectangle spanned a width greater than either of the upper shapes.",
"type": "Conversation",
"score": 0.6418947577476501,
"atomic_facts": [
{
"id": "other_api_20260612095541_af_20260612_00000001",
"content": "The image displays three distinct geometric shapes on a plain, light gray background.",
"score": 0.6418947577476501
}
]
},
"override_id": "o_328f03b40b164c4896640fd2567042cb"
}
]
}
```
```text
HTTP_STATUS:200
TOTAL_TIME:0.055485
```
## 12. Delete memory with tombstone
Request:
```bash
curl -sS --location --request DELETE \
'http://127.0.0.1:8010/memories/other_api_20260612095541_ep_20260612_00000001' \
--header 'Content-Type: application/json' \
--data '{
"user_id": "other_api_20260612095541",
"user_key": "uk_REDACTED",
"session_id": "resource:other_api_20260612095541:r_2700e435f72a49e6a7f736d17f8c7ac7",
"reason": "Gateway API tombstone test"
}'
```
Response:
```json
{
"memory_id": "other_api_20260612095541_ep_20260612_00000001",
"tombstone_id": "t_2cba49bf3b6641ea96865612deebc036",
"status": "deleted"
}
```
```text
HTTP_STATUS:200
TOTAL_TIME:0.006502
```
Repeating the resource search after creating the tombstone:
```bash
curl -sS --location 'http://127.0.0.1:8010/memories/search' \
--header 'Content-Type: application/json' \
--data '{
"user_id": "other_api_20260612095541",
"user_key": "uk_REDACTED",
"query": "图片中有哪些颜色和形状?",
"scope": ["resources"],
"top_k": 5,
"app_id": "default",
"project_id": "default"
}'
```
```json
{"results":[]}
```
```text
HTTP_STATUS:200
TOTAL_TIME:0.067841
```
## 13. Delete resource
Request:
```bash
curl -sS --location --request DELETE \
'http://127.0.0.1:8010/resources/r_2700e435f72a49e6a7f736d17f8c7ac7?user_id=other_api_20260612095541&user_key=uk_REDACTED'
```
Response:
```json
{
"resource_id": "r_2700e435f72a49e6a7f736d17f8c7ac7",
"session_id": "resource:other_api_20260612095541:r_2700e435f72a49e6a7f736d17f8c7ac7",
"uri": "resource://other_api_20260612095541/r_2700e435f72a49e6a7f736d17f8c7ac7",
"status": "deleted"
}
```
```text
HTTP_STATUS:200
TOTAL_TIME:0.014089
```
List after deletion:
```bash
curl -sS --location \
'http://127.0.0.1:8010/resources?user_id=other_api_20260612095541&user_key=uk_REDACTED'
```
```json
{"resources":[]}
```
```text
HTTP_STATUS:200
TOTAL_TIME:0.001226
```
Detail after deletion:
```bash
curl -sS --location \
'http://127.0.0.1:8010/resources/r_2700e435f72a49e6a7f736d17f8c7ac7?user_id=other_api_20260612095541&user_key=uk_REDACTED'
```
```json
{"resources":[]}
```
```text
HTTP_STATUS:200
TOTAL_TIME:0.001223
```

View File

@ -21,6 +21,7 @@ class FakeBackendClient:
def __init__( def __init__(
self, self,
search_results: list[dict[str, Any]] | None = None, search_results: list[dict[str, Any]] | None = None,
search_data: dict[str, list[dict[str, Any]]] | None = None,
health_error: Exception | None = None, health_error: Exception | None = None,
add_failures: int = 0, add_failures: int = 0,
flush_failures: int = 0, flush_failures: int = 0,
@ -29,6 +30,7 @@ class FakeBackendClient:
self.flush_calls: list[dict[str, str]] = [] self.flush_calls: list[dict[str, str]] = []
self.search_calls: list[dict[str, Any]] = [] self.search_calls: list[dict[str, Any]] = []
self.search_results = search_results or [] self.search_results = search_results or []
self.search_data = search_data
self.health_error = health_error self.health_error = health_error
self.add_failures = add_failures self.add_failures = add_failures
self.flush_failures = flush_failures self.flush_failures = flush_failures
@ -56,7 +58,8 @@ class FakeBackendClient:
async def search_memory(self, payload: dict[str, Any]) -> dict[str, Any]: async def search_memory(self, payload: dict[str, Any]) -> dict[str, Any]:
self.search_calls.append(payload) self.search_calls.append(payload)
return {"request_id": "search", "data": {"episodes": self.search_results}} data = self.search_data or {"episodes": self.search_results}
return {"request_id": "search", "data": data}
async def health_check(self) -> dict[str, Any]: async def health_check(self) -> dict[str, Any]:
if self.health_error is not None: if self.health_error is not None:
@ -115,6 +118,75 @@ def create_test_resource(
) )
def test_attachment_repository_deduplicates_and_lists_by_user_session(
repo: MemoryRepository,
) -> None:
values = {
"user_id": "u_123",
"app_id": "default",
"project_id": "default",
"session_id": "chat:c_1",
"resource_id": None,
"content_type": "image",
"name": "picture.png",
"internal_uri": "file:///private/picture.png",
"source": "memory_add_uri",
"sha256": None,
}
first = repo.create_attachment(**values)
second = repo.create_attachment(**values)
assert second["id"] == first["id"]
assert repo.list_attachments_for_session("u_123", "chat:c_1") == [first]
assert repo.list_attachments_for_session("other", "chat:c_1") == []
def test_soft_delete_resource_also_soft_deletes_attachments(
repo: MemoryRepository,
) -> None:
create_test_resource(repo, resource_id="r_1", user_id="u_123")
repo.create_attachment(
user_id="u_123",
app_id="default",
project_id="default",
session_id="resource:u_123:r_1",
resource_id="r_1",
content_type="text",
name="a.txt",
internal_uri="file:///private/a.txt",
source="resource_upload",
sha256="sha-r_1",
)
repo.soft_delete_resource("r_1", "u_123")
assert repo.list_attachments_for_session("u_123", "resource:u_123:r_1") == []
def test_soft_delete_resource_does_not_delete_other_users_attachments(
repo: MemoryRepository,
) -> None:
create_test_resource(repo, resource_id="r_1", user_id="alice")
repo.create_attachment(
user_id="alice",
app_id="default",
project_id="default",
session_id="resource:alice:r_1",
resource_id="r_1",
content_type="text",
name="a.txt",
internal_uri="file:///private/a.txt",
source="resource_upload",
sha256="sha-r_1",
)
repo.soft_delete_resource("r_1", "bob")
attachments = repo.list_attachments_for_session("alice", "resource:alice:r_1")
assert len(attachments) == 1
async def create_user(client: httpx.AsyncClient, user_id: str = "u_123") -> str: async def create_user(client: httpx.AsyncClient, user_id: str = "u_123") -> str:
response = await client.post("/users", json={"user_id": user_id}) response = await client.post("/users", json={"user_id": user_id})
assert response.status_code == 200, response.text assert response.status_code == 200, response.text
@ -343,6 +415,86 @@ async def test_upload_binary_resource_sends_base64_content_to_backend(
assert "uri" not in content assert "uri" not in content
@pytest.mark.asyncio
async def test_upload_resource_creates_attachment_mapping(
config: GatewayConfig,
repo: MemoryRepository,
) -> None:
backend = FakeBackendClient()
async with app_client(config, backend) as client:
user_key = await create_user(client)
response = await client.post(
"/resources",
data={"user_id": "u_123", "user_key": user_key},
files={"file": ("picture.png", b"png bytes", "image/png")},
)
assert response.status_code == 200, response.text
session_id = response.json()["session_id"]
attachments = repo.list_attachments_for_session("u_123", session_id)
assert len(attachments) == 1
assert attachments[0]["resource_id"] == response.json()["resource_id"]
assert attachments[0]["content_type"] == "image"
assert attachments[0]["name"] == "picture.png"
assert attachments[0]["internal_uri"].startswith("file://")
assert attachments[0]["source"] == "resource_upload"
@pytest.mark.asyncio
async def test_register_external_resource_does_not_copy_file_and_uses_ingest_uri(
config: GatewayConfig,
repo: MemoryRepository,
) -> None:
backend = FakeBackendClient()
async with app_client(config, backend) as client:
user_key = await create_user(client)
response = await client.post(
"/resources/external",
json={
"user_id": "u_123",
"user_key": user_key,
"app_id": "default",
"project_id": "default",
"filename": "chart.png",
"mime_type": "image/png",
"content_type": "image",
"size_bytes": 9,
"sha256": "sha-chart",
"source_uri": "minio://beaver-user-files/users/u_123/outputs/chart.png",
"ingest_uri": "http://minio.local/presigned/chart.png",
"title": "Chart",
"description": "Generated chart",
},
)
assert response.status_code == 200, response.text
body = response.json()
resource_id = body["resource_id"]
assert body["session_id"] == f"resource:u_123:{resource_id}"
assert body["uri"] == f"resource://u_123/{resource_id}"
assert body["status"] == "extracted"
assert not config.storage_dir.exists()
resource = repo.get_resource(resource_id)
assert resource is not None
assert resource["uri"] == "minio://beaver-user-files/users/u_123/outputs/chart.png"
assert resource["uri_public"] == 0
assert resource["sha256"] == "sha-chart"
assert resource["size_bytes"] == 9
content = backend.add_calls[0]["messages"][0]["content"][0]
assert content["type"] == "image"
assert content["uri"] == "http://minio.local/presigned/chart.png"
assert content["name"] == "chart.png"
assert content["extras"] == {"resource_id": resource_id, "source": "external_resource"}
assert "base64" not in content
attachments = repo.list_attachments_for_session("u_123", body["session_id"])
assert len(attachments) == 1
assert attachments[0]["internal_uri"] == "minio://beaver-user-files/users/u_123/outputs/chart.png"
assert attachments[0]["source"] == "external_resource"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_upload_resource_uses_current_timestamp( async def test_upload_resource_uses_current_timestamp(
config: GatewayConfig, config: GatewayConfig,
@ -607,6 +759,293 @@ async def test_add_memory_forwards_multimodal_payload_to_backend(
] ]
@pytest.mark.asyncio
async def test_add_memory_propagates_backend_http_error(
config: GatewayConfig,
) -> None:
backend = FakeBackendClient()
request = httpx.Request("POST", "http://backend.test/api/v1/memory/add")
response = httpx.Response(
415,
request=request,
json={"error": {"message": "LibreOffice conversion failed"}},
)
async def fail_add_memory(payload: dict[str, Any]) -> dict[str, Any]:
backend.add_calls.append(payload)
raise httpx.HTTPStatusError(
"Client error '415 Unsupported Media Type'",
request=request,
response=response,
)
backend.add_memory = fail_add_memory # type: ignore[method-assign]
async with app_client(config, backend) as client:
user_key = await create_user(client)
gateway_response = await client.post(
"/memories/add",
json={
"user_id": "u_123",
"user_key": user_key,
"session_id": "chat:c_backend_415",
"messages": [
{
"sender_id": "u_123",
"role": "user",
"timestamp": 1234567890123,
"content": [
{
"type": "doc",
"uri": "file:///tmp/bad.doc",
"ext": "doc",
"name": "bad.doc",
}
],
}
],
},
)
assert gateway_response.status_code == 415
assert gateway_response.json()["detail"] == {
"error": {"message": "LibreOffice conversion failed"}
}
@pytest.mark.asyncio
async def test_add_memory_creates_uri_attachment_mapping(
config: GatewayConfig,
repo: MemoryRepository,
) -> None:
backend = FakeBackendClient()
uri = "file:///home/tom/memory-gateway/tests/simple-multimodal-image.png"
async with app_client(config, backend) as client:
user_key = await create_user(client)
response = await client.post(
"/memories/add",
json={
"user_id": "u_123",
"user_key": user_key,
"session_id": "chat:c_uri",
"messages": [
{
"sender_id": "u_123",
"role": "user",
"timestamp": 1234567890123,
"content": [
{
"type": "image",
"uri": uri,
"name": "simple-multimodal-image.png",
"ext": "png",
}
],
}
],
},
)
assert response.status_code == 200, response.text
attachments = repo.list_attachments_for_session("u_123", "chat:c_uri")
assert [(item["name"], item["internal_uri"], item["source"]) for item in attachments] == [
("simple-multimodal-image.png", uri, "memory_add_uri")
]
assert backend.add_calls[0]["messages"][0]["content"][0]["uri"] == uri
@pytest.mark.asyncio
async def test_add_memory_materializes_base64_attachment(
config: GatewayConfig,
repo: MemoryRepository,
) -> None:
backend = FakeBackendClient()
encoded = base64.b64encode(b"wav bytes").decode("ascii")
async with app_client(config, backend) as client:
user_key = await create_user(client)
response = await client.post(
"/memories/add",
json={
"user_id": "u_123",
"user_key": user_key,
"session_id": "chat:c_base64",
"messages": [
{
"sender_id": "u_123",
"role": "user",
"timestamp": 1234567890123,
"content": [
{
"type": "audio",
"base64": encoded,
"name": "tone.wav",
"ext": "wav",
}
],
}
],
},
)
assert response.status_code == 200, response.text
attachments = repo.list_attachments_for_session("u_123", "chat:c_base64")
assert len(attachments) == 1
attachment = attachments[0]
assert attachment["name"] == "tone.wav"
assert attachment["source"] == "memory_add_base64"
path = Path(attachment["internal_uri"].removeprefix("file://"))
assert path.read_bytes() == b"wav bytes"
assert backend.add_calls[0]["messages"][0]["content"][0]["base64"] == encoded
@pytest.mark.asyncio
async def test_add_memory_multipart_uploads_files_to_chat_session(
config: GatewayConfig,
repo: MemoryRepository,
) -> None:
backend = FakeBackendClient()
messages = [
{
"sender_id": "u_123",
"role": "user",
"timestamp": 1234567890123,
"content": [
{"type": "text", "text": "remember these attachments"},
{
"type": "image",
"upload_id": "image_1",
"name": "picture.png",
"ext": "png",
},
{
"type": "audio",
"upload_id": "audio_1",
"name": "tone.wav",
"ext": "wav",
},
],
}
]
async with app_client(config, backend) as client:
user_key = await create_user(client)
response = await client.post(
"/memories/add/multipart",
data={
"user_id": "u_123",
"user_key": user_key,
"session_id": "chat:c_uploads",
"app_id": "default",
"project_id": "default",
"messages": json.dumps(messages),
},
files=[
("image_1", ("picture.png", b"png bytes", "image/png")),
("audio_1", ("tone.wav", b"wav bytes", "audio/wav")),
],
)
assert response.status_code == 200, response.text
assert response.json() == {
"session_id": "chat:c_uploads",
"backend": {"request_id": "add", "data": {"status": "accumulated"}},
}
content = backend.add_calls[0]["messages"][0]["content"]
assert content == [
{"type": "text", "text": "remember these attachments"},
{
"type": "image",
"name": "picture.png",
"ext": "png",
"base64": base64.b64encode(b"png bytes").decode("ascii"),
},
{
"type": "audio",
"name": "tone.wav",
"ext": "wav",
"base64": base64.b64encode(b"wav bytes").decode("ascii"),
},
]
attachments = repo.list_attachments_for_session("u_123", "chat:c_uploads")
assert [(item["name"], item["source"]) for item in attachments] == [
("picture.png", "memory_add_upload"),
("tone.wav", "memory_add_upload"),
]
for attachment in attachments:
path = Path(attachment["internal_uri"].removeprefix("file://"))
assert path.exists()
@pytest.mark.asyncio
async def test_add_memory_multipart_rejects_missing_upload_file(
config: GatewayConfig,
) -> None:
backend = FakeBackendClient()
messages = [
{
"sender_id": "u_123",
"role": "user",
"timestamp": 1234567890123,
"content": [
{"type": "image", "upload_id": "image_1", "name": "picture.png"}
],
}
]
async with app_client(config, backend) as client:
user_key = await create_user(client)
response = await client.post(
"/memories/add/multipart",
data={
"user_id": "u_123",
"user_key": user_key,
"session_id": "chat:c_missing_upload",
"messages": json.dumps(messages),
},
)
assert response.status_code == 422
assert "missing upload file for upload_id: image_1" in response.text
assert backend.add_calls == []
@pytest.mark.asyncio
async def test_add_memory_deduplicates_retried_base64_attachment(
config: GatewayConfig,
repo: MemoryRepository,
) -> None:
backend = FakeBackendClient()
encoded = base64.b64encode(b"same bytes").decode("ascii")
payload: dict[str, Any] = {
"user_id": "u_123",
"session_id": "chat:c_retry",
"messages": [
{
"sender_id": "u_123",
"role": "user",
"timestamp": 1234567890123,
"content": [
{
"type": "image",
"base64": encoded,
"name": "same.png",
"ext": "png",
}
],
}
],
}
async with app_client(config, backend) as client:
user_key = await create_user(client)
payload["user_key"] = user_key
first = await client.post("/memories/add", json=payload)
second = await client.post("/memories/add", json=payload)
assert first.status_code == 200, first.text
assert second.status_code == 200, second.text
attachments = repo.list_attachments_for_session("u_123", "chat:c_retry")
assert len(attachments) == 1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_flush_memory_forwards_request_to_backend( async def test_flush_memory_forwards_request_to_backend(
config: GatewayConfig, config: GatewayConfig,
@ -639,6 +1078,313 @@ async def test_flush_memory_forwards_request_to_backend(
] ]
@pytest.mark.asyncio
async def test_search_forwards_default_upstream_options(
config: GatewayConfig,
) -> None:
backend = FakeBackendClient()
async with app_client(config, backend) as client:
user_key = await create_user(client)
response = await client.post(
"/memories/search",
json={
"user_id": "u_123",
"user_key": user_key,
"query": "hello",
"scope": ["all_user_memory"],
},
)
assert response.status_code == 200, response.text
assert backend.search_calls == [
{
"user_id": "u_123",
"query": "hello",
"method": "hybrid",
"top_k": 8,
"include_profile": True,
"enable_llm_rerank": True,
"app_id": "default",
"project_id": "default",
}
]
@pytest.mark.asyncio
async def test_search_forwards_all_upstream_options(
config: GatewayConfig,
) -> None:
backend = FakeBackendClient()
async with app_client(config, backend) as client:
user_key = await create_user(client)
response = await client.post(
"/memories/search",
json={
"user_id": "u_123",
"user_key": user_key,
"agent_id": "agent_456",
"query": "hello",
"scope": ["all_user_memory"],
"method": "keyword",
"top_k": -1,
"radius": 0.4,
"include_profile": False,
"enable_llm_rerank": False,
"app_id": "app_1",
"project_id": "project_1",
},
)
assert response.status_code == 200, response.text
assert backend.search_calls == [
{
"agent_id": "agent_456",
"query": "hello",
"method": "keyword",
"top_k": -1,
"radius": 0.4,
"include_profile": False,
"enable_llm_rerank": False,
"app_id": "app_1",
"project_id": "project_1",
}
]
@pytest.mark.asyncio
@pytest.mark.parametrize(
("field", "value"),
[
("method", "invalid"),
("radius", 1.1),
("top_k", 0),
],
)
async def test_search_rejects_invalid_upstream_options(
config: GatewayConfig,
field: str,
value: Any,
) -> None:
backend = FakeBackendClient()
async with app_client(config, backend) as client:
user_key = await create_user(client)
payload = {
"user_id": "u_123",
"user_key": user_key,
"query": "hello",
"scope": ["all_user_memory"],
field: value,
}
response = await client.post("/memories/search", json=payload)
assert response.status_code == 422, response.text
assert backend.search_calls == []
@pytest.mark.asyncio
async def test_search_combines_custom_and_scope_filters(
config: GatewayConfig,
repo: MemoryRepository,
) -> None:
create_test_resource(repo, resource_id="r_1", user_id="u_123")
backend = FakeBackendClient()
custom_filters = {"OR": [{"type": "Conversation"}, {"sender_ids": "u_123"}]}
async with app_client(config, backend) as client:
user_key = await create_user(client)
response = await client.post(
"/memories/search",
json={
"user_id": "u_123",
"user_key": user_key,
"query": "hello",
"scope": ["resources"],
"filters": custom_filters,
},
)
assert response.status_code == 200, response.text
assert backend.search_calls[0]["filters"] == {
"AND": [
custom_filters,
{"session_id": {"in": ["resource:u_123:r_1"]}},
]
}
@pytest.mark.asyncio
async def test_search_labels_all_memory_types(
config: GatewayConfig,
) -> None:
backend = FakeBackendClient(
search_data={
"episodes": [{"id": "ep_1", "session_id": "chat:c_1", "episode": "e"}],
"profiles": [{"id": "profile_1", "profile_data": {"name": "Tom"}}],
"agent_cases": [
{"id": "case_1", "session_id": "chat:c_1", "task_intent": "case"}
],
"agent_skills": [{"id": "skill_1", "content": "skill"}],
"unprocessed_messages": [
{"id": "message_1", "session_id": "chat:c_1", "content": "pending"}
],
}
)
async with app_client(config, backend) as client:
user_key = await create_user(client)
response = await client.post(
"/memories/search",
json={
"user_id": "u_123",
"user_key": user_key,
"query": "hello",
"scope": ["all_user_memory"],
},
)
assert response.status_code == 200, response.text
assert [item["memory_type"] for item in response.json()["results"]] == [
"episode",
"profile",
"agent_case",
"agent_skill",
"unprocessed_message",
]
@pytest.mark.asyncio
async def test_search_returns_attachment_when_raw_contains_filename(
config: GatewayConfig,
repo: MemoryRepository,
) -> None:
repo.create_attachment(
user_id="u_123",
app_id="default",
project_id="default",
session_id="chat:c_1",
resource_id=None,
content_type="image",
name="Picture.PNG",
internal_uri="file:///private/Picture.PNG",
source="memory_add_uri",
sha256=None,
)
backend = FakeBackendClient(
[{"id": "ep_1", "session_id": "chat:c_1", "episode": "Saw picture.png"}]
)
async with app_client(config, backend) as client:
user_key = await create_user(client)
response = await client.post(
"/memories/search",
json={
"user_id": "u_123",
"user_key": user_key,
"query": "picture",
"scope": ["all_user_memory"],
},
)
assert response.status_code == 200, response.text
assert response.json()["results"][0]["attachments"] == [
{
"type": "image",
"name": "Picture.PNG",
"internal_uri": "file:///private/Picture.PNG",
}
]
@pytest.mark.asyncio
async def test_search_omits_unmentioned_and_base64_only_attachments(
config: GatewayConfig,
repo: MemoryRepository,
) -> None:
repo.create_attachment(
user_id="u_123",
app_id="default",
project_id="default",
session_id="chat:c_1",
resource_id=None,
content_type="audio",
name="tone.wav",
internal_uri="file:///private/tone.wav",
source="memory_add_base64",
sha256=None,
)
backend = FakeBackendClient(
search_data={
"unprocessed_messages": [
{
"id": "message_1",
"session_id": "chat:c_1",
"content": [{"base64": "encoded-prefix-tone.wav"}],
}
]
}
)
async with app_client(config, backend) as client:
user_key = await create_user(client)
response = await client.post(
"/memories/search",
json={
"user_id": "u_123",
"user_key": user_key,
"query": "audio",
"scope": ["all_user_memory"],
},
)
assert response.status_code == 200, response.text
assert response.json()["results"][0]["attachments"] == []
@pytest.mark.asyncio
async def test_search_attachment_mapping_is_user_isolated(
config: GatewayConfig,
repo: MemoryRepository,
) -> None:
for user_id, name in (("alice", "alice.png"), ("bob", "bob.png")):
repo.create_attachment(
user_id=user_id,
app_id="default",
project_id="default",
session_id="chat:shared",
resource_id=None,
content_type="image",
name=name,
internal_uri=f"file:///private/{name}",
source="memory_add_uri",
sha256=None,
)
backend = FakeBackendClient(
[
{
"id": "ep_1",
"session_id": "chat:shared",
"episode": "alice.png and bob.png",
}
]
)
async with app_client(config, backend) as client:
user_key = await create_user(client, "alice")
response = await client.post(
"/memories/search",
json={
"user_id": "alice",
"user_key": user_key,
"query": "images",
"scope": ["all_user_memory"],
},
)
assert response.status_code == 200, response.text
assert response.json()["results"][0]["attachments"] == [
{
"type": "image",
"name": "alice.png",
"internal_uri": "file:///private/alice.png",
}
]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_deleted_resource_is_excluded_from_resource_scope_search( async def test_deleted_resource_is_excluded_from_resource_scope_search(
config: GatewayConfig, config: GatewayConfig,