diff --git a/pyproject.toml b/pyproject.toml index 8acdcbce..a54f1ab2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,6 @@ dev = [ "pre-commit>=4.2.0", "pytest-cov>=6.2.1", "honcho-ai", - "fakeredis>=2.32.0", "scipy>=1.15.3", "boto3>=1.42.5", "pytest-xdist>=3.8.0", diff --git a/src/crud/__init__.py b/src/crud/__init__.py index c7767f47..7ce4c249 100644 --- a/src/crud/__init__.py +++ b/src/crud/__init__.py @@ -54,6 +54,7 @@ from .scope import ( get_scope_sessions, get_scopes, remove_session_from_scope, + resolve_scope_peers, ) from .session import ( SessionDeletionResult, @@ -141,6 +142,7 @@ __all__ = [ "get_scope_sessions", "get_scopes", "remove_session_from_scope", + "resolve_scope_peers", # Session "SessionDeletionResult", "get_sessions", diff --git a/src/crud/scope.py b/src/crud/scope.py index c04ac903..a2ef1588 100644 --- a/src/crud/scope.py +++ b/src/crud/scope.py @@ -12,6 +12,7 @@ scope. Conclusions already derived are neither backfilled on add nor reconciled on removal. """ +from collections.abc import Sequence from logging import getLogger from sqlalchemy import Select, select @@ -20,7 +21,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from src import models, schemas from src.cache.client import safe_cache_delete -from src.exceptions import ConflictException, ResourceNotFoundException +from src.exceptions import ( + ConflictException, + ResourceNotFoundException, + ValidationException, +) from src.utils.scopes import ( SCOPE_KIND, is_scope_peer, @@ -232,6 +237,69 @@ async def get_scope_or_raise( return peer +async def resolve_scope_peers( + db: AsyncSession, + workspace_name: str, + scope_names: Sequence[str], +) -> list[str]: + """ + Resolve unprefixed scope names to their backing scope-peer names. + + Used by the read routes that accept a ``scope`` option (chat, + representation, session context, workspace search) to turn user-facing + scope names into the observer peers that implement them. + + Args: + db: Database session + workspace_name: Name of the workspace + scope_names: Unprefixed scope names (duplicates are collapsed, + preserving first-seen order) + + Returns: + The backing scope-peer names, in first-requested order + + Raises: + ResourceNotFoundException: If any named scope does not exist + ValidationException: If a peer occupies a scope's reserved name + without the authoritative kind flag (a legacy collision) + """ + requested: list[str] = [] + seen: set[str] = set() + for name in scope_names: + if name not in seen: + seen.add(name) + requested.append(name) + + peer_names = [scope_peer_name(name) for name in requested] + if not peer_names: + return [] + + result = await db.execute( + select(models.Peer) + .where(models.Peer.workspace_name == workspace_name) + .where(models.Peer.name.in_(peer_names)) + ) + peers_by_name = {peer.name: peer for peer in result.scalars().all()} + + resolved: list[str] = [] + for name, peer_name in zip(requested, peer_names, strict=True): + peer = peers_by_name.get(peer_name) + if peer is None: + raise ResourceNotFoundException( + f"Scope {name} not found in workspace {workspace_name}" + ) + # The kind flag is authoritative and lives in internal_metadata, so a + # legacy peer merely occupying the reserved name is refused rather than + # silently treated as a scope. + if not is_scope_peer(peer.name, peer.internal_metadata): + raise ValidationException( + f"'{name}' does not name a scope: a non-scope peer occupies " + + "its reserved name." + ) + resolved.append(peer_name) + return resolved + + async def get_scope_sessions( workspace_name: str, scope_name: str, diff --git a/src/dialectic/chat.py b/src/dialectic/chat.py index 470aaf15..ba066741 100644 --- a/src/dialectic/chat.py +++ b/src/dialectic/chat.py @@ -21,24 +21,27 @@ from src.utils.scopes import is_scope_peer logger = logging.getLogger(__name__) -def _reject_scope_participants(*peers: models.Peer) -> None: - """Refuse a dialectic run whose observer or observed is a scope peer. +def _reject_scope_observed(peer: models.Peer) -> None: + """Refuse a dialectic run whose *observed* peer is a scope. A scope is a silent observer with ``observe_me=false``: no representation of - one exists to query. Querying *from* a scope's perspective is a read-side - surface that does not exist yet, and not something the raw peer routes expose. + one exists to query, so it can never be the subject. + + The observer position is deliberately NOT checked here. A single `scope` on + chat swaps the observer to the scope peer — answering from a scope's + perspective is the entire point of that option — so a guard here would reject + every scoped chat. The raw path peer is still refused as an observer, by the + route (``routers/peers.py``), where the distinction between "the caller named + a scope" and "the `scope` option resolved to one" is still visible. Raises: - ValidationException: If any participant is a scope. + ValidationException: If the observed peer is a scope. """ - offenders = sorted( - {p.name for p in peers if is_scope_peer(p.name, p.internal_metadata)} - ) - if offenders: + if is_scope_peer(peer.name, peer.internal_metadata): raise ValidationException( - f"Peer name(s) {offenders} are scopes." + f"Peer name '{peer.name}' is a scope." + " No representation is formed of a scope, so a scope cannot be a" - + " dialectic observer or target." + + " dialectic target." ) @@ -76,13 +79,13 @@ async def agentic_chat( if observer != observed: observed_peer = await crud.get_peer(db, workspace_name, observed) - # Resolved-row scope check, not a name check. The routes reject scope - # names up front for a clear error, but that runs before resolution: a - # scope created in between would otherwise be used here as observer or - # target. Checking the rows we just resolved closes that window — an - # absent name already failed above, and an existing unflagged squatter - # cannot retroactively become a scope. - _reject_scope_participants(observer_peer, observed_peer) + # Resolved-row scope check, not a name check. The routes reject a scope + # target up front for a clear error, but that runs before resolution: a + # scope created in between would otherwise be answered about here. + # Checking the row we just resolved closes that window — an absent name + # already failed above, and an existing unflagged squatter cannot + # retroactively become a scope. + _reject_scope_observed(observed_peer) session = None if session_name: @@ -157,13 +160,13 @@ async def agentic_chat_stream( if observer != observed: observed_peer = await crud.get_peer(db, workspace_name, observed) - # Resolved-row scope check, not a name check. The routes reject scope - # names up front for a clear error, but that runs before resolution: a - # scope created in between would otherwise be used here as observer or - # target. Checking the rows we just resolved closes that window — an - # absent name already failed above, and an existing unflagged squatter - # cannot retroactively become a scope. - _reject_scope_participants(observer_peer, observed_peer) + # Resolved-row scope check, not a name check. The routes reject a scope + # target up front for a clear error, but that runs before resolution: a + # scope created in between would otherwise be answered about here. + # Checking the row we just resolved closes that window — an absent name + # already failed above, and an existing unflagged squatter cannot + # retroactively become a scope. + _reject_scope_observed(observed_peer) session = None if session_name: diff --git a/src/routers/peers.py b/src/routers/peers.py index d5425a83..7843081e 100644 --- a/src/routers/peers.py +++ b/src/routers/peers.py @@ -5,6 +5,7 @@ import logging from collections.abc import AsyncIterator from contextlib import suppress from time import perf_counter +from typing import Any from fastapi import APIRouter, Body, Depends, Path, Query, Response from fastapi.responses import StreamingResponse @@ -28,7 +29,7 @@ from src.exceptions import ( from src.security import JWTParams, require_auth from src.telemetry import prometheus_metrics from src.telemetry.events import EmbeddingCallPurpose, GetContextEvent, emit -from src.utils.filter import extract_session_allowlist +from src.utils.filter import MAX_SESSION_ALLOWLIST_ENTRIES, extract_session_allowlist from src.utils.schema_conversion import json_response_schema_to_pydantic from src.utils.scopes import ( is_scope_peer, @@ -46,6 +47,73 @@ router = APIRouter( ) +def _validate_scope_option( + *, + filters: dict[str, Any] | None, + session_id: str | None, + jwt_params: JWTParams, +) -> None: + """Enforce the v1 `scope` exclusions and auth rule (chat/representation). + + `scope` is mutually exclusive with `filters` and `session_id` (422), and a + scope's member sessions may exceed a peer's own membership, so scoped + reads require a workspace- or admin-level key. + + 401 rather than 403: every other scope surface refuses a narrow key with 401 + — the `/scopes` router via `require_auth`, and the `scopes` field on session + create — so a peer key would otherwise get two different codes for the same + feature depending on which side of it was touched. + """ + if filters is not None: + raise ValidationException("`scope` and `filters` are mutually exclusive") + if session_id: + raise ValidationException("`scope` and `session_id` are mutually exclusive") + if jwt_params.p is not None: + raise AuthenticationException( + "`scope` requires a workspace- or admin-level key" + ) + + +async def _resolve_scope_option( + workspace_id: str, + scope: str | list[str], + *, + db_action: str, +) -> tuple[str | None, list[str] | None]: + """Map a validated `scope` option to (observer_override, session_allowlist). + + A single scope swaps the observer to the scope peer: conclusion recall is + then confined to the (scope, observed) collection and message recall to + the scope's session membership by existing observer semantics. A list of + scopes keeps the path peer as observer and returns the union of the + scopes' member sessions as an explicit allowlist (fail-closed when empty). + """ + async with tracked_db(db_action, read_only=True) as scope_db: + if isinstance(scope, str): + [scope_peer] = await crud.resolve_scope_peers( + scope_db, workspace_id, [scope] + ) + return scope_peer, None + + scope_peers = await crud.resolve_scope_peers(scope_db, workspace_id, scope) + union: list[str] = [] + seen: set[str] = set() + for scope_peer in scope_peers: + for session_name in await get_peer_session_names( + scope_db, workspace_id, scope_peer + ): + if session_name not in seen: + seen.add(session_name) + union.append(session_name) + + if len(union) > MAX_SESSION_ALLOWLIST_ENTRIES: + raise ValidationException( + "The scopes' combined membership exceeds the maximum of " + + f"{MAX_SESSION_ALLOWLIST_ENTRIES} sessions per request" + ) + return None, union + + @router.post( "/list", response_model=Page[schemas.Peer], @@ -243,6 +311,22 @@ async def chat( ), ) + # Scoped reads: a single scope swaps the observer to the scope + # peer; a list of scopes becomes a session allowlist over their union. + observer = peer_id + scope_session_union: list[str] | None = None + if options.scope is not None: + _validate_scope_option( + filters=options.filters, + session_id=options.session_id, + jwt_params=jwt_params, + ) + observer_override, scope_session_union = await _resolve_scope_option( + workspace_id, options.scope, db_action="peers.chat.resolve_scope" + ) + if observer_override is not None: + observer = observer_override + # The session id arrives in the body, so require_auth can't gate on it. A # peer-scoped key may only scope a chat to a session its peer belongs to; # without this check it could read any session's messages (the dialectic @@ -274,6 +358,12 @@ async def chat( if not set(session_allowlist) <= member_sessions: raise AuthenticationException("JWT not permissioned for this resource") + # A list of scopes resolves to a session allowlist over their union, which + # replaces any filters-derived allowlist (the two are mutually exclusive, so + # only one can be set). + if scope_session_union is not None: + session_allowlist = scope_session_union + # Convert the caller's JSON Schema so malformed schemas fail immediately with 422 response_model: type[BaseModel] | None = None if options.response_format is not None: @@ -291,9 +381,12 @@ async def chat( ) # Re-check on the resolved row: the name-level check above ran before the # peer was resolved, so a scope created in between would be picked up here - # as existing and used as the chat observer. - observer = peers_result.resource[0] - if is_scope_peer(observer.name, observer.internal_metadata): + # as existing and used as the chat observer. Deliberately NOT named + # `observer` — that holds the effective observer, which a single `scope` + # has already swapped to the scope peer, and rebinding it here would + # silently undo the swap. + path_peer = peers_result.resource[0] + if is_scope_peer(path_peer.name, path_peer.internal_metadata): raise ValidationException( "No representation is formed of a scope, so a scope cannot be a " + "chat observer or target." @@ -325,7 +418,7 @@ async def chat( workspace_name=workspace_id, session_name=options.session_id, query=options.query, - observer=peer_id, + observer=observer, observed=options.target if options.target is not None else peer_id, reasoning_level=options.reasoning_level, session_allowlist=session_allowlist, @@ -339,7 +432,8 @@ async def chat( workspace_name=workspace_id, session_name=options.session_id, query=options.query, - observer=peer_id, + # a single `scope` swaps the observer to the scope peer + observer=observer, # if target is given, that's the observed peer. otherwise, observer==observed # and it's answered from the omniscient Honcho perspective observed=options.target if options.target is not None else peer_id, @@ -361,9 +455,6 @@ async def chat( @router.post( "/{peer_id}/representation", response_model=schemas.RepresentationResponse, - dependencies=[ - Depends(require_auth(workspace_name="workspace_id", peer_name="peer_id")) - ], ) async def get_representation( workspace_id: str = Path(...), @@ -371,6 +462,9 @@ async def get_representation( options: schemas.PeerRepresentationGet = Body( ..., description="Options for getting the peer representation" ), + jwt_params: JWTParams = Depends( + require_auth(workspace_name="workspace_id", peer_name="peer_id") + ), ): """Get a curated subset of a Peer's Representation. A Representation is always a subset of the total knowledge about the Peer. The subset can be scoped and filtered in various ways. @@ -407,6 +501,24 @@ async def get_representation( options.filters, must_include=options.session_id ) + # Scoped reads: a single scope swaps the observer to the scope + # peer; a list of scopes becomes a session allowlist over their union. + observer = peer_id + scope_session_union: list[str] | None = None + if options.scope is not None: + _validate_scope_option( + filters=options.filters, + session_id=options.session_id, + jwt_params=jwt_params, + ) + observer_override, scope_session_union = await _resolve_scope_option( + workspace_id, options.scope, db_action="peers.representation.resolve_scope" + ) + if observer_override is not None: + observer = observer_override + if scope_session_union is not None: + session_allowlist = scope_session_union + try: embedding: list[float] | None = None if options.search_query: @@ -452,7 +564,8 @@ async def get_representation( representation = await crud.get_working_representation( workspace_id, db=read_session, - observer=peer_id, + # a single `scope` swaps the observer to the scope peer + observer=observer, observed=observed, session_allowlist=[options.session_id] if options.session_id is not None @@ -611,6 +724,25 @@ async def get_peer_context( This is useful for getting all the context needed about a peer without making multiple API calls. """ + # Scope peers may not appear on the generic peer-context surface: no + # representation is formed of a scope, and scoped reads go through the + # `scope` option on chat/representation/session-context instead. Flag-based + # rather than prefix-based, so a legacy peer merely occupying the reserved + # name keeps working; strict on a reserved name that does not exist yet, + # since nothing here creates it. Costs no query when no reserved name is + # present, and runs before any embedding work. + scope_candidates = [ + n for n in (peer_id, target) if n is not None and is_scope_peer_name(n) + ] + if scope_candidates: + async with tracked_db("peers.context.scope_check", read_only=True) as s_db: + await crud.reject_scope_observed( + s_db, + workspace_id, + scope_candidates, + action="Use the `scope` option on the read routes instead.", + ) + # If no target specified, get the peer's own context (self-observation) observed = target if target is not None else peer_id context_started = perf_counter() diff --git a/src/routers/sessions.py b/src/routers/sessions.py index b86b5706..10b769c4 100644 --- a/src/routers/sessions.py +++ b/src/routers/sessions.py @@ -675,19 +675,17 @@ async def get_session_peers( @router.get( "/{session_id}/context", response_model=schemas.SessionContext, - dependencies=[ - Depends( - require_auth( - workspace_name="workspace_id", - session_name="session_id", - allow_member_read=True, - ) - ) - ], ) async def get_session_context( workspace_id: str = Path(...), session_id: str = Path(...), + jwt_params: JWTParams = Depends( + require_auth( + workspace_name="workspace_id", + session_name="session_id", + allow_member_read=True, + ) + ), db: AsyncSession = read_db, tokens: int | None = Query( None, @@ -712,6 +710,10 @@ async def get_session_context( None, description="A peer to get context for. If given, response will attempt to include representation and card from the perspective of that peer. Must be provided with `peer_target`.", ), + scope: str | None = Query( + None, + description="An (unprefixed) scope name to use as the perspective source: the representation and peer card of `peer_target` are read from the scope's observations instead of the global (or `peer_perspective`) view. Must be provided with `peer_target`; mutually exclusive with `peer_perspective`. Requires a workspace- or admin-level key.", + ), limit_to_session: bool = Query( default=False, description="Only used if `search_query` is provided. Whether to limit the representation to the session (as opposed to everything known about the target peer)", @@ -756,11 +758,9 @@ async def get_session_context( ) # peer_target is the *observed* peer, and no representation or card is ever - # formed of a scope. peer_perspective (the observer) is left alone: a scope is - # a legitimate perspective, and the read-side scope surface will build on that. + # formed of a scope. Strict variant: an observed position that creates + # nothing, so a reserved name which does not exist yet must be refused too. if peer_target is not None: - # Strict variant: an observed position that creates nothing, so a reserved - # name which does not exist yet must be refused too. await crud.reject_scope_observed( db, workspace_id, @@ -771,6 +771,36 @@ async def get_session_context( ), ) + # peer_perspective is an observer position, where a scope is mechanically + # legitimate — but `scope` below is the supported way to ask for a scope's + # perspective, and routing through it is what keeps the observer mechanics + # hidden. Flag-based (not prefix-based) so a legacy peer merely occupying the + # reserved name keeps working, same as everywhere else. + if peer_perspective is not None: + await crud.reject_scope_peers( + db, + workspace_id, + [peer_perspective], + action="Use the `scope` parameter instead.", + ) + + if scope is not None: + if peer_perspective: + raise ValidationException( + "`scope` and `peer_perspective` are mutually exclusive" + ) + if not peer_target: + raise ValidationException( + "peer_target must be provided if scope is provided" + ) + # A scope's perspective spans sessions beyond this one, so scoped reads + # require a workspace- or admin-level key. 401, matching every other + # scope surface (see _validate_scope_option in routers/peers.py). + if jwt_params.p is not None or jwt_params.s is not None: + raise AuthenticationException( + "`scope` requires a workspace- or admin-level key" + ) + if not peer_target: # No representation or card needed summary, messages = await _get_session_context_task( @@ -804,6 +834,24 @@ async def get_session_context( observer = peer_perspective or peer_target observed = peer_target + # Member-read lets a peer-scoped key reach this route, but membership grants + # access to the *session*, not to a co-member's representation or peer card. + # The observer is whose knowledge is being read, so a peer-scoped key may only + # read from its own perspective — mirroring + # `POST /peers/{peer_id}/representation`, where require_auth pins the observer + # to the path peer and any `target` is that observer's own view. A bare + # `peer_target` naming another peer is the omniscient view of them, which is + # nobody's own perspective, so it is refused too. Workspace/admin and + # session-scoped tokens are unaffected. + if jwt_params.p is not None and jwt_params.p != observer: + raise AuthenticationException("JWT not permissioned for this resource") + + # A scope swaps the perspective source: the scope peer becomes the + # observer for both the working representation and the peer card, so the + # scoped collection and scoped card are read instead of the global ones. + if scope is not None: + [observer] = await crud.resolve_scope_peers(db, workspace_id, [scope]) + # Pre-compute embedding outside the DB session (best-effort) embedding: list[float] | None = None if search_query: diff --git a/src/routers/workspaces.py b/src/routers/workspaces.py index f7bcdcd2..42c111ad 100644 --- a/src/routers/workspaces.py +++ b/src/routers/workspaces.py @@ -7,11 +7,12 @@ from fastapi_pagination import Page from fastapi_pagination.ext.sqlalchemy import apaginate from sqlalchemy.ext.asyncio import AsyncSession -from src import crud, schemas +from src import crud, models, schemas from src.config import settings -from src.dependencies import db, read_db +from src.crud.message import get_peer_session_names +from src.dependencies import db, read_db, tracked_db from src.deriver.enqueue import enqueue_deletion, enqueue_dream -from src.exceptions import AuthenticationException +from src.exceptions import AuthenticationException, ValidationException from src.security import JWTParams, require_auth from src.utils.search import search @@ -141,16 +142,38 @@ async def delete_workspace( ) async def search_workspace( workspace_id: str = Path(...), - body: schemas.MessageSearchOptions = Body( + body: schemas.WorkspaceMessageSearchOptions = Body( ..., description="Message search parameters" ), ): """ Search messages in a Workspace using optional filters. Use `limit` to control the number of results returned. + + Pass `scope` to restrict the search to a scope's member sessions. A scope + with no member sessions returns no results (fail-closed). """ # take user-provided filter and add workspace_id to it filters = body.filters or {} + if body.scope is not None: + if "session_id" in filters: + raise ValidationException( + "`scope` and a 'session_id' filter are mutually exclusive" + ) + async with tracked_db( + "workspaces.search.resolve_scope", read_only=True + ) as scope_db: + [scope_peer] = await crud.resolve_scope_peers( + scope_db, workspace_id, [body.scope] + ) + scope_sessions = await get_peer_session_names( + scope_db, workspace_id, scope_peer + ) + if not scope_sessions: + # A scope with no member sessions matches nothing, not everything. + no_results: list[models.Message] = [] + return no_results + filters["session_id"] = {"in": scope_sessions} filters["workspace_id"] = workspace_id return await search(body.query, filters=filters, limit=body.limit) diff --git a/src/schemas/__init__.py b/src/schemas/__init__.py index cc00d76a..32e144b3 100644 --- a/src/schemas/__init__.py +++ b/src/schemas/__init__.py @@ -55,6 +55,7 @@ from src.schemas.api import ( WorkspaceBase, WorkspaceCreate, WorkspaceGet, + WorkspaceMessageSearchOptions, WorkspaceUpdate, ) from src.schemas.configuration import ( @@ -155,6 +156,7 @@ __all__ = [ "WorkspaceBase", "WorkspaceCreate", "WorkspaceGet", + "WorkspaceMessageSearchOptions", "WorkspaceUpdate", # internal "DocumentBase", diff --git a/src/schemas/api.py b/src/schemas/api.py index f686d007..411f2d14 100644 --- a/src/schemas/api.py +++ b/src/schemas/api.py @@ -12,6 +12,7 @@ from urllib.parse import urlparse import tiktoken from pydantic import ( + AfterValidator, AliasChoices, BaseModel, BeforeValidator, @@ -117,6 +118,19 @@ def _validate_scope_name(name: str) -> str: return name +_ScopeName = Annotated[str, AfterValidator(_validate_scope_name)] + +# The `scope` read option (chat / representation): one scope name, or a bounded +# list of them. The length cap sits on the list member so it bounds the *list* — +# a single name is already bounded by `_validate_scope_name`, and a union-level +# `max_length` would cap that name's characters instead. The upper bound matches +# `SessionCreate.scopes`; the lower one rejects `[]`, which would otherwise +# resolve to an empty allowlist and silently recall nothing. +_ScopeOption = ( + _ScopeName | Annotated[list[_ScopeName], Field(min_length=1, max_length=100)] +) + + # --------------------------------------------------------------------------- # Workspace schemas # --------------------------------------------------------------------------- @@ -245,6 +259,19 @@ class PeerRepresentationGet(BaseModel): "must be included in the allowlist." ), ) + scope: _ScopeOption | None = Field( + None, + description=( + "Optional (unprefixed) scope name(s) to confine the representation. " + "A single scope reads the scope's own representation of the target " + "peer, formed only from the scope's member sessions. A list of " + "scopes restricts the representation to conclusions from the union " + "of the scopes' member sessions (explicit allowlist, fail-closed: " + "an empty union yields an empty representation). Mutually " + "exclusive with `filters` and `session_id`. Requires a workspace- " + "or admin-level key." + ), + ) target: str | None = Field( None, description="Optional peer ID to get the representation for, from the perspective of this peer", @@ -695,6 +722,20 @@ class MessageSearchOptions(BaseModel): return v.replace("\x00", "") +class WorkspaceMessageSearchOptions(MessageSearchOptions): + """Workspace-level message search options, extended with `scope`.""" + + scope: str | None = Field( + default=None, + description=( + "Optional (unprefixed) scope name restricting search to the " + "scope's member sessions. A scope with no member sessions returns " + "no results. Mutually exclusive with a 'session_id' key in " + "`filters`." + ), + ) + + # --------------------------------------------------------------------------- # Dialectic schemas # --------------------------------------------------------------------------- @@ -714,6 +755,19 @@ class DialecticOptions(BaseModel): "also set, it must be included in the allowlist." ), ) + scope: _ScopeOption | None = Field( + None, + description=( + "Optional (unprefixed) scope name(s) to confine recall. A single " + "scope answers from the scope's own representation of the target " + "peer: conclusion recall is confined to what the scope observed " + "and message recall to the scope's member sessions. A list of " + "scopes restricts recall to the union of the scopes' member " + "sessions (explicit allowlist, fail-closed: an empty union " + "recalls nothing). Mutually exclusive with `filters` and " + "`session_id`. Requires a workspace- or admin-level key." + ), + ) target: str | None = Field( None, description="Optional peer to get the representation for, from the perspective of this peer", diff --git a/tests/conftest.py b/tests/conftest.py index 1ec64055..b57e3786 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,9 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import jwt import pytest import pytest_asyncio -from cashews.backends.interface import ControlMixin from cashews.picklers import PicklerType -from fakeredis import FakeAsyncRedis from fastapi import Request from fastapi.responses import JSONResponse from fastapi.testclient import TestClient @@ -385,54 +383,32 @@ async def db_session(db_engine: AsyncEngine): @pytest_asyncio.fixture(scope="session") async def fake_cache_session(): - """Set up fakeredis for caching once per test session.""" + """Set up a taskless in-memory cache once per test session. + + Cashews' normal memory backend starts a periodic expiry task on whichever + event loop first uses it. Tests use both pytest-asyncio loops and TestClient + portal loops, so that task can be cancelled when its originating loop closes + and then leak a CancelledError into the next app startup. Disabling the + periodic sweep keeps the backend loop-agnostic; expired entries are still + discarded lazily when read. + """ # Store original settings original_enabled = settings.CACHE.ENABLED original_url = settings.CACHE.URL - # Create a fake redis instance that persists for the session - fake_redis = FakeAsyncRedis(decode_responses=True) - - # Patch redis creation to use fakeredis - # Cashews uses redis.asyncio.from_url to create connections - def fake_redis_from_url(*_args: Any, **_kwargs: Any): - return fake_redis - - # Patch the cashews backend's _disable property to avoid ContextVar issues - # This works around cashews' ContextVar not being properly initialized in TestClient context - - original_disable_property = ControlMixin._disable # pyright: ignore[reportPrivateUsage] - - @property # type: ignore - def patched_disable_property(self): # pyright: ignore - try: - return original_disable_property.fget(self) # pyright: ignore[reportOptionalCall] - except LookupError: - # Return empty set as default if ContextVar not set in current context - return set() # pyright: ignore - - # Start patching - redis_patch = patch("redis.asyncio.from_url", fake_redis_from_url) - redis_patch.start() - ControlMixin._disable = patched_disable_property # pyright: ignore[reportPrivateUsage, reportAttributeAccessIssue] - try: - # Enable caching and set URL for tests + # Use the same backend from pytest-asyncio and TestClient event loops. settings.CACHE.ENABLED = True - settings.CACHE.URL = "redis://fake-redis:6379/0" - - # Setup cache for tests that don't use TestClient (direct CRUD tests) - # For TestClient tests, the app's lifespan handler will also call cache.setup() - # The ContextVar patch above handles any context issues + settings.CACHE.URL = "mem://?check_interval=0" cache.setup( - "redis://fake-redis:6379/0", pickle_type=PicklerType.SQLALCHEMY, enable=True + settings.CACHE.URL, + pickle_type=PicklerType.SQLALCHEMY, + enable=True, ) - yield fake_redis + yield cache finally: - # Stop the patches - redis_patch.stop() - ControlMixin._disable = original_disable_property # pyright: ignore[reportPrivateUsage, reportAttributeAccessIssue] + await cache.close() # Restore original settings settings.CACHE.ENABLED = original_enabled @@ -440,21 +416,21 @@ async def fake_cache_session(): @pytest_asyncio.fixture(scope="function", autouse=True) -async def fake_cache(fake_cache_session: FakeAsyncRedis): +async def fake_cache(fake_cache_session: Any): # pyright: ignore[reportUnusedParameter] """Clear cache between tests.""" # Clear cache before each test - await fake_cache_session.flushall() # pyright: ignore[reportUnknownMemberType] + await cache.clear() yield cache # Clear cache after each test - await fake_cache_session.flushall() # pyright: ignore[reportUnknownMemberType] + await cache.clear() @pytest.fixture(scope="function") async def client( db_session: AsyncSession, - fake_cache_session: FakeAsyncRedis, # pyright: ignore[reportUnusedParameter] + fake_cache_session: Any, # pyright: ignore[reportUnusedParameter] monkeypatch: pytest.MonkeyPatch, ) -> AsyncGenerator[TestClient, Any]: """Create a FastAPI TestClient for the scope of a single test function""" @@ -964,6 +940,7 @@ def mock_tracked_db(request: pytest.FixtureRequest): "src.deriver.consumer.tracked_db", "src.deriver.enqueue.tracked_db", "src.routers.peers.tracked_db", + "src.routers.workspaces.tracked_db", "src.crud.representation.tracked_db", "src.dreamer.orchestrator.tracked_db", "src.dreamer.dream_scheduler.tracked_db", diff --git a/tests/crud/test_representation_manager.py b/tests/crud/test_representation_manager.py index 0e05463b..6c5e094c 100644 --- a/tests/crud/test_representation_manager.py +++ b/tests/crud/test_representation_manager.py @@ -236,7 +236,7 @@ class TestRepresentationManagerSoftDelete: class TestRepresentationManagerSessionScoping: """Tests that the session allowlist is applied uniformly to every query path. - Regression for DEV-1994: session_name used to be applied only to the + Regression: session_name used to be applied only to the recent-documents query; the semantic and most-derived paths ignored it, so limit_to_session leaked cross-session conclusions. """ @@ -368,7 +368,7 @@ class TestRepresentationManagerSessionScoping: assert mock_query.await_args.kwargs["filters"] == { "session_name": {"in": [session_a.name]}, # Scoped recall serves only levels with a trustworthy session - # stamp (ALLOWLIST_SAFE_LEVELS / DEV-2201). + # stamp (ALLOWLIST_SAFE_LEVELS). "level": {"in": ["explicit"]}, } @@ -445,7 +445,7 @@ class TestRepresentationManagerSessionScoping: ) # Scoping also narrows to levels whose session stamp is trustworthy - # (see ALLOWLIST_SAFE_LEVELS / DEV-2201). + # (see ALLOWLIST_SAFE_LEVELS). assert manager._build_filter_conditions(session_allowlist=[]) == { # pyright: ignore[reportPrivateUsage] "session_name": {"in": []}, "level": {"in": ["explicit"]}, diff --git a/tests/dialectic/test_scope_preflight.py b/tests/dialectic/test_scope_preflight.py new file mode 100644 index 00000000..d3bacb47 --- /dev/null +++ b/tests/dialectic/test_scope_preflight.py @@ -0,0 +1,82 @@ +"""The scope guard inside the dialectic entry points. + +The route tests mock `agentic_chat` wholesale (`mock_llm_call_functions` in +tests/conftest.py), so the preflight *inside* it has no coverage there — which is +how a guard that rejected every scoped chat went unnoticed. These call it +directly with the agent stubbed, so no LLM work happens. +""" + +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi.testclient import TestClient +from nanoid import generate as generate_nanoid +from sqlalchemy.ext.asyncio import AsyncSession + +from src.dialectic.chat import agentic_chat +from src.exceptions import ValidationException +from src.models import Peer, Workspace +from src.utils.scopes import scope_peer_name + + +async def _create_scope( + client: TestClient, db_session: AsyncSession, workspace_name: str +) -> str: + """Create a scope and commit it — the preflight opens its own connection.""" + scope_name = str(generate_nanoid()) + response = client.post( + f"/v3/workspaces/{workspace_name}/scopes", json={"id": scope_name} + ) + assert response.status_code in [200, 201] + await db_session.commit() + return scope_name + + +@pytest.mark.asyncio +async def test_scope_observer_reaches_the_agent( + client: TestClient, + db_session: AsyncSession, + sample_data: tuple[Workspace, Peer], +): + """A single `scope` swaps the observer to the scope peer, so the preflight must + let a scope through in the observer position — otherwise every scoped chat 422s.""" + workspace, peer = sample_data + scope_name = await _create_scope(client, db_session, workspace.name) + + with patch("src.dialectic.chat.DialecticAgent") as agent_cls: + agent_cls.return_value.answer = AsyncMock(return_value="answered") + answer = await agentic_chat( + workspace_name=workspace.name, + session_name=None, + query="what do you know?", + observer=scope_peer_name(scope_name), + observed=peer.name, + ) + + assert answer == "answered" + assert agent_cls.call_args.kwargs["observer"] == scope_peer_name(scope_name) + + +@pytest.mark.asyncio +async def test_scope_observed_still_rejected( + client: TestClient, + db_session: AsyncSession, + sample_data: tuple[Workspace, Peer], +): + """The invariant the guard exists for: no representation is formed of a scope, + so it can never be the subject — even if the route's name check was raced.""" + workspace, peer = sample_data + scope_name = await _create_scope(client, db_session, workspace.name) + + with ( + patch("src.dialectic.chat.DialecticAgent") as agent_cls, + pytest.raises(ValidationException, match=scope_peer_name(scope_name)), + ): + await agentic_chat( + workspace_name=workspace.name, + session_name=None, + query="what do you know?", + observer=peer.name, + observed=scope_peer_name(scope_name), + ) + agent_cls.assert_not_called() diff --git a/tests/routes/test_scope_reads.py b/tests/routes/test_scope_reads.py new file mode 100644 index 00000000..eafdecaf --- /dev/null +++ b/tests/routes/test_scope_reads.py @@ -0,0 +1,750 @@ +"""Tests for the `scope` option on the read routes. + +A single scope swaps the observer to the scope peer, so recall is confined to +the (scope, observed) collection and the scope's member sessions by existing +observer semantics. A list of scopes keeps the path peer as observer and +restricts recall to the union of the scopes' member sessions (the +session-allowlist arm). +""" + +from typing import Any + +import pytest +from fastapi.testclient import TestClient +from nanoid import generate as generate_nanoid +from sqlalchemy.ext.asyncio import AsyncSession + +from src import crud, models +from src.config import settings +from src.models import Peer, Workspace +from src.security import JWTParams, create_jwt +from src.utils.scopes import scope_peer_name + + +def _create_scope(client: TestClient, workspace_name: str, scope_name: str): + response = client.post( + f"/v3/workspaces/{workspace_name}/scopes", json={"id": scope_name} + ) + assert response.status_code in [200, 201] + return response + + +def _create_session( + client: TestClient, + workspace_name: str, + session_name: str | None = None, + **extra: Any, +) -> str: + session_name = session_name or str(generate_nanoid()) + response = client.post( + f"/v3/workspaces/{workspace_name}/sessions", + json={"id": session_name, **extra}, + ) + assert response.status_code in [200, 201] + return session_name + + +def _add_sessions_to_scope( + client: TestClient, workspace_name: str, scope_name: str, session_names: list[str] +) -> None: + response = client.post( + f"/v3/workspaces/{workspace_name}/scopes/{scope_name}/sessions", + json={"session_ids": session_names}, + ) + assert response.status_code == 204, response.text + + +async def _seed_documents( + db_session: AsyncSession, + workspace_name: str, + *, + observer: str, + observed: str, + contents: list[tuple[str, str | None]], +) -> None: + """Seed a collection plus documents for an (observer, observed) pair. + + ``contents`` is a list of (content, session_name) tuples. + """ + collection = models.Collection( + workspace_name=workspace_name, + observer=observer, + observed=observed, + ) + db_session.add(collection) + await db_session.flush() + db_session.add_all( + [ + models.Document( + workspace_name=workspace_name, + observer=observer, + observed=observed, + content=content, + session_name=session_name, + ) + for content, session_name in contents + ] + ) + await db_session.commit() + + +async def _seed_legacy_collision_peer( + db_session: AsyncSession, workspace_name: str, scope_name: str +) -> None: + """Create a plain peer squatting on a scope's reserved internal name.""" + db_session.add( + models.Peer( + workspace_name=workspace_name, + name=scope_peer_name(scope_name), + ) + ) + await db_session.commit() + + +class TestScopeReadValidation: + """4xx paths shared by chat and representation. + + Chat validation happens before any LLM work, so these are safe to exercise. + """ + + def _chat( + self, client: TestClient, workspace: Workspace, peer: Peer, body: dict[str, Any] + ): + return client.post( + f"/v3/workspaces/{workspace.name}/peers/{peer.name}/chat", + json={"query": "what do you know?", **body}, + ) + + def _representation( + self, client: TestClient, workspace: Workspace, peer: Peer, body: dict[str, Any] + ): + return client.post( + f"/v3/workspaces/{workspace.name}/peers/{peer.name}/representation", + json=body, + ) + + def test_unknown_scope_404( + self, client: TestClient, sample_data: tuple[Workspace, Peer] + ): + workspace, peer = sample_data + unknown = str(generate_nanoid()) + assert ( + self._chat(client, workspace, peer, {"scope": unknown}).status_code == 404 + ) + assert ( + self._representation( + client, workspace, peer, {"scope": unknown} + ).status_code + == 404 + ) + + def test_unknown_scope_in_list_404( + self, client: TestClient, sample_data: tuple[Workspace, Peer] + ): + workspace, peer = sample_data + scope_name = str(generate_nanoid()) + _create_scope(client, workspace.name, scope_name) + resp = self._representation( + client, workspace, peer, {"scope": [scope_name, str(generate_nanoid())]} + ) + assert resp.status_code == 404 + + async def test_non_scope_peer_as_scope_422( + self, + client: TestClient, + db_session: AsyncSession, + sample_data: tuple[Workspace, Peer], + ): + """A peer squatting on the reserved name without the kind flag is not a scope.""" + workspace, peer = sample_data + scope_name = str(generate_nanoid()) + await _seed_legacy_collision_peer(db_session, workspace.name, scope_name) + + assert ( + self._chat(client, workspace, peer, {"scope": scope_name}).status_code + == 422 + ) + assert ( + self._representation( + client, workspace, peer, {"scope": scope_name} + ).status_code + == 422 + ) + + def test_scope_plus_filters_422( + self, client: TestClient, sample_data: tuple[Workspace, Peer] + ): + workspace, peer = sample_data + scope_name = str(generate_nanoid()) + _create_scope(client, workspace.name, scope_name) + body = {"scope": scope_name, "filters": {"session_id": ["s1"]}} + assert self._chat(client, workspace, peer, body).status_code == 422 + assert self._representation(client, workspace, peer, body).status_code == 422 + + def test_scope_plus_session_id_422( + self, client: TestClient, sample_data: tuple[Workspace, Peer] + ): + workspace, peer = sample_data + scope_name = str(generate_nanoid()) + _create_scope(client, workspace.name, scope_name) + body = {"scope": scope_name, "session_id": "s1"} + assert self._chat(client, workspace, peer, body).status_code == 422 + assert self._representation(client, workspace, peer, body).status_code == 422 + + def test_peer_scoped_jwt_401( + self, + client: TestClient, + sample_data: tuple[Workspace, Peer], + monkeypatch: pytest.MonkeyPatch, + ): + """A scope's sessions may exceed the peer's own membership: workspace/admin only. + + 401, matching every other scope surface — see _validate_scope_option. + """ + workspace, peer = sample_data + scope_name = str(generate_nanoid()) + _create_scope(client, workspace.name, scope_name) + + monkeypatch.setattr(settings.AUTH, "USE_AUTH", True) + monkeypatch.setattr(settings.AUTH, "JWT_SECRET", "test-secret") + client.headers["Authorization"] = ( + f"Bearer {create_jwt(JWTParams(w=workspace.name, p=peer.name))}" + ) + + assert ( + self._chat(client, workspace, peer, {"scope": scope_name}).status_code + == 401 + ) + assert ( + self._representation( + client, workspace, peer, {"scope": scope_name} + ).status_code + == 401 + ) + + # A session-scoped key gets the same answer, but from `require_auth` + # rather than from `_validate_scope_option`: these routes declare + # `peer_name` and no `session_name`, so an `s` token never reaches the + # handler at all. Asserted here so the handler's peer-only check stays + # sufficient — if either route ever starts declaring a session, this + # fails and the check needs the `s` arm the session-context route has. + client.headers["Authorization"] = ( + f"Bearer {create_jwt(JWTParams(w=workspace.name, s='any-session'))}" + ) + assert ( + self._chat(client, workspace, peer, {"scope": scope_name}).status_code + == 401 + ) + assert ( + self._representation( + client, workspace, peer, {"scope": scope_name} + ).status_code + == 401 + ) + + # A workspace-level key is allowed through validation (404 here only + # if the scope were unknown; representation of an empty scope is 200). + client.headers["Authorization"] = ( + f"Bearer {create_jwt(JWTParams(w=workspace.name))}" + ) + assert ( + self._representation( + client, workspace, peer, {"scope": scope_name} + ).status_code + == 200 + ) + + def test_scope_union_cap_422( + self, + client: TestClient, + sample_data: tuple[Workspace, Peer], + monkeypatch: pytest.MonkeyPatch, + ): + workspace, peer = sample_data + scope_name = str(generate_nanoid()) + _create_scope(client, workspace.name, scope_name) + session_a = _create_session(client, workspace.name) + session_b = _create_session(client, workspace.name) + _add_sessions_to_scope( + client, workspace.name, scope_name, [session_a, session_b] + ) + + monkeypatch.setattr("src.routers.peers.MAX_SESSION_ALLOWLIST_ENTRIES", 1) + resp = self._representation(client, workspace, peer, {"scope": [scope_name]}) + assert resp.status_code == 422 + assert "maximum" in resp.json()["detail"] + + @pytest.mark.parametrize( + "scope", + [ + pytest.param([], id="empty-list"), + pytest.param(["s"] * 101, id="over-list-cap"), + pytest.param([scope_peer_name("already-prefixed")], id="double-prefixed"), + pytest.param(["ok", "not a name!"], id="bad-charset-element"), + pytest.param(scope_peer_name("already-prefixed"), id="single-prefixed"), + ], + ) + def test_scope_option_bounds_422( + self, + client: TestClient, + sample_data: tuple[Workspace, Peer], + scope: str | list[str], + ): + """Schema-level bounds on `scope`, before any scope is resolved: the list is + bounded at both ends, and every element is validated as an unprefixed scope + name (so a double-prefixed one is a 422, not a 404 for `scope.scope.x`).""" + workspace, peer = sample_data + assert self._chat(client, workspace, peer, {"scope": scope}).status_code == 422 + assert ( + self._representation(client, workspace, peer, {"scope": scope}).status_code + == 422 + ) + + +class TestRepresentationWithScope: + async def test_single_scope_reads_scope_collection( + self, + client: TestClient, + db_session: AsyncSession, + sample_data: tuple[Workspace, Peer], + ): + """A single scope swaps the observer: only the (scope, peer) collection is read.""" + workspace, peer = sample_data + scope_name = str(generate_nanoid()) + _create_scope(client, workspace.name, scope_name) + session_a = _create_session(client, workspace.name) + session_b = _create_session(client, workspace.name) + _add_sessions_to_scope(client, workspace.name, scope_name, [session_a]) + + # Conclusions the scope observed (session A) ... + await _seed_documents( + db_session, + workspace.name, + observer=scope_peer_name(scope_name), + observed=peer.name, + contents=[("scoped fact about hiking", session_a)], + ) + # ... and global self-observations from another session + await _seed_documents( + db_session, + workspace.name, + observer=peer.name, + observed=peer.name, + contents=[("global fact about cooking", session_b)], + ) + + resp = client.post( + f"/v3/workspaces/{workspace.name}/peers/{peer.name}/representation", + json={"scope": scope_name}, + ) + assert resp.status_code == 200 + representation = resp.json()["representation"] + assert "scoped fact about hiking" in representation + assert "global fact about cooking" not in representation + + async def test_scope_list_unions_member_sessions( + self, + client: TestClient, + db_session: AsyncSession, + sample_data: tuple[Workspace, Peer], + ): + """A scope list keeps the global observer and applies the union allowlist.""" + workspace, peer = sample_data + scope_a = str(generate_nanoid()) + scope_b = str(generate_nanoid()) + _create_scope(client, workspace.name, scope_a) + _create_scope(client, workspace.name, scope_b) + session_a = _create_session(client, workspace.name) + session_b = _create_session(client, workspace.name) + session_c = _create_session(client, workspace.name) + _add_sessions_to_scope(client, workspace.name, scope_a, [session_a]) + _add_sessions_to_scope(client, workspace.name, scope_b, [session_b]) + + # All conclusions live in the GLOBAL (peer, peer) collection: only the + # union session-allowlist can explain the filtering below (this is the + # dynamic session-allowlist arm, not the observer swap). + await _seed_documents( + db_session, + workspace.name, + observer=peer.name, + observed=peer.name, + contents=[ + ("fact from session a", session_a), + ("fact from session b", session_b), + ("fact from session c", session_c), + ("sessionless dream fact", None), + ], + ) + + resp = client.post( + f"/v3/workspaces/{workspace.name}/peers/{peer.name}/representation", + json={"scope": [scope_a, scope_b]}, + ) + assert resp.status_code == 200 + representation = resp.json()["representation"] + assert "fact from session a" in representation + assert "fact from session b" in representation + assert "fact from session c" not in representation + assert "sessionless dream fact" not in representation + + def test_empty_scope_list_fails_closed( + self, client: TestClient, sample_data: tuple[Workspace, Peer] + ): + """A scope with no member sessions yields an empty representation.""" + workspace, peer = sample_data + scope_name = str(generate_nanoid()) + _create_scope(client, workspace.name, scope_name) + + resp = client.post( + f"/v3/workspaces/{workspace.name}/peers/{peer.name}/representation", + json={"scope": [scope_name]}, + ) + assert resp.status_code == 200 + assert "fact" not in resp.json()["representation"] + + +class TestChatWithScope: + """Verify what the chat route hands the dialectic, without real LLM work. + + ``agentic_chat`` is mocked in conftest (``mock_llm_call_functions``); the + scoped peer-card fetch happens inside it and is covered end-to-end by the + session-context test. Here we assert the route passes the right observer / + observed / session_names — the wiring that keys the card fetch. + """ + + def test_single_scope_swaps_observer( + self, + client: TestClient, + sample_data: tuple[Workspace, Peer], + mock_llm_call_functions: dict[str, Any], + ): + workspace, peer = sample_data + scope_name = str(generate_nanoid()) + _create_scope(client, workspace.name, scope_name) + + resp = client.post( + f"/v3/workspaces/{workspace.name}/peers/{peer.name}/chat", + json={"query": "what do you know?", "scope": scope_name}, + ) + assert resp.status_code == 200 + + kwargs = mock_llm_call_functions["agentic_chat"].await_args.kwargs + # The scope peer is the observer; the path peer stays the observed + assert kwargs["observer"] == scope_peer_name(scope_name) + assert kwargs["observed"] == peer.name + # Single-scope confinement rides on observer semantics, not an allowlist + assert kwargs["session_allowlist"] is None + + def test_scope_list_passes_union_allowlist( + self, + client: TestClient, + sample_data: tuple[Workspace, Peer], + mock_llm_call_functions: dict[str, Any], + ): + workspace, peer = sample_data + scope_a = str(generate_nanoid()) + scope_b = str(generate_nanoid()) + _create_scope(client, workspace.name, scope_a) + _create_scope(client, workspace.name, scope_b) + session_a = _create_session(client, workspace.name) + session_b = _create_session(client, workspace.name) + _add_sessions_to_scope(client, workspace.name, scope_a, [session_a]) + _add_sessions_to_scope(client, workspace.name, scope_b, [session_b]) + + resp = client.post( + f"/v3/workspaces/{workspace.name}/peers/{peer.name}/chat", + json={"query": "what do you know?", "scope": [scope_a, scope_b]}, + ) + assert resp.status_code == 200 + + kwargs = mock_llm_call_functions["agentic_chat"].await_args.kwargs + # Union path: the path peer stays the observer, the allowlist is the union + assert kwargs["observer"] == peer.name + assert kwargs["observed"] == peer.name + assert set(kwargs["session_allowlist"]) == {session_a, session_b} + + +class TestWorkspaceSearchWithScope: + def _seed_message( + self, client: TestClient, workspace_name: str, session_name: str, peer: Peer + ) -> None: + resp = client.post( + f"/v3/workspaces/{workspace_name}/sessions/{session_name}/messages", + json={ + "messages": [{"peer_id": peer.name, "content": "needle in haystack"}] + }, + ) + assert resp.status_code == 201 + + def test_search_restricted_to_scope_sessions( + self, client: TestClient, sample_data: tuple[Workspace, Peer] + ): + workspace, peer = sample_data + scope_name = str(generate_nanoid()) + _create_scope(client, workspace.name, scope_name) + session_a = _create_session(client, workspace.name, peers={peer.name: {}}) + session_b = _create_session(client, workspace.name, peers={peer.name: {}}) + _add_sessions_to_scope(client, workspace.name, scope_name, [session_a]) + self._seed_message(client, workspace.name, session_a, peer) + self._seed_message(client, workspace.name, session_b, peer) + + # Unscoped: both sessions' messages match + resp = client.post( + f"/v3/workspaces/{workspace.name}/search", + json={"query": "needle"}, + ) + assert resp.status_code == 200 + assert {m["session_id"] for m in resp.json()} == {session_a, session_b} + + # Scoped: only the scope's member session + resp = client.post( + f"/v3/workspaces/{workspace.name}/search", + json={"query": "needle", "scope": scope_name}, + ) + assert resp.status_code == 200 + results = resp.json() + assert results + assert {m["session_id"] for m in results} == {session_a} + + def test_empty_scope_returns_no_results( + self, client: TestClient, sample_data: tuple[Workspace, Peer] + ): + workspace, peer = sample_data + scope_name = str(generate_nanoid()) + _create_scope(client, workspace.name, scope_name) + session_a = _create_session(client, workspace.name, peers={peer.name: {}}) + self._seed_message(client, workspace.name, session_a, peer) + + resp = client.post( + f"/v3/workspaces/{workspace.name}/search", + json={"query": "needle", "scope": scope_name}, + ) + assert resp.status_code == 200 + assert resp.json() == [] + + def test_unknown_scope_404( + self, client: TestClient, sample_data: tuple[Workspace, Peer] + ): + workspace, _ = sample_data + resp = client.post( + f"/v3/workspaces/{workspace.name}/search", + json={"query": "needle", "scope": str(generate_nanoid())}, + ) + assert resp.status_code == 404 + + def test_scope_plus_session_id_filter_422( + self, client: TestClient, sample_data: tuple[Workspace, Peer] + ): + workspace, _ = sample_data + scope_name = str(generate_nanoid()) + _create_scope(client, workspace.name, scope_name) + resp = client.post( + f"/v3/workspaces/{workspace.name}/search", + json={ + "query": "needle", + "scope": scope_name, + "filters": {"session_id": "s1"}, + }, + ) + assert resp.status_code == 422 + + +class TestSessionContextWithScope: + async def test_scope_swaps_perspective_source( + self, + client: TestClient, + db_session: AsyncSession, + sample_data: tuple[Workspace, Peer], + ): + """`scope` reads the scope's collection and the scoped peer card.""" + workspace, peer = sample_data + scope_name = str(generate_nanoid()) + _create_scope(client, workspace.name, scope_name) + session_name = _create_session(client, workspace.name, peers={peer.name: {}}) + _add_sessions_to_scope(client, workspace.name, scope_name, [session_name]) + + await _seed_documents( + db_session, + workspace.name, + observer=scope_peer_name(scope_name), + observed=peer.name, + contents=[("scoped fact about hiking", session_name)], + ) + await _seed_documents( + db_session, + workspace.name, + observer=peer.name, + observed=peer.name, + contents=[("global fact about cooking", session_name)], + ) + await crud.set_peer_card( + db_session, + workspace.name, + peer_card=["SCOPED CARD"], + observer=scope_peer_name(scope_name), + observed=peer.name, + ) + await crud.set_peer_card( + db_session, + workspace.name, + peer_card=["GLOBAL CARD"], + observer=peer.name, + observed=peer.name, + ) + await db_session.commit() + + # Without scope: the global (self) perspective + resp = client.get( + f"/v3/workspaces/{workspace.name}/sessions/{session_name}/context", + params={"peer_target": peer.name}, + ) + assert resp.status_code == 200 + data = resp.json() + assert "global fact about cooking" in data["peer_representation"] + assert data["peer_card"] == ["GLOBAL CARD"] + + # With scope: the scope's perspective + resp = client.get( + f"/v3/workspaces/{workspace.name}/sessions/{session_name}/context", + params={"peer_target": peer.name, "scope": scope_name}, + ) + assert resp.status_code == 200 + data = resp.json() + assert "scoped fact about hiking" in data["peer_representation"] + assert "global fact about cooking" not in data["peer_representation"] + assert data["peer_card"] == ["SCOPED CARD"] + + def test_scope_requires_peer_target( + self, client: TestClient, sample_data: tuple[Workspace, Peer] + ): + workspace, peer = sample_data + scope_name = str(generate_nanoid()) + _create_scope(client, workspace.name, scope_name) + session_name = _create_session(client, workspace.name, peers={peer.name: {}}) + + resp = client.get( + f"/v3/workspaces/{workspace.name}/sessions/{session_name}/context", + params={"scope": scope_name}, + ) + assert resp.status_code == 422 + + def test_scope_and_peer_perspective_mutually_exclusive( + self, client: TestClient, sample_data: tuple[Workspace, Peer] + ): + workspace, peer = sample_data + scope_name = str(generate_nanoid()) + _create_scope(client, workspace.name, scope_name) + session_name = _create_session(client, workspace.name, peers={peer.name: {}}) + + resp = client.get( + f"/v3/workspaces/{workspace.name}/sessions/{session_name}/context", + params={ + "peer_target": peer.name, + "peer_perspective": peer.name, + "scope": scope_name, + }, + ) + assert resp.status_code == 422 + + def test_unknown_scope_404( + self, client: TestClient, sample_data: tuple[Workspace, Peer] + ): + workspace, peer = sample_data + session_name = _create_session(client, workspace.name, peers={peer.name: {}}) + + resp = client.get( + f"/v3/workspaces/{workspace.name}/sessions/{session_name}/context", + params={"peer_target": peer.name, "scope": str(generate_nanoid())}, + ) + assert resp.status_code == 404 + + def test_narrow_keys_rejected_401( + self, + client: TestClient, + sample_data: tuple[Workspace, Peer], + monkeypatch: pytest.MonkeyPatch, + ): + """Peer- and session-scoped keys may not widen reads through a scope.""" + workspace, peer = sample_data + scope_name = str(generate_nanoid()) + _create_scope(client, workspace.name, scope_name) + session_name = _create_session(client, workspace.name, peers={peer.name: {}}) + _add_sessions_to_scope(client, workspace.name, scope_name, [session_name]) + + monkeypatch.setattr(settings.AUTH, "USE_AUTH", True) + monkeypatch.setattr(settings.AUTH, "JWT_SECRET", "test-secret") + url = f"/v3/workspaces/{workspace.name}/sessions/{session_name}/context" + params = {"peer_target": peer.name, "scope": scope_name} + + # Peer-scoped key (member read grants access to the route, not to scope) + client.headers["Authorization"] = ( + f"Bearer {create_jwt(JWTParams(w=workspace.name, p=peer.name))}" + ) + assert client.get(url, params=params).status_code == 401 + + # Session-scoped key + client.headers["Authorization"] = ( + f"Bearer {create_jwt(JWTParams(w=workspace.name, s=session_name))}" + ) + assert client.get(url, params=params).status_code == 401 + + # Workspace-scoped key is allowed + client.headers["Authorization"] = ( + f"Bearer {create_jwt(JWTParams(w=workspace.name))}" + ) + assert client.get(url, params=params).status_code == 200 + + +class TestScopePeerGuardrailClosure: + """Scope peers are rejected on the generic perspective/context surfaces.""" + + def test_session_context_rejects_scope_peer_target( + self, client: TestClient, sample_data: tuple[Workspace, Peer] + ): + workspace, peer = sample_data + scope_name = str(generate_nanoid()) + _create_scope(client, workspace.name, scope_name) + session_name = _create_session(client, workspace.name, peers={peer.name: {}}) + + resp = client.get( + f"/v3/workspaces/{workspace.name}/sessions/{session_name}/context", + params={"peer_target": scope_peer_name(scope_name)}, + ) + assert resp.status_code == 422 + + def test_session_context_rejects_scope_peer_perspective( + self, client: TestClient, sample_data: tuple[Workspace, Peer] + ): + workspace, peer = sample_data + scope_name = str(generate_nanoid()) + _create_scope(client, workspace.name, scope_name) + session_name = _create_session(client, workspace.name, peers={peer.name: {}}) + + resp = client.get( + f"/v3/workspaces/{workspace.name}/sessions/{session_name}/context", + params={ + "peer_target": peer.name, + "peer_perspective": scope_peer_name(scope_name), + }, + ) + assert resp.status_code == 422 + + def test_peer_context_rejects_scope_peer( + self, client: TestClient, sample_data: tuple[Workspace, Peer] + ): + workspace, peer = sample_data + scope_name = str(generate_nanoid()) + _create_scope(client, workspace.name, scope_name) + + # As the path-level peer + resp = client.get( + f"/v3/workspaces/{workspace.name}/peers/{scope_peer_name(scope_name)}/context" + ) + assert resp.status_code == 422 + + # As the target + resp = client.get( + f"/v3/workspaces/{workspace.name}/peers/{peer.name}/context", + params={"target": scope_peer_name(scope_name)}, + ) + assert resp.status_code == 422 diff --git a/tests/routes/test_scope_route_policy.py b/tests/routes/test_scope_route_policy.py index 79884d1f..9332ca46 100644 --- a/tests/routes/test_scope_route_policy.py +++ b/tests/routes/test_scope_route_policy.py @@ -14,6 +14,16 @@ persisted a conclusion about something that carries ``observe_me=false``. One route, two positions, opposite verdicts. The same split applies to `schedule_dream`, the peer-card routes, and session context. +One refinement, added with the `scope` read option: on the *read* routes an +observer position is refused too, even though a scope there is mechanically +legitimate. Asking for a scope's perspective is what `scope` is for, and routing +through it is what keeps the observer mechanics hidden — so `peer_perspective`, +`GET /peers/{peer_id}/context`, chat and representation all refuse a raw scope +peer name and point at `scope` instead. The invariant above still governs the +storage side, where `observer_id` / `observer` remain ALLOW: a scope observing is +the entire mechanism. Read "OBSERVER" as "may observe", not "may be named as one +on any route". + So classification here is keyed by ``(method, path, position)``, where position is the request parameter carrying the peer name. Every derived triple must appear in `POLICY` as either REFUSE or ALLOW-with-a-reason; a new one fails @@ -253,6 +263,14 @@ def _b_card_observer_get(c: TestClient, ws: str, _s: str, p: str): return c.get(f"/v3/workspaces/{ws}/peers/{p}/card?target={_OTHER}") +def _b_peer_context_observer(c: TestClient, ws: str, _s: str, p: str): + return c.get(f"/v3/workspaces/{ws}/peers/{p}/context") + + +def _b_peer_context_target(c: TestClient, ws: str, _s: str, p: str): + return c.get(f"/v3/workspaces/{ws}/peers/{_OTHER}/context?target={p}") + + def _b_context_perspective(c: TestClient, ws: str, s: str, p: str): query = f"?peer_perspective={p}&peer_target={_OTHER}" return c.get(f"/v3/workspaces/{ws}/sessions/{s}/context{query}") @@ -387,10 +405,15 @@ POLICY: tuple[Case, ...] = ( "GET", f"{_W}/sessions/{{session_id}}/context", "peer_perspective", - False, - reason=_OBSERVER_OK, + True, + refuse_missing=False, + missing_reason=( + "The perspective peer is resolved before the flag-based guard runs, so a " + "reserved name that does not exist yet is a 404 — the same answer any " + "absent peer gets here — and nothing on this path creates it." + ), + missing_status=(404,), build=_b_context_perspective, - allow_status=(200,), ), Case( "GET", @@ -552,21 +575,17 @@ POLICY: tuple[Case, ...] = ( "GET", f"{_W}/peers/{{peer_id}}/context", "peer_id", - False, - reason=( - "Read-only. The read-side scope surface is not implemented yet; the " - "`scope` option on the context routes will own it when it lands." - ), + True, + refuse_missing=True, + build=_b_peer_context_observer, ), Case( "GET", f"{_W}/peers/{{peer_id}}/context", "target", - False, - reason=( - "Read-only, and empty for a scope now that nothing can write knowledge " - "about one. The read-side scope surface is not implemented yet." - ), + True, + refuse_missing=True, + build=_b_peer_context_target, ), Case( "GET", diff --git a/tests/routes/test_sessions.py b/tests/routes/test_sessions.py index 181336e1..27a4151a 100644 --- a/tests/routes/test_sessions.py +++ b/tests/routes/test_sessions.py @@ -7,7 +7,9 @@ from nanoid import generate as generate_nanoid from sqlalchemy.ext.asyncio import AsyncSession from src import models +from src.config import settings from src.models import Peer, Workspace +from src.security import JWTParams, create_jwt def test_get_or_create_session(client: TestClient, sample_data: tuple[Workspace, Peer]): @@ -1284,6 +1286,59 @@ def test_get_session_context_with_peer_perspective( assert "peer_card" in data +@pytest.mark.asyncio +async def test_get_session_context_peer_key_denied_for_co_member_perspective( + client: TestClient, + db_session: AsyncSession, + sample_data: tuple[Workspace, Peer], + monkeypatch: pytest.MonkeyPatch, +): + """`allow_member_read` gets a peer-scoped key onto this route, but it may only + read from its OWN perspective. A co-member's representation and peer card are + not session data, so membership must not hand them over.""" + test_workspace, alice = sample_data + bob = str(generate_nanoid()) + client.post( + f"/v3/workspaces/{test_workspace.name}/peers", + json={"name": bob, "metadata": {}}, + ) + session_id = str(generate_nanoid()) + client.post( + f"/v3/workspaces/{test_workspace.name}/sessions", + json={"id": session_id, "peer_names": {alice.name: {}, bob: {}}}, + ) + # Membership is read on a separate committed-only connection by the auth + # dependency, so it must be committed before a member-scoped read. + await db_session.commit() + + monkeypatch.setattr(settings.AUTH, "USE_AUTH", True) + monkeypatch.setattr(settings.AUTH, "JWT_SECRET", "test-secret") + client.headers["Authorization"] = ( + f"Bearer {create_jwt(JWTParams(w=test_workspace.name, p=alice.name))}" + ) + url = f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/context" + + # Bob's view of alice — alice is not the observer. + assert ( + client.get( + url, params={"peer_target": alice.name, "peer_perspective": bob} + ).status_code + == 401 + ) + # The omniscient view of bob — nobody's own perspective. + assert client.get(url, params={"peer_target": bob}).status_code == 401 + # Alice's own perspective on bob is hers to read, as is her own global view. + assert ( + client.get( + url, params={"peer_target": bob, "peer_perspective": alice.name} + ).status_code + == 200 + ) + assert client.get(url, params={"peer_target": alice.name}).status_code == 200 + # Session data itself is still readable by any member. + assert client.get(url).status_code == 200 + + def test_get_session_context_peer_perspective_without_target_fails( client: TestClient, sample_data: tuple[Workspace, Peer] ): diff --git a/tests/unified/README.md b/tests/unified/README.md index dd40e038..0ef1d987 100644 --- a/tests/unified/README.md +++ b/tests/unified/README.md @@ -39,6 +39,9 @@ Tests are defined in JSON files. A test definition consists of a name, optional * `create_session`: Create a new session, optionally with peers and config. * `add_message`: Add a single message. * `add_messages`: Add multiple messages. + * `create_scope`: Create a scope and optionally add member sessions. Add the + sessions *before* the messages you want in scope — membership only affects + messages ingested after a session joins. 3. **Waiting**: * `wait`: Wait for duration or "queue_empty". @@ -46,6 +49,18 @@ Tests are defined in JSON files. A test definition consists of a name, optional 4. **Querying & Assertions**: * `query`: Perform an action and assert on the result. * `target`: "chat", "get_context", "get_peer_card", "get_representation" + * `scope`: confine the read to a scope (or, for chat/representation, to + the union of several). Valid for "chat", "get_representation" and + "get_context"; the latter takes a single scope and requires + `observed_peer_id`. + +### Raw HTTP vs the SDK + +Most steps drive the Honcho Python SDK. `create_scope` and any query carrying +`scope` go over raw HTTP instead, because the published SDK trails the API and +exposes neither. Calling the API directly also tests the contract the SDK is +generated from, so a wrong status code or response shape surfaces here rather +than being masked by client-side validation. ### Assertions diff --git a/tests/unified/runner.py b/tests/unified/runner.py index b99cd37a..6d78b06b 100644 --- a/tests/unified/runner.py +++ b/tests/unified/runner.py @@ -37,6 +37,7 @@ from tests.unified.schema import ( AddMessageAction, AddMessagesAction, ContainsAssertion, + CreateScopeAction, CreateSessionAction, ExactMatchAssertion, JsonMatchAssertion, @@ -215,6 +216,38 @@ class UnifiedTestExecutor: self.client: Honcho = honcho_client self.anthropic: AsyncAnthropic | None = anthropic_client + # --- raw HTTP ----------------------------------------------------------- + # Some surfaces (scopes, the `scope` read option) exist in the API before the + # published SDK exposes them. Calling them directly also tests the contract + # the SDK is generated from, so a wrong status or shape surfaces here instead + # of being masked by client-side validation. + + @property + def workspace_id(self) -> str: + workspace_id = getattr(self.client, "workspace_id", None) + if not workspace_id: + raise ValueError("Honcho client has no workspace_id") + return str(workspace_id) + + async def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: + """Call a /v3 workspace-scoped path directly, raising on error status.""" + url = f"{str(self.client.base_url).rstrip('/')}/v3/workspaces/{self.workspace_id}{path}" + # Carry the same credential the SDK resolved (from `HONCHO_API_KEY`, unless + # passed explicitly). The harness sets no AUTH vars of its own, so auth is + # off by default — but it inherits `AUTH_USE_AUTH` from the environment, + # and these raw calls are the only ones here that would not be authorized. + headers: dict[str, str] = dict(kwargs.pop("headers", None) or {}) + api_key = getattr(getattr(self.client, "_http", None), "api_key", None) + if api_key: + headers.setdefault("Authorization", f"Bearer {api_key}") + async with httpx.AsyncClient(timeout=120.0) as raw: + response = await raw.request(method, url, headers=headers, **kwargs) + if response.is_error: + raise AssertionError( + f"{method} {path} failed: {response.status_code} {response.text[:400]}" + ) + return response + async def execute(self, test_def: TestDefinition, test_name: str) -> bool: logger.info(f"Starting test: {test_name}") @@ -311,6 +344,15 @@ class UnifiedTestExecutor: ) await session.aio.add_messages(msgs) + elif isinstance(step, CreateScopeAction): + await self._request("POST", "/scopes", json={"id": step.scope_id}) + if step.session_ids: + await self._request( + "POST", + f"/scopes/{step.scope_id}/sessions", + json={"session_ids": step.session_ids}, + ) + elif isinstance(step, WaitAction): if step.duration: await asyncio.sleep(step.duration) @@ -344,6 +386,9 @@ class UnifiedTestExecutor: raise TimeoutError("Deriver queue did not empty within timeout") async def perform_query(self, step: QueryAction) -> Any: + if step.scope is not None: + return await self._perform_scoped_query(step) + if step.target == "chat": if not step.observer_peer_id: raise ValueError("observer_peer_id required for chat") @@ -395,6 +440,60 @@ class UnifiedTestExecutor: return None + async def _perform_scoped_query(self, step: QueryAction) -> Any: + """Run a `scope`-confined read over raw HTTP (no SDK parameter for it).""" + if step.target == "chat": + if not step.observer_peer_id: + raise ValueError("observer_peer_id required for chat") + if step.input is None: + raise ValueError("input required for chat") + body: dict[str, Any] = {"query": step.input, "scope": step.scope} + if step.session_id: + body["session_id"] = step.session_id + if step.observed_peer_id: + body["target"] = step.observed_peer_id + if step.reasoning_level: + body["reasoning_level"] = step.reasoning_level + response = await self._request( + "POST", f"/peers/{step.observer_peer_id}/chat", json=body + ) + return response.json()["content"] + + if step.target == "get_representation": + if not step.observer_peer_id: + raise ValueError("observer_peer_id required for get_representation") + body = {"scope": step.scope} + if step.observed_peer_id: + body["target"] = step.observed_peer_id + if step.input: + body["search_query"] = step.input + response = await self._request( + "POST", f"/peers/{step.observer_peer_id}/representation", json=body + ) + return response.json()["representation"] + + if step.target == "get_context": + if not step.session_id: + raise ValueError("session_id required for get_context") + if not step.observed_peer_id: + raise ValueError("observed_peer_id required for a scoped get_context") + # `scope` on session context takes a single scope name. + if isinstance(step.scope, list): + raise ValueError("get_context accepts a single scope, not a list") + params: dict[str, Any] = { + "scope": step.scope, + "peer_target": step.observed_peer_id, + "summary": str(step.summary).lower(), + } + if step.max_tokens is not None: + params["tokens"] = step.max_tokens + response = await self._request( + "GET", f"/sessions/{step.session_id}/context", params=params + ) + return response.json() + + raise ValueError(f"`scope` is not supported for target {step.target!r}") + async def check_assertion(self, result: Any, assertion: Any): result_str = str(result) diff --git a/tests/unified/schema.py b/tests/unified/schema.py index 161d4f08..b0fa84b4 100644 --- a/tests/unified/schema.py +++ b/tests/unified/schema.py @@ -63,6 +63,22 @@ class AddMessagesAction(TestStep): messages: list[MessageItem] +class CreateScopeAction(TestStep): + """Create a scope and optionally add member sessions. + + Driven over raw HTTP rather than the SDK: scopes are a new API surface the + published SDK does not expose yet, and gating coverage on an SDK release + would leave the feature untested at exactly the point it needs testing. + """ + + step_type: Literal["create_scope"] = "create_scope" + scope_id: str = Field(..., description="Unprefixed scope name") + session_ids: list[str] = Field( + default_factory=list, + description="Existing sessions to add as members of the scope", + ) + + # --- Wait Actions --- @@ -152,6 +168,11 @@ class QueryAction(TestStep): # for chat - optional JSON Schema the response must conform to response_format: dict[str, Any] | None = None + # Confine the read to one scope (observer swap) or to the union of several + # scopes' member sessions. Forces the raw-HTTP path, since the SDK has no + # `scope` parameter. Valid for chat, get_representation and get_context. + scope: str | list[str] | None = None + assertions: list[ LLMJudgeAssertion | ContainsAssertion @@ -174,6 +195,7 @@ class TestDefinition(BaseModel): | CreateSessionAction | AddMessageAction | AddMessagesAction + | CreateScopeAction | WaitAction | ScheduleDreamAction | QueryAction, diff --git a/tests/unified/test_cases/scope_confines_recall.json b/tests/unified/test_cases/scope_confines_recall.json new file mode 100644 index 00000000..77589ca8 --- /dev/null +++ b/tests/unified/test_cases/scope_confines_recall.json @@ -0,0 +1,120 @@ +{ + "description": "A scope confines recall to its member sessions. Alice states one fact in a session that belongs to the 'work' scope and a different, contradictory-sounding fact in a session outside it. A scoped read must surface only the in-scope fact; the unscoped read sees both. This is the observer swap: the scope peer is the observer, so conclusion recall comes from the (scope, alice) collection and message recall from the scope's membership.", + "steps": [ + { + "step_type": "create_session", + "session_id": "work_session", + "description": "In-scope session", + "peer_configs": { + "alice": { "observe_me": true }, + "assistant": { "observe_others": true } + } + }, + { + "step_type": "create_session", + "session_id": "personal_session", + "description": "Out-of-scope session — must never leak into a scoped read", + "peer_configs": { + "alice": { "observe_me": true }, + "assistant": { "observe_others": true } + } + }, + { + "step_type": "create_scope", + "scope_id": "work", + "session_ids": ["work_session"], + "description": "Scope covers only work_session. Membership must precede the messages: it only affects messages ingested after the session joins." + }, + { + "step_type": "add_messages", + "session_id": "work_session", + "messages": [ + { + "peer_id": "alice", + "content": "I'm a staff platform engineer and I work primarily in Rust." + }, + { + "peer_id": "alice", + "content": "My current project is migrating our billing service off Postgres triggers." + } + ] + }, + { + "step_type": "add_messages", + "session_id": "personal_session", + "messages": [ + { + "peer_id": "alice", + "content": "Outside work I'm training for a marathon in Chicago this October." + }, + { + "peer_id": "alice", + "content": "I've been learning to play the upright bass on weekends." + } + ] + }, + { + "step_type": "wait", + "target": "queue_empty", + "flush": true + }, + { + "step_type": "query", + "target": "get_representation", + "observer_peer_id": "assistant", + "observed_peer_id": "alice", + "scope": "work", + "description": "Scoped representation: only what the scope observed.", + "assertions": [ + { + "assertion_type": "llm_judge", + "prompt": "Does this text describe Alice's professional life (engineering, Rust, or the billing/Postgres project) WITHOUT mentioning marathon running, Chicago, or the upright bass? Answer true only if the professional material is present and the personal material is entirely absent.", + "pass_if": true + }, + { + "assertion_type": "not_contains", + "text": "marathon" + }, + { + "assertion_type": "not_contains", + "text": "bass" + } + ] + }, + { + "step_type": "query", + "target": "chat", + "observer_peer_id": "assistant", + "observed_peer_id": "alice", + "scope": "work", + "input": "What do you know about Alice's hobbies outside of work?", + "description": "A scoped chat cannot answer from out-of-scope sessions, so it should report not knowing rather than surfacing the marathon or the bass.", + "assertions": [ + { + "assertion_type": "llm_judge", + "prompt": "Does this response indicate that it does not know about Alice's hobbies outside work, or only discuss her professional life? Answer false if it mentions marathon running, Chicago, or playing the bass.", + "pass_if": true + }, + { + "assertion_type": "not_contains", + "text": "marathon" + } + ] + }, + { + "step_type": "query", + "target": "chat", + "observer_peer_id": "assistant", + "observed_peer_id": "alice", + "input": "What do you know about Alice's hobbies outside of work?", + "description": "Control: the same question unscoped. Proves the scoped result above is the scope working, not the deriver simply having failed to record the personal session.", + "assertions": [ + { + "assertion_type": "llm_judge", + "prompt": "Does this response mention marathon running, Chicago, or playing the upright bass? Answer true if at least one of Alice's out-of-work hobbies is described.", + "pass_if": true + } + ] + } + ] +} diff --git a/uv.lock b/uv.lock index 61c5dbc9..1bae556f 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-08-05T17:35:29.589887Z" +exclude-newer = "2026-08-07T22:25:26.806369Z" exclude-newer-span = "P5D" [manifest] @@ -730,19 +730,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] -[[package]] -name = "fakeredis" -version = "2.35.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "redis" }, - { name = "sortedcontainers" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/50/b748233c02fa77e5105238190cc9bb58b852eb1c8b1d0763230d3a5b745a/fakeredis-2.35.1.tar.gz", hash = "sha256:5bae5eba7b9d93cb968944ac40936373cf2397ff71667d4b595df65c3d2e413f", size = 189118, upload-time = "2026-04-12T17:05:58.539Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/27/b8b057a23f7777177e92d3a602fd866751b6b45014964548997e92e048fd/fakeredis-2.35.1-py3-none-any.whl", hash = "sha256:67d97e11f562b7870e11e5c30cf182270bfb2dd37f6707dba47cc6d91628d1b9", size = 129678, upload-time = "2026-04-12T17:05:56.86Z" }, -] - [[package]] name = "fastapi" version = "0.136.1" @@ -1200,7 +1187,6 @@ dev = [ { name = "basedpyright" }, { name = "boto3" }, { name = "coverage" }, - { name = "fakeredis" }, { name = "honcho-ai" }, { name = "interrogate" }, { name = "pre-commit" }, @@ -1254,7 +1240,6 @@ dev = [ { name = "basedpyright", specifier = ">=1.29.4" }, { name = "boto3", specifier = ">=1.42.5" }, { name = "coverage", specifier = ">=7.6.0" }, - { name = "fakeredis", specifier = ">=2.32.0" }, { name = "honcho-ai", editable = "sdks/python" }, { name = "interrogate", specifier = ">=1.7.0" }, { name = "pre-commit", specifier = ">=4.2.0" }, @@ -3611,15 +3596,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] -[[package]] -name = "sortedcontainers" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, -] - [[package]] name = "sqlalchemy" version = "2.0.49"