"""ContextReader — URI-based full-content read.
Powers the read_memory tool interface. Since search_memory already
returns L2 results with abstract, read_memory only reads the actual
md file content. Enforces account-level ACL.
"""
from __future__ import annotations
import logging
from core.errors import AccessDeniedError, ValidationError
from core.interfaces import ContextFS
from core.models import RequestContext, RetrievedBlock
logger = logging.getLogger(__name__)
class ContextReader:
def __init__(self, fs: ContextFS | None = None, *, storage_backend: str = "agfs") -> None:
self._fs = fs
self._storage_backend = storage_backend
def read(
self,
uri: str,
ctx: RequestContext,
) -> RetrievedBlock:
self._check_acl(uri, ctx)
block = RetrievedBlock(uri=uri, level_hit="L2")
if self._fs is None:
logger.warning("[ContextReader] no ContextFS configured")
return block
try:
read_uri = uri
if read_uri.endswith("/content.md"):
read_uri = read_uri[: -len("/content.md")]
node = self._fs.read_node(read_uri, ctx)
except Exception as exc:
logger.warning("[ContextReader] read_node failed for %s: %s", uri, exc, exc_info=True)
return block
block.category = node.category
block.owner_space = node.owner_space
block.overview = node.overview
block.content_excerpt = node.content
return block
def _check_acl(self, uri: str, ctx: RequestContext) -> None:
if ctx is None:
raise ValidationError("ctx", "RequestContext is required for read operation")
if ctx.account_id is None or not ctx.account_id.strip():
raise ValidationError("account_id", "account_id is required for read operation")
prefix = f"ctx://{ctx.account_id}/"
if uri.startswith("ctx://") and not uri.startswith(prefix):
raise AccessDeniedError(uri, ctx.account_id, "account mismatch")
if self._storage_backend == "sql":
vos = ctx.visible_owner_spaces
if vos is not None and len(vos) > 0:
_owner_match = False
for space in vos:
kind, _, oid = space.partition(":")
space_prefix = f"ctx://{ctx.account_id}/{kind}s/{oid}/"
if uri.startswith(space_prefix):
_owner_match = True
break
if not _owner_match:
raise AccessDeniedError(uri, ctx.account_id, "owner_space mismatch")
else:
if uri.startswith(f"ctx://{ctx.account_id}/users/"):
user_prefix = f"ctx://{ctx.account_id}/users/{ctx.user_id}/"
if not uri.startswith(user_prefix):
raise AccessDeniedError(uri, ctx.account_id, "owner_space mismatch")
if ctx.agent_id and uri.startswith(f"ctx://{ctx.account_id}/agents/"):
agent_prefix = f"ctx://{ctx.account_id}/agents/{ctx.agent_id}/"
if not uri.startswith(agent_prefix):
raise AccessDeniedError(uri, ctx.account_id, "owner_space mismatch")
else:
if uri.startswith(f"ctx://{ctx.account_id}/users/"):
user_prefix = f"ctx://{ctx.account_id}/users/{ctx.user_id}/"
if not uri.startswith(user_prefix):
raise AccessDeniedError(uri, ctx.account_id, "owner_space mismatch")
if ctx.agent_id and uri.startswith(f"ctx://{ctx.account_id}/agents/"):
agent_prefix = f"ctx://{ctx.account_id}/agents/{ctx.agent_id}/"
if not uri.startswith(agent_prefix):
raise AccessDeniedError(uri, ctx.account_id, "owner_space mismatch")